Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Latest research roundsNeueste Forschungsrunden

Tnodump: A Read-Only Runtime Capture Tool for TNO, and the Ghidra Import Prepared for the Moment a TNO Image Exists

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

In plain wordsIn einfachen Worten

Wolfenstein: The New Order (TNO) keeps its program code encrypted on disk under Steam's copy protection (SteamStub), so it cannot be read directly. This report copies the method already used for Wolfenstein II (TNC): a small tool that only reads the running game's memory and saves the unpacked code, plus a prepared Ghidra import to decompile it. The tool was tested only against a harmless stand-in program; neither game was launched for this report. A real TNO capture was made afterwards and is the basis of the reports from round 7 on.

  • The tool only asks Windows for read access and copies four sections of the program, each checked with its own hash; nothing is ever written into the game.
  • Its self-test on a stand-in program passed: a real capture, a repeat capture with identical hashes, and three failure cases that all leave zero bytes on disk.
  • The reviewer found a first-pass bug (files could stay on disk after a failed capture); it was fixed and the third failure case was added to test it.
  • The prepared import read 506 of 506 function ranges correctly from a test capture; about 50,884 functions are expected for TNO, against 121,682 for TNC.
  • The game was never started for this report, and finding TNO's console command table was left open for a later track.

Wolfenstein: The New Order (TNO) speichert seinen Programmcode unter Steams Kopierschutz (SteamStub) verschlüsselt auf der Festplatte, deshalb lässt er sich nicht direkt lesen. Dieser Bericht übernimmt die schon für Wolfenstein II (TNC) genutzte Methode: ein kleines Werkzeug, das den Speicher des laufenden Spiels nur liest und den entpackten Code speichert, dazu ein vorbereiteter Ghidra-Import zum Dekompilieren. Getestet wurde das Werkzeug nur an einem harmlosen Ersatzprogramm; für diesen Bericht wurde keines der beiden Spiele gestartet. Ein echtes Speicherabbild von TNO wurde danach erstellt und ist die Grundlage der Berichte ab Runde 7.

  • Das Werkzeug fordert von Windows nur Lesezugriff an und kopiert vier Abschnitte des Programms, jeden mit eigenem Hash geprüft; ins Spiel wird nie etwas geschrieben.
  • Sein Selbsttest an einem Ersatzprogramm bestand: ein echtes Abbild, ein Wiederholungsabbild mit identischen Hashes und drei Fehlerfälle, die alle null Bytes auf der Festplatte hinterlassen.
  • Der Prüfer fand einen Fehler aus dem ersten Durchgang (nach einem gescheiterten Abbild konnten Dateien liegen bleiben); er wurde behoben und der dritte Fehlerfall dafür ergänzt.
  • Der vorbereitete Import las aus einem Testabbild 506 von 506 Funktionsbereichen korrekt; für TNO werden etwa 50.884 Funktionen erwartet, gegenüber 121.682 bei TNC.
  • Für diesen Bericht wurde das Spiel nie gestartet, und die Konsolenbefehlstabelle von TNO zu finden blieb für eine spätere Untersuchung offen.

Technical detailsTechnische Details

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

Round-6 tooling pass: how the read-only TNC runtime capture was made, and an equivalent capture tool for The New Order (tools/tno_runtime_capture.py) plus a prepared Ghidra import, so that TNO's SteamStub-encrypted .text can be decompiled as soon as a capture exists. The tool was tested against a harmless stand-in process with a positive control, a determinism check and three negative controls; the games were never launched. From round 7 on, the reports work from a captured TNO runtime image (re_probes/runtime_tno_186352).

Pass 2 of 2. Pass 1's verifier found a real bug (files could land on disk before a capture failure was detected) and two gaps (an untested failure path; an unvalidated multi-snapshot selection). All three are fixed below, re-tested for real, and verified with the verifier's own reproduction method. See "Pass 1 errors" near the end for the short version.

Read-only on the game installs and on all project files outside this track's own area. New files only: tools/tno_runtime_capture.py, tools/ghidra_runtime_import_tno.py, a 4-line additive change to tools/decomp.py (new --gzf flag, default unchanged — see §4), scratch work under re_probes/tnodump/ (left empty, cleaned up after every test run), and this report. TNO and TNC were never launched. Every test below ran against a throwaway python.exe child process this track spawned and killed itself.

