Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Latest research roundsNeueste Forschungsrunden

TNO Map Loading in Code: Command Handlers, Map_f, Chunk Mounting, and a Working DLC-Extension Pipeline

Partly solvedTeilweise gelöst Original page in the GitHub wikiOriginalseite im GitHub-Wiki

In plain wordsIn einfachen Worten

This report read the decrypted program code of Wolfenstein: The New Order (TNO) to see how map commands and archive mounting work. map, devmap and three related commands all run through one shared function, and in a normal retail launch a missing pre-built combo map file only causes a warning, not an abort. The biggest result: TNO scans a dlc folder next to base for subfolders with an info.txt, checks the listed app ID against Steam ownership, and adds that folder's own archives to the game, which an earlier report thought did not exist. All of this is static code reading; the dlc folder route has not been tried in the running game.

  • map, cmap, devmap, devcmap and checkmap are real commands that all run through the same map-loading function.
  • In retail, a missing _combo .proc file only logs 'You need to build the combo map'; what happens later during loading was not traced.
  • A DLC subfolder with an info.txt (an appID owned on Steam, a dlcID from 1 to 16) gets its own master.index and chunks added to the 14 base chunk pairs; it adds content, it does not override existing content.
  • Using TNO's own app ID 201810 so that the ownership check passes is a guess; mapInfo also has a previously unknown dlcId field.
  • Open and untested in game: whether the dlc scan runs in retail and whether the app ID check passes; a test with an empty test folder under dlc, then an info.txt, is described.

Dieser Bericht hat den entschlüsselten Programmcode von Wolfenstein: The New Order (TNO) gelesen, um zu sehen, wie Kartenbefehle und das Einbinden von Archiven funktionieren. map, devmap und drei verwandte Befehle laufen alle über eine gemeinsame Funktion, und bei einem normalen Start der Verkaufsversion führt eine fehlende vorgebaute Combo-Kartendatei nur zu einer Warnung, nicht zum Abbruch. Das wichtigste Ergebnis: TNO durchsucht einen dlc-Ordner neben base nach Unterordnern mit einer info.txt, prüft die dort eingetragene App-ID gegen den Steam-Besitz und bindet die eigenen Archive dieses Ordners ins Spiel ein, was ein früherer Bericht für nicht vorhanden hielt. Alles beruht auf statischem Codelesen; der dlc-Ordner-Weg wurde im laufenden Spiel noch nicht ausprobiert.

  • map, cmap, devmap, devcmap und checkmap sind echte Befehle, die alle über dieselbe Kartenlade-Funktion laufen.
  • In der Verkaufsversion schreibt eine fehlende _combo-.proc-Datei nur „You need to build the combo map“ ins Log; was danach beim Laden passiert, wurde nicht verfolgt.
  • Ein DLC-Unterordner mit einer info.txt (eine auf Steam besessene appID, eine dlcID von 1 bis 16) bekommt seine eigene master.index und Chunks zu den 14 Basis-Chunk-Paaren hinzugefügt; er fügt Inhalte hinzu, überschreibt aber keine vorhandenen.
  • TNOs eigene App-ID 201810 zu nutzen, damit die Besitzprüfung besteht, ist eine Vermutung; mapInfo hat außerdem ein bisher unbekanntes Feld dlcId.
  • Offen und im Spiel ungetestet: ob der dlc-Scan in der Verkaufsversion läuft und ob die App-ID-Prüfung besteht; ein Test mit einem leeren Testordner unter dlc und danach einer info.txt ist beschrieben.

Technical detailsTechnische Details

Der technische Teil ist auf Englisch – so, wie er im Wiki steht. Unterstrichene Begriffe haben eine Erklärung auf Deutsch.

Round-7 research pass on how The New Order chooses and loads a map, read from decompiled code: map, devmap and three siblings share idCommonLocal::Map_f, whose retail-path check for a combo map is only a warning, and the master.index mount is confirmed from code. The main new result is that TNO scans <install-root>\dlc\ for folders with an info.txt, checks an app ID against Steam ownership, and mounts that folder's own master.index — a DLC-extension route for custom content. Static evidence only; the folder-scan route is untested in game.

Track tnomaploading, round 7, 2026-10-01. First round with a decrypted TNO runtime image (re_probes/runtime_tno_186352/module.bin, pid 186352, base 0x7ff74a390000, captured read-only from the running retail game) and a TNO-scoped Ghidra snapshot (D:/wolf-decomp/ghidra/tno_runtime.gzf, 50,884 functions from .pdata, 2,369 RTTI vtables labelled, no curated function names). Every prior TNO round (r4_tnocustom.md, r6_tnoconsole.md, r6_tnodecls.md, r6_tnomegatex.md) explicitly stated TNO's .text was unreachable ("no TNO runtime image exists"); that is no longer true, and this round spends its whole budget on the one question that needed it most: how does TNO's own code choose and load a map, and what does that mean for a custom/modified map.

