Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Latest research roundsNeueste Forschungsrunden

Where TNC Verifies Data: A Map of Every Integrity Check

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

In plain wordsIn einfachen Worten

This report collects every integrity check the project has found in Wolfenstein II: The New Colossus: archive hashes, texture files, game definitions, save signatures, DLC ownership and self-checks of the game program. For each it states when it runs and what happens if data is wrong. Almost everything was found by reading the code, not by running the game, apart from a few cited in-game observations. The key result for modders: only the archive header hashes and, for sound, a valid sound spec file really stop a mod from loading.

  • Archive header hashes must be recomputed after any edit, or the archive is refused; per-file data hashes are off by default.
  • A wrong save signature does not block loading; it only marks the save as dev mode. A wrong length is what causes File Corrupt.
  • An inherit edit that points at a definition loaded later silently yields empty defaults, with no warning.
  • DLC ownership is checked only in menu code, not when files are loaded; an unowned DLC folder was mounted in the running game.
  • No sign was found that the game checks its own program bytes, but this is no evidence found, not proven absent.

Dieser Bericht sammelt alle Integritätsprüfungen, die das Projekt in Wolfenstein II: The New Colossus gefunden hat: Archiv-Hashes, Texturdateien, Spieldefinitionen, Spielstand-Signaturen, DLC-Besitz und Selbstprüfungen des Spielprogramms. Für jede steht, wann sie läuft und was bei falschen Daten passiert. Fast alles wurde durch Lesen des Codes gefunden, nicht durch Spielen, bis auf einige zitierte Beobachtungen im Spiel. Das Wichtigste für Modder: Nur die Header-Hashes der Archive und beim Sound eine gültige Sound-Spec-Datei verhindern wirklich, dass eine Mod lädt.

  • Die Header-Hashes eines Archivs müssen nach jeder Änderung neu berechnet werden, sonst wird es abgelehnt; Daten-Hashes pro Datei sind standardmäßig aus.
  • Eine falsche Spielstand-Signatur blockiert das Laden nicht; sie markiert den Stand nur als Dev-Modus. File Corrupt entsteht durch eine falsche Länge.
  • Ein inherit-Edit, der auf eine später geladene Definition zeigt, liefert still leere Standardwerte, ohne Warnung.
  • DLC-Besitz wird nur im Menücode geprüft, nicht beim Laden der Dateien; ein nicht gekaufter DLC-Ordner wurde im laufenden Spiel eingebunden.
  • Es wurde kein Hinweis gefunden, dass das Spiel seine eigenen Programmbytes prüft, aber das heißt nur nichts gefunden, nicht bewiesen abwesend.

Technical detailsTechnische Details

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

A synthesis pass pulling together every data-integrity check this project has found in The New Colossus — archive header/entry hashes, texdb/cache checks, decl validation, save signatures, DLC entitlement, and whether the executable checks its own bytes — into one map with address, trigger condition, and consequence for each. Most individual checks were already found and disassembled by earlier passes; this report's own new contribution is the last one (§7): a direct scan proving no evidence exists that the engine hashes or checksums its own executable bytes at runtime. All findings are static (disassembly plus spot-checks against a decrypted runtime memory capture) except where a specific runtime observation is cited; the game was not launched for this pass. The practical takeaway for a modder: only two things are load-bearing in the sense of "wrong → refused load or crash" — the IDCL archive header hashes after any edit, and (for sound) a syntactically valid prodspec.json. Nearly everything else is either unchecked, checked but non-fatal, or checked only behind a CVar that is off by default in retail.

1. Archive integrity (IDCL)

Check Condition Consequence on mismatch
Magic IDCL, version always mount refused
Header hash +0x08 always, every mount mount refused (not a crash)
Metadata end marker always hard crash (FatalError+int3) — the one fatal header-level check
Metadata hash +0x20 always (v12) mount refused
Entry data_hash (+0x50) only if resourceStorage_checkDataCheckSum CVar ≠ 0 — retail default is 0, measured live hard crash if it does run, but by default it never does
Entry hash_0x60 ("default hash") never recomputed from content by any reader — every comparison site checks it against another stored field a builder is free to set it to anything self-consistent

