Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Latest research roundsNeueste Forschungsrunden

Tnoscriptvm: The DoomScript VM, Read from the Decrypted TNO Runtime Image

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

In plain wordsIn einfachen Worten

This report read the decrypted program code of Wolfenstein: The New Order (TNO) to understand its script system (DoomScript). Map scripts are plain text files compiled fresh every time they load, by the same routine the script console command uses, and every trigger or relay goes through one shared activate function that can call a script. A newly found console command, trigger, can fire any named entity without walking into a trigger zone. A small custom event (a trigger, then a script that fires a spawn, a sound and a door) was built on a copy of the chapter 1 map and passed all structural checks, but nothing was tested in the running game.

  • Map scripts (.script) and maps (.entities) are ordinary 'file' entries; there is no precompiled bytecode, the script is compiled from text at every load.
  • Every trigger and relay funnels into one shared activate function (121 call sites), which can run the onTrigger method of a bound script.
  • TNO has a script command (runs one typed line of script) and a trigger command (fires any named entity), both newly found testing tools.
  • The custom event on map c01p1 passed every check on a copy: 37/37 call links resolve, and the chunk rebuild left 25/25 unrelated entries unchanged.
  • Open: whether script and trigger can be reached in the retail console, and whether the edited event really fires in game; nothing was run in game.

Dieser Bericht hat den entschlüsselten Programmcode von Wolfenstein: The New Order (TNO) gelesen, um sein Skriptsystem (DoomScript) zu verstehen. Kartenskripte sind einfache Textdateien, die bei jedem Laden neu kompiliert werden, und zwar von derselben Routine, die auch der Konsolenbefehl script nutzt; jeder Auslöser und jedes Relais läuft über eine gemeinsame Aktivierungsfunktion, die ein Skript aufrufen kann. Ein neu gefundener Konsolenbefehl, trigger, kann jede benannte Entity auslösen, ohne dass man in eine Auslösezone laufen muss. Ein kleines eigenes Ereignis (ein Auslöser, dann ein Skript, das einen Spawn, einen Sound und eine Tür auslöst) wurde auf einer Kopie der Karte aus Kapitel 1 gebaut und bestand alle strukturellen Prüfungen, aber im laufenden Spiel wurde nichts getestet.

  • Kartenskripte (.script) und Karten (.entities) sind gewöhnliche „file“-Einträge; es gibt keinen vorkompilierten Bytecode, das Skript wird bei jedem Laden aus Text kompiliert.
  • Jeder Auslöser und jedes Relais läuft über eine gemeinsame Aktivierungsfunktion (121 Aufrufstellen), die die onTrigger-Methode eines gebundenen Skripts ausführen kann.
  • TNO hat einen Befehl script (führt eine eingetippte Skriptzeile aus) und einen Befehl trigger (löst jede benannte Entity aus), beides neu gefundene Testwerkzeuge.
  • Das eigene Ereignis auf Karte c01p1 bestand jede Prüfung auf einer Kopie: 37/37 call-Verweise lösen auf, und beim Neuaufbau des Chunks blieben 25/25 unbeteiligte Einträge unverändert.
  • Offen: ob script und trigger in der Konsole der Verkaufsversion erreichbar sind und ob das bearbeitete Ereignis im Spiel wirklich auslöst; im Spiel wurde nichts ausgeführt.

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 on The New Order's DoomScript VM, read from the decrypted runtime image: scripts are plain-text file resources compiled at load by the same routine the script console command uses; entity-to-script dispatch is a global name table plus per-entity virtual dispatch; and idEntity::Activate is the bridge. A minimal custom trigger → script → spawn/sound/door event is designed from real retail names and proven structurally on a copy. Nothing was tested in game.

This track had something r6_tnoscript.md explicitly did not: a decrypted, disassemblable TNO runtime image (re_probes/runtime_tno_186352/module.bin, captured read-only from a running WolfNewOrder_x64.exe, PID 186352, base 0x7FF74A390000) and a Ghidra snapshot built from it (D:/wolf-decomp/ghidra/tno_runtime.gzf, 50 884 functions carved exactly from .pdata, 2 369 RTTI vtables labelled, no function names yet). Everything below that cites an FUN_<addr>/RVA and a decompiled excerpt is new evidence this pass produced by querying that snapshot with tools/decomp.py <name> [--xrefs] --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf; nothing from r6 was re-derived, only extended with code-level proof where r6 had only strings and decl text. No game was launched, no process attached — the image was captured earlier and is read here exactly like a file.

