Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Latest research roundsNeueste Forschungsrunden

R4 Research Report: TNC String Tables and the Key Hash

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

In plain wordsIn einfachen Worten

This report decoded how Wolfenstein II: The New Colossus stores its on-screen texts (strings/*.lang) and how a text key is turned into the number the game looks up. The files are plain text inside the already-solved encryption container, and the key number is an FNV-1a hash with two subtle details found in the code. A test changed one existing text and added a brand-new one in a copy of a real archive, and both read back exactly; whether the new text shows up in the running game was not tested.

  • All 50 shipped .lang files are UTF-8 text inside encrypted cfile entries.
  • The key hash ignores upper/lower case, treats bytes 0x80 and above as signed, and turns a result of 0 into 1.
  • No key collisions in the 23,224-key English file or in any of the 50 files.
  • Round trip: one edited and one new key read back byte-for-byte; the other 23,223 keys were unchanged.
  • New text must go into the base archive or patches, not a DLC chunk, because the table is built at startup.

Dieser Bericht hat entschlüsselt, wie Wolfenstein II: The New Colossus seine Bildschirmtexte (strings/*.lang) speichert und wie ein Textschlüssel in die Nummer umgerechnet wird, nach der das Spiel sucht. Die Dateien sind einfacher Text im bereits gelösten Verschlüsselungs-Container, und die Schlüsselnummer ist ein FNV-1a-Hash mit zwei subtilen Details aus dem Code. Ein Test änderte einen vorhandenen Text und fügte einen ganz neuen in die Kopie eines echten Archivs ein, beide ließen sich exakt zurücklesen; ob der neue Text im laufenden Spiel erscheint, wurde nicht getestet.

  • Alle 50 mitgelieferten .lang-Dateien sind UTF-8-Text in verschlüsselten cfile-Einträgen.
  • Der Schlüssel-Hash ignoriert Groß-/Kleinschreibung, behandelt Bytes ab 0x80 vorzeichenbehaftet und macht aus dem Ergebnis 0 eine 1.
  • Keine Schlüsselkollisionen in der englischen Datei mit 23.224 Schlüsseln und in keiner der 50 Dateien.
  • Round Trip: ein geänderter und ein neuer Schlüssel wurden Byte für Byte zurückgelesen; die übrigen 23.223 blieben unverändert.
  • Neuer Text muss ins Basisarchiv oder die Patches, nicht in einen DLC-Chunk, weil die Tabelle beim Start gebaut wird.

Technical detailsTechnische Details

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

Builds on the existing cfile encryption and mission-text research. New tooling: a stdlib-only probe that reuses the project's existing crypto and archive modules as written.

Summary

The question was how TNC's localization string tables (strings/*.lang) are structured, how a #str_... key is hashed to the runtime id that decls and UI reference, and whether a writer can prove a full round trip that both edits an existing key and adds a genuinely new one. Every strings/*.lang entry in every shipped archive is UTF-8 text wrapped in the already-solved cfile encryption container; the runtime key id is FNV-1a-32 with ASCII case-folding, disassembled byte-for-byte at two independent call sites in the game binary, with two non-obvious details (sign-extension of each byte, and a 0→1 remap for the one unlucky hash result) that no prior report had traced. A test build changed one existing key and added one brand-new key directly inside a real copy of a shipped archive, written through the project's actual archive-write path, and reading it back from a freshly reopened archive object reproduced both changes byte-for-byte with all other keys untouched. The container, hash and round-trip are proven against real game files. Whether a new key added this way is actually readable and shown on screen once the game is running is inferred, not confirmed in game — the game was never launched.

1. The container

Every strings/<lang>.lang entry in the archives that ship one is of type cfile, stored uncompressed at the archive layer (the archive's own compression is skipped because the payload is already AES+Kraken-wrapped by the cfile container). No .lang entry of type file exists anywhere in this game's archives — a prior, unrelated document's numbers for a file-type .lang almost certainly describe the other game's (unencrypted) string table, not this one.

2. The text format (measured on all 50 retail files)

EF BB BF  "// string table"\r\n "//"\r\n \r\n "{"\r\n
\t"#key"<tab-or-spaces>"value"\r\n   ... one line per key ...
\r\n "}" 0x00                        (one literal NUL, no trailing \r\n)
  • Keys are case-mixed in the file itself (#STR_QUICK_EQUIP next to #str_dlg_...) — this is exactly why the hash folds case.
  • 316 lines carry extra quoted fields after the value (UI layout numbers), untouched by a value edit.
  • All ~1.08 million key occurrences across all 50 files are plain ASCII.
  • One real retail bug: patch_1/strings/english.lang has an unterminated quoted value for one key; patch_2/patch_3/patch_4 all fixed it. Since the game always mounts the highest patch level, patch_1's broken copy is never actually loaded — informational, not a modding obstacle.

3. The id hash — disassembled, not just labeled

def fnv1a_lower(s: bytes) -> int:
    h = 0x811C9DC5
    for b in s:
        if 0x41 <= b <= 0x5A:                      # 'A'..'Z', ASCII only
            b += 0x20
        signed = b - 0x100 if b >= 0x80 else b      # movsx, not movzx
        h = ((h ^ (signed & 0xFFFFFFFF)) * 0x01000193) & 0xFFFFFFFF
    return h or 1                                   # 0 is the "not found" sentinel

Two details a prior report's "FNV-1a" label had missed, both confirmed directly against the disassembly at two identical call sites (table build and FindIndex):

  1. Sign-extension, not zero-extension. Any byte >= 0x80 (any accented character in a non-English key) XORs in with its top bits set to 1. Getting this wrong silently mis-hashes any key containing a non-ASCII byte.
  2. A raw result of 0 is remapped to 1. 0 is the engine's own "key not found" sentinel; a hash that happened to land on 0 would be indistinguishable from a missing key. Confirmed with a constructed negative-control string that genuinely hashes to 0 before the remap.

Checked against real data: zero id collisions across the full 23,224-key authoritative file, and zero collisions in any of the 50 shipped .lang files individually — exactly the precondition the engine enforces as a fatal error at load time.

4. How a key is referenced

A decl field of the idStrId type (e.g. a mission objective's title) is looked up by this same hash at decl-parse time against whatever table happens to be loaded; not found, or plain text without #str_, becomes id 0, displayed as an empty string. The table itself is built exactly once at startup from the base archive and patches — a DLC chunk mounted while loading a map is never consulted for .lang, and the table only reloads on a language change. One caveat found and kept separate: 14 #str_ keys are compiled directly into one UI screen's own binary dictionary and are not reachable just by adding a key to .lang at all — that screen's own format is a different, unsolved problem.

5. The round trip

  1. Decrypt strings/english.lang from a copy of a real archive the normal way (the same decrypt function the project's asset browser already uses).
  2. Change one existing, low-risk value in place (a Challenge-screen label) and append one brand-new line with a key that never existed in retail — after first computing its hash and checking it doesn't collide with any of the 23,224 existing ids (the check caught that the same key in upper case would collide, confirming it isn't a no-op check).
  3. Re-pack with the same compression level retail uses and re-encrypt with the real per-file key derivation.
  4. Write it through the project's real archive-apply path — the same call the normal mod patcher uses for every mod, not a bespoke test writer. The growth (+16 bytes) fit inside the existing padding gap behind the entry, so no archive rebuild was needed; a larger addition would fall back to the normal rebuild path automatically.
  5. Close the archive, open a brand-new archive object on the same file, find the entry again, decrypt and parse it: both the changed and the new key read back exactly as written, and the other 23,223 keys compare equal to the original.
  6. Negative controls: decrypting the new blob under the wrong entry name, or with one flipped byte, both fail the authentication tag as expected.

6. Recipe for new weapon/mission text

  1. Decrypt the target strings/<lang>.lang from inside the base archive/patches (not a DLC chunk — the table is built before any DLC mounts).
  2. Pick a key, compute its hash, confirm it doesn't collide with the existing id set.
  3. Append the new line, leave every other line byte-identical.
  4. Repeat per shipped language, or accept the key showing as empty text in languages it wasn't added to.
  5. Re-pack/re-encrypt and let the normal mod patch pipeline write it — it already uses the exact path step 4 above exercised directly.

Open questions

  • Whether the running game actually logs the new key count and whether a decl referencing the new key displays it on screen — not run, the game was never launched.
  • Whether adding a key to only english.lang (not every shipped language) is acceptable, or whether a missing key inside an otherwise-present file behaves differently from a missing file (the existing fallback-to-English-on-missing-file case is proven; a missing-key case is not the same thing and wasn't tested).
  • Whether a much larger batch of new keys (tens or hundreds, for a full weapon set) still fits the zero-padding gap or routinely needs the normal rebuild path — only one 16-byte growth was measured; the rebuild path itself is already known to be fast enough to not be a real obstacle.