Practical reading: the header hash chain (+0x20 then +0x08, in that order) is the only part of the format that must be freshly computed after any edit, or the container is refused at mount — independent of the CVar. The entry-level data_hash/hash_0x60 fields only matter at all if someone flips that CVar on, which retail does not do, and even then only data_hash is actually recomputed from content. The project's own archive writer reproduces the full chain and round-trips real archives byte-identically, including a live repair of a broken custom container.

2. texdb / textures.cache

Magic-only checks, no content hash anywhere in the chain:

  • .texdb magic (8 bytes) is checked on mount; the 16-byte "build ID" header field is copied into the runtime object and never compared — a custom .texdb can carry arbitrary bytes there.
  • The .texdb format itself carries no length field for the texture block — the read length comes entirely from the paired .bimage's own mip table. A mismatch between the two reads the wrong byte count (Oodle decode failure or garbage pixels), not caught by any texdb-side check.
  • textures.cache is magic-only if present, and fully optional — a missing file falls through silently to the ordinary .texdb path, runtime-confirmed with no error logged.

The only thing actually standing between "load the right texture" and "load garbage" is the IDCL entry data_hash governing the paired .bimage entry — and that one is off by default (§1).

3. Decl validation

One hard check, one silent gap, both in the inherit parser:

Condition Consequence
Parent currently mid-load (true circular inheritance) FatalError — hard crash
Parent not yet loaded (exists, but later in the block-load manifest order) nothing — copies the unloaded parent's empty class defaults, no warning, no error, no log line

This is a real, measured modding trap: it is the confirmed root cause of a previously-diagnosed broken mod, where an inherit edit silently produced class defaults because the new target decl loads after the one being edited in the manifest. A companion edit in the same patch that only touched the child's own edit block (no inherit change) worked fine, because that has no ordering dependency. Forward references by name (fields like projectileDecl/valueDecl, as opposed to inherit itself) are unaffected — retail has working forward-pointing cases of its own.

Decl text parsing itself was not seen to reject anything in any corpus read in this project (thousands of decls, all 29 .entities maps parse clean). The closest measured precedent for what the engine's own parser does with malformed data (a related JSON validator for DLC sound) is an unconditional hard crash, not a graceful skip — offered as a style precedent, not a measurement of the decl parser itself.

4. Save signatures

.verify is a strong cryptographic tag that, surprisingly, does not gate loading:

Check Condition Consequence
.verify HMAC-SHA256 tag matches every load, except profile.bin (explicitly exempted by name) mismatch does not stop loading. It only sets a flag; the save is permanently marked dev-mode/cheat, and the game later prints "Loading Dev Mode or corrupted save game."
Payload length matches declared length every load mismatch → the actual SAVEGAME_E_CORRUPTED flag — this, not .verify, is the real source of a "File Corrupt" rejection
4-byte MD5-fold header same gate same SAVEGAME_E_CORRUPTED flag

Algorithm: verify = nonce(8) || HMAC_SHA256(key, entire file), where key = SHA256(nonce || a runtime-only heap value || a fixed 11-byte secret || basename(file)). The runtime-only component has no static occurrence anywhere in a 36 MB memory capture and no readable setter — offline forgery of a valid .verify tag is not currently possible from this project's evidence, but per the table above, that is also irrelevant to whether a save loads, only to whether it loads quietly instead of flagged.

5. DLC entitlement/ownership

