Measured against the original archives in the backup using tools/probe_ui_assets.py.
All numbers are derived from 14 archives containing 203,843 assets.
Short Answer
A genuine in-game menu styled like the game's main menu is not feasible. Not because the files are locked — they reside normally in the archive and can be overwritten. But because what one would need to write inside is an undocumented binary format from id Software, which appears nowhere else: idSWF, Version 20. No tool on Earth writes it, and generating it from nothing would be a reverse-engineering project of its own, whose only test would consist of starting the game and seeing if it crashes.
What is possible: rewriting every text string in the menu. Menu titles, option names, help lines, difficulty levels, journal entries — 7,154 strings, of which 367 are in the menu. This isn't a menu, but it's more than nothing: you can rename the existing menu.
1. What Actually Exists
There is no asset type swf, gui, menu, hud, or shell. The interface is distributed across three locations:
| Location | Content | Count |
|---|---|---|
Type file, generated/swf/*.bswf |
The surfaces themselves (Logic + Layout) | 9 variants |
Type file, generated/swf/*.bimage |
The image atlas for each surface | 9 variants |
Type file, strings/*.lang |
String tables, 6 languages | 6 |
Type file, textures/guis/**/*.dct |
Collection object renderings | 144 |
Type swfresource |
Decls: Sounds, Fonts, Category Titles | 11 |
Type font |
Distance-Field Fonts | 6 |
Type image / material under gui/, hud/ |
HUD and Menu Graphics | 208 / 804 |
The nine surfaces:
tungshell_new 371.411 B Main Menu (Start, Chapter, Enigma, Options)
tungpause_new 139.752 B Pause Menu
tunginventory_new 122.155 B Journal / Collectibles
tunghud 119.204 B HUD
tungtutorial_new 27.958 B Tutorial Overlays
tungload_new 14.360 B Loading Screen
tungmodelbrowser 13.661 B Model Browser (Extras)
tungdialog 9.241 B Dialog Boxes
vo 2.423 B Subtitles
tunghud, tunginventory_new, tungmodelbrowser, tungtutorial_new, and vo appear in 13 of the 14 archives — any change must affect all copies.
2. The Format: Not Scaleform, Not SWF
The initial assumption was Scaleform. It is incorrect. The file header states:
42 53 57 14 ... -> "BSW" + 0x14
No FWS, no CWS, no ZWS (Adobe SWF), no GFX/CFX (Scaleform). It is idSWF, Version 20 — id Software's own Flash clone, present since Rage/Doom 3 BFG in id Tech. Confirmed by symbol names in the EXE:
idSWF, idSWFSprite, idSWFSpriteInstance, idSWFScriptFunctionAS2,
idSWFConstantPoolAS2, SWF_DICT_SHAPE/SPRITE/FONT/EDITTEXT, swfTag_t,
w:\tech5\engine\guis\swf\SWF_Load.cpp
A .bswf contains: String table (ActionScript-2 identifiers like inhibitControl, _absLeft_settings, easeInOutSine), compiled AS2 bytecode, Sprite/Shape/EditText dictionaries, and timelines. The image atlas is stored separately in .bimage — a BIM (\x09MIB, the same format as image assets; wolfsdk/bim.py already reads the header).
Consequence: No existing tool opens it. JPEXS reads SWF, the Scaleform exporter reads GFX; neither applies here. Generating a .bswf would require writing serializers for the Dictionary, timeline, and AS2 bytecode — plus a matching BIM atlas. A BIM encoder now exists in this SDK (wolfsdk/image.py, to_bim()), but only for RGBA8 and only with the replacement image as the header template; see docs/limits.md, Section 5. This does not change the effort required to create an own .bswf.
3. Do They Fall Into The Particle Trap? No — But They Have Their Own
The particle trap does not apply here. Evidence:
.bswf,.bimage, and.langare ordinary archive entries of typefilewith their own offset,usize, andcsize— exactly like any Decl.- There is no associated
.binunderbase/generated/. There are 76 files there that the game itself writes — 73 of them are particle caches, the rest are model and world caches. Nothing fromgenerated/swf/. - The log (
qconsole.log, 4,858 lines) contains zero lines ofidFile_RingBuffer.
But there is a second storage class, and the UI sits right in the middle. Within the file type, some endings are stored as 100% uncompressed while others are stored as 100% deflate-compressed — and this is not a compression heuristic:
| Ending | Entries | raw | deflate | would compress to | actually |
|---|---|---|---|---|---|
.bswf |
69 | 69 | 0 | 23 % | raw |
.bimage |
69 | 69 | 0 | 21 % | raw |
.lang |
6 | 6 | 0 | 28 % | raw |
.dct |
144 | 144 | 0 | 100 % | raw |
.png |
14 | 14 | 0 | 100 % | raw |
.script |
97 | 0 | 97 | 20 % | deflate |
.entities |
25 | 0 | 25 | 7 % | deflate |
.txt |
2 | 0 | 2 | 6 % | deflate |
.dct and .png are already compressed internally — applying Deflate to them yields no further benefit, so "raw" is the correct choice. .bswf, .bimage, and .lang would instead quarter in size. 5.6 MB of string tables lie uncompressed on disk, while 700 kB of scripts sit beside them compressed. This is not a coincidence; this is a decision: these files are apparently read via a path that requires the bytes verbatim — memory mapping or streaming rather than Inflate.
The Patcher can now do this. resources.plan_in_place() has a raw write path:
if entry.stored_raw: # csize == usize
return (data, len(data)) if len(data) == entry.csize else None
A raw-stored entry thus accepts a replacement of exactly the same length, and the bytes go unchanged to their offset — no compression, no change of storage class. Archive.rebuild() receives the storage class as well (packed = data if entry.stored_raw else _deflate(data)).
The question remains: whether the Engine tolerates a raw-stored entry being switched to Deflate is not measured — but based on this table, one should not bet on it. The raw write path does not even ask that question.
This adds length fidelity: the margin is 0 Bytes, not 3 to 15. Between the payloads lies alignment padding, but the Patcher does not touch it — a shrinking csize shifts the addressing of all subsequent entries (No address, error: 487, Modding-Limits.md §4). Going longer or shorter only works via rebuild(), which then writes a 151-MB archive (chunk0) or up to 2.8 GB anew if one touches tunghud in all 13 copies.
What follows from this
If you want to change strings/english.lang, you have exactly one clean way: replace byte-length-faithfully and write raw back. That is the same trick as _same_width() in tools/build_gore_deep.py, just at the text level: "CONTINUE" can become "WEITER ", not "WEITERSPIELEN".
The write path for this exists: plan_in_place() places the bytes directly at the offset when csize == usize, instead of sending them through deflate_exact(), and marks a replacement of another length. Neither offset nor csize moves. This has already been built as mods/menue_marke — three lines of the English string table, padded on the right with spaces to the old width, 868.273 bytes like the original.
It was applied and viewed in-game but did not appear.
Side note, historical:
deflate_exact()on an 868-kB file would hang with a guaranteed empty result. Measured: a Deflate pass takes 0.10 s and shrinks the file from 868.273 to 246.796 Bytes. The function then attempts up to 4,608 fill variants — roughly 8 minutes — to reach exactly 868.273 Bytes. It cannot: between these two sizes there are 621 kB, and the filler material reaches at most 4.6 kB in length. The raw write path also bypasses this — it does not calldeflate_exact()for such entries at all.
4. What can be changed data-drivenly
4.1 The String Table — the real gain
strings/english.lang is a plain text file (UTF-8 with BOM, CRLF) in the known Decl-near format:
// string table
//
{
"#str_menu_continue" "CONTINUE"
"#str_menu_game_difficulty" "DIFFICULTY"
"#str_menu_difficulty_veryhard" "ÜBER"
"#str_menu_difficulty_hard_help" "Hard difficulty setting for the experienced gamer."
...
}
7,154 keys, of which 367 have the prefix #str_menu_. They contain practically the entire menu: title, tabs, option lines, help text, keybinding labels, difficulty names, chapter names, dialog boxes. Six languages: English, French, Italian, Polish, Russian, Spanish. German is not present in this installation.
This allows renaming existing menu entries — for example, an option line that you don't need anyway can be renamed to "GORE-MOD ACTIVE". What you cannot do: add a line. Which lines exist is decided by the code, not the table.
4.2 The swfresource Decls
Eleven text decls, normally patchable, small (26–2,340 B). They control:
- Menu length — each tone after every click (
soundEffects) - Menu music —
tungshell:music/menu/main_menu - Text effects — which outline to which font (
fontEffects) - Representation —
snapToPixel,useBlackbars - Category titles in the Journal —
tunginventory_newwith ninecategoryDecls, each Material +#str_key
This is the one place where a decl directly impacts the menu. It is more than just cosmetics.
4.3 Other on-screen text types
| Type | Count | What |
|---|---|---|
job |
169 | Jobs/Goals in the Journal |
tutorialEvent |
140 | Tutorial overlays (screen, bindset, activateSound) |
secrets |
78 | Collectibles |
achievement |
50 | Achievements |
loadingscreen |
33 | Loading screen tips (tips) |
secrettype |
11 | Categories of collectibles |
All text, all patchable. loadingscreen is the most convenient channel for a mod note: load_general has tips = { num = 0; } — there one could enter custom tips that appear on loading. (Untested, but it is an ordinary Decl.)
4.4 What does not work: adding lines
Verified: tungshell_new.bswf contains zero Cvar names. No trace of g_, pm_, com_, r_. Option lines are therefore not built from a table in the SWF, but generated by C++ code and passed into the SWF. The hope to bypass an unused option line by renaming a Cvar string to a custom Cvar is thus moot — the string does not exist.
In the SWF there are only 14 #str_ keys (Enigma tab, keybinding, loading indicator). Everything else comes at runtime.
5. The existing developer interface
debugmenu.txt (22,419 B) and debugmenucheck.txt (159,248 B) lie as text in the archive and are patchable.
wolfsdk/devmenu.py uses this: it attaches its own page "WOLFSDK" to debugmenu.txt — with Cheat toggles, movement speed, FOV, difficulty. The approach is flawless on the data side: tools/verify_devmenu.py checks four invariants (valid JSON, full file parses further, injection idempotent, removal restores original byte-exactly) and all hold. It is cleanly built work.
It still doesn't work. The DevGUI is not connected in the Retail build. According to docs/limits.md (Section 2): devgui can be set to 1 via +toggle devgui without a ban warning and without being deleted by the Cheat-Reset — yet F8 and F10 still did nothing (measured, at the main menu). The page exists in the file, but no consumer for debugmenu.txt was found in the executable (code only, from strings), and the game resets devgui to 0 outside a level, so the earlier reading "never rendered because the Renderer is not connected" is not established. See Modding-Limits.md §2.
The approach was therefore correctly conceived but applied at the wrong lever. It would be immediately usable if the Renderer were accessible — which fails in the Steam version due to the SteamStub encryption of the .text section.
6. Assessment: Is an In-Game Menu realistic?
| Stage | What | Effort | Verdict |
|---|---|---|---|
| 1 | Rewrite menu text (.lang, length-preserving) |
none — the raw write path is in the Patcher | doable, untested in-game |
| 2 | Menu sounds, music, font effects (swfresource) |
trivial, existing Decl-Pipeline | doable |
| 3 | Loading screen tips, tutorial text, journal entries | trivial | doable |
| 4 | Replace HUD/menu graphics (image, .bimage) |
Encoder present (only RGBA8), size forces Rebuild | doable, appearance in-game unverified |
| 5 | Change menu structure (rows, pages, logic) | idSWF-Serializer + AS2-Assembler | no |
| 6 | Custom Menu via DevGUI | Renderer not connected in Retail | no |
The honest answer: no. A menu that looks and behaves like the game's menu, with its own pages and its own toggles, is not accessible via data files. Both paths to it — writing a custom .bswf or using the DevGUI — are blocked, for different reasons: one by an undocumented binary format, the other by the missing Renderer.
What remains is renaming the existing menu. This is significantly less than hoped for, and we should not speak of it here as if it were more than it is. It suffices for "This installation runs with Mod X" in the main menu. It does not suffice for "Toggle mod on/off via button press".
7. Unverified Traces
Three threads have been noticed and were not pursued because the game would need to be launched for that. They are listed here so they do not get lost — not as a promise.
7.1 swf_loadBinary
The EXE knows the Cvar swf_loadBinary, description: "used to set whether to load binary swf from generated". If it is set to 0, the engine presumably loads a real Adobe .swf instead of the .bswf. Raw .swf files are not included in the archives — you would have to provide one yourself, and the SWF format is documented unlike idSWF.
Open questions, all unresolved: Is the Cvar affected by the Retail ban? Does the engine search for files on disk at all in production mode? Under what path? And even if everything aligns: you would still have to rebuild the entire main menu in AS2, otherwise it is gone.
7.2 fs_overlaypath
Description in the EXE: "(Read Only) Extra location for game files (with precedence over fs_basepath)". An overlay path with precedence would be the most convenient mod channel of all — no archives to touch, just place files alongside. Whether it still does something in the Retail build and whether it can be set via the command line is unverified.
The fact that the engine reads from disk in production mode at all is shown by the log:
WARNING: Resource loaded in production mode: font : futura com book while loading font futura com book from swf tunginventory_new — seven such lines per session, for font, cm, aas, and foliageModel.
7.3 The Script Language — the most interesting thread
script/*.script are text files in the archive — 37 different ones, of which 5 are shared (in 13 archives each) and 32 card-specific, totaling 97 entries. Deflate-compressed and thus patchable via the existing pipeline. script/script_main.script says about itself: "This is the main script that is loaded before any level scripts load." It exists in all 13 card archives.
The script interface (script/script_events.script, 247 kB autogenerated typeinfo) includes, among others:
scriptEvent void setcvar( string name, string value );
scriptEvent string getcvar( string name );
scriptEvent void executeCommandText( string command );
scriptEvent void drawText( string text, vector origin, float scale,
vector color, int align, float lifetime );
scriptEvent float getUserButtons(); // on $player1
scriptEvent void wait( float time );
Plus 23 BUTTON_* constants. In principle, this allows building a loop that reads key presses, draws text, and sets Cvars — a crude menu without any SWF involvement.
However, the limitations are significant and should not be downplayed:
drawTextdraws at a world coordinate, not on the screen. You would have to calculate the text position every frame in front of the player's eyes. A screen-text event did not appear in the typeinfo.- Whether
setcvarbypasses the Retail lock is unknown. It is a direct script event, not a console path — but this has not been measured.executeCommandText, on the other hand, will likely fail against the lock just like+exec. - A script error is fatal: the engine compiles when loading cards and aborts on syntax errors.
- And there remains an in-game text overlay, not a menu.
Of the three traces, this is the only one that works without a new binary format. If this project intends to pursue further, this is the thread to pull — with the expectation of an overlay, not a menu.