Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Latest research roundsNeueste Forschungsrunden

Adding New Entries to TNO Archives: Format, Runtime Lookup, a Working Writer

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

In plain wordsIn einfachen Worten

Game content in Wolfenstein: The New Order (TNO) sits in archives where, until now, existing entries could only be replaced. This report built a writer that appends one brand-new material entry to a copy of one archive, and read the matching loader code in the newly decrypted game program to see how the engine would find it. All 4,241 old entries came back unchanged and the new one was found, and the code shows the engine builds a fresh name lookup table for each archive at every start, so nothing else would need updating. This is proven on file copies and by reading code; whether the running game actually finds the new entry was not tested.

  • 14 of 14 checks passed: all 4,241 original entries are byte-identical and the new entry's data reads back exactly.
  • The engine stops with a fatal error if entries are not in ascending order in the file, so the writer always appends at the very end; the check for this was read directly in the code.
  • The engine reads exactly as many entries as the count in the file header says, so the count must be raised; the project's own reader is more lenient and would hide this mistake.
  • Each of the 14 archives gets its own lookup table, built fresh from its index file at every game start; there is no cached list that would need updating.
  • Open: the in-game test, whether the lookup uses the name alone or type plus name, and about 4 unexplained bytes in each entry's 13-byte tail.

Spielinhalte von Wolfenstein: The New Order (TNO) liegen in Archiven, in denen bisher nur vorhandene Einträge ersetzt werden konnten. Dieser Bericht hat einen Schreiber gebaut, der an die Kopie eines Archivs einen ganz neuen Material-Eintrag anhängt, und dazu den passenden Ladecode im neu entschlüsselten Spielprogramm gelesen, um zu sehen, wie die Engine ihn finden würde. Alle 4.241 alten Einträge kamen unverändert zurück und der neue wurde gefunden; der Code zeigt, dass die Engine bei jedem Start für jedes Archiv eine frische Nachschlagetabelle über die Namen baut, sodass sonst nichts angepasst werden müsste. Das ist an Dateikopien und durch Codelesen bewiesen; ob das laufende Spiel den neuen Eintrag wirklich findet, wurde nicht getestet.

  • 14 von 14 Prüfungen bestanden: Alle 4.241 ursprünglichen Einträge sind Byte für Byte gleich, und die Daten des neuen Eintrags lassen sich exakt zurücklesen.
  • Die Engine bricht mit einem fatalen Fehler ab, wenn die Einträge in der Datei nicht aufsteigend liegen; deshalb hängt der Schreiber immer ganz am Ende an, und die Prüfung dafür wurde direkt im Code gelesen.
  • Die Engine liest genau so viele Einträge, wie der Zähler im Dateikopf angibt, also muss der Zähler erhöht werden; der projekteigene Leser ist großzügiger und würde diesen Fehler verdecken.
  • Jedes der 14 Archive bekommt eine eigene Nachschlagetabelle, die bei jedem Spielstart frisch aus seiner Indexdatei gebaut wird; eine zwischengespeicherte Liste, die man anpassen müsste, gibt es nicht.
  • Offen: der Test im Spiel, ob die Suche nur den Namen oder Typ plus Name nutzt, und rund 4 ungeklärte Bytes im 13 Byte langen Endstück jedes Eintrags.

Technical detailsTechnische Details

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

Round-7 research pass, one of the first with a decrypted TNO runtime image: can a brand-new entry be added to a TNO archive, and would the engine find it? A Python writer appends a new material entry to a copy of chunk1; every original entry reads back byte-identically and all checks pass, including negative controls tied to the engine's own decompiled loader checks (ascending offsets, the per-chunk hash table, the entry-count field). Structural proof on copies only; whether the running game finds the new entry is untested.

Track tnoaddentry, round 7, 2026-10-01. Scope: TNO only (<Steam library>\steamapps\common\Wolfenstein.The.New.Order, id Tech 5, WolfNewOrder_x64.exe). Read-only on both game installs and on every project file except this track's own folder re_probes/tnoaddentry/ and this report. The game was not launched, no debugger attached, no process touched.

