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(rva0x847db0) — TNO'sscriptconsole command, registered in the command table atFUN_7ff74abddb00(rva0x84db00) asAddCommand(cmdSystem, "script", FUN_7ff74abd7db0, "executes a line of script", 0). Decompiled, it builds the string"void ConsoleFunctionN() {<typed text>;}"(format strings_void__s_____s___="void %s() {%s;}", numberedConsoleFunction0,ConsoleFunction1, … to avoid name clashes) and callsFUN_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.scriptfile. A guard ("A console script command is already...") prevents re-entrancy; no cheat/restriction flag is visible in this function or in itsAddCommandregistration (see §7 for what that is and is not evidence of).FUN_7ff74abffad0(rva0x86fad0) — a thin, 4-line wrapper (CompileText(program, filename, text, flags)-shaped) with zero statically-found callers (--xrefsreturns 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):
- Read-only dump of the real
c01p1.entities/.scriptfrom the pristine backup (build_gore_deep.pristine(), same helper r6 used) — confirmstype="file", usize/csize exactly as in §2. - Compose the edit as plain text (§7) —
c01p1.entities2 356 914→2 357 171 B (+257),c01p1.script22 953→23 711 B (+758;.read_text()strips the file's trailing-no- newline quirk, immaterial here). - 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 newcall="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).
- both files stay brace-balanced after the edit (+3 pairs in
- 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. - Round-trip through the project's own, unmodified writer, on a private copy: the
whole real
chunk1.index/chunk1.resourcespair (73 MB) is copied from the pristine backup intoD:/wolf-decomp/tmp/tnoscriptvm/base/with a hand-built one-pairmaster.index; a baretypes.SimpleNamespace(base=..., backup=...)stands in forwolfsdk.game.Game(confirmedInstallation.__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.entitiesedit into the same rebuild too, because both live in one archive and_apply()groups by archive — confirmed by readingwolfsdk/patch.py's own_apply, not assumed). - Fresh re-open and byte-exact verification: a brand-new
Archiveobject (not the oneapply()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 invariantpatch.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/.entitiesare both plaintype="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
scriptconsole 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 (RespondsToat vtbl+0x20,ProcessEventat 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'sonTrigger(activator)method by the literal runtime strings"onTrigger"/"activator"— the code-level confirmation of r6'sscriptObject.typebridge (§5).- TNO retail registers a
scriptconsole command (compile+run one statement) and atrigger <name>console command (callidEntity::Activateon 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-mapLoadMap→ script-compile call site, reached with the map's own filename and the resource-read text — very likely given it sharesFUN_7ff74ad0a0d0's exact signature shape, but the real caller was not located (§3). - That the per-map relay
call = "X"spawnarg (distinct fromscriptObject) is resolved insideidTarget_Count's own override at vtbl slot+0x4c8seen inidEntity::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 thetrigger/teleport/damageconsole 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 theAddCommandcall 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+0x4c8directly to find thecall="X"resolver function. Not completed this pass (would need a raw vtable-bytes read, not justtools/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/disasmScriptconsole 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-classcall="X"resolver) — the single most concrete unfinished lead from this pass. FUN_7ff74abffad0's real caller (theLoadMap-side script compile call) — not located; Ghidra's static xrefs returned none, and the task brief's fallback (manual RIP-relative scan ofmodule.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):
- 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 pathidEntity::Activatefrom §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. - 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 entertrigger wolfsdk_r7_relay— no walking through the real trigger volume needed. If thereupontarget_spawn_plane_1fires again,sound_sp_condor9_door_closeplays again andjoint_func_mover_condor9_fuselage_doorturns again, the complete trigger→script→spawn/sound/door path newly designed in this report is confirmed live — with the smallest possible attack surface, becausetrigger <name>calls any relay entity directly by name, without any new collision volume.
Related: Research Report: R6 Tno Script, TNO Modding Surface, TNO Maps.