Read-only throughout. Never launched the game, never attached a debugger. Queried tools/decomp.py (max 2 concurrent calls per the tool's own copy-on-open design) and, where Ghidra's string xrefs were empty/incomplete (stated explicitly below every time this happened, not glossed over), scanned module.bin directly for 8-byte pointer references, reusing tools/tnc_runtime_code.py's own method (its Image class, pointed at the TNO snapshot directory instead of TNC's). New files live under re_probes/tnomaploading/ (one script, probe_maploading.py, read-only, reproduces every string/offset cited below from scratch, with one negative control — run it yourself: python re_probes/tnomaploading/probe_maploading.py → 20/20 known strings match, mapInfo field table OK, ALL OK).

Question

How TNO chooses and loads a map in code: map/devmap command handlers (if they exist), chapter decls, mapInfo.vmtrChunkNumber, master.index chunk mounting, megatexture binding; what a custom or modified map needs; and the best route to load a modified or new map in TNO, building on r4_tnocustom.md.

Answer (short)

devmap and four siblings (map, cmap, devcmap, checkmap) are real, registered console commands, all routed through one shared function this round identifies by its own embedded debug string as idCommonLocal::Map_f (RVA 0x148ec0). It parses argv, recognises flags combo/dev/reset/ check/nextMapDiscSwap, and — the one finding this round ranks everything else under — in a normal production/retail launch its only map-existence check is whether maps/<name>/_combo/<name>.proc exists, and that check is advisory, not a hard gate: missing it only logs "You need to build the combo map for: %s" and the function still proceeds to start the map. The hard "Can't find map '%s'" abort (checking maps/<name>.entities directly) only fires on a non-production build path (see §2 — gated by a flag this round could identify but not fully pin down the write-site of). master.index's own format is confirmed from code, not just static bytes: magic "RE5" + a version byte, a count, then per-chunk two filename reads, each opened through the filesystem singleton and appended to one growable array (§3).

The single biggest new result: that growable chunk array is not only filled from the base 14 chunkN.index/chunkN.resources pairs. idSessionLocal's own EnumerateDownloadableContent (RVA 0x4ee2b0, own debug string) genuinely scans <install-root>\dlc\ on local disk for subfolders containing a valid info.txt, reads an appID and a dlcID (must be 1..16) out of it, checks appID against a Steamworks ownership call, and on success queues the folder; OpenDLCFileHandles (RVA 0x16d090, own debug string) then opens that folder's own master.index the exact same way as the base one and appends its chunk pairs into the same array — with a by-id "already loaded, skipping" guard. This is a real, working, code-confirmed extension point that r4_tnocustom's two rounds concluded did not exist for TNO ("no dlc_0-style route", ranked last, based on reading the wrong game's decompiler, since no TNO image existed yet). It is not the same shape as TNC's dlc_0 precedence (this is pure addition of new chunk pairs, not an override of existing ones) — but it is a genuine, intended, always-live mounting path for a custom map's own archive, gated only by a Steam-ownership check this report shows is very likely satisfiable with the base game's own AppID (confirmed from the local Steam install: 201810, <Steam library>\steamapps\appmanifest_201810.acf). This is now Route 1, replacing both of r4_tnocustom's untested/unsolved Routes 2 and 3.

1. The command table: map/cmap/devmap/devcmap/checkmap and siblings

Ghidra's static string xrefs were mostly empty for this binary (stated in every prior TNO round as a structural limitation of the quick import — "no command table for TNO yet"); --strings still lists the strings and, where a reference happened to resolve, names the containing function. Searching "loads a combo map" (the exact cvar/command description text r6_tnoconsole.md's own mined table already found for devmap/cmap/ devcmap/checkmap) hit exactly one function for every one of its five description variants:

python tools/decomp.py --strings "loads a combo map" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
  "loads a combo map"                                        <- FUN_7ff74a4c7950 (x2)
  "loads a combo map in developer mode"                      <- FUN_7ff74a4c7950 (x2)
  "loads a combo map in developer mode from checkpoint load" <- FUN_7ff74a4c7950

Decompiling FUN_7ff74a4c7950 (RVA 0x137950) shows it is the console command-registration block itself: 40+ consecutive calls through idCmdSystemLocal's vtable (+0x18, confirmed by its own RTTI label vtbl_idCmdSystemLocal on a related call site, matching the already-known engine family) — each call is literally AddCommand(name, handler, description, completionFn). Reading the raw command-name string operands (Ghidra doesn't label them, since nothing in the quick import's analyzer set resolves a bare DAT_… as a C string unless something else references it — read directly from module.bin instead, same method as everywhere else in this report):