New this round, not available to any prior TNO track: a decrypted TNO runtime image (re_probes/runtime_tno_186352/module.bin, captured read-only from the running game, PID 186352) and a Ghidra project built from it (D:/wolf-decomp/ghidra/tno_runtime.gzf), queried via python tools/decomp.py <rva|name> [--xrefs] --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf. r6_tnodecls.md (round 6) explicitly flagged "no TNO runtime image exists" as the reason several of its findings stayed INFERRED and its single named next step was "build and self-test an append-entry writer... still not built." Both limits are gone this round: the writer is built and self-tested below (§3), and three of round 6's INFERRED items move to PROVEN by direct decompiled-code read (§2).

Answer in one paragraph

The writer works, structurally, end to end. A Python script (re_probes/tnoaddentry/probe_append.py) appends one brand-new (material, "material/tnoaddentry_test") entry to a disposable copy of chunk1.index + chunk1.resources — cloning a real donor entry's unexplained tail bytes rather than guessing them, placing the new payload 16-byte-aligned strictly after the current end of .resources (so the offset-ascending invariant holds by construction), and bumping both header counters plus the file-size header field. Re-read with this project's own wolfsdk.resources.Archive/ResourceIndex reader: all 4241 original entries come back byte-identical (type, name, path, offset, usize, csize, and their byte position in the file, §3), the new entry is found by (type, name), and its payload decompresses back to the exact bytes written. 14 of 14 checks pass, including three negative controls tied directly to mechanisms this round newly decompiled from the real engine (§2/§4): an entry placed at offset 0 instead of true EOF fails the exact ascending-offset check idBackgroundLoader::BeginBackgroundLoads performs (decompiled, not just inferred from a crash message, like round 6 had to); a reused (chunk,type) id is not rejected by the file format itself, confirming round 6's id-uniqueness rule is a convention the writer must enforce, not something the format checks; and, most importantly, this project's own reader is strictly more lenient than the real engine: it resyncs to EOF and finds the new entry even if the header's entry-count field is left un-bumped, while the real engine's own parse loop — newly decompiled this round, idResourceManagerLocal::Init, FUN_7ff74a4f9680 @ rva 0x169680 — reads that exact header field as an explicit loop bound and would stop before ever reaching an entry appended past it. Whether the real game actually resolves the new name at runtime was not tested (the game was never launched, per the rules) — but the mechanism that would have to do it is no longer a black box: Init builds, separately per chunk (not merged globally, not a sorted list), a classic open-chained hash table over every entry's name (the idHashIndex::Add pattern — hash, mask, indexChain[new] = hashTable[h]; hashTable[h] = new; — decompiled directly, §2.2), from entries it parses fresh out of the chunk's own .index file every time the game starts. A correctly-appended entry — ascending offset, bumped header count, cloned tail — is exactly the shape of data this function already walks and hashes 4241 times over for chunk1 alone; there is no cached, precomputed, or baked lookup table anywhere else in the pipeline that would need to be separately invalidated. That is the strongest statement this round can make without turning the game on.

§1 Archive format — not re-derived, cited

wolfsdk/resources.py, docs/formats.md, docs/limits.md and r6_tnodecls.md already nail this down; nothing here is re-measured except where §2 moves an INFERRED claim to PROVEN. Quick recap for context:

  • master.index: magic 03 53 45 52, BE u32 pair-count, then LE-length-prefixed index/resources name pairs. 14 pairs in TNO, flat, no DLC tier (r4_tnocustom.md). Not touched this round — appending one entry to an existing chunk never needs to.
  • chunkN.index header (0x2C = 44 bytes): magic(4) + BE u32 "file size − 32"(4) + 24 zero bytes + BE u32 counter A(4) + BE u32 counter B(4) + BE u32 zero(4). Entries follow: 3 LE-length-prefixed ASCII strings (type, name, path) + BE u32 × 3 (offset, usize, csize) + a variable tail.
  • r6_tnodecls.md (round 6, re-verified across all 14 chunks): the tail is 13 bytes for every decl-ish type checked (entityDef, weapon, damage, ammo, projectile, aiBehavior, material, trackingParms, faction, gamemodesp, soundmixing) — 9 zero bytes + a BE u32 id that is unique per (chunk, type), not globally. sound/particle/ renderProg/cuttableResource have real duplicate ids and longer, heterogeneous tails (133/205 B) — explicitly avoided as a donor type below.
  • Counter A ≈ distinct (type,name) pair count in that chunk (exact in 12/14 chunks); counter B ≈ raw entry count including duplicates (r6_tnodecls.md §3). Confirmed again this round on chunk1: counterA=3508, counterB=4241, and len(entries)==4241 exactly.
  • No checksum anywhere in an entry or in the file (docs/formats.md).
  • Payloads are raw DEFLATE (wbits=-15, Z_SYNC_FLUSH-terminated, not Z_FINISH) or stored verbatim (csize==usize); 16-byte-aligned, packed with strictly ascending offsets (docs/limits.md §3/§4).

