Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Research reportsForschungsberichte

TNC Command Registry – Runtime Image Analysis

SolvedGelöst Original page in the GitHub wikiOriginalseite im GitHub-Wiki

In plain wordsIn einfachen Worten

In Wolfenstein II: The New Colossus, typing map or devmap in the console gives "unknown command". This report listed all 234 registered commands from a memory image of the running game. It found that the console runs in a restricted mode that only searches 34 exposed commands. The command resourceExec is one of those 34 and, according to the code, runs a script file in unrestricted mode. That could unlock map loading without patching the game, but this has not been tested at runtime.

  • 234 commands are registered; two independent methods agree exactly with a live table (0 differences).
  • map, devmap, teleport and killMonsters are registered but not exposed; spawn, noclip and lowercase god do not exist.
  • The restriction is an internal context value, not a cvar; developer-mode settings do not change it.
  • resourceExec sets this value to unrestricted, so a script file run through it should be able to call map (static finding only).
  • loadGame and saveGame are exposed and can be called from the console directly.

In Wolfenstein II: The New Colossus liefert map oder devmap in der Konsole nur „unknown command“. Dieser Bericht hat alle 234 registrierten Befehle aus einem Speicherabbild des laufenden Spiels aufgelistet. Ergebnis: Die Konsole läuft in einem eingeschränkten Modus, der nur 34 freigegebene Befehle durchsucht. Der Befehl resourceExec gehört zu diesen 34 und führt laut Code eine Skriptdatei im uneingeschränkten Modus aus. Das könnte das Laden von Karten ohne Patch am Spiel ermöglichen, wurde aber noch nicht im laufenden Spiel getestet.

  • 234 Befehle sind registriert; zwei unabhängige Methoden stimmen exakt mit einer Live-Tabelle überein (0 Abweichungen).
  • map, devmap, teleport und killMonsters sind registriert, aber nicht freigegeben; spawn, noclip und ein kleingeschriebenes god gibt es nicht.
  • Die Sperre ist ein interner Kontextwert, keine Cvar; Entwicklermodus-Einstellungen ändern daran nichts.
  • resourceExec setzt diesen Wert auf „uneingeschränkt“. Eine darüber ausgeführte Skriptdatei sollte daher map aufrufen können (nur statischer Befund).
  • loadGame und saveGame sind freigegeben und lassen sich direkt aus der Konsole aufrufen.

Technical detailsTechnische Details

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

Worker w-cmdtable-a1, Task cmdtable. Source: re_probes/runtime_87400/module.bin (92,188,672 Bytes, Layout "virtual RVA", Base 0x7FF62C210000, pid 87400). Tool: tools/probe_cmdtable.py, Result: re_probes/cmdtable/commands.json. All data is purely static and read-only; no game launch, no process attach, no write access.

PYTHONPATH=re_probes/python_deps python tools/probe_cmdtable.py

Summary

map and devmap are registered. They are simply not discoverable because the Dispatcher searches in one of two command lists depending on the execution context. The console always runs in a restricted context and sees only 34 of 234 commands. This is not a Cvar or a launch parameter, but an internal context value – however, there is already an unlocked command that legally sets it to "unrestricted": resourceExec.

This confirms result variant (c) of the briefing, with the caveat: the command exists, is gated, and the gate is reachable without a patch – but runtime confirmation is still pending.

1. Registry Structure

idCmdSystemLocal resides as a Singleton in .data at RVA 0x4104420 (vtable 0x2978618, verified). It contains two idList heads:

Field RVA Meaning Status in Snapshot
+0x08 / +0x10 / +0x14 0x4104428 List all 234 / Capacity 256
+0x28 / +0x30 / +0x34 0x4104448 List exposed 34 / Capacity 64
+0x200b8 0x41244d8 Restriction-Level 0 (Idle)

The two arrays themselves are Heap allocations (0x15e579120, 0x21c170200) and lie outside the Module Capture. A pure data run over the live table is therefore impossible offline – enumeration had to proceed via registration.

Node layout (read from AddCommand itself, not assumed), 0x38 Bytes:

+0x00 name*   +0x08 function*   +0x10 argCompletion*   +0x18 description*   +0x20 flags

idCmdSystemLocal::AddCommand (vtable slot 4, RVA 0x1264840, identified via the string idCmdSystemLocal::AddCommand: %s already defined) converts the Flags:

