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):
- Not a hard requirement in production:
maps/<name>/_combo/<name>.procexisting. Missing it only logs a warning inMap_fitself. (What happens later — whether the renderer/entity-spawn path that actually reads this.procfile 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 theStartNewGame-equivalent call, not past it. Flagged as an open question, not glossed over.) - Needed only on the non-production code path (§2.2's
elsebranch):maps/<name>.entities(or.map) must exist, checked directly by path. For a normal retail launch this check does not run at all. - Chapter-select and the actual
mapInfo/vmtrChunkNumberdecl fields (below, §4) were not found referenced anywhere inMap_fitself — this function only cares about the map name string and the two files named above;mapInfo/vmtrChunkNumberare read elsewhere (most likely at the point the resource manager actually opens the content, downstream ofMap_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:
- Opens
master.index(two candidate names tried,DAT_7ff74b9254f8then a fallbackDAT_7ff74b925500, both resolved through the filesystem singleton's vtable+0xf8, an "open file" call reused everywhere in this report). - On total failure (neither name opens): logs
idLib::SetProduction( PROD_DEVELOPMENT ), zeroesDAT_7ff74c181dd8(§2.2), and returns early — no base chunks are mounted at all in this branch. - On success: reads a 4-byte magic, compared against the constant
0x03534552— byte order'R' 'E' '5' 0x03, i.e."RE5"+ a version byte, confirmingdocs/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 forNentries, then loopsNtimes: reads two length-prefixed strings per entry (the.indexand.resourcesfilenames 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 — plausiblydiscNumberor a DLC-id slot, not traced further) into one growable array atparam_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:
- Asks a session-like singleton (
DAT_7ff74b936d10, vtable+600=+0x258) for a package count, then for each indexiasks the same singleton for a package id (vtable+0x260) and a package path string (vtable+0x268). - For each package: checks whether that id is already present in the
installation's chunk array (by the same per-entry int tag
OpenDLCFileHandleschecks, at struct offset+0x9cof each chunk-array entry — the exact fieldFUN_7ff74a4faf20populates fromDAT_7ff74b933820for base chunks too) — if so, logs"...already loaded, skipping..."and moves on. - 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 structMap_fand 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):
- Gets the filesystem's base path (vtable
+0x130, arg0) and appends the literal"\dlc"(own string0x7ff74b2ad1e0) — i.e.<install root>\dlc, a sibling ofbase\, not inside it. - 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. - For every entry found (name length
> 2): logs"Found DLC '%s'...", builds<entry>/<name>/info.txtvia 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 — confirmsr4_tnocustom.md§4's staticdlc\<name>\info.txtfinding directly from the scanning code, not just from a string block. - 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-shapeddlcInfoVarsstructure (confirmed field-lookup pattern below) — on parse failure:"...FAILED to parse dlcInfoVars in info.txt (%s)."(0x7ff74b2ad320, exact string match forr4_tnocustom.md's own static citation of this dialect name). - Looks up
appID(own string0x7ff74b2ad2a0) 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 SteamworksISteamApps-equivalent accessor; if it returns null (no Steamworks interface at all) or its vtable+0x38call (passed theappID) returns false, the package is rejected outright:"\t\tUser does not own this DLC.\n"(0x7ff74b2ad300) — this is the code-level confirmation ofr4_tnocustom.md§4's "Steam-ownership-gated" read of the staticinfo.txtblock, now proven as an actual branch, not inferred from the string's mere presence. - On success: looks up
dlcID(own string0x7ff74b2ad2a8— resolvingr4_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)"at0x7ff74b2ad2d8, is the range check fired when the parseddlcIDinteger is not in1..16inclusive (iVar4 - 1U < 0x10as an unsigned-wraparound range check) — confirmed from the actual comparison, not guessed from the message text alone. On a valid 1–16dlcID: 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 fieldOpenDLCFileHandlesiterates later via the vtable+0x258/+0x260/+0x268calls 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,checkmapare real, registeredidCmdSystemLocalcommands; all five route through one shared function this round identifies by its own embedded string asidCommonLocal::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/dlcInfoVarsparse,appIDSteam-ownership check,dlcID1..16range check) feedsOpenDLCFileHandles(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 refutesr4_tnocustom.md's "nodlc_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 offset0x68),vmtrChunkNumber(struct offset0x6c),installChunkAllocationNumber(struct offset0x70), all typedint, independently re-derivable by scanning for the one pointer tovmtrChunkNumber's string (§5,probe_maploading.py).- The default map-file extension is
entities, selectable tomap, exactly matchingfile:maps/<id>.entitiesalready found by reading decl text inr4_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 loggedidLib::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+0x38call being SteamworksISteamApps-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 customdlc\*\info.txt'sappIDwould pass the ownership check (§4.1) — plausible, untested. - That
mapInfo.dlcIdis meant to cross-reference a mounted DLC package's own id (§5) — plausible from the matching field name and the shared1..16/+0x9cid-tag shape, not traced into the code that actually readsmapInfoat map-load time (not found, time-boxed). - What happens downstream of
Map_f'sStartNewGame-equivalent call if the_combo/<name>.procfile 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): "Adlc_0-style folder precedence route does NOT exist for TNO" — concluded frommapspecbeing 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.
Route 3 — self-built extra chunk pair appended directly to the base master.index — superseded by Route 1, not recommended
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 doesappID=201810actually 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'sStartNewGame-equivalent call if_combo/<name>.procgenuinely does not exist (§2.3) — does the renderer silently skip world geometry, or crash later? Not traced. - Where is
mapInfo/vmtrChunkNumberactually read at map-load time (which function resolves a map name to "which mounted chunk" via this field)? Not found this round —Map_fitself never references it. - Does a mounted DLC package's
mapInfo.dlcIdneed to match its owninfo.txt'sdlcIDfor anything to work, or is chunk search purely content-based regardless of this field (asr6_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):
- Create an empty folder
dlc\mod_test\directly next tobase\(<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. - Start the game normally, then search the log file
(
%USERPROFILE%\Saved Games\MachineGames\Wolfenstein The New Order\base\qconsole.log) after the start. - Expected, if the code path found in this report actually runs
in retail: a line
Found DLC 'mod_test'...followed byError, found mod_test directory without info.txt!— that confirms risk-free that thedlc\scan is active in the real retail build (not only compiled in, but dead).
Step 2 (only once step 1 shows the lines):
- Put an
info.txtinto the same folder with (raw dialect form, asr4_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.