Question

TNO has no unpacked runtime image, which blocks decompiling it (.text is SteamStub-encrypted in both EXEs on disk — docs/ghidra.md, entropy 8.000). TNC has one (re_probes/runtime_87400/), captured read-only from a live, already-running process while SteamStub had unpacked .text in memory for it. How was that capture done, and can the same method be built for TNO as a tool the maintainer can run once, safely, while TNO sits at the main menu — tested now, without the game, against a harmless stand-in process, with a negative control — with the Ghidra import prepared so decompiling starts the moment a TNO image exists?

Answer (short)

Yes, done and tested. The TNC capture (tools/tnc_runtime_read.py) is ~110 lines of stdlib ctypes: OpenProcess(0x410, …) — PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, nothing else — CreateToolhelp32Snapshot/Module32FirstW/NextW to find the target module's base/size inside that PID, then ReadProcessMemory for the PE header plus four sections (.text .rdata .data .pdata), written to module.bin + manifest.json (base, size, per-section RVA/size/sha256, build sha256 of the file on disk). No write, thread or debug API appears anywhere in the file. tools/tnc_runtime_commands.py layers one more read-only pass on top for the console-command tables, gated by an exact build-hash check.

I built the direct TNO equivalent, tools/tno_runtime_capture.py, by parametrizing the one TNC-specific thing in that file (the hardcoded target module name) and adding a default output path (re_probes/runtime_tno_<pid>/) and an explicit post-write verification pass. Usage for the maintainer is one command with TNO running:

python tools/tno_runtime_capture.py --pid <PID>

I could not run this against TNO (never launch the games). Instead I ran its built-in self-test (--selftest) for real, right now, with no game involved at all — see §2 for the full transcript. It passed: a positive control (capture a live stand-in process, verify every section hash from the bytes actually on disk), a determinism check (two captures of the same idle process hash identically), and three negative controls (unknown module name; a dead PID; every per-section read failing right after a successful header read) that all fail closed with zero bytes written. The third control is new in this pass — it exercises the exact path pass‑1's verifier found broken; see "Pass 1 errors".