0x12648e8  mov  ecx, [rsp+0xa8]     ; flags-Argument of caller
0x12648f3  or   esi, 4
0x12648f6  and  al, 2
0x12648f8  cmove esi, ecx           ; stored = (arg & 2) ? arg|4 : arg
...
0x1264a37  test byte [rdi+0x20], 4  ; only with bit 2 also in the second list
0x1264a3b  je   ...

Bit 1 (0x2) is passed as a constant at the call site; Bit 2 (0x4) derives from AddCommand and decides inclusion in the exposed list. There is no code path that changes this subsequently.

2. Two Independent Attacks

Code Attack – Disassembly (capstone 5.0.7, vendored) of every function referencing the Singleton or calling AddCommand directly; Register/Stack-slot tracker reads name, callback, description, argCompletion, flags from each call site. Result: 165 call sites (virtually call [rax+0x20] and directly call 0x1264840 in idCmdSystemLocal::Init).

Data Attack – two static registration forms in .data:

  • Self-registration list, head at 0x30B5900 → first node 0x2fe4d00, read by Init at 0x1264794 and passed to AddCommand via a loop (0x126479b–0x12647ca). 63 nodes (0x30 Bytes, {next,name,fn,comp,desc,flags}).
  • Flat table at 0x2efd230, 0x28-Byte Records: 6 entries (mdnt_*).

Sum: 165 + 63 + 6 = 234. Overlap 0.

3. Checks

Control Result
Positive control: writeConfig, conDump, listCvars, exec, bind, toggle, TakeScreenshot all present – OK
Cross-check against live table re_probes/runtime_87400/commands_live.json 234 vs. 234, only_live=[], only_static=[], 0 flag deviations, 0 exposed deviations
Negative control --sec-delta 0x8 / 0x28 / 0x40 / -0x1000 / 0x200000 each terminates with Exit 2, no table
Negative control --base-delta 0x10000 Terminate: vtable 0x2968618 != expected 0x2978618
Negative control --sec-delta -0x1b0 (Offset containing a module pointer) Terminate at vtable comparison, not at pointer test
Negative control with --ignore-anchor (intentionally bypassing anchor guard) Chain-Walk yields 0 nodes, no plausible table

Flag distribution is unambiguous: 0x6 → 34 times, all exposed; 0x0 → 196 times; 0x200000 → 4 times (God, InfiniteHealth, NoPlayerDeath, Notarget), none of which are exposed. Flag↔exposed correlation: 234/234.

4. Why map reports "Unknown command"

Dispatcher idCmdSystemLocal vtable slot 16, RVA 0x1265150:

0x12651a7  mov   eax, 0x28              ; Offset exposed list
0x12651ac  mov   ecx, 8                 ; Offset all list
0x12651b1  cmp   dword [r14+0x200b8], 0
0x12651b9  cmove eax, ecx               ; Raw bytes 0f 44 c1 = CMOVZ

Level 0 → search in all 234; Level ≠0 → search only in the 34 exposed. No match → 0x126533e → Unknown command '%s'\n (0x2982790).

The level (cmdSystem+0x200b8, RVA 0x41244d8) is not a Cvar. A complete scan of all 121,682 functions / 7,072,620 instructions yields 146 references; as a literal, only 0 (constructor 0x1214619) and 1 are written, everything else is Save/Restore pairs or passed-through parameters. Setting it to 1 is done by:

Context Function
Console input 0xa8db00 (sets 0xa8db77, restores 0xa8e42a), 0xa90410
Start parameter +… 0xa5fdf0 (references com_enableDeveloperMode, devMode_)
Start Config 0xa60220
Common Init/Frame 0xa638b0
Command exec 0x12638e0 (0x1264170)

Therefore, map, devmap, and also +map as a start parameter fail, while writeConfig/conDump/listCvars work. The snapshot shows Level 0 because it was taken in idle state outside any command execution – this is consistent, not a contradiction.

Addendum to the Cvar question: devMode_enable (int value at 0x2fe3790, currently 1) only switches the branch at 0x12652ce ("Attempting to call a developer command" → warning and execution, instead of "Dev Mode is not enabled…"). This branch is unreachable at Level 1 because the search there never happens outside the exposed list. com_enableDeveloperMode=1, devMode_enable=1, g_demoDevMode=1 change nothing at Level – the measurement is thus fully explained.

