Skip to content

Asset Loading Paths

LopeKinz edited this page Oct 1, 2026 · 2 revisions

Loading Paths: Loose Files Instead of Archive Rewriting?

Investigated for Part 1 (WolfNewOrder_x64.exe, id Tech 5) and Part 2 (NewColossus_x64vk.exe, id Tech 6). Evidence tool: tools/probe_loadpaths.py (read-only, does not start the game).

The .text sections of both EXEs are SteamStub-encrypted (Section .bind), so there is no disassembler proof. Read data includes .rdata/.data, the plaintext .cfg files of the games, packagemapspec.json, the dlc folders, and the actual runtime logs from Saved Games.


Proof Levels

Every statement below carries one of these three stamps. They are not interchangeable.

Stamp Means
STRING The text is in the binary. Nothing more. A dead code path looks just as good.
CVAR Looks like a registered Cvar, description assigned via literal neighborhood (same uncertainty as in docs/cvars-tnc.md). Whether it is describable at runtime is not stated by this.
LAUFZEIT Proven in the actual log or on the disk. The only hard proof.

Short Answer

Part 1: very likely yes, via fs_overlaypath. The engine has a documented second read path with precedence over fs_basepath; it shows its search paths in the log, and it writes loose files to base/generated/ as proven. What is missing is proof that an asset placed there displaces an archived one. This is a ten-minute experiment (Section "Experiments", E1–E3).

Part 2: there is a matching Cvar, but no runtime proof. resource_loadLooseAssets literally means "check for loose assets when satisfying file io from a resource file". Whether it works in a Retail build is open (E5).

The hint from the task does not apply. +image_useLooseStreaming 1 in base/build.cfg of Part 2 has nothing to do with loose files on the disk. The description in the binary reads:

image_useLooseStreaming — 0 = Use virtual texture atlas streaming, 1 = Use loose texture streaming