§2 Runtime resource lookup — newly decompiled this round

All of this is read directly out of WolfNewOrder_x64.exe's own code via the new TNO Ghidra snapshot. RVA = address − base (0x7ff74a390000 per re_probes/runtime_tno_186352/manifest.json). Every function below was reached by following a string the task flagged as a likely dead end for Ghidra's own xref index ("no references found") — scanning is unnecessary here because tools/decomp.py --strings did return real callers for every string actually used below; no manual RIP-relative scan was needed this round.

2.1 The ascending-offset invariant — now PROVEN in code, not just inferred from a crash

docs/limits.md documented FATAL ERROR: idBackgroundLoader::BeginBackgroundLoads: FileTable is out of order as a measured crash message from an earlier, now-abandoned append attempt — real, but only ever observed as an effect. This round traces the actual check:

python tools/decomp.py --strings "BeginBackgroundLoads" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
=> 0x7ff74b2008a0  "idBackgroundLoader::BeginBackgroundLoads: FileTable is out of order"
   <- FUN_7ff74a4fefe0 @ 7ff74a4fefe0   (rva 0x16efe0)

Decompiled (python tools/decomp.py FUN_7ff74a4fefe0 --xrefs --gzf ...), the load-bearing fragment:

if ((0 < (longlong)uVar10) &&
   (*(uint *)(*(longlong *)param_1[1] + 0x2c8 + uVar8) <
    *(uint *)(*(longlong *)param_1[1] + -0x30 + uVar8))) {
    FUN_7ff74ad5abb0(s_idBackgroundLoader__BeginBackgro_7ff74b2008a0);
    pcVar2 = (code *)swi(3);   // int3 / fatal trap
    (*pcVar2)();
    return;
}

Two in-memory entry records, stride 0x2f8 (760) bytes apart (-0x30 + 0x2f8 == 0x2c8 — same field, one record back), each comparing their field at relative offset +0x2c8. §2.3 independently identifies +0x2c8 as the parsed offset field (it is read first, immediately after the three length-prefixed strings, in the per-entry parser). This is exactly docs/limits.md's invariant, now read directly out of the comparison instruction rather than inferred from its failure message: if entries[i].offset < entries[i-1].offset (file order), the loader traps. Our writer's negative control NC1 (§4) re-implements this exact comparison in Python and confirms both halves: appended-at-EOF passes, a hypothetical offset-0 placement fails it the same way the real code would.

2.2 Per-chunk hash table — answers "hash tables? sorted lists? per-chunk?"

python tools/decomp.py --strings "idResource|ResourceCache|FindResource" --gzf ...
=> "idResourceManagerLocal"            (RTTI)
=> "idResourceList::HASH_SIZE"         (RTTI)
=> "idResourceManagerLocal::Init2"     <- FUN_7ff74a4fccf0 @ rva 0x16ccf0
=> "idResourceManagerLocal::Init - index header bad magic %d"
   <- FUN_7ff74a4f9680 @ rva 0x169680   (this is Init, not Init2)

FUN_7ff74a4f9680 (idResourceManagerLocal::Init) loops once per archive (14 chunks), and for each one:

  1. Validates the chunk's own magic — the exact literal compared is 0x3534552, i.e. 03 53 45 52 read as one 32-bit word — a second, independent confirmation of MAGIC straight from the engine's own check, not just from file-format reverse engineering.
  2. Reads a header count field and uses it as the only loop bound for parsing that chunk's entries (while (iVar16 < uStack_518) { ... FUN_7ff74a4f9330(...) ... }) — there is no EOF-sentinel or resync-based termination in the real engine, unlike this project's own ResourceIndex.load() (which resyncs until end-of-buffer regardless of any header count, by design — docs/formats.md calls this out as deliberate robustness against the unknown tail). This is the single most practically important finding of this round: the header's entry count is load-bearing for the real engine even though it is invisible to this project's own reader — confirmed as a real gap by NC3 (§4).
  3. For each of those entries, calls FUN_7ff74a4f9330 (the per-entry stream parser, §2.3) and then — the actual lookup-structure build — computes a hash of the entry's name and inserts it into a per-archive open-chained hash table, the textbook idHashIndex::Add shape:
uVar11 = func_0x7ff74ad58000(*puVar1);              // hash(name)
uVar19 = *(uint *)((longlong)plVar12 + 0x1c);        // hashMask
if (/* table empty/needs growth */) {
    FUN_7ff74ad62c30(plVar12, (int)plVar12[2], iVar10);   // grow buckets
} else if (/* chain array too small */) {
    FUN_7ff74ad629d0(plVar12, iVar16 + 1);                // grow chain
}
lVar13 = (longlong)(int)(hashMask & uVar11 & uVar19);
*(undefined4 *)(indexChain + lVar21*4) = *(undefined4 *)(hashTable + lVar13*4);
*(int *)(hashTable + lVar13*4) = iVar16;             // hashTable[h] = newIndex

This is idHashIndex::Add(key, index) from id Tech's shared container library (indexChain[index] = hashTable[h]; hashTable[h] = index;), instantiated once per archive object, built fresh from that one chunk's own entries every time Init() runs. This directly answers the question: hash table, not a sorted list, and scoped per chunk, not merged across all 14 — matching r6_tnodecls.md's independently measured per-chunk id-uniqueness scope (§3 there) with an actual mechanism, not just a correlation.

func_0x7ff74ad58000 (the hash function itself) was not independently disassembled this round — it falls in a gap this quick Ghidra import did not analyze (!! no function contains this address) — so it is cited by its call shape (hash-in, masked bucket index out, immediately fed into an idHashIndex-style insert) rather than by its own body. Plausibly idStr::Hash or an equivalent FNV/djb2-style string hash used throughout id Tech; not proven, flagged as open (§6).

What exactly gets hashed — name alone, or type+name — is not fully traced. FUN_7ff74a4f9330 (§2.3) parses type, name and path as three separate fields; the string handed to the hash call in Init comes out of a local buffer sized like a single idStr, strongly suggesting name alone, with type presumably available as a secondary check against whatever the chain lookup returns (every sampled name in the corpus is already type-namespaced by convention, e.g. weapon/knife_base, which would make type-less name collisions rare in practice regardless). Not conclusively pinned — flagged INFERRED, not PROVEN (§6).

2.3 The per-entry parser — confirms the field layout our Python reader assumes

