Skip to content

Research Report R8 Devmenu Fix Verify

LopeKinz edited this page Oct 4, 2026 · 1 revision

Verification of mods/tnc_devmenu_fix (Static Only, runtime_87400 Snapshot)

Round-8 static verification of the tnc_devmenu_fix mod against the TNC runtime snapshot: the selector branch and field offsets, the restriction level, devmap and the si_* cvars, and escaped quotes in decl strings. All four claims hold (one gap noted), and the build tool needs no change. Static only; nothing was run in game.

Verdicts: 1 HOLDS, 2 HOLDS (one gap noted), 3 HOLDS, 4 HOLDS (static evidence only). No claim refuted, no change to tools/build_devmenu_fix.py required. Nothing was run in game; no files touched except this one.

1. Selector branch and field offsets: HOLDS

Selector body re-read from re_probes/devmenu_restore/selector-unwind.txt (unwind reconstruction of 0x3D03B0). Item stride 0x150, item base = *(decl+0x80) + idx*0x150.

Field table (reflection records in .rdata, name ptr + offset dword), read from module.bin:

field file off of record item offset type
devMenuDisplayName 0x21252e0 +0x00 idStr
devMenuSubList 0x2125330 +0x30 decl ptr
devMenuMapName 0x2125380 +0x38 idStrMapName (data +0x40, len +0x48)
devMenuTestExtraLayers 0x21253c0 +0x68 idList
devMenuCommandStrings 0x2125420 +0x88 idList<idStr> (ptr +0x88, count +0x90)
devMenuCommandStrings_AfterMapLoad 0x2125470 +0xA8 idList<idStr>
devMenuCheckpoint / Spawnspot +0xC8 / +0xF8 idStr
devTimelineType +0x128 enum
devVariation +0x130 decl ptr
devPart / devProgression / devGameType +0x138 / +0x140 / +0x148 int / ptr / enum

So +0x48 is the length of devMenuMapName (idStr at +0x38, data +0x40, len +0x48; the playSnapMap branch uses +0x40 as the char*), +0x88/+0x90 is devMenuCommandStrings, NOT AfterMapLoad (+0xA8). Command strings are read as *(+0x88) + 8 + k*0x30 (idStr data ptr at +8, stride 0x30), formatted with "%s\n" (0x25fcd44) and appended to the buffer.

Gates in the selector, state 0x2ac==0 && 0x2ad==0 && 0x2ae==0 (normal list state):

  • name length (+0x10) != 0, else return (we always set a display name).
  • strcmp(name, DAT_ec) where DAT_ec = "..." (RVA 0x26118ec): equal -> return. Only a label literally "..." is skipped. The follow-up test ((~u)>>31)*-2 == 1 can never be true (value is 0 or 2), so it never returns.
  • +0x30 == 0 (no sublist): true for our entries.
  • +0x148 == 1 (GAME_TYPE_PVP; enum order read at file 0x20ab8e8: 0 CAMPAIGN, 1 PVP, 2 SCORE, 3..6 CHRONICLE_0..3): we never emit devGameType, so the field keeps its zero default. Default value not read from a constructor (not traced), but retail items without devGameType (demo "Xmas-2015") take the normal campaign path, which is consistent.
  • +0x48 == 0 && +0x90 > 0 -> buffer loop. Variation ptr (+0x130) is NOT tested in this branch at all. Our entries carry no devMapName, so this holds. After the loop: both buffers are executed, 0x2ac/0x2ad/0x2ae cleared, return (menu stays open, Update then resets +0x2b4 to -1). Display name compare and variation pointer do not block the branch.

Execution: FUN_7ff62d475800(cmdSys, 0) then (cmdSys, 1) = RVA 0x1265800. Disassembly (0x1265800-0x1265afd): param (ebx) is written to the restriction level 0x41244d8 first, the buffer is then chosen by that level (cmp [rbp+0x200b8],0 ; cmove edi,0x50 -> 0x4104470 at level 0, 0x41144a0 otherwise), lines are executed in a loop (jg 0x12658a4 after call 0x1265150 while buffer length > 0), and the old level is restored at 0x1265aca. Caveat: Ghidra shows only one line because 0x1265150 is marked noreturn; the raw loop is there. Result: the unrestricted buffer is ALWAYS drained at level 0, the restricted buffer at level 1.

2. Restriction level: HOLDS (one gap)