"Loose" here means individual textures instead of Atlas — so .texdb streaming instead of virtualtextures/*.pages atlases. Both are archives. This also fits the installation: Part 2 has a chunk_N.texdb for every chunk_N.resources, and virtualtextures/ contains only map pages. Neighbors in the literal pool are exclusively renderer Cvars (is_poolSize, is_maxTextureSize, is_enableStreaming). No file system reference. Dead end — but one that had to be checked once.


Part 1 — Wolfenstein: The New Order

The File System Has an Override Path

CVAR — from .rdata at 0x141057b38–0x141057cb0, descriptions lie in the same pool block:

Cvar Description in Binary
fs_basepath (Read Only) Location for game files
fs_overlaypath (Read Only) Extra location for game files (with precedence over fs_basepath)
fs_savepath (Read/Write) Location for development storage files, overwrites the default savegame path as well for the PC
fs_cachepath (Read/Write) Location for temporary files
fs_installpath (Read/Write) Location for installed files
fs_generatedPath Location of generated data, default generated

STRING — the corresponding Enum exists as an RTTI name: FSPATH_OVERLAY, FSPATH_BASE_AND_OVERLAY (0x140d7e938, mirrored at 0x140dd2fc8). An overlay path is therefore not just a Cvar, but its own level in the path resolution — there is a mode that searches both Base and Overlay.

LAUFZEIT — the engine prints its search paths on startup. From Saved Games/.../Wolfenstein The New Order/base/ErrorLog_*.txt:

------ Initializing File System ------
Current search path:
%USERPROFILE%/Saved Games/MachineGames/Wolfenstein The New Order/base/
<Steam library>/steamapps/common/Wolfenstein.The.New.Order/base/
file system initialized.

Two entries because fs_overlaypath is empty. A base/ prefix is appended to each path (in the Binary, during FS initialization, there are five consecutive base literals at addresses 0x141058b40–0x141058b68 — one per path level). A configured overlay path should appear here as a third line, above the Steam path. This is E1 and costs nothing.

The Engine writes loose files to the game directory

RUNTIME — this is the strongest single finding for Part 1.

D:\...\Wolfenstein.The.New.Order\base\generated\ contains 76 loose files. Timestamps: 2026-09-20 13:24 to 2026-09-21 19:35. The EXE and all .resources are from 2026-09-19 10:53 (Installation). The files were therefore written at runtime by the game, not delivered. The time 13:24 matches ErrorLog_09-20-2026__01-24-10pm.txt to the minute.

Content:

base/generated/decls/particle/<name>.bin          74 files, Magic b'\x06TRP'
base/generated/md6/models/weapons/.../..bmd6skl    2 files
base/generated/world/c12/<name>.bcm                1 file, Magic b'BCM9'

The path appears as a format string in the Binary: STRING generated/decls/particle/%s.bin at 0x140e9e078.

These are cooked runtime forms. The source Decl lies in the archive under generated/decls/particle/wolf/blood/bloodsplat01.decl (verified in chunk2.index) — with .decl, while the loose file has .bin. It is therefore not an override of the same name, but a neighboring cache. That the Engine also reads it on the next start is the obvious explanation for why it creates it at all — but it is an explanation, not evidence. E2 decides this.

Additionally RUNTIME: Couldn't create FileCacheHeader.bin in the Log — another failed loose write attempt.

An override path that explicitly targets the archive

CVAR — 0x140ec1320 / 0x140ec1370:

r_renderProgProdOverridePath — load renderprogs from this path instead of from resource file in production

Literally, an Engine cannot say: read this from disk instead of the archive, even in Retail. The adjacent filename: STRING generated/renderprogs/%s_%s.bin (0x140ec13c0).

This is proof for one asset class (Shaders), not all. But it shows that the Retail build did not generally compile this mechanism out. Part 2 has no equivalent.

The Engine searches for Decls under a loose path

RUNTIME — hundreds of lines of this form in every Log:

WARNING: couldn't read production file for decl 'generated/decls/renderProg/laserbeam.decl' while loading renderProg laserbeam
WARNING: couldn't read production file for decl 'generated/decls/achievement/achievement_01.decl' while loading achievement achievement_01
WARNING: couldn't read production file for decl 'generated/decls/material/lights/solidwhite.decl' while loading material lights/solidWhite

Format string: STRING generated/decls/%s/%s.decl (0x140e51dc8).

Caution, here lies the trap. The same name is also the key within the archives — generated/decls/achievement/achievement_01.decl appears exactly like that in chunk0.index. The warning could therefore equally mean "not found in archive". The string does not prove that a filesystem access took place. E2 clarifies this with fs_reportReads 1: if the path appears there in the access log, it was the filesystem; if it does not appear, it was the Resource container.

Additional load chains (Part 1)

  • RUNTIME WARNING: File 'maps/game/wolf/c05_07_09/c05_07_09.map' wasn't preloaded, trying to slow load it from disc — there is a fallback path for single-file I/O if an asset is not in the preload block. "from disc" is ambiguous (archive on disk vs. loose file).
  • RUNTIME base/master.index — \x03SER, Big-Endian counter 14, followed by 28 length-prefixed names (chunk0.index, chunk0.resources, …). This is the mount list of archives. One additional line in it = one additional archive. Small, text-based, easy to extend — and the only found method to shove Part 1 as an additional archive without rewriting an existing one (E4).
  • RUNTIME couldn't exec D:\...\Wolfenstein.The.New.Order/binary.cfg and couldn't exec package.cfg. Both are searched for. binary.cfg is located in the game folder, not in base/. According to Home.md, the engine resets values set there afterwards — measured, not guessed. Useless for Cvars; untested as a carrier for commands (exec, path).
  • RUNTIME idLib::SetProduction( PROD_PRODUCTION ) — the Retail build runs in production mode. In id-tech engines, this mode typically disables exactly the disk fallbacks that are relevant here. This is the main risk for E2/E3.

Part 2 — Wolfenstein II: The New Colossus

The Cvar at Issue

CVAR — 0x1426cf2d0, description in the same block at 0x1426cef70:

resource_loadLooseAssets — check for loose assets when satisfying file io from a resource file

This is literally the sought mechanism: before serving a file access that would be handled by a resource container, check for a loose file.

What is uncertain about this: the Name↔Description mapping runs via pool adjacency, not via the call site (which lies in the encrypted .text). However, the description is the only one in the entire binary that links "loose assets" with file I/O, and resource_loadLooseAssets is the only candidate. I consider the assignment to be secure — the effect is not.

This Cvar does not exist in Part 1 (checked, --self-test fails if it appears there).

The Path Stages Are Cut Differently Than in Part 1

STRING — fsPath_t / fsPathSearch_t at 0x14278e130:

FSPATH_BASE   FSPATH_SAVE   FSPATH_SOURCE   FSPATH_GENERATED   FSPATH_CACHE   FSPATH_INSTALL
FSPATH_SEARCH_DEFAULT  _BASE  _SAVE  _SOURCE  _GENERATED  _CACHE  _INSTALL

No FSPATH_OVERLAY, no fs_overlaypath. Part 2 does not have the Overlay mechanism of Part 1. Instead (CVAR, 0x14297d300–0x14297d960):

Cvar Description in Binary
fs_basepath (Read Only) Location for game files
fs_sourcepath (Read Only) Location for source files
fs_pathDeclOverride (Read Only) Secondary Location for decl game files
fs_generatedPath Location of generated data
fs_copyfiles If set will copy all files opened for read to this path, i.e. 'fs_copyfiles x:/work/d00m/' may be set on the command line or post engine load

fs_pathDeclOverride is what remains of fs_overlaypath — narrowed to Decls. Assignment to description: the word "decl" appears in both; no other candidate exists in the block.

fs_copyfiles is valuable for the investigation itself: it logs every read file into a target directory. This allows seeing without guessing what the engine actually retrieves from the filesystem (E6).

STRING — '/source' at 0x14297e0a8, directly during FS Init: /source is likely appended to fs_sourcepath, just as base was in Part 1.

What Part 2 Executes on Its Own

STRING, all within the Init block 0x1426c2400–0x1426c2610 or 0x1426e3f80:

File How Where
build.cfg Executing build.cfg... base/ (in plain text there)
default.cfg resourceExec default.cfg -s from the Resource-File, not from disk
default_sp.cfg / default_mp.cfg resourceExec default_sp.cfg -s same
initial_video_%s.cfg exec initial_video_%s.cfg -s Disk (base/initial_video_high.cfg, etc.)
binary.cfg exec "%s/binary.cfg" -s Game folder, as in Part 1
package.cfg resourceExec package.cfg -s Resource-File
xb_*.cfg Disk

Important: resourceExec is a separate command — STRING Execute a configuration file stored in the resource file (0x1426c2540) and USAGE: resourceExec <filename> -s [define]: execute a script file. There are thus two sources for Cfgs, and the Engine chooses one per file. The fact that base/default.cfg and base/default_sp.cfg still exist as loose files nearby is a hint — but not proof that the loose ones win. The Binary knows both ways (Failed to read default.cfg, Could not find default.cfg. Check paths.).

All loose Cfgs have been read completely. They reveal nothing about load paths: build.cfg sets four Cvars, default.cfg binds keys, default_sp.cfg is a list of reset commands, and initial_video_*.cfg are Renderer Presets ("auto-generated file, do not modify"). No hint at Mod-, Overlay-, or Search paths.

No Log Without Doing Something

Part 2 does not write standard qconsole.log. CVAR, 0x1426c5548–0x1426c5778:

Cvar Description / Value
logFile Switch
logFileName name of log file, if empty, qconsole.log will be used
logFilePersistent If the qconsole log should persist between launches
logFilePathType 0 = FSPATH_SAVE, 1 = FSPATH_BASE

Without these, every experiment in Part 2 is blind. +logFile 1 +logFilePersistent 1 is the first step of every measurement (E5).

The search path output also exists here: STRING ------ Initializing File System ------, Current search path:, lists search paths (command path), dirtree, dir, copy.


packagemapspec.json — fully evaluated

Only Part 2. Plain-text JSON, read by the Engine: STRING packagemapspec.json (0x1426d0250), right next to it the key names basechunkid (…0240), chunkid (…0238), refchunks (…0228), patchinfo (…0268). This is the parser block.

What's inside it

base/packagemapspec.json, 31 entries:

{ "basechunkid": 6, "chunkid": 9, "isbasechunk": false, "layer": 1,
  "name": "game/wolf/c05/c05p1" }
Field Meaning (inferred from data + filenames)
name Map name as loaded by the game (maps/<name>.entities, etc.)
chunkid in which chunk_<id>.resources / .texdb the map lies
basechunkid which chunkbase_<id> belongs to as a common foundation
isbasechunk only common and init (both chunkid 0)
layer 0 or 1; for DLC -1
refchunks only in DLC Specs: list of chunks loaded from
patchinfo.patchlevel 4 in base/, 2/3 in the DLCs

The assignment is verifiable: chunkid 1,3,4,5,7–16 and basechunkid 6 match exactly with existing files chunk_1.resources … chunk_16.resources and chunkbase_6.resources. patchlevel 4 matches patch_1..patch_4 and patch_1_chunkbase_6..patch_4_chunkbase_6. The format string %s_%d_%s_%d at 0x1426cf720 builds exactly patch_<n>_chunkbase_<m>.

Could entries be added there?

Hypothesis, not proven. A new entry with its own chunkid plus matching chunk_<id>.resources/.texdb in base/ is the most plausible reading of the file. But:

  • patchinfo.patchlevel controls how many patch_N layers are searched. An incorrect number can cause a startup failure.
  • The file is managed by Steam; each game update overwrites it.
  • STRING Only ID_DEV_TOOLS builds (win64-dev, vulkan-dev) can run without a package (0x1426cf7b0) — the Retail build requires a valid package set.

The cleanest way to add an entry without touching base/ is via the DLC folder: it has its own packagemapspec.json.


The dlc folder — the only manufacturer-provided expansion path

Both games have this, but with different formats. This is the most interesting finding besides fs_overlaypath.

Part 1: dlc/<name>/info.txt

RUNTIME from the log:

EnumerateDownloadableContent: <Steam library>\steamapps\common\Wolfenstein.The.New.Order\dlc
OpenDLCFileHandles: 0 packages mounted.

The folder does not exist in this installation — the engine still scans it and reports 0. STRING chain at 0x140f1d1e0 (order = logic):

'\dlc'
'EnumerateDownloadableContent: %s'
'Found DLC: %s...'
'%s/%s/info.txt'
'Error, found %s directory without info.txt!'
'Could not read info.txt (%s)'
'appID'
'dlcID'
'...successfully mounted '%s.  ID: %d'
'Found invalid dlcID in info (%d)'
'User does not own this DLC.'
'...FAILED to parse dlcInfoVars in info.txt (%s).'

and at 0x140e6f5f0 the mount output:

'OpenDLCFileHandles: %d packages mounted.'
'* Content Package #%d:'
'  - id: %d'
'  - path: %s'
'  - already loaded - skipping...'
'base'

Thus, the expected format is known:

<GameFolder>/dlc/<any name>/info.txt      Keys: appID, dlcID
<GameFolder>/dlc/<any name>/base/         The archives

info.txt is a dlcInfoVars file, so ID-tech key/value-text (presumably appID "201810" / dlcID "1", the value assignment format is unoccupied). The hurdle is User does not own this DLC. — there is an authorization check against the appID via Steam. Whether a possessed appID (the main game's, 201810) passes the check is exactly the open question. E7.

Part 2: dlc/dlc_<n>/dlcinfo.json

Here the folder exists and is filled. RUNTIME, structure of the three packages (all identical in layout):

dlc/dlc_1/dlcinfo.json                      {"dlcid": 1}
dlc/dlc_1/base/packagemapspec.json          custom map list, layer -1, refchunks
dlc/dlc_1/base/chunk_20.resources
dlc/dlc_1/base/chunk_20.texdb
dlc/dlc_1/base/maps/game/…
dlc/dlc_1/base/sound/soundbanks/…
dlc/dlc_1/base/bink/chronicles/…           .bk2-Videos, loose
dlc/dlc_1/virtualtextures/maps/game/…

dlc_1 → chunk 20, dlc_2 → 22, dlc_3 → 24.

STRING chain at 0x1426cf6e0:

'%s/dlcinfo.json'
'dlc'
'%s/base'
'SearchDLC: Could not load mapspec for dlc id %d from %s'
'SearchDLC: could not read existing dlc info file: %s'
'dlcid'
'SearchDLC: found %d dlcs'
'resource_dlcDbgSearchPath'   Description: 'Search path for dlcs'

This format is significantly better documented than Part 1 and contains no visible ownership check: no User does not own this DLC, no appID in the chain. A dlc/dlc_4/ with {"dlcid": 4}, its own packagemapspec.json, and chunk_26.resources is thus the most plausible way to inject additional content into Part 2 without touching a single delivered file. Hypothesis — E8.

resource_dlcDbgSearchPath presumably allows moving the search location. If this is correct, one does not even need write permissions in the Steam folder for the attempt. That would be the most elegant test of all.


Negative findings

Full results. All checked via tools/probe_loadpaths.py, category "Negative finding".

  • No fs_game, no fs_mod, no fs_addon, no modDir — in either of the two EXEs. The Quake/Doom 3 mod folder has disappeared from this engine line. Anyone looking for "like Doom 3" is searching in vain.
  • No FSPATH_OVERLAY in Part 2. The overlay path from Part 1 does not exist there.
  • No resource_loadLooseAssets in Part 1.
  • No r_renderProgProdOverridePath in Part 2.
  • image_useLooseStreaming is not a file path switch (see above).
  • Both builds report idLib::SetProduction( PROD_PRODUCTION ). Anything the engine does differently "in production" it does here differently.

Experiments

Each tests exactly one hypothesis. Not performed — you start the game yourself. Sorted by effort, E1 first.

Always start via the Steam URL, not via the EXE (see Home.md: directly started ends Part 1 immediately):

steam://rungameid/201810//<arguments>

No experiment requires a change to delivered files, except E4, E7, and E8 — where that is explicitly stated.


E1 — Is fs_overlaypath accepted at all? (Part 1, 2 minutes, low risk)

Hypothesis: fs_overlaypath is settable and lands as the third line above the Steam path in the search path list.

  1. Create folder: C:\wolfmods\base\ (empty).
  2. Start with +fs_overlaypath "C:\wolfmods".
  3. End game.
  4. Open Saved Games\MachineGames\Wolfenstein The New Order\base\ErrorLog_*.txt, inspect block Current search path:.

Proof provided if it reads:

Current search path:
C:/Users/.../Saved Games/.../base/
C:/wolfmods/base/
<Steam library>/.../Wolfenstein.The.New.Order/base/

Refuted if the list remains unchanged two lines — then the Cvar CVAR_ROM is evaluated before the command line. Next attempt: same line in binary.cfg inside the game folder (which is searched and executed according to log; that it resets Cvar values must not apply for FS-init because it runs before).

Without E1, E2 is pointless.


E2 — Does a loose file beat the archive? (Part 1, 15 minutes, low risk)

This is the experiment. Everything else is preparation.

Hypothesis: A file under <fs_overlaypath>/base/<ArchivePath> is read instead of the archive entry.

Choose an asset whose effect you see immediately and which depends on only one place. Suggestion: a weapon damage decl.

  1. python -m wolfsdk cat damage:damage/tungsten/mg60 > mg60.decl and set the damage value to something absurd (e.g., 500).
  2. Determine the exact archive path of the asset — do not guess:
    python -c "d=open(r'<Steam library>\steamapps\common\Wolfenstein.The.New.Order\base\chunk0.index','rb').read(); i=d.find(b'damage/tungsten/mg60'); print(d[i-60:i+160])"
    
    (For other chunks the same with chunk1.index … chunk13.index.) The path looks like generated/decls/damage/tungsten/mg60.decl.
  3. Place the file exactly under this path: C:\wolfmods\base\generated\decls\damage\tungsten\mg60.decl
  4. Start with +fs_overlaypath "C:\wolfmods" +fs_reportReads 1 +fs_debug 1 +g_showHud 1. (fs_reportReads = Report every filesystem reads — CVAR, present in both games, 0x1410505c0 or 0x14297d440.)
  5. Fire an MG60 in the game.

Proof provided if the damage changes. Then modding for Part 1 is immediately a copy operation and wolfsdk apply may leave the archive alone.

Important interim finding, even if nothing happens: fs_reportReads 1 logs every file access. If the log shows an attempt on C:/wolfmods/base/generated/decls/damage/..., then the engine asks for the loose file and rejects it only (Format? Stale-Check?) — that is a completely different starting point than no request at all.

Invalidated if fs_reportReads shows no access to the Overlay path. In that case, the Resource layer serves the Decl directly from the container, and the filesystem is never queried — entirely expected in PROD_PRODUCTION.

Variant 2b, if 2a fails: place the same file into D:\...\base\generated\decls\damage\tungsten\mg60.decl, i.e., into the folder where the Engine has demonstrably written itself. Then a game file is touched — but only a newly created one, not an archive; deleting it restores the original state.


E3 — Does the Engine read its own generated Cache? (Part 1, 10 minutes, low-risk)

Hypothesis: base/generated/decls/particle/<name>.bin is read at startup and not regenerated on every run.

  1. Record timestamps and sizes of all 76 files: python tools/probe_loadpaths.py (Section "RUNTIME: Files on disk") or Get-ChildItem -Recurse "D:\...\base\generated" | Select FullName,Length,LastWriteTime.
  2. Start the game, play the same location as during generation (the blood particles were created when shooting at an enemy), then exit.
  3. Query timestamps again.

Proof provided if the files remain unchanged — then they were read rather than rewritten, and the folder is a genuine read path.

Invalidated if all timestamps are new — then it is a pure debug dump without reading back.

Counter-check (stronger): overwrite one of the .bin files with null bytes (copy beforehand!). If it breaks and the game or effect crashes, it was read. If it repairs itself, it was a cache with Stale-Check — that also proves reading.


E4 — Does master.index accept an additional entry? (Part 1, 30 minutes, modifies a game file)

Hypothesis: a 15th Chunk pair in base/master.index is mounted and its Assets displace those from the existing Chunks (or supplement them).

Modifying base/master.index — 520 bytes, copy beforehand. No archive is rewritten.

  1. Backup base/master.index.
  2. Build a tiny custom chunk14.resources + chunk14.index with wolfsdk containing exactly one modified Decl (the same as in E2).
  3. In master.index, increment the counter from 14 to 15 (Big-Endian u32 at offset 4) and append two length-prefixed names: \x0d\x00\x00\x00chunk14.index and \x11\x00\x00\x00chunk14.resources.
  4. Start, check the log for Load N resources using M of K total resource files — K must increase.

Proof provided if K increases and the change takes effect in-game. Partial success if K increases but the change does not take effect: then mount order is decisive, and the new Chunk must come before the originals. Invalidated if the game fails to start — restore master.index.


E5 — Does resource_loadLooseAssets work? (Part 2, 20 minutes, low-risk)

Hypothesis: with resource_loadLooseAssets 1, Part 2 reads a loose file instead of the container entry.

Prerequisite: turn on logging first, otherwise you see nothing.

  1. Start with +logFile 1 +logFilePersistent 1 +logFilePathType 0 +resource_loadLooseAssets 1 +fs_reportReads 1
  2. Play until the main menu, then exit.
  3. Open Saved Games\MachineGames\Wolfenstein II The New Colossus\base\qconsole.log.

First check: does the log file exist at all? If not, logFile is not settable via command line — then try the same line in base/build.cfg (which is demonstrably executed, Executing build.cfg...), but this modifies a game file (192 bytes, copy beforehand).

Second check: does the log contain the block Current search path:? Then you know where loose files belong.

Third check: does fs_reportReads show more file accesses after setting resource_loadLooseAssets 1 than without? That would be the signature of "check for loose assets": the Engine queries the filesystem additionally on every container read.

Proof provided when the access counter measurably increases. Then proceed to Step 4: create a file under the displayed base path and check if it wins — analogous to E2.


E6 — What does Part 2 actually read from the disc? (Part 2, 15 minutes, low-risk, very revealing)

Hypothesis: fs_copyfiles copies every read file into a target directory, thereby revealing without guessing which names the filesystem sees.

  1. Create an empty folder C:\wolfread\.
  2. Start with +fs_copyfiles "C:/wolfread/" +logFile 1.
  3. Go to the main menu and exit.
  4. Inspect C:\wolfread\.

Proof provided when a directory structure appears there. The directory names within are the exact paths under which loose placeholder files must lie. This saves every guess required in E5.

Refuted/uninteresting when the folder remains empty: then everything in Part 2 truly runs over containers, and there is no file path to sample. That would be a clean, final No — and thus an equally valuable result.

The same applies to Part 1 (fs_copyfiles does not exist there — verified); there fs_reportReads 1 performs the equivalent, only without copies.


E7 — Can a DLC package be slipped in? (Part 1, 45 minutes, places files in the game folder)

Hypothesis: a self-created dlc/<name>/ with info.txt and base/ will be mounted.

Create new files under D:\...\Wolfenstein.The.New.Order\dlc\. The folder does not exist today; complete deletion restores the original state. No delivered file is touched.

What has been measured so far

The format was previously listed here as "unknown". Field names are now retrieved from the EXE, and the dialect is not — both with evidence. Reproduce: python tools/dlcinfo.py, check: --selbsttest.

The Literal Block. MSVC packs string constants of a translation unit contiguously. The block for the DLC function lies in .rdata between \dlc (0x140F1D1E0) and ...FAILED to parse dlcInfoVars in info.txt (%s). (0x140F1D320), 376 bytes, 14 Literals — the entire function in source order:

0x140F1D1E0  '\dlc'
0x140F1D1E8  'EnumerateDownloadableContent: %s\n'
0x140F1D210  '\tFound DLC: %s...'
0x140F1D228  '%s/%s/info.txt'
0x140F1D238  'and info.txt!\n'
0x140F1D248  'Error, found %s directory without info.txt!\n'
0x140F1D278  'Could not read info.txt (%s)'
0x140F1D298  '%s/%s'
0x140F1D2A0  'appID'        <- Field name
0x140F1D2A8  'dlcID'        <- Field name
0x140F1D2B0  "\t\t...successfully mounted '%s.  ID: %d\n"
0x140F1D2D8  'Found invalid dlcID in info (%d)'
0x140F1D300  '\t\tUser does not own this DLC.\n'
0x140F1D320  '\t...FAILED to parse dlcInfoVars in info.txt (%s).'

Twelve of these are format strings and messages from the function itself, exactly two are identifiers: appID and dlcID. A third field name cannot stand as a string literal there — the list is complete, not selected.

The Cross-check (all numbers from tools/dlcinfo.py):

Check Finding Control Group
Proximity: Identifiers per 376-byte window here 2 2000 random .rdata windows: Mean 6.4; 44.8% also have ≥ 2
Uniqueness in the whole EXE appID 1 ×, dlcID 1 × only 16% of arbitrary .rdata identifiers occur 1 ×
Self-reference via a message dlcID appears verbatim in Found invalid dlcID in info (%d) appID is not mentioned by any message
Pointers from .data (pattern cvar_table.py) none, for both 82.7% of arbitrary .rdata identifiers are referenced

The first line is the crucial part: Proximity alone proves nothing here. Almost half of all equally wide windows satisfy this same criterion. What contributes is the combination of two things: dlcID is explicitly named by the error message of the function itself, and both strings appear exactly once in the 24 MB binary file — they could therefore not come from anywhere else. The pointer test yields a negative result as expected: the names are RIP-relative LEA operands in .text, and .text is SteamStub-encrypted. There is no static field table that one could read out.

Mandatory fields and defaults. Both fields are factually mandatory; default values do not exist in the image. idDict::GetInt returns 0 when the key is missing; a missing dlcID thus lands in Found invalid dlcID in info (0), a missing appID in User does not own this DLC. — both times without any specific indication that only that field is missing.

The dialect is not decidable, and this is a measurement result, not a gap: the parse call site is located in .text. What is measurable is which idDict readers Part 1 actually carries — three, all with their own marker string:

Reader Marker Address
idDict::Parse(idParser&), parenthesized pairs Expected quoted string, but found '%s' 0x1410575B8
INI ' dictionary INI reader', '%s[%s]' 0x1410577B0
JSON 'JSON reader', '" : "' 0x141057830

The write format of the same idDict stands next to it: \t"%s"\t%s (0x141057848) — keys in quotes, value naked, tabulator in between.

Two weak indices speak for the parenthesized reader:

  • In idSessionLocal there are two identical pairs (filename, source name) directly adjacent to each other, with 16 bytes spacing: default.tss / netvars (0x140F16500/0x140F16510) and info.txt / dlcInfo (0x140F16558/0x140F16568). Only the parenthesized reader needs its own source name — INI and JSON name their parser themselves.
  • The INI reader flattens sections to section[key] (%s[%s]). What is sought is the naked name appID. A [dlcInfo] header thus does not fit that.

One index speaks for JSON: Part 2 provides three actual files for the same task, and they look like this (dlc\dlc_1\dlcinfo.json, 19 bytes, literally from disk):

{
   "dlcid" : 1
}

Keys in quotes, : as separator, value naked, three spaces indentation — exactly what the JSON writer of idDict produces ('" : "'). It is a file of another subsystem from three years later, but it is the house dialect of the same company for precisely this purpose. Part 1 has the same JSON reader, naming its file only .txt.

All are indices, no measurement. Hence four versions, ordered by probability — to output with python tools/dlcinfo.py --variante a (or b, c, d):

a)  {                        b)  {
    →   "appID"→"201810"         "appID" : 201810,
    →   "dlcID"→"1"              "dlcID" : 1
    }                            }
                     (a: → = tabulator, b: three spaces like in Part 2)

c)  appID "201810"           d)  appID = 201810
    dlcID "1"                    dlcID = 1

201810 is the Steam AppID of Part 1 (from appmanifest_201810.acf). c is the previous hypothesis from this document — it now stands in third place because it has no evidence behind it.

The Steam Block. In .rdata/.data, there is no list of allowed DLC IDs and no AppID constant in the DLC block — the check derives its value from the file. Part 1 loads steam_api64.dll dynamically; the ownership test runs via ISteamApps (0x14121D1B8). Which method is invoked (BIsSubscribedApp checks ownership, BIsDlcInstalled checks an installed DLC) is defined in .text and is not measurable. The path is therefore not proven to be. For BIsSubscribedApp(201810), Steam says yes because the base game belongs to the user — precisely why the own AppID appears in all four versions. A game launch decides it.

Execution

  1. Create dlc\test\base\ and place a small custom archive inside. (base appears in the literal block of OpenDLCFileHandles at 0x140E6F67C, between - already loaded - skipping... and File '%s' wasn't preloaded, trying to slow load it from disc. Weak evidence — base is a generic string. In Part 2, it is explicitly %s/base — see E8.)
  2. Write dlc\test\info.txt, Version a.
  3. Launch and check the ErrorLog/qconsole.log.
  4. Repeat with b, then c, then d when seeing ...FAILED to parse — one version per launch, the log states exactly where it failed each time.

How to Recognize What Happened

The messages verbatim from the EXE, in the order they occur:

Log Meaning
EnumerateDownloadableContent: <path> The scan is running. Already present in today's log.
Found DLC: test... Directory seen.
Error, found test directory without info.txt! Path correct, file not found — wrong name/location.
Could not read info.txt (<path>) File exists, not readable.
...FAILED to parse dlcInfoVars in info.txt (<path>) Wrong dialect — try next version.
Found invalid dlcID in info (<n>) Dialect correct, dlcID value rejected. Try another value.
User does not own this DLC. Dialect correct, ownership check triggers.
...successfully mounted 'test. ID: 1 Mounted.
OpenDLCFileHandles: 1 packages mounted. + * Content Package #0: / - id: 1 / - path: ... File handles open.

Proof provided if the last two lines appear.
Refuted if User does not own this DLC. appears even with the own AppID. Then the Steam authorization check triggers and the path is too short for Part 1 — which is an equally valid result.

The starting situation is already in the log. In %USERPROFILE%\Saved Games\MachineGames\Wolfenstein The New Order\base\qconsole.log, today it shows EnumerateDownloadableContent: D:\...\Wolfenstein.The.New.Order\dlc and immediately below OpenDLCFileHandles: 0 packages mounted. — the folder name dlc directly in the game root directory is thus not guessed, but output by the game itself. The 0 is the comparison value.

For Part 2, all this does not apply. NewColossus_x64vk.exe knows neither info.txt nor appID/dlcID nor EnumerateDownloadableContent — the anchor strings are completely missing. There, the path is at %s/dlcinfo.json (0x1426CF6E0) with the single key dlcid (0x1426CF7A8, lowercase) and %s/base (0x1426CF730), searched under ../dlc/. Part 2 also lacks the bracketed idDict reader entirely (Expected quoted string occurs 0 times in EXE 0 ×) — only INI and JSON. Two games, two formats.

For Part 2, this is no longer a puzzle: the three DLCs are delivered on the disc and show the structure completely — dlc\dlc_1\dlcinfo.json (content above), alongside dlc\dlc_1\base\ with packagemapspec.json, chunk_20.resources, chunk_20.texdb, maps\, sound\, bink\, and a custom dlc\dlc_1\virtualtextures\. A self-built dlc_4 must follow this pattern — that is E8, and E8 should therefore run before E7: there is nothing to guess.

Unexplained: The literal and info.txt!\n (0x140F1D238) appears mid-block. It is not a suffix of any other message, nor does it indicate an obvious call site. This suggests a second check that requires both base and info.txt — however, this is speculation, not measurement.


E8 — DLC package for Part 2 (45 minutes, creates files in the game folder)

Hypothesis: A dlc/dlc_4/ directory with its own packagemapspec.json will be mounted without ownership verification.

First, the risk-free preliminary step: Start with +logFile 1 +resource_dlcDbgSearchPath "C:/wolfdlc" and check the log for SearchDLC: found %d dlcs. If it shows 0 instead of 3, the CVar has indeed redirected the search path — and then the entire attempt can take place on C:\wolfdlc\, without creating a single file in the Steam folder. This is the clean approach; adopt it if it works.

Next:

  1. <SearchPath>\dlc_4\dlcinfo.json = { "dlcid": 4 }
  2. <SearchPath>\dlc_4\base\packagemapspec.json following the pattern of dlc_1, but with "chunkid": 26, "basechunkid": 6, "layer": -1, and a name pointing to an existing map (e.g., game/wolf/c01/c01p1) — this tests overwriting rather than creating anew.
  3. <SearchPath>\dlc_4\base\chunk_26.resources with the modified asset.

Proof is provided if the log reports SearchDLC: found 4 dlcs and the change takes effect in-game. Intermediate result at SearchDLC: Could not load mapspec for dlc id 4 from ... — the folder is found, but packagemapspec.json is not accepted. Then adjust the JSON, not the folder.


Order of operations if time is tight

  1. E6 (Part 2, 15 min) — answers the fundamental question for Part 2: "is there a file path at all?" with yes or no.
  2. E1 → E2 (Part 1, 20 min) — answers the same question for Part 1 and decides whether wolfsdk can function in the future without archive write access.
  3. E5 (Part 2) — only makes sense if E6 has shown something.
  4. E3 — a small supplementary signal for Part 1.
  5. E8 Preliminary (resource_dlcDbgSearchPath) — risk-free, and if it works, the DLC path for Part 2 is testable without any intervention.
  6. E4, E7, E8 — only if all easy paths lead nowhere.

Reproduce evidence

python tools/probe_loadpaths.py             # all evidence, sorted by topic
python tools/probe_loadpaths.py --self-test # checks that the key witnesses are still present

The self-test verifies two things: that the core strings exist (fs_overlaypath, r_renderProgProdOverridePath, resource_loadLooseAssets, fs_pathDeclOverride, …) and that the negative findings remain negative (no resource_loadLooseAssets in Part 1, no fs_overlaypath in Part 2). If it fails after a game update, this document is outdated.

Clone this wiki locally