FUN_7ff74a4f9330 (called once per entry from Init's loop) reads, in this exact stream order: a 4-byte length + bytes (type string, immediately hashed into a cached field — a detail our own reader does not need and does not replicate), then two more length-prefixed strings via a shared helper (name, path), then three consecutive 4-byte big-endian-ish fields read back to back — exactly SIZE_FIELDS = 12 bytes, exactly where wolfsdk/resources.py's struct.unpack_from(">3I", buf, pos) already reads them, confirmed by the comparison in §2.1 using the first of these three as "offset." Then a genuinely structured tail, not just unexplained padding: a 4-byte count, followed by that many 24-byte sub-records (explaining why audio types — sample/sound, which carry extra per-variant data — measured longer, heterogeneous tails in r6_tnodecls.md §3), then two more small fixed fields. For count==0 (the common case for decl-ish types) this accounts for 9 of the 13 bytes r6_tnodecls.md measured; the remaining ~4 bytes were not reconciled this round (flagged open, §6) — not load-bearing for the writer, because the writer never synthesizes a tail from scratch: it clones a real donor entry's tail verbatim and only overwrites the trailing id (§3), which is provably a valid instance of whatever this structure actually is, by construction, regardless of whether every byte in it is explained.

2.4 Two more functions read, for completeness

  • FUN_7ff74a4faf20 parses master.index (0-hit on "DLC" anywhere in this path, consistent with r4_tnocustom.md's "flat, no DLC tier" finding): same magic check, then a BE-u32 pair count, then a loop reading two length-prefixed strings per iteration into a 160-byte-stride archive array — structurally the same thing wolfsdk/resources.py:load_master() already does, now independently confirmed from the engine's own code.
  • FUN_7ff74a4fb430 ("could not open resource file %s" / "chunk missing in LoadNamedResourceFile" / "The map %s isn't present in the chunk list") is the per-chunk-open entry point that calls BeginBackgroundLoads (§2.1) and also separately opens generated/buildGame/<map>.mapResources — a different, per-map manifest layer, out of scope for this track (append-entry, not map-authoring) and already the subject of the TNC-side mapmanifest track's brief for the other game.

§3 The writer — built and self-tested

re_probes/tnoaddentry/probe_append.py. Operates only on a disposable copy (D:/wolf-decomp/tmp/tnoaddentry/copy/chunk1.{index,resources}, shutil.copyd once from base/_wolfsdk_backup, the project's own confirmed-pristine backup — never the live install, never written to in place). chunk1 chosen as the smallest pair (751 KB index / 73 MB resources) for a fast, cheap round-trip.

Recipe:

  1. Pick a donor entry of the target type (material, chosen for its clean, uniform 13-byte tail per r6_tnodecls.md — sound/particle avoided on purpose, see §1). Clone its tail bytes verbatim; only the trailing 4-byte id is replaced.
  2. New id = max(existing ids of this type in this chunk) + 1 (measured 170 material entries in chunk1, max id 2491 → new id 2492) — r6_tnodecls.md's already-measured per-(chunk,type) rule, re-applied here for the first time in a real writer.
  3. New payload (129 B decl text) is DEFLATE-packed the way the game's own packer does (wolfsdk/resources._deflate, Z_SYNC_FLUSH) and appended 16-byte-aligned strictly after the current end of .resources — so its offset is, by construction, larger than every existing offset (§2.1).
  4. New entry bytes (3 length-prefixed strings + offset/usize/csize + the cloned-and-patched tail) are appended to the end of the .index buffer.
  5. Both header counters are bumped by 1 (one new raw entry, one new distinct name) and the header's "file size − 32" field is recomputed — all three confirmed present and correctly valued on the pristine file before any edit (asserted in the script).

Result, re-read with wolfsdk.resources.Archive/ResourceIndex (this project's own reader, unmodified):

Original: 4241 entries, index 751275 B, resources 72937791 B
Header: magic=b'\x03SER' size_field=751243 (filesize-32=751243) counterA=3508 counterB=4241
Donor: <Entry material:water/water_c01p1_close 1851B @42908448> tail=13 bytes 000000000000000000000009bb
material ids in chunk1: 170 entries, max=2491 -> new_id=2492
New payload: usize=129 csize=103 pad=1 new_offset=72937792 (prev max offset=72928064)

After append: 4242 entries (expected 4242)
  ok  count increased by exactly 1
  ok  all 4241 original entries byte-identical (type/name/path/offset/usize/csize/pos)
  ok  index bytes outside header-counters/size-field/appended-tail are untouched
  ok  .resources bytes [0:72937791] byte-identical to the pristine original
  ok  new entry found via Archive.find((type,name))
  ok  new entry fields match what was written
  ok  Archive.read(new_entry) == original payload bytes
  ok  header size_field == new filesize-32
  ok  header counterA/+1, counterB/+1

14/14 checks passed

This is the exact property the task asked for: append one entry, re-read with the project reader, every other entry byte-identical — now proven, not just designed on paper the way r6_tnodecls.md §3 left it.

§4 Negative controls

Every positive claim above has a paired negative control, re-runnable from the same script:

id claim result
NC1a appended-at-EOF passes the real ascending-offset check (§2.1, reimplemented exactly) pass
NC1b the same entry, hypothetically placed at offset 0 instead of EOF, fails that same check fails, as expected — proves the check is discriminating, not vacuously true
NC2 a reused (chunk,type) id (duplicate of an existing material id) is not caught by this project's own reader not caught — confirms r6_tnodecls.md's id-uniqueness rule is a convention the writer must enforce; the file format itself has no such constraint
NC3a this project's reader finds the new entry even when the header's entry-count field is left un-bumped found anyway (our reader resyncs to EOF, ignoring header counts)
NC3b …but the real engine's own count-bounded parse loop (§2.2, Init) would not reach an un-bumped append correctly predicted to miss it — the single sharpest "our tool is more lenient than the real engine" gap this round found

NC3 is the most important one: it is a direct, constructed demonstration that "round-trips through our own tooling" (what round 6 called the achievable ceiling without a runtime image) and "the real engine would parse it" are not the same claim, and that the gap between them is now a known, specific, fixable thing (bump the header count), not an unknown unknown.

§5 Would an appended entry actually be found? Synthesis

Three independent conditions, each now traced to a specific decompiled check, all satisfiable by the writer in §3:

  1. Offset must ascend (§2.1, BeginBackgroundLoads) — satisfied by appending strictly after the current end of .resources.
  2. The header's entry count gates whether Init ever parses the new entry at all (§2.2) — satisfied by bumping counter B (and A, for the distinct-name count the file format also tracks).
  3. The parsed entry is then hashed into a per-chunk table by name (§2.2) — nothing about this step is name-specific or type-specific in a way that would reject a well-formed new entry; it is the exact same code path every one of the chunk's other 4241 entries already goes through.

What this round cannot settle without launching the game: whether FUN_7ff74a4f9330's per-entry parse has some additional validation this round's reading missed (the ~4 reconciled-vs-13 tail byte gap, §2.3, §6), and whether anything downstream of the hash-table lookup (the actual decl/material/sound consumer, e.g. the decl-text parser or the material compiler) imposes a constraint the archive layer does not — out of scope for an archive-format track either way. Structurally, though, this is the first TNO round to end with "here is the exact mechanism that would have to find it, traced to its own insert loop" rather than "the mechanism is unknown, encrypted, out of reach."

PROVEN

  • The chunkN.index/.resources append-entry writer works end to end on a real archive copy: 14/14 checks, all 4241 pre-existing entries byte-identical after appending one new (material, name) entry, new entry found and its payload round-trips exactly (§3).
  • idBackgroundLoader::BeginBackgroundLoads (FUN_7ff74a4fefe0, rva 0x16efe0) enforces the offset-ascending invariant by direct comparison of two in-memory entries' offset fields — previously known only from its crash message, now read in the comparison instruction itself (§2.1).
  • idResourceManagerLocal::Init (FUN_7ff74a4f9680, rva 0x169680) builds, separately for each of the 14 chunks, an open-chained hash table (idHashIndex::Add pattern) over every entry it parses from that chunk's own .index file — answers "hash table, per-chunk" directly from decompiled code (§2.2).
  • Init's per-chunk entry-parse loop bound is an explicit header count field read fresh from the file, not an EOF sentinel — there is no termination condition that would let the real engine "just keep reading" past a stale count the way this project's own resync-based reader does (§2.2, confirmed discriminating by NC3).
  • The per-entry parser (FUN_7ff74a4f9330, rva 0x169330) reads type, name, path, then three consecutive 4-byte fields at exactly the stream position wolfsdk/resources.py already treats as offset/usize/csize — independent confirmation of the field layout from the engine's own parser, not just from successful round-trips against retail data (§2.3).
  • master.index parsing (FUN_7ff74a4faf20, rva 0x16af20) matches wolfsdk/resources.py:load_master() structurally: magic check, BE u32 pair count, length-prefixed name pairs (§2.4).
  • The magic 03 53 45 52 is checked by the engine as the literal 32-bit value 0x3534552 in at least two independent call sites (Init and master.index's parser) — a second, code-level confirmation of MAGIC (§2.2, §2.4).
  • The chunkN.index header's three maintained fields — "file size − 32" at offset 4, counter A at 0x20, counter B at 0x24 — hold their expected values on the real, unmodified chunk1.index (asserted in the script before any edit): size_field == filesize-32 and counterB == len(entries) exactly (§3).

INFERRED

  • That the per-chunk hash key is the entry's name alone, not a composed type+name string — supported by the hashed value coming from a single-idStr-sized local buffer right after the per-entry parse, but the exact contents of that buffer were not traced byte-for-byte (§2.2).
  • That func_0x7ff74ad58000 (the hash function itself) is a generic string hash (plausibly idStr::Hash) — inferred from its call shape (hash in, masked bucket out, fed straight into an idHashIndex-style insert), not from reading its own body (it sits in a gap this quick Ghidra import did not disassemble) (§2.2).
  • That a correctly-appended entry (ascending offset, bumped header count, cloned tail) would be accepted by the real engine's Init and become findable by name at runtime — every condition this round could check statically is satisfied by the writer in §3, but this is still a prediction from code reading, not an observed in-game result (§5).
  • That the ~4-byte gap between this round's traced tail structure (9 bytes for count==0) and r6_tnodecls.md's measured 13-byte tail reflects an unresolved field this round's reading missed, rather than a measurement error in either report — plausible, not reconciled (§2.3, §6).

FAILED HYPOTHESES

  • None outright refuted this round — this track built on round 6's open item rather than re-testing a specific round-6 claim. The one thing this round's own NC3 control disproves is an implicit assumption a writer might otherwise make: "if our own reader accepts an appended entry, the real engine would too." False — shown directly (§4): the real engine's parse loop is bounded by a header field our reader does not even consult.

§6 Open questions

  1. The actual in-game test (below) — not run, per the rules (never launch the game). This is the single biggest remaining gap between "proven structurally" and "proven to work."
  2. Whether the per-chunk hash key is name-only or type+name (§2.2) — would need one more decompiler pass specifically on what fills the local buffer handed to the hash call inside Init's loop body.
  3. func_0x7ff74ad58000's own body (the hash function) — in a gap this quick Ghidra import did not analyze; a deeper/slower analysis pass (not attempted this round, out of the ~15s-per-call budget this track had) could resolve it.
  4. The exact ~4-byte gap in the tail's structure (§2.3) — not load-bearing for the writer (which clones real tails verbatim) but would fully close r6_tnodecls.md's own open "tail not fully understood" item if resolved.
  5. Whether anything downstream of the archive/hash layer (the decl-text parser, the material compiler, a "known type names" allowlist) rejects a well-formed new entry for a reason this track's scope — the archive container, not the decl/material content pipeline — did not reach.

Recipe (for a future round that makes the writer productive)

(Translated from German.)

  1. Take re_probes/tnoaddentry/probe_append.py as the template, swap the game-data path (SRC_DIR) and the target type/name/payload.
  2. Always append at the real end of the file (never write into the middle of the file) — that is the one invariant that leads to a crash immediately when violated (§2.1).
  3. Carry both header counters and the "file size−32" field along — invisible to this project, but live-verified as load-deciding for the real engine (§2.2, §4 NC3).
  4. Never build tail bytes by hand — clone a real entry of the same type and set only the last 4 bytes (the running ID) to max(existing IDs of this type in this chunk) + 1 (§3, already measured in r6_tnodecls.md).

In-game test (for the maintainer)

(Translated from German.)

Nothing of this has been tried in the game yet — everything below is the smallest next step, not a result. Two stages, from the lowest-risk to the most meaningful:

  1. Stage 1 — does the engine load the bigger file at all without complaint? Experimentally put the chunk1 test folder built in this report (D:\wolf-decomp\tmp\tnoaddentry\copy\chunk1.index + chunk1.resources) over a backup copy of base/chunk1.index/chunk1.resources (never overwrite the original, back up beforehand as usual), start the game, look at qconsole.log. Expected according to this report: no line zlib inflate error, index header bad magic, FileTable is out of order and no crash at start — that directly confirms whether Init (§2.2) accepts the file that is one entry larger, before anything at all references the new name.
  2. Stage 2 — is the new name really found? First use python -m wolfsdk paths <type:name> to find a text decl field that already references an existing material name (e.g. a diffusemap or skin reference), redirect this field experimentally to the new name (material/tnoaddentry_test or a clone of a real, already used material) (set patch, the same mechanism as example_white_handgun/widerstand, already checked live). Load a map on which this field is actually used. Expected: the affected object looks unchanged (the cloned material is bit-identical to the original) and qconsole.log shows no "resource not found" line for the new name — that would be the first real proof that an appended entry is found by name by the running engine, not only by this project's own reading tool.

No damage expected in either stage: stage 1 touches no existing bytes (only appending), stage 2 clones real, already working content under a new name — in the worst case the map does not load or the object is missing, none of which damages a save.

Files

File Content
re_probes/tnoaddentry/probe_append.py The writer + self-test + 3 negative controls (§3/§4), re-runnable, writes only under D:/wolf-decomp/tmp/tnoaddentry/.
re_probes/agent_reports/r7_tnoaddentry.md This report.

Related: Research Report: R6 Tno Decls, Research Report: R4 Tno Custom Maps, File Formats, Modding Limits, TNO Modding Surface.