I then prepared tools/ghidra_runtime_import_tno.py, the TNO sibling of the proven tools/ghidra_runtime_import.py (which turns TNC's capture into 121,682 named, decompilable functions today). It autodetects the newest re_probes/runtime_tno_*/ snapshot, uses docs/ghidra/rtti_tno.json for class/vtable labels (same static image base 0x140000000 as TNC, asserted at runtime against the RTTI file, not assumed), and fails with one clear, actionable line right now, since no TNO snapshot exists yet (verified — see §3). Its core non-Ghidra logic (parsing .pdata RUNTIME_FUNCTION entries out of a captured image) I validated against a real throwaway capture: 506 functions parsed correctly out of a live python.exe's own .pdata. I also added one additive, backward-compatible flag to tools/decomp.py (--gzf, default = the current TNC path) so the existing decompiler query tool can point at a TNO GZF the moment one exists, without touching how it is used for TNC today.

What this does not and cannot do: it does not produce a TNO image — nobody but the maintainer can run the capture tool, because that requires TNO actually running, and the task forbids this agent from launching or attaching to either game. Nothing about TNO's code, commands or cvars was learned here; this track is infrastructure, proven on a stand-in, ready for the maintainer's one real run.

1. How the TNC capture actually works (read, not redone)

tools/tnc_runtime_read.py, Reader.__init__:

self.handle = self.k.OpenProcess(0x410, False, pid)   # QUERY_INFORMATION|VM_READ
...
snap = self.k.CreateToolhelp32Snapshot(0x18, pid)      # TH32CS_SNAPMODULE|SNAPMODULE32
... Module32FirstW/NextW until szModule.lower()=="newcolossus_x64vk.exe"

snapshot() then reads the PE header at the module base (0x1000 bytes), parses the section table itself (no pefile/third-party dependency — raw struct.unpack_from over the IMAGE_FILE_HEADER/IMAGE_SECTION_HEADER layout), and for exactly .text .rdata .data .pdata issues one ReadProcessMemory per section at base+rva, size vsize. Every section actually captured gets a sha256 and "captured": true in the manifest; sections outside that allowlist (.rsrc .reloc .bind — the SteamStub loader itself, icons, relocations) are listed with their PE metadata but never read. re_probes/runtime_87400/manifest.json confirms this is exactly what landed: access: "read only 0x410", layout: "virtual RVA", base 0x7ff62c210000, size 92,188,672, four sections with sha256+captured, the rest without. disk_sha256 ties the capture to one exact build of the file on disk (tools/tnc_runtime_commands.py re-asserts this hash before trusting any vtable offset it hardcodes).

The output is explicitly not a launchable or relocatable image — it's a sparse bytearray the size of the module, PE header at offset 0, each captured section's bytes written at its own RVA, everything else zero. That is exactly the "virtual RVA" layout tools/ghidra_runtime_import.py expects: it rebases a Ghidra program to the manifest's live base, and because RVA numbering is base-independent, .pdata RUNTIME_FUNCTION entries, RTTI VA-minus-static-preferred-base math and the console-command callback RVAs from tools/tnc_runtime_commands.py all line up without any relocation step.

tools/tnc_runtime_commands.py is a second, independent read-only pass: it re-derives idCmdSystemLocal's vtable RVA, asserts it against the known build hash and an expected vtable - base delta before trusting it, then walks two pointer arrays (all, exposed) and reads each command's name string, refusing to dereference any pointer that falls outside the module's own address range ("refusing speculative read"). This is the part that is not yet reproduced for TNO — see §5, Open questions.

2. tools/tno_runtime_capture.py — design and the self-test, run for real

Design: identical safety contract to tnc_runtime_read.py (PROCESS_QUERY_INFORMATION | PROCESS_VM_READ = 0x410, nothing else — grep the file, no WriteProcessMemory/CreateRemoteThread/ SetThreadContext/QueueUserAPC/DebugActiveProcess/SuspendThread appears), with three differences, all additive:

  1. Target module name is a parameter (--module, default WolfNewOrder_x64.exe — confirmed as the exe filename both in wolfsdk/game.py's Title table and by listing the install directory). This is what let me point the exact same code path at a harmless python.exe child for testing instead of TNO.
  2. output.mkdir() now happens after the module is found, not before (TNC's version creates the output directory, then opens the process and looks up the module — a failed lookup there still leaves an empty directory). Moving it after means a failed module-lookup run leaves nothing on disk.
  3. An explicit verify() pass: after writing, it re-reads module.bin + manifest.json back from disk and recomputes every captured section's sha256 independently, raising on any mismatch. This is the "verifies section hashes" step — it checks what's actually on disk, not just the in-memory bytes right after the read. (Pass‑1 bug, fixed in pass 2: snapshot() used to write module.bin+manifest.json to disk and then call verify(), so a capture where the header read succeeded but every per-section read then failed raised ValueError with non-empty files already on disk — contradicting the "zero bytes on failure" claim this report made in pass 1. snapshot() now wraps the write+verify in try/except Exception: shutil.rmtree(output); raise, so any failure after the directory is created — zero sections captured, a hash mismatch on re-read, anything — deletes it again before the exception propagates. See "Pass 1 errors" below.)

Default output: re_probes/runtime_tno_<pid>/ (matching the TNC naming convention runtime_<pid>), overridable with --out, both still constrained to stay under the repo's re_probes/ (same guard TNC's script has).

The test (no game, real run, full output below)

python tools/tno_runtime_capture.py --selftest

spawns one throwaway python.exe -c "import time; time.sleep(120)" as a stand-in for "TNO sitting idle at the main menu" (a stable, loaded, running process with real PE sections) and runs:

  • Positive control — capture that child's own python.exe module by PID, assert all four target sections got captured, run verify().
  • Determinism check — capture the same still-running process a second time, assert the two runs' per-section sha256 are identical (proves the read is a stable, repeatable observation, not reading garbage/timing- dependent memory).
  • Negative control A — same real, running PID, but ask for a module name that isn't loaded in it (doesnotexist.exe). Must raise, and must leave no output directory.
  • Negative control B — a PID that has already exited (a second throwaway child, killed and reaped first). Must raise, and must leave no output directory.
  • Negative control C (new in pass 2) — same real, running PID and module, but Reader.read is monkeypatched so the fixed-size 0x1000 header read still succeeds while every per-section read raises. This is the exact failure shape pass‑1's verifier used to reproduce the bug: the header parses fine, sections() returns real section metadata, but nothing gets captured. Must raise, and must leave no output directory — this is the control that pass 1 was missing.

Actual transcript from this session, unmodified (re-run after the fix):

spawned harmless stand-in: pid=120328 exe=<user folder>\AppData\Local\hermes\hermes-agent\venv\Scripts\python.exe
positive control OK: pid=120328 module=python.exe sections captured=['.text', '.rdata', '.data', '.pdata'] verified=4
determinism OK: repeat capture hashes identical (4 sections)
negative control A OK (bad module name): pid 120328 has no 'doesnotexist.exe' module
negative control B OK (dead pid 163524): [WinError 299] Nur ein Teil der ReadProcessMemory- oder WriteProcessMemory-Anforderung wurde abgeschlossen.
negative control C OK (all section reads fail): no sections captured -- nothing to verify
SELFTEST PASSED

(Transcript kept verbatim. The WinError 299 text is the German-locale Windows message for "Only part of a ReadProcessMemory or WriteProcessMemory request was completed.")

I also independently re-ran the verifier's own reproduction recipe (spawn a throwaway python.exe, monkeypatch Reader.read to fail every non-header read, call snapshot() directly) against the fixed code outside the self-test harness, to be sure the fix holds and not just the new assertion: raised ValueError: no sections captured -- nothing to verify as before, but now output dir exists after failure: False — confirmed zero bytes on disk.

One thing worth flagging precisely, not glossed over: for negative control B, OpenProcess(0x410, …) on the already-exited PID actually succeeded (a just-freed PID's kernel object can still answer briefly), and the toolhelp module snapshot also returned a module entry for it — it was the actual ReadProcessMemory call that then failed (WinError 299, partial read), which the code correctly surfaces as an OSError and treats as a hard failure (no file written). The control still proves what it needs to prove — a stale/wrong PID cannot produce a capture — but the exact failure point is one step later than I initially expected (read, not open). Noted here so nobody re-derives a wrong assumption from the error class alone.

After the run, re_probes/tnodump/ is empty (the self-test creates its scratch dir under there and shutil.rmtrees it in a finally block) — confirmed with ls immediately after.

3. tools/ghidra_runtime_import_tno.py — prepared, not runnable yet

Sibling of the proven tools/ghidra_runtime_import.py, same transaction order (disable analyzers before creating any function — docs/ghidra.md's documented pitfall: AutoAnalysisManager reads enablement at event time, and 121,682 createFunction() events already queued the wrong analyzers once before this was understood, 1409s vs ~290s), same two analyzers left on (ASCII Strings, x86 Constant Reference Analyzer), same GZF-export-to-temp -then-rename so a concurrent decomp.py read never sees a half-written file. Three differences:

  1. Autodetects the snapshot — find_snapshot() globs re_probes/runtime_tno_*/ for the newest one with both module.bin and a manifest.json whose sections list has at least one entry with "captured": true, instead of a hardcoded PID (TNC's script hardcodes runtime_87400 because that one capture is the project's fixed reference; TNO's PID will be whatever the maintainer's capture run used). New in pass 2: the manifest-content check — pass 1 only checked that module.bin/manifest.json exist, so a broken, zero-section snapshot (which could not actually happen before the pass‑1 bug fix above, but could in principle from a hand-edited or corrupted manifest) would have silently outranked a good older one by mtime alone. A snapshot that fails the check is skipped with one line to stderr, not picked silently. Tested with its own self-test (no Ghidra/JVM/game needed): python tools/ghidra_runtime_import_tno.py --selftest builds two throwaway re_probes/runtime_tno_999999001/ (good, older) and ..._999999002/ (broken: "captured": false, newer) fixtures, asserts find_snapshot() picks the good older one over the broken newer one, and deletes both. Run just now:

skipping ...\re_probes\runtime_tno_999999002: invalid/empty manifest (no captured sections) find_snapshot() selftest OK: skipped broken newer ..._999999002, picked good older ..._999999001 2. No curated function names, no console-command table — TNC's script names ~234 functions from tnc_runtime_commands.py's live command dump plus 8 hand-verified RVAs from other tracks' reports. Nothing equivalent exists for TNO yet: nobody has had a decompilable TNO image to find idCmdSystemLocal's vtable RVA in. CURATED_NAMES = {}; the command-table step is skipped outright rather than faked. This is real, flagged in §5/Open questions, not hidden in the diff. 3. Project/GZF names say tno (wolf_tno_runtime.gpr, tno_runtime.gzf), written beside the TNC ones under the same D:/wolf-decomp/ghidra/, so both can coexist and tools/decomp.py --gzf <path> picks between them.

Tested now, without Ghidra, in three ways:

  • --selftest (new in pass 2): find_snapshot()'s good-vs-broken selection logic, against real fixture directories — see above.
  • Ran it with no TNO snapshot present (the actual current state, re-checked again after this pass's edits): it prints exactly

no re_probes/runtime_tno_<pid>/ snapshot found -- run python tools/tno_runtime_capture.py --pid <PID> once while TNO is at the main menu, then re-run this script. and exits — no stack trace, no partial Ghidra project created. This is the state the moment this report is written, so it's a real test, not a hypothetical one. - Pulled read_pdata_funcs() out and ran it standalone against a real throwaway python.exe capture made the same way tno_runtime_capture.py makes one (same code path, same self-test child-process pattern): it parsed 506 well-formed (start_rva, end_rva) function ranges straight out of that capture's .pdata section, all start < end, matching the exact struct layout (<III triples: BeginAddress, EndAddress, UnwindInfoAddress) the proven TNC pipeline and tools/tnc_runtime_code.py both already rely on. This is the one piece of TNO-import logic that can be exercised without a real TNO capture or a running Ghidra/JVM, and it checks out.

RTTI_IMAGE_BASE = 0x140000000 is asserted, not assumed: the script reads docs/ghidra/rtti_tno.json["image_base"] and asserts it equals 0x140000000 before using it for any VA→RVA math — docs/ghidra.md already states both EXEs share this static preferred base, and rtti_tno.json's own header confirms it.

4. tools/decomp.py — one additive flag

Added --gzf PATH (default: the existing hardcoded TNC path, unchanged behavior for every existing call site/report that invokes decomp.py without it). Once ghidra_runtime_import_tno.py has produced D:/wolf-decomp/ghidra/tno_runtime.gzf, querying it needs nothing new:

python tools/decomp.py <rva|name> --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf

Everything else about decomp.py — the copy-on-open throwaway project per call so concurrent agents never contend on a lock, --xrefs/--callers/ --strings — is untouched and still applies to TNO once there's a GZF to point it at.

PROVEN

  • The exact read-only mechanism re_probes/runtime_87400/ was captured with: OpenProcess(0x410, …) + toolhelp module lookup + per-section ReadProcessMemory, four sections only, sha256 per section, no write API anywhere in the capturing code (tools/tnc_runtime_read.py, read in full).
  • tools/tno_runtime_capture.py performs a real, verified, read-only capture of a live process's module by PID + module name: proven by running it against a throwaway python.exe child, twice, with identical per-section hashes both times, and with every captured section's hash re-verified from the bytes written to disk.
  • All three negative controls fail closed with zero bytes written: a wrong module name on a real PID, a PID that no longer exists, and (new in pass 2, fixing the gap pass‑1's verifier found) a real PID/module whose header read succeeds but every per-section read then fails.
  • tools/ghidra_runtime_import_tno.py's find_snapshot() skips a broken (zero-captured-sections) snapshot even when it is the newest by mtime, picking the good older one instead — proven against two real throwaway fixture directories, both deleted after the check.
  • tools/ghidra_runtime_import_tno.py's .pdata-parsing logic (the only part of the Ghidra-import pipeline that doesn't require Ghidra itself) correctly parses RUNTIME_FUNCTION ranges from a real captured image (506/ 506 well-formed ranges from a live python.exe).
  • tools/ghidra_runtime_import_tno.py fails safely and informatively when no TNO snapshot exists — the actual current state, tested for real.
  • TNO's static preferred image base is 0x140000000, same as TNC (docs/ghidra/rtti_tno.json, docs/ghidra/pdata_tno.json, cross-checked against docs/ghidra.md) — the precondition the RTTI-labeling step in the import script relies on, asserted at runtime, not hardcoded blindly.

INFERRED (not run against TNO itself — cannot be, see Rules)

  • That tno_runtime_capture.py --pid <PID> will succeed against the real WolfNewOrder_x64.exe process the same way it did against python.exe. Inferred from: identical Win32 API usage to the proven TNC tool (which has captured a real game process), identical PE-parsing code, and a clean self-test against a real OS process. The one thing this cannot rule out without an actual run is whether SteamStub has fully unpacked TNO's .text in memory by the time the maintainer captures it (true for TNC's capture — the module's .text section there is real, decompilable code per the existing 121,682-function pipeline) — TNO uses the same packer per docs/ghidra.md's identical entropy table (8.000 for both .text sections on disk), so the same in-memory-unpacking behavior is expected, but "expected by analogy" is not "measured for TNO."
  • That tools/ghidra_runtime_import_tno.py will complete end-to-end once a real TNO snapshot exists — the Ghidra-dependent two-thirds of it (import, disassemble, function creation, analysis, GZF export) is untouched, line-for-line, from the script that has already done exactly this for TNC (121,682 functions, ~290s limited analysis). Only the snapshot-independent third (.pdata parsing) could be tested here and was.

Failed hypotheses / rejected designs

  • Auto-discovering TNO's PID by process name, so the maintainer could run the tool with zero arguments. Rejected in favor of requiring --pid explicitly, matching the proven TNC tool: matching by szExeFile alone over a process-list snapshot can't safely disambiguate if more than one process ever shares that name, and a silent wrong-PID match is worse than asking the maintainer to paste one number from Task Manager. Correction (pass 2): pass 1 cited wolfsdk/game.py's Game.launch() docstring as evidence for this, claiming it "notes a Steam-side bounce/relaunch quirk" implying two concurrently-live same-named processes. Re-read for this pass: that docstring actually describes the opposite — a direct (non-Steam) launch of the SteamStub-wrapped exe gets bounced back to the Steam client and the bounced process is killed immediately, "dies so early that it never even opens qconsole.log" — a dead-on-arrival process, not a second live one. It is not evidence for "more than one same-named process can be running at once." The design decision (require --pid) is still kept, because the simpler reason above — no safe way to disambiguate multiple same-named processes in general, and it matches the already-proven TNC tool exactly — is sufficient on its own and doesn't need that citation.
  • Capturing every PE section, not just .text/.rdata/.data/.pdata. Rejected to match the proven TNC capture exactly: .rsrc/.reloc/.bind (the SteamStub loader itself) add nothing the decompiler needs and only grow the image/attack surface of "what did we read from a running game."
  • Keeping output.mkdir() before the module lookup (TNC's original order). Changed for the TNO tool specifically because the self-test's negative controls need to assert zero footprint, and an empty leftover directory on every failed/mistyped run would be exactly the kind of project-file clutter the rules ask to avoid.

Recipe for the maintainer

  1. Start TNO, get to the main menu (idle state — nothing loading, no level active — minimizes the chance a section read lands mid-stream-write; this is also when TNC's reference capture was most likely taken, per how every later TNC track describes runtime_87400 as a stable, reusable snapshot).
  2. Task Manager → Details tab → find WolfNewOrder_x64.exe → note the PID.
  3. In a second window, from the repo root: python tools/tno_runtime_capture.py --pid <PID> This only reads; TNO keeps running untouched. Output: re_probes/runtime_tno_<PID>/module.bin + manifest.json, with per-section sha256 already verified against what's on disk before the command returns.
  4. Close TNO (capture is done, no need to keep it running).
  5. ``` python tools/ghidra_runtime_import_tno.py

Needs the same Ghidra+PyGhidra-venv setup `docs/ghidra.md` already walks through for TNC (`<user folder>\tools\pfade.json` etc.) — if that's set up for TNC already, nothing new to install. Expect roughly TNC's order of magnitude (~50,884 functions vs. TNC's 121,682 — TNO's `.pdata` is smaller, so import should be faster, not slower). 6. python tools/decomp.py --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf `` works exactly like the existing TNCdecomp.pycalls throughoutre_probes/agent_reports/r*.md`, just pointed at TNO's GZF.

From there, the natural next track is a TNO analog of tools/tnc_runtime_commands.py — finding idCmdSystemLocal's vtable RVA in the new TNO disassembly the same way r4_consolecmds.md/ tnc_runtime_commands.py did for TNC — which would unlock the same resourceExec-style non-exposed-command story (docs/limits.md already shows TNO's retail cvar lock is narrower than one might assume, e.g. g_permaGodMode/g_permaInfiniteAmmo are not locked — a code-level command/cvar table for TNO would make that list exact instead of empirically sampled).

Open questions

  • Whether SteamStub has actually finished unpacking TNO's .text in memory by the main menu, the same way it evidently had for TNC's capture (see INFERRED above) — only answerable by the maintainer's actual capture + a quick entropy/disassembly sanity check on the result (first instructions should decode as plausible x86-64, not near-random bytes).
  • TNO's idCmdSystemLocal vtable RVA / command-table layout — not found yet, needed for a tnc_runtime_commands.py TNO equivalent, needs a real TNO disassembly to locate (chicken-and-egg with this track's own output: solvable the moment step 5/6 above produces a queryable GZF).
  • Whether TNO's class hierarchy differs from TNC's beyond what docs/ghidra.md already shows via RTTI alone (TNO has no Havok RTTI, no idManagedClass in the inheritance chain) — decompiled method bodies, once available, would show whether that's a deeper engine difference or just vtable slot content.

Pass 1 errors

Pass‑1's verifier ran this track's code for real and found one actual bug and two untested gaps. All fixed and re-verified in pass 2:

  1. Bug (confirmed, fixed): snapshot() wrote module.bin+manifest.json to disk before checking whether any section was captured. If the PE header read succeeded but every per-section ReadProcessMemory then failed, the function raised ValueError("no sections captured") with non-empty files already on disk — directly contradicting pass 1's "zero bytes on disk on failure" claim. Fix: the write + verify() call is now wrapped in try/except Exception: shutil.rmtree(output); raise, so any failure after the directory is created deletes it again. Re-verified two ways: a new negative control C in --selftest (header read OK, all section reads fail → raises, out.exists() is False), and an independent re-run of the verifier's own monkeypatch reproduction outside the self-test harness, which now prints output dir exists after failure: False.
  2. Missed test (added): neither original negative control exercised the above path (both failed earlier — module lookup, or a dead handle). Negative control C above closes this gap directly.
  3. Missed test (added): ghidra_runtime_import_tno.py's find_snapshot() picked the newest runtime_tno_*/ directory by mtime with no check on its contents, so a broken, later capture could have silently shadowed a good earlier one. find_snapshot() now requires at least one "captured": true section in the manifest and skips (with a stderr line) anything that doesn't qualify; a new --selftest flag proves this with two real throwaway fixture dirs (good-older vs. broken-newer) and deletes them afterward.
  4. Inaccurate citation (corrected): pass 1's justification for requiring --pid over PID auto-discovery cited wolfsdk/game.py's Game.launch() docstring as describing two same-named processes able to run concurrently. Re-read for this pass: that docstring describes a dead-on-arrival bounced process (killed immediately, no window, no log), not a concurrency scenario. The design decision itself is unchanged and still matches the proven TNC tool; the citation is corrected under "Failed hypotheses" above rather than repeated here.

Not changed: the verifier's other five checks (safe_api_only, selftest_reproducible in its pre-fix form, pdata_506_count, ghidra_import_fails_clean, decomp_gzf_additive, rtti_image_base, module_name_and_files_claim) were not refuted and needed no fix; nothing in this pass touched tools/decomp.py or the RTTI/pdata TNO docs.

Smallest in-game test for the maintainer

(Translated from German.)

No in-game test is needed for this tool itself — it does not touch TNO as long as TNO is not running. The only step that actually needs TNO is the one capture command:

  1. Start TNO, wait until the main menu (do not load a map).
  2. Task Manager → Details → read the PID of WolfNewOrder_x64.exe.
  3. Run python tools/tno_runtime_capture.py --pid <PID> — it only reads, writes nothing into the game process, TNO stays untouched in the main menu.
  4. Check the output directly afterwards: re_probes/runtime_tno_<PID>/ manifest.json should show four sections with "captured": true and one sha256 each (same form as re_probes/runtime_87400/manifest.json). That alone already proves: the capture tool works against the real game just as in the self-test against the Python stand-in process.
  5. Then close TNO normally. The rest (Ghidra import, decompiler queries) runs offline against the file, without TNO having to run again.

Related: Research Report: R4 Console Commands, Research Report: R7 Tno Cheat Gate, Ghidra Analysis, Modding Limits.