Builds on, does not redo: re_probes/agent_reports/r6_tnoscript.md (the two-layer model — entity/target/trigger decl graph + a real idTech4/Doom3-lineage script VM; 913 scriptEvent native signatures; 211 OP_* bytecode mnemonics; the call="X" / scriptObject.type entity↔script bridges; the "no new trigger volumes, new relays/ functions yes" authoring ceiling). r6's own measurements (211 opcodes, 913 events, 116 Target/Trigger classes, the 513/516 call= cross-check) are cited, not re-measured.

Question

TNO's DoomScript VM: how and from where are script programs loaded (resource type, file names, compiled at load or precompiled bytecode); how does an entity trigger a script function; what is the event dispatch table (name → native function); can a modder add a new script function, reachable from a trigger; design a minimal custom event (trigger → script → spawn/sound/door) and prove the data side structurally on a copy.

Answer in one paragraph

Everything r6 inferred from strings and decl text is now confirmed at the code level, plus three new mechanisms r6 could not see without a runtime image. Script resource type: confirmed directly from a live archive read this pass — c01p1.entities/c01p1.script are both type="file" entries (maps/game/wolf/c01/c01p1.entities, usize 2 356 914/csize 159 750; .script, usize 23 692/csize 4 634), the same generic "file" asset type as everything else — there is no dedicated script-bytecode resource type and no precompiled bytecode cache asset anywhere in the archives; DoomScript is plain text, compiled fresh every time it loads. Compilation happens in one shared function, FUN_7ff74acfda60 (rva 0x96da60), confirmed to read the script_scriptEventsPath cvar, then auto-include /script/script_events.script and /script/script_defs.script, compile the given file, and log "Compiled '%s': %.1f ms" — this is the same routine TNO's own script console command uses to compile a single typed-in statement on the fly (wrapped as void ConsoleFunctionN() { <text>; }), proven by both call sites sharing the identical wrapper FUN_7ff74ad0a0d0 (rva 0x97a0d0), which after compiling walks every referenced function and logs "function '%s' was not defined" for anything called but never given a body — the exact runtime counterpart of r6's static "513/516 call= fields resolve" measurement. Entity→script dispatch is two-level, not a flat name→pointer table: a global, linearly-searched name table (FUN_7ff74abce600, rva 0x83e600) maps an event name to a shared idEventDef-style descriptor used by 9 different call sites project- wide (not just scripts); the actual native call goes through idEntity's own vtable — CallScriptEvent (FUN_7ff74acff9f0, rva 0x96f9f0) asks the target object "do you respond to this event" via vtable slot +0x20, then dispatches through vtable slot +0x10 (FUN_7ff74abcf130, rva 0x83f130), whose own debug log literally reads "%d: '%s' activated by '%s'" — the classic idTech4 idClass::ProcessEvent signature. idEntity::Activate itself (FUN_7ff74aa6bfc0, rva 0x6dbfc0, confirmed by its own debug string " %d: %s: idEntity::Activate", called from 121 distinct sites across the whole entity codebase — i.e. the one shared base every idTarget_*/idTrigger_* class funnels into) is where r6's scriptObject.type bridge is proven at code level: if the entity carries a bound script object, Activate looks up a method named "onTrigger" on it by name and calls it with one argument named "activator" — exactly the object X { void onTrigger( entity activator ); } shape r6 read out of script_util.script source text, now seen as literal runtime strings in the dispatcher itself, not inferred from the .script grammar. New, not in r6 at all: the same idEntity::Activate is what TNO's trigger <entityname> console command calls (FUN_7ff74abd8be0, rva 0x848be0, registered in the same AddCommand table as "triggers an entity") — meaning a modder can fire any relay or scriptObject-bound entity by name from the console, without a physical trigger volume at all, sidestepping r6 §7's "no new trigger volumes" ceiling for testing purposes. Yes, a modder can add a new script function reachable from a trigger: this track built the exact edit on a byte-for-byte copy of c01p1.entities/c01p1.script (a new idTarget_Count relay wired into the existing sound_trigger_condor9_enter volume's targets list, calling a new call_wolfsdk_r7_demo function that fires an existing spawn relay, an existing sound relay, and drives an existing door mover — spawn/sound/door, as asked) and round-tripped it through the project's own, unmodified archive writer (wolfsdk.resources/ wolfsdk.patch) on a private scratch copy of the real chunk, never touching the actual game install: all structural checks passed, including the exact full-chunk-rebuild path the project's code already has for exactly this case (§6). None of this was run inside the actual running game — the rules forbid launching it; §9 gives the maintainer the smallest console-only test to close that last gap himself.

1. What this track measured, reproducibly

python tools/decomp.py <rva|name> [--xrefs] --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
python re_probes/tnoscriptvm/prove_custom_event.py

The first command (~15 s/call, used ~10 times this pass, each citation below gives the exact arguments) reads re_probes/runtime_tno_186352/module.bin through the pre-built Ghidra snapshot — no re-analysis, no game launch. The second is this track's own script (below, §6); it reads the real archives once, read-only, via build_gore_deep.pristine() (the same helper r6's own tnoscript/extract.py uses), then does every write in D:/wolf-decomp/tmp/tnoscriptvm/, never under the real install. Its two inputs/outputs are kept in this track's own folder: re_probes/tnoscriptvm/orig/ (the two files exactly as read from the pristine backup) and re_probes/tnoscriptvm/edited/ (after this track's edit).