name string (VA) handler description
map (0x7ff74b1f111c) FUN_7ff74a4d9ff0 (RVA 0x149ff0) "loads a combo map"
cmap (0x7ff74b1f19a4) FUN_7ff74a4d9ff0 (same) "loads a combo map"
devmap (0x7ff74b1f19d4) FUN_7ff74a4da050 (RVA 0x14a050) "loads a combo map in developer mode"
devcmap (0x7ff74b1f1a08) FUN_7ff74a4da050 (same) "loads a combo map in developer mode"
checkmap (0x7ff74b1f1a50) FUN_7ff74a4da0c0 (RVA 0x14a0c0) "loads a combo map in developer mode from checkpoint load"
slowmap FUN_7ff74a4d9f70 "loads a map using slow non-shipp[ing path]"
restartmap FUN_7ff74a4dc960 "restarts the current map"
restartmaphere FUN_7ff74a4dcb30 "restarts the current map and tel[eports]"
nextmap FUN_7ff74a4da120 "transitions to the next map"

All five map-loading handlers (map/cmap/devmap/devcmap/checkmap) are thin wrappers: each prepends its own argv, appends one or two literal flag words, and calls the same core function, FUN_7ff74a4d8ec0 (RVA 0x148ec0):

FUN_7ff74a4d9ff0 (map, cmap)      : appends "combo"
FUN_7ff74a4da050 (devmap, devcmap): appends "dev", then "combo"
FUN_7ff74a4da0c0 (checkmap)       : appends "check"