Exists as a named subsystem, but not on the file-mounting path at all:

  • A map name is looked up in the base game's own map spec first, unconditionally. If found, base wins outright and the DLC list is never even consulted — this is name precedence, not ownership, and is the actual reason a DLC folder can never take over an existing base map.
  • DLC folder discovery has no call to the entitlement check anywhere in or near it.
  • The real Steam-ownership check (idEntitlements::Get) exists, with exactly 2 callers in the entire binary, both inside chapter-select/menu construction code for one narrow UI object type — nowhere near the resource-mounting engine code that actually decides what loads.

Runtime-confirmed consequence: an unowned DLC folder carrying a non-owned dlc-id was discovered, mounted, and its map spec entry won a direct name collision against the real, owned DLC content — producing visibly different content with zero entitlement-related log output. This is the mechanism the project's proven custom-map route (loading a modified map through dlc/dlc_0 folder precedence) actually relies on, and it is why that route works at all.

6. Anti-tamper of the executable: nothing found

Both game executables have their code section SteamStub-encrypted (entropy 8.000, indistinguishable from random) — this is pre-execution DRM against static analysis, not a runtime self-check, and the project's whole reverse-engineering corpus already works around it via a one-time decrypted memory capture.

Whether the engine itself checks its own on-disk/in-memory bytes at runtime was the one sub-question not yet measured. A direct scan of every call site (40 total) to the three generic hash primitives this project has fully solved found zero call sites that pass a length operand anywhere near the size of the whole module or the whole code section — every one of the 40 is already accounted for by the checks in §§1–4, or by a hot-reload content-diff pattern (comparing two freshly-computed hashes against each other, not against a stored "known good" value). A targeted search of this project's entire corpus for debugger-detection or self-checksum indicators also returns zero hits. Honest limit: this rules out a hard-coded-length self-hash through these three specific primitives; it does not rule out a runtime-computed length, a fourth unidentified hash primitive, or a WinAPI crypto-based check. The honest claim is "no evidence found," not "proven absent."

Summary table

Thing Consequence if wrong Retail default
IDCL header hashes mount refused always checked
IDCL metadata end marker hard crash always checked
IDCL entry data_hash hard crash, but only reachable with a CVar flipped on never checked
IDCL entry hash_0x60 never recomputed — free to set n/a
.texdb / .bimage block length match not checked anywhere — garbage pixels or decode failure never checked
textures.cache fully optional magic-only if present
decl inherit, circular parent hard crash always checked
decl inherit, not-yet-loaded parent silently wrong, no warning never checked
save .verify cosmetic — flags dev-mode, doesn't block loading always computed, never gates loading
save payload length / header fold this is what "File Corrupt" means always checked
DLC ownership irrelevant to whether files mount effectively unenforced on the load path
exe's own bytes nothing found that checks this n/a

FAILED HYPOTHESES (explicitly, so they are not re-tried)

  • "hash_0x60 is the texdb key directly." Refuted — the real key formula masks it and combines it with a mip-level field.
  • "A wrong .verify blocks save loading." Refuted — it only flags dev mode.
  • "The DLC enumerated later wins a name collision" (plain last-write-wins). Falsified by the actual runtime collision result.
  • "TNC has no visible DLC ownership check at all" (an earlier project note). Superseded — the check exists, it is just nowhere near the file-mounting path.
  • "The exe checksums itself using the same primitives it uses for save/archive hashes." This pass's own negative control — not excluded for mechanisms outside the three checked.

Open questions

  • SAVEGAME_E_DLC_NOT_FOUND's setter site was never pinned to an address in this corpus — string-level evidence only. This is the one DLC-related bit flag that could still mean a save-load-time ownership check exists, separate from the menu-time one that was fully traced.
  • The exact type-identity constants gating the one idEntitlements::Get call site are read as "plausibly only one specific chapter-type is ever checked," from RTTI-style code shape — not resolved to a named class.

Related: Modding Limits, TNC Texture Database, Save Games and Profiles, TNC Custom-Map Status, Research Report: Datahash, Research Report: Idclhash, Research Report: Saveverify.