2. Resource type, file names, compile-at-load vs. precompiled (PROVEN)

Read directly off a live archive entry this pass (not re-derived from r6's file-offset EXE scan):

found in chunk1.index chunk1.resources
  maps/game/wolf/c01/c01p1.entities: usize=2356914 csize=159750 stored_raw=False, type=file
  maps/game/wolf/c01/c01p1.script:   usize=23692   csize=4634   stored_raw=False, type=file

Both are ordinary type="file" entries, deflate-compressed like most text assets — the same resource type r6 already established for .entities; this pass confirms .script sits in the identical bucket, with no third, bytecode-shaped resource type anywhere alongside it. Compilation itself, decompiled:

=== FUN_7ff74acfda60 @ rva 0x96da60 ===
  cVar1 = func_0x7ff74ad54f90(&DAT_7ff74c14d890);          // script_scriptEventsPath cvar set?
  pcVar5 = cVar1 == 0 ? func_0x7ff74abcdac0(&DAT_7ff74c14d890)
                      : s__script_script_events_script_7ff74b3c5700;  // "/script/script_events.script"
  FUN_7ff74ad58040(lVar3, pcVar5); ...; func_0x7ff74ad83e20(param_1, lVar3);   // #include it
  FUN_7ff74ad58040(lVar3, s_include_7ff74b3c5720); ...                        // "#include"
  FUN_7ff74ad58040(lVar3, s__script_script_defs_script_7ff74b3c5730); ...     // "/script/script_defs.script"
  *(undefined4 *)(param_1 + 0x510) = 1;
  FUN_7ff74acf7770(param_1);                                 // parse/compile the file
  while (*(char *)(param_1 + 0x558) == '\0') { FUN_7ff74acfd970(param_1, &DAT_7ff74b949028); }
  ...
  FUN_7ff74ad5a710(s_Compiled___s_____1f_ms_7ff74b3c5788, param_3, (double)(ms));

This is the exact routine behind r6's "Compiled '%s': %.1f ms" / "Compile failed in file %s." strings, now seen as code, not just .rdata literals: it force-includes script_events.script then script_defs.script (both confirmed, matching r6 §3.2/§4.1 exactly), compiles the caller-supplied file, times it, and is fatal if FUN_7ff74acf7770 (the statement/function parser — same function the "scriptEvents can only defined in the global namespace" and "Unexpected end of file inside function" diagnostics from r6 §3.2 come from) never clears the completion flag at param_1+0x558.

Nothing here reads a precompiled bytecode blob. param_3 (the actual text) is handed in by the caller already in memory — this function's own job is parse+compile, never file I/O — and the two call sites found this pass (§3) pass it either the per-map script's source text already read from the "file"-type resource, or (for the console) a freshly built string from a typed-in command. Both confirm "compiled at load/at use, every time", not "compiled once, cached as bytecode" — there is no cache, no .scriptc/.bscript resource type in the archive index at all (consistent with r6's own full archive-type census, not re-run here).

3. The compiler is shared with a live, already-shipped console command (new this pass)

=== FUN_7ff74ad0a0d0 @ rva 0x97a0d0 (the function that actually calls FUN_7ff74acfda60) ===
  FUN_7ff74ad58040(param_1 + 0xd0, param_2);         // param_2 = filename/context label
  uVar2 = FUN_7ff74ad09db0(param_1, param_2, 0);      // register this compile unit
  uVar4 = func_0x7ff74abcdac0(param_1 + 0xd0);
  FUN_7ff74acfda60(&stack..., param_3, uVar4, uVar2);  // param_3 = the actual source text
  // -- after compiling, walk every function and flag ones referenced but never defined:
  ... s_function__s_was_not_defined_7ff74b3c6bd8 ...   // "function '%s' was not defined"

Two call sites, found via --xrefs on FUN_7ff74ad0a0d0:

  • FUN_7ff74abd7db0 (rva 0x847db0) — TNO's script console command, registered in the command table at FUN_7ff74abddb00 (rva 0x84db00) as AddCommand(cmdSystem, "script", FUN_7ff74abd7db0, "executes a line of script", 0). Decompiled, it builds the string "void ConsoleFunctionN() {<typed text>;}" (format string s_void__s_____s___ = "void %s() {%s;}", numbered ConsoleFunction0, ConsoleFunction1, … to avoid name clashes) and calls FUN_7ff74ad0a0d0(programObj, "console", thatWrappedText, 9) — the exact same compiler entry point used for map scripts, just fed a filename label "console" instead of a real path and a synthetic one-function source instead of a map's .script file. A guard ("A console script command is already...") prevents re-entrancy; no cheat/restriction flag is visible in this function or in its AddCommand registration (see §7 for what that is and is not evidence of).
  • FUN_7ff74abffad0 (rva 0x86fad0) — a thin, 4-line wrapper (CompileText(program, filename, text, flags)-shaped) with zero statically-found callers (--xrefs returns none). This is the Ghidra-xrefs-incomplete case the task brief warned about; it was not chased further with a manual RIP-relative scan this pass (time budget) — see §8 "not pushed further".

PROVEN: the per-map .script compiler and the live script console command are the same function, parameterised only by filename label and source text. INFERRED, not directly observed: that the per-map auto-load path (naming-convention loading, r6 §5) reaches FUN_7ff74acfda60 through FUN_7ff74abffad0 specifically rather than some other caller not found by static xrefs — very likely given the shared signature and the log string match, but the actual LoadMap-side call site (where "maps/.../cXXpY.script" gets built and handed in) was not located this pass.

4. Event dispatch: a global name table plus per-entity virtual dispatch, not a flat map (PROVEN)

=== FUN_7ff74abce600 @ rva 0x83e600 — generic {count, ptr[]} name lookup ===
longlong * FUN_7ff74abce600(char *name) {
  for (i = 0; i < DAT_7ff74c12fd70; i++) {
    candidate = (&DAT_7ff74c127d70)[i];
    if (streq(name, candidate)) return candidate;   // (inlined char-by-char compare)
  }
  return 0;
}

This exact helper has 9 distinct call sites across the binary (--xrefs: rvas 0x359332, 0x64605c, 0x710fc7, 0x722e95, 0x83e774, 0x846983, 0x84a4ea, 0x84c4ad, 0x9686ad) — it is a generic name-table lookup reused for several unrelated tables, not written specifically for events. One of those 9 call sites is inside the compiler's own scriptEvent declaration handler:

=== FUN_7ff74acf8660 @ rva 0x968660 — parses "scriptEvent <type> name(...)" ===
  lVar6 = FUN_7ff74abce600(param_1);   // look up name in the global event table
  if (lVar6 == 0) { error("Unknown event '%s'", name); ... }
  else { // validate return type, param count, param types against the FOUND entry
         // ... or register a brand-new one if this exact name was never seen ...
  }

So DAT_7ff74c12fd70/DAT_7ff74c127d70 (count + pointer array) is the global idEventDef-equivalent table — every one of the 913 scriptEvent lines r6 counted in script_events.script parses through exactly this function at compile time, either matching an existing native-registered entry or adding a new script-only one. This answers "name → native function" directly: the name only resolves to a descriptor (return type, arg format string, argument count) here — not a function pointer. The function pointer lives one level down, per entity class, found by tracing the actual call:

=== FUN_7ff74acff9f0 @ rva 0x96f9f0 — CallScriptEvent (bytecode OP_SYSCALL-class glue) ===
  if (eventDef == NULL) error("NULL function in CallScriptEvent");
  targetObj = lookup-on-interpreter-locals-stack(...);
  if (targetObj == NULL) { error("Attempted to call %s on a list %s"); ... }
  respondsTo = (**(code**)(*targetObj + 0x20))(targetObj, eventDef);   // vtbl slot 0x20
  if (respondsTo) {
      marshal bytecode args by format char ('s','f','i','e','v','x', ...) into a local buffer;
      FUN_7ff74abcf130(targetObj, &ret, eventDef, argsBuf);            // <-- the real call
  } else {
      error("Function '%s' not supported on type ...");
  }

=== FUN_7ff74abcf130 @ rva 0x83f130 — the actual native dispatch ===
  if (eventDebugOn && eventDef == &someMarkerEventDef) {
      log("%d: '%s' activated by '%s'", entnum, eventName, activatorName);   // "%d: '%s' activated by '%s'"
  }
  (**(code **)(*targetObj + 0x10))(targetObj, ret, eventDef, args, 0xfffffffffffffffe);  // vtbl slot 0x10

This is the dispatch table the question asked for, precisely characterised: a single global name → descriptor table (shared infrastructure, FUN_7ff74abce600), plus a per-entity-class virtual table with two fixed slots every idClass-descendant carries — +0x20 = "do I respond to this event" (a compile-time-generated predicate, classic idTech4 EventMap) and +0x10 = "run it" (idClass::ProcessEvent-equivalent). The "%d: '%s' activated by '%s'" debug format is the idTech4 giveaway: it is the exact shape of Doom3 GPL's own idClass::ProcessEventArgPtr debug line, strong independent confirmation this is the unmodified id Tech 4 event architecture, not a TNO-specific redesign.

5. idEntity::Activate: the entity→script bridge, confirmed at code level (PROVEN, new vs r6)

=== FUN_7ff74aa6bfc0 @ rva 0x6dbfc0 — idEntity::Activate(entity activator) ===
  // (debug-print branches using DAT_7ff74c132cf8 verbosity level; one of their own
  //  format strings is literally " %d: %s: idEntity::Activate", confirming the name)
  if ( (*(byte*)(this + 0xe) & 0x10) != 0 ) {           // "has a bound scriptObject" flag
      ...
      (**(code**)(*this + 0x4c8))(this, activator);      // per-class Activate hook (vtbl)
      scriptObj = (**(code**)(*this + 0x278))(this);     // GetScriptObject()
      if (scriptObj != NULL) {
          call = LookupMethod(this, "onTrigger");         // literal string "onTrigger"
          if (call != NULL) {
              BindArg(call, 0, "activator", activator);    // literal string "activator"
              RunAsThread(call, 1);
          }
      }
      ...
  }

121 distinct call sites reference this one function (--xrefs, truncated list in the raw tool output — every idTarget_*/idTrigger_*/mover/door/AI-proxy class that fires a trigger funnels into this single shared entry point). The "onTrigger"/"activator" strings are not inferred from .script source grammar this time — they are the literal runtime method-name and argument-name strings the dispatcher itself looks up, i.e. this is the code r6 could only reconstruct by reading object X { void onTrigger( entity activator ); } in script_util.script's source text. Both lines of evidence now agree exactly.

Not fully traced: the separate call = "X" field (an idTarget_Count's own spawnarg, distinct from scriptObject) is almost certainly resolved inside the per-class virtual hook at vtbl slot +0x4c8 seen above (idTarget_Count's own override of the base Activate hook) rather than in the shared base function — that per-class override was not individually decompiled this pass (would need vtbl_idTarget_Count's slot 0x4c8 read out of the raw vtable bytes and the function at that address decompiled; the RTTI labelling only names vtable starts, not per-slot methods, matching the stated limitation of this Ghidra snapshot). Flagged as the single most concrete next step, not resolved here — see §8.

6. Two console commands that are direct, code-level levers for this question (new this pass)

Found while tracing §3/§5, in the same AddCommand registration block (FUN_7ff74abddb00, rva 0x84db00):

Command Handler rva Description string What it does
script FUN_7ff74abd7db0 0x847db0 "executes a line of script" compiles and immediately runs one typed-in DoomScript statement, through the exact same compiler as map .script files (§3)
trigger FUN_7ff74abd8be0 0x848be0 "triggers an entity" looks up an entity by name and calls idEntity::Activate on it directly (§5) — no physical trigger volume needed

trigger's handler, decompiled:

=== FUN_7ff74abd8be0 @ rva 0x848be0 ===
  if (argc == 2) {
      ent = FindEntityByName(argv[1]);       // gameLocal vtbl slot 0x438
      if (ent != NULL) { idEntity_Activate(ent, localPlayer); return; }
      print("entity not found");
  } else print("usage: trigger <name of entity to trigger>");

This is a second, independent confirmation of §5's dispatch (console → idEntity::Activate → scriptObject onTrigger / per-class call= hook), and — more importantly for a modder — a way to fire any named relay entity, including a brand-new one added to .entities, without needing a physical collision-model trigger volume at all. r6 §7 established that new trigger volumes cannot be authored (binary cm dependency); this command sidesteps that entirely for testing, because it activates by name, not by geometry. Whether script/trigger are actually reachable in the shipped retail build (vs. gated by the separate cheat-cvar/"production mode" system r6 §6a found strings for) is genuinely not resolved by this evidence — see §9.

7. Design: a minimal custom event, grounded in real retail names (not invented)

Built on maps/game/wolf/c01/c01p1.entities / .script exactly as shipped (chapter 1, part 1 — the same map r6 cites throughout). All three reused names are real, already-present retail entities in this exact file, confirmed by direct grep before use, not assumed:

sound_trigger_condor9_enter   -- existing idTrigger volume (r6's own cited example)
target_spawn_plane_1          -- existing idTarget_Spawn relay   (the "spawn" leg)
sound_sp_condor9_door_close   -- existing sound-speaker relay    (the "sound" leg)
joint_func_mover_condor9_fuselage_door -- existing door mover, already driven by
                                           retail's own call_joint_func_mover_condor9_fuselage_door_open/_close

The new wiring (full diff context in §8/§10, files in re_probes/tnoscriptvm/):

  entity { entityDef sound_trigger_condor9_enter { ... edit = { ... targets = {
-     num = 5;
+     num = 6;
      item[0] = "sound_tr_enter_cockpit";
      ...
      item[4] = "sound_tr_engine_strain";
+     item[5] = "wolfsdk_r7_relay";
  } ... } } }

+ entity {
+     entityDef wolfsdk_r7_relay {
+     inherit = "target/relay"; class = "idTarget_Count"; expandInheritance = false;
+     edit = { "int" count = 1; delay = 0.0; call = "call_wolfsdk_r7_demo"; repeat = false; }
+ } }
// appended to c01p1.script:
void call_wolfsdk_r7_demo( entity activatedEntity, entity activator )
{
    // spawn: re-fire an existing idTarget_Spawn relay already in this map
    $target_spawn_plane_1.activate( activator );

    // sound: re-fire an existing sound-speaker relay, same idiom retail's own
    // call_joint_func_mover_condor9_fuselage_door_close uses
    $sound_sp_condor9_door_close.activate( activator );

    // door: drive an existing door mover directly, same idiom retail's own
    // call_joint_func_mover_condor9_fuselage_door_open uses
    $joint_func_mover_condor9_fuselage_door.time( 2 );
    $joint_func_mover_condor9_fuselage_door.accelTime( 0 );
    $joint_func_mover_condor9_fuselage_door.decelTime( 0 );
    $joint_func_mover_condor9_fuselage_door.rotateOnce( '-60 0 0' );
}

This is deliberately the smallest new event that touches all three outcomes asked for (spawn/sound/door) while inventing nothing: every $name the new function calls is a real entity already in this file, every scriptEvent (activate, time, accelTime, decelTime, rotateOnce) is one of r6's 913 measured script_events.script signatures, and the relay class/grammar is retail's own idTarget_Count/target/relay pattern (r6 §4.3, 8 029 retail instances of this exact class).

8. Structural proof, on a copy — re_probes/tnoscriptvm/prove_custom_event.py

Six steps, all reproducible with one command (python re_probes/tnoscriptvm/prove_custom_event.py), nothing written outside re_probes/tnoscriptvm/ (small text copies, source) and D:/wolf-decomp/tmp/tnoscriptvm/ (the scratch archive copy, ~141 MB):

  1. Read-only dump of the real c01p1.entities/.script from the pristine backup (build_gore_deep.pristine(), same helper r6 used) — confirms type="file", usize/csize exactly as in §2.
  2. Compose the edit as plain text (§7) — c01p1.entities 2 356 914→2 357 171 B (+257), c01p1.script 22 953→23 711 B (+758; .read_text() strips the file's trailing-no- newline quirk, immaterial here).
  3. Structural checks on the text alone:
    • both files stay brace-balanced after the edit (+3 pairs in .entities, +1 in .script — counted, not assumed);
    • re-running r6 §5's exact cross-check (call="X" → case-insensitive top-level function match) over the edited pair: 37/37 resolve, including the new call="call_wolfsdk_r7_demo" → the new function — the same rule that gave 513/516 over all 25 retail maps now gives 100 % over this one edited map;
    • all three $names the new function calls, and the new relay itself, are confirmed present/wired by direct text search (not just written and trusted).
  4. In-place vs. rebuild, measured, not predicted: wolfsdk.resources.plan_in_place() says the edited .entities (+257 B into a 159 750 B compressed slot with slack) still fits in place, but the edited .script (+758 B into a tight 4 634 B slot) does not — concretely measuring r6 §7/§8's "0–428 byte budget, else full rebuild" finding for this exact edit instead of citing the general number.
  5. Round-trip through the project's own, unmodified writer, on a private copy: the whole real chunk1.index/chunk1.resources pair (73 MB) is copied from the pristine backup into D:/wolf-decomp/tmp/tnoscriptvm/base/ with a hand-built one-pair master.index; a bare types.SimpleNamespace(base=..., backup=...) stands in for wolfsdk.game.Game (confirmed Installation.__init__ only ever reads .base/ .backup); Installation(fake_game).apply({...}) is called for real — on this private copy only. Result: rebuilt=['chunk1.resources'], missing=[] — the apply() call itself independently confirms step 4's prediction by choosing the full-rebuild branch (and pulls the easily-in-place .entities edit into the same rebuild too, because both live in one archive and _apply() groups by archive — confirmed by reading wolfsdk/patch.py's own _apply, not assumed).
  6. Fresh re-open and byte-exact verification: a brand-new Archive object (not the one apply() used) opens the rebuilt scratch copy; both edited entries read back byte- identical to the in-memory edited text; the rebuilt index is still strictly offset-ascending (4 241 entries — the exact invariant patch.py's own docstring says the engine's background loader enforces, "FileTable is out of order" otherwise); and 25 unrelated control entries from the same chunk, snapshotted before the rebuild, are byte-identical after it — nothing else in the chunk was disturbed.
OK  c01p1.entities               braces balanced before and after, +3 pairs added
OK  c01p1.script                 braces balanced before and after, +1 pairs added
call="X" fields in edited .entities: 37, resolved: 37, missing: 0
OK  new call="call_wolfsdk_r7_demo" resolves to the new function (case-insensitive), same rule as the 513/516 retail measurement
OK  all 3 $-names ... are real entityDefs already present in c01p1.entities
OK  new relay wolfsdk_r7_relay is both defined and wired as targets.item[5] of the existing sound_trigger_condor9_enter trigger volume
c01p1.entities: csize=159750, edited deflates to fit in place? yes
c01p1.script:   csize=4634, edited deflates to fit in place? no -- needs full chunk rebuild
apply() journal: assets=['file:maps/game/wolf/c01/c01p1.entities', 'file:maps/game/wolf/c01/c01p1.script'] rebuilt=['chunk1.resources'] missing=[]
OK  apply() took the REBUILD branch, exactly as predicted from plan_in_place()
OK  both edited files read back byte-identical after the rebuild
OK  rebuilt index is still strictly offset-ascending (4241 entries)
control check: 25/25 untouched entries unchanged by the rebuild
=== ALL STRUCTURAL CHECKS PASSED ===

This is the data side proven, structurally, on a copy — exactly what the task asked for. It is not a claim that the game accepts and runs this edit; that needs a launch, which the rules forbid here (§9 gives the maintainer the test).

PROVEN (this pass, code-level, reproducible with tools/decomp.py/the script above)

  • .script/.entities are both plain type="file" archive entries; no bytecode-cache resource type exists; DoomScript compiles from source text every time it is loaded — confirmed from a live entry read (§2) and from the compiler entry point itself (§2–3).
  • The per-map script compiler and TNO's live script console command are the same function (FUN_7ff74acfda60/FUN_7ff74ad0a0d0), parameterised by filename label and source text (§3).
  • Post-compile, the engine itself validates every called-but-undefined function ("function '%s' was not defined") — the runtime counterpart of r6's static 513/516 measurement (§3).
  • Event dispatch is two-level: a global name→descriptor table shared by 9 unrelated lookups (FUN_7ff74abce600), plus per-entity-class virtual dispatch (RespondsTo at vtbl +0x20, ProcessEvent at vtbl +0x10), confirmed by the idTech4-shaped debug string "%d: '%s' activated by '%s'" (§4).
  • idEntity::Activate (121 callers) is the single shared entry every trigger/relay class funnels into; when a scriptObject is bound, it calls that object's onTrigger(activator) method by the literal runtime strings "onTrigger"/"activator" — the code-level confirmation of r6's scriptObject.type bridge (§5).
  • TNO retail registers a script console command (compile+run one statement) and a trigger <name> console command (call idEntity::Activate on a named entity directly, no physical volume needed) — both new findings, both direct testing levers (§6).
  • A minimal trigger→script→spawn/sound/door event, built from real retail names in c01p1.entities/.script, round-trips byte-exact through the project's own unmodified archive writer on a private copy of the real chunk, taking the predicted full-rebuild path, with 25 unrelated entries confirmed untouched (§7–8).

INFERRED (plausible from the code shown, not directly observed)

  • That FUN_7ff74abffad0 (zero static callers found) is the actual per-map LoadMap→ script-compile call site, reached with the map's own filename and the resource-read text — very likely given it shares FUN_7ff74ad0a0d0's exact signature shape, but the real caller was not located (§3).
  • That the per-map relay call = "X" spawnarg (distinct from scriptObject) is resolved inside idTarget_Count's own override at vtbl slot +0x4c8 seen in idEntity::Activate — architecturally the obvious place, but that per-class override was not individually decompiled (§5, §10).
  • That the edited event (§7) would actually fire correctly in a running game — the data- side round trip is proven (§8), the runtime behaviour is not (no launch performed, per the rules).

FAILED HYPOTHESES / dead ends (named, not hidden)

  • Expected FUN_7ff74abe2d90 (the 5th argument shared by the trigger/teleport/ damage console commands) to be a cheat/restriction-level gate predicate. Decompiled: it is an argument tab-completion callback (walks the live entity list and calls the console's autocomplete-add function) — unrelated to permissions. No restriction-level gate was found anywhere in the AddCommand call signature itself; if one exists it is in a different, shared dispatch chokepoint not located this pass (consistent with r6 §6a leaving the same question open for cvars).
  • Tried to read vtbl_idTarget_Count's slot +0x4c8 directly to find the call="X" resolver function. Not completed this pass (would need a raw vtable-bytes read, not just tools/decomp.py's function-centric queries) — capped at the time budget, named as the concrete next step in §10, not silently dropped.

Open questions

  • Does the live retail build actually let a player reach the script/trigger/ disasmScript console commands at all (vs. TNO's own cheat-cvar/"production mode" gate r6 §6a found strings for)? Still genuinely open — needs either an in-game test (§9) or locating the shared command-dispatch chokepoint, neither done here.
  • The exact function at vtbl_idTarget_Count+0x4c8 (the per-class call="X" resolver) — the single most concrete unfinished lead from this pass.
  • FUN_7ff74abffad0's real caller (the LoadMap-side script compile call) — not located; Ghidra's static xrefs returned none, and the task brief's fallback (manual RIP-relative scan of module.bin) was not run for this specific function this pass.

Smallest test for the maintainer

(Translated from German.)

Two independent tests, from the simplest to the most meaningful — both only via the console, without any file edit, provided the console is reachable at all in the retail version (that is the one open point only a real start can settle):

  1. Without any mod installation: in a running map, open the console and enter trigger sound_trigger_condor9_enter (c01p1, the zeppelin interior). If exactly the same 5 sounds then play as when really walking through the trigger, it is proven: the dispatch path idEntity::Activate from §5/§6 really runs in the game, not only in the code. script sys.println("r7 alive") additionally tests the compiler from §3 in isolation.
  2. With the mod edit built in this track (re_probes/tnoscriptvm/edited/, applied via the normal WolfSDK patch route, not via the test script, which only works on a copy): in c01p1 open the console and enter trigger wolfsdk_r7_relay — no walking through the real trigger volume needed. If thereupon target_spawn_plane_1 fires again, sound_sp_condor9_door_close plays again and joint_func_mover_condor9_fuselage_door turns again, the complete trigger→script→spawn/sound/door path newly designed in this report is confirmed live — with the smallest possible attack surface, because trigger <name> calls any relay entity directly by name, without any new collision volume.

Related: Research Report: R6 Tno Script, TNO Modding Surface, TNO Maps.