All three handlers verified directly against this report's own re-read strings (re_probes/tnomaploading/probe_maploading.py line-level checks). There is also a separate, standalone helper, FUN_7ff74acb4350 (RVA 0x924350), that formats a literal "devmap %s\n" string and feeds it through idCmdSystemLocal's vtable +0x48 (a BufferCommandText/ AppendCommandText-shaped call, same vtable object as AddCommand above) — this is some menu/UI flow (unidentified — no xrefs to this helper either, Ghidra's import limitation again) directly injecting a devmap <name> command the same way a player typing it in the console would, i.e. a second, code-confirmed, non-console way devmap gets invoked.

2. The core handler: idCommonLocal::Map_f (RVA 0x148ec0)

This function's own embedded string proves its name — one of its error paths is literally FUN_7ff74ad5a710(s_idCommonLocal__Map_f_map_doesnt_h...) = "idCommonLocal::Map_f map doesnt have valid data". This is the strongest possible identification this method can give (not a guess from shape or analogy — the function tells you its own class::method name).

Argument parsing (param_1 is an idCmdArgs-shaped struct: an int32 argc at offset 0, argv[0] not used here, argv[1] = the map name at offset +4, further argv[N] from offset +6*4 onward stepping by two int32 slots): a loop from argv[2] onward checks each token case-sensitively against five literals — "combo", "dev", "reset", "check", "nextMapDiscSwap" — setting one local flag byte per hit. "check" sets two flags at once (its own flag, and the same bit "combo" sets) — so checkmap's single appended "check" token reaches the identical downstream branch as map/cmap's appended "combo", and devmap/devcmap append both "dev" and "combo" explicitly. Net effect, re-derived from all five wrapper handlers together: all five player-facing map commands set the same "combo" flag one way or another — there is no map-loading command that skips the combo-flag branch in §2.2 below.

2.1 File extension: .entities by default, .map as a named fallback

Before any existence check, the function builds a default extension string "map", then asks (via a second helper) whether the configured/queried extension resolves to "entities" — if so, swaps the extension string to "entities". This is the parser-level confirmation (not just static-text inference, as in r4_tnocustom.md) that a map name is ultimately resolved to a maps/<name>.<ext> path with ext defaulting to entities — matching file:maps/<id>.entities exactly as r4_tnocustom.md found by reading decl text. The two extensions are not interchangeable at the call site: if the extension is "entities" and the existence check (next section) fails, an extra hint fires: "Use the writeEntitiesFile command to convert the map to an entities file.\n" (own string, 0x7ff74b1f7f20) — i.e. the engine still recognises a raw, uncompiled .map (editor format) as a legitimate predecessor to the shipped .entities, consistent with the standard id Tech 5 build pipeline (.map → writeEntitiesFile → .entities).

2.2 Production branch: the _combo/<name>.proc check is a warning, not a gate

A global flag (DAT_7ff74c181dd8) selects between two completely different bodies of logic. Strong circumstantial evidence for its meaning: the only other place in this image that writes to it is FUN_7ff74a4faf20 (§3, the base-archive mount function), and it writes 0 there in the same few instructions as a logged call to idLib::SetProduction( PROD_DEVELOPMENT ) — i.e. this flag reads as "is this a shipping/production run" (non-zero) vs. "development, no base archive could even be opened" (zero). INFERRED, not proven — this report did not find or trace the call that sets it to non-zero on a normal run, only the one call that zeros it in a degraded scenario.

When the flag is non-zero (the expected state for any real, retail launch where master.index opened successfully — see §3):

maps/%s/_combo/%s.proc            <- own string, 0x7ff74b1f7f70
You need to build the combo map for: %s   <- own string, 0x7ff74b1f7f88 (LOG ONLY)

The file-existence check (filesystem singleton's vtable +0x88, same call shape used everywhere else in this function) runs, and if it returns false, the function logs the warning and keeps going — there is no early return, no goto, nothing skipped; the very next lines unconditionally set up session-start fields ("direct"/"topic"/"player_start" substrings, own-string-confirmed at 0x7ff74b1f77f4/0x7ff74b1f77fc/0x7ff74b1f7ff8) and call through to what is almost certainly idSessionLocal::StartNewGame (two further vtable calls on a second singleton, offsets +0x98 then +0x48, immediately followed by two paired FUN_7ff74a4d6060(5, …) / FUN_7ff74a4d6060(6, …) calls that read exactly like a loading-screen push/pop pair). This is the single most actionable finding in this report: a map that ships maps/<name>.entities but has no baked _combo/<name>.proc at all is not rejected by Map_f — it only gets one log line, and the engine still attempts to start it.

When the flag is zero (development/no-archive path), the function instead builds maps/%s (own string 0x7ff74b1f7ef0), runs the same vtable +0x88 existence check against maps/<name>.<ext> directly, and this one does gate: on failure it logs "Can't find map '%s'\n" (own string 0x7ff74b1f7ef8), optionally appends the writeEntitiesFile hint (§2.1), and jumps straight to the function's cleanup/exit label — no session start happens at all. On success it instead checks for an optional <name>_region.map sibling file and, if present, logs "Loading region map: %s\n" and substitutes <name>_region for the rest of the call — a cosmetic per-region overlay feature, not required for a basic load.

2.3 What this means for "what does a custom/modified map need"

Combining §2.1–§2.2, re-stated as a checklist, ranked by how hard each requirement actually is to satisfy, per this code (not per guesswork):

  1. Not a hard requirement in production: maps/<name>/_combo/<name>.proc existing. Missing it only logs a warning in Map_f itself. (What happens later — whether the renderer/entity-spawn path that actually reads this .proc file for geometry crashes or silently no-ops when it's absent — is not traced this round; §2.2's own "proceeds anyway" behaviour is proven only up to the StartNewGame-equivalent call, not past it. Flagged as an open question, not glossed over.)
  2. Needed only on the non-production code path (§2.2's else branch): maps/<name>.entities (or .map) must exist, checked directly by path. For a normal retail launch this check does not run at all.
  3. Chapter-select and the actual mapInfo/vmtrChunkNumber decl fields (below, §4) were not found referenced anywhere in Map_f itself — this function only cares about the map name string and the two files named above; mapInfo/vmtrChunkNumber are read elsewhere (most likely at the point the resource manager actually opens the content, downstream of Map_f — not traced this round, time-boxed in favour of the DLC-mount finding below, which this report ranks as far higher-value for "how do I add a map").

3. master.index: the base-archive mount, confirmed from code

--strings "master.index" resolves two containing functions. The first, FUN_7ff74a4faf20 (RVA 0x16af20), is the base-archive loader called once at startup:

  1. Opens master.index (two candidate names tried, DAT_7ff74b9254f8 then a fallback DAT_7ff74b925500, both resolved through the filesystem singleton's vtable +0xf8, an "open file" call reused everywhere in this report).
  2. On total failure (neither name opens): logs idLib::SetProduction( PROD_DEVELOPMENT ), zeroes DAT_7ff74c181dd8 (§2.2), and returns early — no base chunks are mounted at all in this branch.
  3. On success: reads a 4-byte magic, compared against the constant 0x03534552 — byte order 'R' 'E' '5' 0x03, i.e. "RE5" + a version byte, confirming docs/formats.md's already-documented archive magic directly from the mounting code, not just from static archive bytes. Reads a 4-byte count (N), ensures array capacity for N entries, then loops N times: reads two length-prefixed strings per entry (the .index and .resources filenames for that chunk pair), opens each through the same filesystem singleton (+0xf8), and stores both handles plus a small int tag (read from a separate fixed-size array, DAT_7ff74b933820, indexed by chunk number — plausibly discNumber or a DLC-id slot, not traced further) into one growable array at param_1[2]/param_1[3] (capacity/count fields at struct offsets +0x1c/+0x20).

This is the exact mechanism TNO's own 14 chunkN.index/chunkN.resources pairs get mounted by — now PROVEN from the mounting code itself, where every prior round only had the static master.index bytes and the project's own reader (wolfsdk/resources.py) to go on.

4. The new finding: TNO's DLC content-package mount, end to end, proven

The second function referencing "master.index", FUN_7ff74a4fd090 (RVA 0x16d090), carries its own debug string: "OpenDLCFileHandles: %d packages mounted.\n" (0x7ff74b1ff5f0). It:

  1. Asks a session-like singleton (DAT_7ff74b936d10, vtable +600=+0x258) for a package count, then for each index i asks the same singleton for a package id (vtable +0x260) and a package path string (vtable +0x268).
  2. For each package: checks whether that id is already present in the installation's chunk array (by the same per-entry int tag OpenDLCFileHandles checks, at struct offset +0x9c of each chunk-array entry — the exact field FUN_7ff74a4faf20 populates from DAT_7ff74b933820 for base chunks too) — if so, logs "...already loaded, skipping..." and moves on.
  3. Otherwise builds <package path>\master.index, opens it through the same filesystem singleton call used for the base mount, checks the same "RE5" magic, reads a count and loops reading index/resources filename pairs exactly like §3 — and appends every resulting chunk pair into the very same growable array the base 14 chunks live in (param_1[0x10]/param_1[0x18]/param_1[0x1c], the same struct Map_f and the base mount both operate on via the installation object).

Where does that package count/id/path come from? --strings "OpenDLCFileHandles" led to it; searching nearby led to the actual discovery function, idSessionLocal::EnumerateDownloadableContent (own string "EnumerateDownloadableContent: %s\n", 0x7ff74b2ad1e8, FUN_7ff74a87e2b0, RVA 0x4ee2b0):

  1. Gets the filesystem's base path (vtable +0x130, arg 0) and appends the literal "\dlc" (own string 0x7ff74b2ad1e0) — i.e. <install root>\dlc, a sibling of base\, not inside it.
  2. Lists the directory (FUN_7ff74ada39c0, given a "/"-shaped pattern argument) — a genuine filesystem directory listing, confirmed by the surrounding pure-filesystem call shapes, not a Steamworks call at this step.
  3. For every entry found (name length > 2): logs "Found DLC '%s'...", builds <entry>/<name>/info.txt via the template string "%s/%s/info.txt" (0x7ff74b2ad228), checks existence (filesystem +0x88). Missing it logs "Error, found %s directory without info.txt!\n" and the folder is skipped — confirms r4_tnocustom.md §4's static dlc\<name>\info.txt finding directly from the scanning code, not just from a string block.
  4. If present: reads the whole file (filesystem +0xC8/200, a "read file into buffer" call), parses it with a dedicated parser (FUN_7ff74a8627c0, own string context "info.txt") into a dict-shaped dlcInfoVars structure (confirmed field-lookup pattern below) — on parse failure: "...FAILED to parse dlcInfoVars in info.txt (%s)." (0x7ff74b2ad320, exact string match for r4_tnocustom.md's own static citation of this dialect name).
  5. Looks up appID (own string 0x7ff74b2ad2a0) as an integer. Calls a global function-pointer getter (DAT_7ff74af6ec38) for what is, by every contextual shape here (a lazily-fetched global interface, an integer "app id" argument, and the very next log line), almost certainly a Steamworks ISteamApps-equivalent accessor; if it returns null (no Steamworks interface at all) or its vtable +0x38 call (passed the appID) returns false, the package is rejected outright: "\t\tUser does not own this DLC.\n" (0x7ff74b2ad300) — this is the code-level confirmation of r4_tnocustom.md §4's "Steam-ownership-gated" read of the static info.txt block, now proven as an actual branch, not inferred from the string's mere presence.
  6. On success: looks up dlcID (own string 0x7ff74b2ad2a8 — resolving r4_tnocustom.md's own counting caveat about this exact string appearing once as a field name and once inside prose: that prose string, "Found invalid dlcID in info (%d)" at 0x7ff74b2ad2d8, is the range check fired when the parsed dlcID integer is not in 1..16 inclusive (iVar4 - 1U < 0x10 as an unsigned-wraparound range check) — confirmed from the actual comparison, not guessed from the message text alone. On a valid 1–16 dlcID: logs "\t\t...successfully mounted '%s. ID: %d\n" (0x7ff74b2ad2b0) and appends the parsed package (path + id) to the session object's own growable DLC-package list (struct offset +0xec78, the exact field OpenDLCFileHandles iterates later via the vtable +0x258/+0x260/+0x268 calls in §4 above — confirmed the two functions operate on the same struct by this shared offset, not just by being "nearby" in the binary).

Chain, stated as one sentence: a folder at <TNO install root>\dlc\ <anything>\ containing a valid info.txt with an appID the running Steam account owns and a dlcID in 1..16 gets its own master.index + chunk pairs mounted into the live installation on every normal launch, merged into the exact same chunk array the base 14 chunks live in — proven end to end through four functions (EnumerateDownloadableContent → OpenDLCFileHandles → the shared base-mount reader logic reused for the DLC master.index → Map_f's own existence checks, which (§2.2) do not distinguish a base-chunk map from a DLC-chunk one at all).

4.1 Why appID = 201810 is the obvious, low-risk choice to test with

TNO's own Steam AppID, read directly from this machine's own Steam library metadata (read-only, outside both forbidden game-install directories):

<Steam library>\steamapps\appmanifest_201810.acf
  "appid"  "201810"
  "name"   "Wolfenstein: The New Order"

Since the ownership check (§4 step 5) is almost certainly a "does this Steam account own app <id>"-style call, using TNO's own AppID as the info.txt's appID value means the check is checking "does the player own the game they are currently running" — trivially true for anyone who can launch the game at all. This is INFERRED, not tested (no game was launched) — flagged honestly as the one load-bearing guess this whole route rests on, and it is exactly what the smallest in-game test below is designed to confirm or refute cheaply, before anything else is built on top of it.

5. mapInfo's vmtrChunkNumber: a decl-field reflection table, and a new sibling field

--strings "vmtrChunkNumber" found the string but Ghidra's static xrefs were empty ("no references found" — the known import limitation, stated up front). Per the task's own fallback method, scanned module.bin directly for an 8-byte little-endian pointer to that string's address — exactly one hit anywhere in the whole 36 MB image, at file offset 0x127f758. Reading the surrounding bytes as a repeating 0x48-byte (9-qword) record (type-name pointer, an unidentified pointer, the field-name pointer — this is the slot the search found — a packed (flags:u32 << 32 | structOffset:u32) value, and two more unidentified slots) gives three consecutive, clearly decl-field-registration-shaped records:

dlcId                          struct offset 0x68   (type "int")
vmtrChunkNumber                struct offset 0x6c   (type "int")
installChunkAllocationNumber   struct offset 0x70   (type "int")

Re-derived from scratch, not hard-coded, by re_probes/tnomaploading/probe_maploading.py (mapInfo field table OK). vmtrChunkNumber's identity and int typing is now PROVEN from the engine's own field-reflection table, not just from parsing retail decl text as r4_tnocustom.md did. New, not in any prior report: the immediately-preceding field is dlcId — a mapInfo-decl member this project has never documented, sitting 4 bytes before vmtrChunkNumber in the parsed struct. No shipped retail mapInfo: decl was ever found setting a non-default dlcId (consistent with TNO shipping zero DLC content), but the field being real, typed, and reflected means mapInfo was built with DLC-sourced maps in mind from the start — and its name lines up exactly with §4's dlcID (the info.txt field, range 1..16) and OpenDLCFileHandles's own per-chunk id tag (struct offset +0x9c). INFERRED, not traced: that a mapInfo decl's dlcId is meant to be set to the same id a DLC package registers itself under in its own info.txt, as the mechanism that tells the engine which mounted chunk a given map's content lives in when more than one DLC is mounted. Not required to use Route 1 below (a single DLC package with its own self-contained chunk already works via Installation-style "search every mounted chunk" lookups, per r6_tnodecls.md §4's already- proven "patch every copy" code read) — flagged as a mechanism worth setting correctly by analogy, not as a blocking requirement.

Megatexture binding itself (landPageFile/vmtr.pages reading) was already fully decoded by r6_tnomegatex.md from decl text and file format alone; this round did not re-derive it and found no code path in Map_f itself that touches it directly (consistent with megatexture data being resolved lazily by the renderer from the decl, not by the map-load command).

PROVEN vs INFERRED

PROVEN (direct decompilation + own-string identification, this round):

  • map, cmap, devmap, devcmap, checkmap are real, registered idCmdSystemLocal commands; all five route through one shared function this round identifies by its own embedded string as idCommonLocal::Map_f (§1–§2).
  • In the production/retail code path, Map_f's only map-existence check (maps/<name>/_combo/<name>.proc) is advisory — missing it only logs "You need to build the combo map for: %s" and the function proceeds to the session-start calls regardless (§2.2).
  • master.index's magic ("RE5" + version byte), its count field, and its per-entry two-filename-then-open structure, read directly from the mount function, not from static archive bytes alone (§3).
  • A genuine, always-live DLC content-package discovery and mount pipeline exists and is wired end to end: EnumerateDownloadableContent (disk scan of <install root>\dlc\, info.txt/dlcInfoVars parse, appID Steam-ownership check, dlcID 1..16 range check) feeds OpenDLCFileHandles (opens <package>\master.index, same magic, appends chunk pairs into the same array the base 14 chunks live in, per-id dedup) (§4). This directly refutes r4_tnocustom.md's "no dlc_0-style route exists for TNO" as a blanket statement — true for precedence/override (still true, not challenged here), false for addition, which is a real, shipped, always-running mechanism.
  • TNO's own Steam AppID is 201810 (read from the local Steam library metadata, appmanifest_201810.acf).
  • mapInfo's field-reflection table: dlcId (new, struct offset 0x68), vmtrChunkNumber (struct offset 0x6c), installChunkAllocationNumber (struct offset 0x70), all typed int, independently re-derivable by scanning for the one pointer to vmtrChunkNumber's string (§5, probe_maploading.py).
  • The default map-file extension is entities, selectable to map, exactly matching file:maps/<id>.entities already found by reading decl text in r4_tnocustom.md — now confirmed from the resolving code itself (§2.1).

INFERRED (plausible from context, not traced to a definite call target):

  • DAT_7ff74c181dd8's exact meaning ("is this a production/shipping run") — based on it being zeroed in the same breath as a logged idLib::SetProduction( PROD_DEVELOPMENT ) call, in the one branch where no base archive could be opened at all (§2.2, §3). The call that sets it non-zero on a normal run was not found.
  • DAT_7ff74af6ec38's getter and its vtable +0x38 call being Steamworks ISteamApps-shaped ownership checks (BIsSubscribedApp/ BIsDlcInstalled-equivalent) — based on the argument shape (one integer "app id"), the surrounding log text ("User does not own this DLC"), and the lazy-global-getter pattern, not from a resolved vtable/RTTI name.
  • That using TNO's own AppID (201810) as a custom dlc\*\info.txt's appID would pass the ownership check (§4.1) — plausible, untested.
  • That mapInfo.dlcId is meant to cross-reference a mounted DLC package's own id (§5) — plausible from the matching field name and the shared 1..16/+0x9c id-tag shape, not traced into the code that actually reads mapInfo at map-load time (not found, time-boxed).
  • What happens downstream of Map_f's StartNewGame-equivalent call if the _combo/<name>.proc file genuinely does not exist (§2.3 point 1) — not traced past that call.

FAILED HYPOTHESIS (refined from a prior round, not invented fresh):

  • r4_tnocustom.md (both passes): "A dlc_0-style folder precedence route does NOT exist for TNO" — concluded from mapspec being a 0-hit string and no (type,name) ever being duplicated across TNO's 14 base chunks, using the only decompiler available at the time (which indexed TNC's runtime, not TNO's — explicitly flagged as a dead end in that report's own §6). Still true as a precedence/override claim (nothing in this round found a shadowing mechanism), but this round's §4 shows TNO has a different, additive DLC-mount mechanism that is real, compiled in, and runs on every launch — the earlier conclusion was correct about the specific TNC-style mechanism it tested for, and silent about the one that actually exists, because the tooling to find it did not exist yet.

Ranked routes

Route 1 — ship a dlc\<name>\ content package with its own master.index + chunk(s) — NEW, strongest evidence, untested in-game

Mechanism: fully proven end to end in code (§3–§4). Build a minimal archive pair (reusing wolfsdk/resources.py's own writer/reader, already proven against TNO's real chunk format by r6_tnodecls.md/r4_tnocustom.md) with whatever mapInfo/file:*.entities/chapter decls are wanted — either copies of existing maps, edited copies, or (if a new, from-scratch (type,name) entry is wanted) new entries, which this format accepts far more readily than TNC's IDCL (r6_tnodecls.md §3: no checksum, no header hash chain, a resync-tolerant parser, and the append-id rule is now fully known: max(existing ids of this type in this chunk) + 1, though an actual append-writer was never built in any TNO round, this one included — still the single largest remaining engineering gap, not a research gap). Ship a master.index for just this package (magic "RE5" + version byte + count + filename pairs, §3) alongside an info.txt with appID=201810 and a dlcID in 1..16 not already used. Never tested in-game — the ownership-check guess (§4.1) and the writer itself are both unbuilt/ unverified this round; ranked first purely on code-evidence strength, not on having been run.

Route 2 — edit an existing campaign map's own chunk in place — unchanged, strongest already-verified evidence

Everything r4_tnocustom.md/r6_tnodecls.md/r6_tnomegatex.md already proved end-to-end against the real write path is untouched by this round: mods/supersoldaten_c15 (10/25 maps are genuinely in-place-rewritable, c05_07_09 chosen as the smallest real example, 12/12 offline checks pass including negative controls). This round's §2.2 finding is actually good news for this route specifically: since the _combo/<name>.proc check is advisory, an in-place .entities edit that doesn't touch geometry (like supersoldaten_c15's enemy-type swap) was never at any risk from that check in the first place — it only matters for a map whose _combo folder doesn't exist at all, i.e. Route 1's brand-new maps, not Route 2's edits of existing ones.

r4_tnocustom.md listed this as untested/unmeasurable without a TNO runtime image. That image now exists, and this round found a better-evidenced way to get the identical technical effect (an extra chunk pair merged into the live installation) through a mechanism the retail binary already implements and runs unconditionally (Route 1) — hand-editing the base master.index's own count/entry list would touch the one file every other mod and the base game itself depends on, for no benefit Route 1 doesn't already offer more safely. Not re-tested this round; superseded, not refuted.

Open questions

  • Is DAT_7ff74af6ec38's target genuinely Steamworks, and does appID=201810 actually pass its ownership check? The single most important unresolved question this report produced — see the in-game test below, which is designed to answer exactly this, cheaply, before anything else is built.
  • What happens after Map_f's StartNewGame-equivalent call if _combo/<name>.proc genuinely does not exist (§2.3) — does the renderer silently skip world geometry, or crash later? Not traced.
  • Where is mapInfo/vmtrChunkNumber actually read at map-load time (which function resolves a map name to "which mounted chunk" via this field)? Not found this round — Map_f itself never references it.
  • Does a mounted DLC package's mapInfo.dlcId need to match its own info.txt's dlcID for anything to work, or is chunk search purely content-based regardless of this field (as r6_tnodecls.md §4's "patch every copy" code read would suggest)? INFERRED only (§5).
  • An append-entry writer for a brand-new (type,name) decl inside a from-scratch chunk — r6_tnodecls.md's own open item, still unbuilt here too; Route 1 does not strictly need it (copying/editing existing decls' text into a new chunk works without ever appending a new entry type-name pair, since the new chunk's own index is built fresh, not patched) — but building it would let Route 1 also start from a genuinely blank slate instead of copied retail content.

Smallest in-game test for the maintainer

(Translated from German. The example folder name has been changed to a neutral mod_test; any folder name works.)

Goal: check the one assumption this whole new route rests on as cheaply as possible — whether TNO's dlc\ folder scan runs in retail at all, and whether the Steam ownership check passes with the game's own app ID — before anything bigger is built.

Step 1 (without any file, pure scan evidence):

  1. Create an empty folder dlc\mod_test\ directly next to base\ (<Steam library>\steamapps\common\Wolfenstein.The.New.Order\dlc\mod_test\) — no file in it, only the empty folder. Not a single original file is touched.
  2. Start the game normally, then search the log file (%USERPROFILE%\Saved Games\MachineGames\Wolfenstein The New Order\base\qconsole.log) after the start.
  3. Expected, if the code path found in this report actually runs in retail: a line Found DLC 'mod_test'... followed by Error, found mod_test directory without info.txt! — that confirms risk-free that the dlc\ scan is active in the real retail build (not only compiled in, but dead).

Step 2 (only once step 1 shows the lines):

  1. Put an info.txt into the same folder with (raw dialect form, as r4_tnocustom.md §4 already found statically — field names now confirmed from the code, §4 above):

appID 201810 dlcID 1 2. Restart the game, check qconsole.log again: - Line ...successfully mounted 'mod_test. ID: 1 → the ownership check with the own app ID passes — that is the confirmation this whole route is waiting for, and makes route 1 the next sensible expansion stage (own master.index + map data in this folder). - Line User does not own this DLC. → the assumption from §4.1 is refuted, the own app ID is not enough; then the next step would be to try another, guaranteed-owned app ID (e.g. another already installed Steam app ID from the same account). - Neither of the two lines, or a crash → the parser (dlcInfoVars) expects a different format than assumed here; then "...FAILED to parse dlcInfoVars..." would be the next line to search for.

Both steps are a pure folder/file-creation test outside any original file, nothing is overwritten, nothing has to be reset except deleting the folder dlc\mod_test\ again.

Reproducibility

python tools/decomp.py --strings "loads a combo map" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
python tools/decomp.py 0x148ec0 --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf   # idCommonLocal::Map_f
python tools/decomp.py 0x16af20 --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf   # base master.index mount
python tools/decomp.py 0x16d090 --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf   # OpenDLCFileHandles
python tools/decomp.py 0x4ee2b0 --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf   # EnumerateDownloadableContent
python re_probes/tnomaploading/probe_maploading.py
  # 20/20 known strings match
  # negative control ('doesNotExistTNO_xyz_marker'): not found (expected)
  # mapInfo field table OK
  # ALL OK

Related: Research Report: R4 Tno Custom Maps, Research Report: R6 Tno Console, Research Report: R6 Tno Decls, Research Report: R6 Tno Megatexture, File Formats, TNO Maps, Asset Loading Paths.