The selector chooses the buffer from the ambient level (0x3d0497: cmp [0x41244d8],0; lea rcx,0x4104470; cmovne rcx, 0x41144a0; call 0x12124a0 = idStr append). If ambient level were 1 the lines would land in the restricted buffer and run at level 1 (non-exposed commands rejected, "Attempting to call a developer command"). Full list of writers of 0x41244d8 (my own capstone scan of .text, not just r4's table): 0xa5fe5c (+args), 0xa6029a (startup exec), 0xa654c8 (Init), 0xa8db77 and 0xa90aaa (console key handlers), 0x1264170 (exec), 0xa7a76f (resourceExec, level 0), 0x126586f (ExecuteBuffer arg), 0x1214619 (constructor). NEW vs r4: 0xaa87fb in FUN 0xaa8780: sets level 1, appends a bound command + "\n" into the RESTRICTED buffer (0x41144a0), restores. It is the key-bind dispatcher; it only buffers, it does not run game code. Every site is a save/set/restore bracket. None of them wraps the widget Update (0x3D0260, vtable 0x260F360 slot +0x50, a generic per-frame widget update). Hence ambient level is 0 when Update runs. Gap: I did not trace the call chain from the UI frame up to the root, so "no bracket is an ancestor of the widget update" is argued from the bracket list (all are input/exec/Init), not proven by a call graph. A bind to a menu confirm key would only buffer, not run Update, so it does not matter.

3. devmap and si_* cvars: HOLDS (this was the important one)

Registration: commands_live.json "all" table (234) contains devmap (flags 0, callback 0xa7e0d0; slowcombomap is an alias with the same callback), not in the exposed 34. Fine at level 0.

Cvar objects (name read at object+0x48, flags at +0x60):

  • 0x2F4F140 si_spawnspot (0x8 = CHEAT), 0x2F4F1D0 si_nextlayeractive (0x8), 0x2F4F260 si_filllayeractive (0x8), 0x2F4F2F0 si_devprogression (0x8), 0x2F4F530 si_gamemode (0xA = INT|CHEAT), 0x2F4F5C0 si_mainmenustart (0x9).
  • si_checkpointName is NOT among the addresses given in the brief: it is at 0x2F4D3B0 (flags 0x8).
  • No INIT (0x4000) or ROM (0x8000) bit on any of them. Set path for a console line is idCVarSystemLocal vtbl+0x88 (0x1239f20) -> idCVar::Set(force=0) = 0x1239140: INIT -> "can only be set on the command line", ROM -> "is read only", level!=0 and no NOCHEAT(0x10) -> "Attempting to set a developer cvar". There is no CHEAT (0x8) gate in that function. At level 0 all of our cvars are settable.
  • si_filllayeractive "": tokenizer 0x11fc230/0x11fc380 marks quotes with 0xff, strips them later and keeps empty quoted tokens, so argc==2 and Set(cvar,"") runs (argc==1 would only print the current value).

Does the map parser overwrite them? No. Scan of every rip-relative reference to the objects (+0..0x90):

  • Parser 0xA7BD10-0xA7D816 touches only: reads si_nextlayeractive (+8) at 0xa7ced9 and si_filllayeractive at 0xa7cf0b, each ONLY if (local arg string length == 0) (cmp [rbp+0x28],0 / cmp [rbp-8],0), i.e. when no -layername/-filllayername was given the cvar is the default. Also reads si_nextbakevariation (0x2f4d328) and si_gamemode value (0xa7cf71). It WRITES only at 0xa7d5c5: si_nextlayeractive := "" and si_nextbakevariation := "" AFTER consumption (end of parse, decomp line 1551 of native-map-decomp.txt).
  • Parser never references si_spawnspot, si_checkpointName, si_devprogression. -spawnspot/-checkpoint args go to locals.
  • si_spawnspot is consumed (read, then cleared to "") at 0x11dc885-0x11dc8d6 during map start; si_checkpointName consumed/cleared at 0x11b6a02-0x11b6a64; si_devprogression consumed at 0x11b808b, cleared 0x11b8122. All one-shot inputs, which is what the cvar lines rely on. Other writers: native launch 0x3ECF50 (the code the build mimics: same cvars, defaults spawn "player_start_1", gamemode 0 / 2 / 3..6 + mainmenustart 1 for score/chronicle), 0x991745 (chapter start), loadGame 0xa85924/0xa85edb, regression 0xb20dd4/0xb20ea0. None runs between our lines and devmap. Failed hypothesis: "parser resets si_* to defaults when no args": not found anywhere. Minor, not verified: the native launch also copies the shell difficulty into si_nextGameDifficulty / si_challengeDifficulty / si_freedomChronicleDifficulty (0x2f4f410/380/6e0) when < 7. The fix leaves them untouched (empty/previous). Whether an empty difficulty cvar matters at map start is unchecked. Buffer capacity: append (0x12124a0) fails silently if a static buffer is full; our ~8 short lines are fine.

4. \" in decl strings: HOLDS (static, indirect)

  • Engine idLexer escape reader at 0x11fd090 handles \" -> 0x22, \\ -> 0x5c, \n \t etc. (skipped only if lexer flag 0x20000 is set). A \" therefore does not terminate a string when that flag is clear.
  • Retail evidence of the same escape in text decls: credits entry0 = "Vladimir \"Nomad\" Goryachev";, gameMode online/campaign titleStorageModeName = "\"Campaign\""; (read from the retail archive, read-only). No retail devMenuOption decl contains \" (all 168 lines checked), so for devMenuOption specifically this is inference from the shared reflection string reader, not proof. Unresolved: whether the devMenuOption reader sets lexer flag 0x20000 (not found, would be unusual).
  • Even without unescape the command-line tokenizer 0x11fc230 has its own backslash path (0x11fc1a0), but I did not trace it, so do not rely on it.
  • Repo's wolfsdk/decl.py unquote and the build self-check (check_texts) agree with the writer. Existing retail retail devMenuCommandStrings (demo: "con_noprint 0", "pm_enablewanderer 1") contain no quotes, so the proven in-game path has never carried quotes. First in-game run is the real test of claim 4.
  • Cheap insurance if it fails in game: drop the quotes where the value has none (si_spawnspot player_start_1, si_gamemode 0), and for the empty ones use si_x "" only (tokenizer needs the quotes for empties); layer lists have no spaces so they work unquoted too (si_nextlayeractive layer/code,layer/c01p1). The tokenizer treats // before the first quote as a comment, / alone is fine, so unquoted layer paths and devmap maps/... are safe.

Summary of corrections

None needed. Documentation fix only: brief's cvar address list lacks si_checkpointName (0x2F4D3B0); 0x2F4F2F0 is si_devprogression, 0x2F4F260 is si_filllayeractive.

Related: Research Report: R4 Console Commands, Research Report: R9 Dev Starts, Developer Mode.

Clone this wiki locally