5. Likely path without patch: resourceExec

resourceExec is one of the 34 exposed commands (flags 0x6, callback 0xa7a040), so callable from the console:

USAGE: resourceExec <filename> -s [define]: execute a script file

Unlike exec (enforces Level 1), it sets the level to 0:

0xa7a74b  mov   ebx, [0x41244d8]     ; save old level
0xa7a76d  mov   edx, edi             ; edi = 0
0xa7a76f  mov   [0x41244d8], edx     ; Level := 0
0xa7a790  lea   rdi, [0x4104470]     ; buffer "unlimited"
0xa7a797  lea   r15, [0x41144a0]     ; buffer "limited"
0xa7a7a6  test  edx, edx
0xa7a7a8  cmovne rcx, r15            ; edx=0 -> unlimited buffer

There are two separate command buffers in .data that preserve the level across the delayed execution. The frame drain empties them with the respective level:

0xa6569d xor edx, edx ; Level 0 0xa6569f call 0x1265800 ; -> unrestricted buffer 0xa656a4 mov edx, 1 ; Level 1 0xa656b0 call 0x1265800 ; -> restricted buffer

This causes the content of a script file executed via resourceExec to run at Level 0, allowing the dispatcher to search all 234 commands – map, devmap, teleport, killMonsters, etc., would be accessible there.

Status: static derivation, not verified at runtime. Verifiable prediction: a script file in the Resource archive containing the line map <name>, invoked as resourceExec <file>. A runtime test was forbidden to me.

Second verifiable prediction that would falsify the model: listCmds selects its list at 0x1262bf3/0x1262bfa also via the level. Therefore, it must output exactly 34 commands when entered in the console. Similarly, toggle (flags 0x0) must deliver "Unknown command" in the console, even though it is registered.

6. Verdict by Name

Name registered Flags exposed Callback Source
map yes 0x0 no 0xa7e2c0 Call site 0xa6f707
devmap yes 0x0 no 0xa7e0d0 Call site 0xa6f765
spawn no – – – no call site, no record
teleport yes 0x0 no 0xf3bdf0 Call site 0xf46751
god (small) no – – – –
God (large) yes 0x200000 no 0xfdfaa0 Registrar list 0x2fb4bf0
noclip no – – – –
killMonsters yes 0x0 no 0xf39df0 Call site 0xf4683c
reloadDecls yes 0x0 no 0xa415e0 Call site 0xa3a682
listCmds yes 0x6 yes 0x1262b10 Init, direct call 0x12646b6

Additional map loaders, all with flags 0x0 and thus not exposed: fastmap (0xa7dee0), loosemap (0xa7e3e0), cmap (0xa7e2c0, same callback as map), checkmap (0xa7dcc0), slowmap/slowmapcheckpoint/slowcombomap, nextmap (0xa80600), RestartMap*, loadGameIntoMap (0xa85d80), LoadPreviousMapFromSaveGame (0x11dedd0), restartmapwithlobby (0xa80830). They all share the argument completion 0xa7e500.

For the save route relevant: loadGame (0xa85680) and saveGame (0xa86250) are exposed (flags 0x6) and thus directly callable from the console; the non-exposed loadGameIntoMap uses the same completion function 0x12667c0.

The 34 exposed commands: activateConsole, bind, BuildInfo, BuildInfoToClipBoard, clear, conDump, configVersion, dumpWarnings, echo, exec, exit, find, getviewpos, listBinds, listCmds, listCvars, loadGame, parse, print, quit, resourceExec, RS_ValidateContainers, saveGame, TakeScreenshot, ToggleMainMenu, unbind, unbindAll, vid_restart, vstr, vt_delayedRestart, vt_restart, wait, where, writeConfig

7. Open Points and Risks

  • The resourceExec path is not verified at runtime. It remains unclear from which path the file is read (the name suggests the Resource archive) and whether name resolution imposes additional restrictions.
  • One call site (0x12647bf) remains intentionally unresolved in the code attack – this is the loop that iterates through the Registrar list, and is covered by the data attack.
  • "String present in .rdata" was not consistently treated as evidence. Each of the 234 entries either has a decoded AddCommand call site or a validated static record; the count matches exactly with the live table.