Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Latest research roundsNeueste Forschungsrunden

Tnoscript: How The New Order Drives Map Events (Trigger Graph + a Real idTech4 Script VM)

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

In plain wordsIn einfachen Worten

This report studied how Wolfenstein: The New Order (TNO) makes things happen in its levels. It found two layers of one system: plain-text entities in each map that trigger each other through target lists, and a real compiled scripting language from the Doom 3 family that those triggers can call. New relays and new script functions could be written with the existing tools, but brand-new trigger volumes cannot, because their shapes live in baked collision files that cannot be newly added. Everything comes from reading game files and text strings in the program (TNO's code was still encrypted then); nothing was tested in the running game.

  • The program contains 197 trigger and target entity types; 112 of them are used in the 25 retail maps and 85 never are, including an unused type that sets cvars.
  • The script language has 211 bytecode instructions (found in 3 identical copies) and 913 built-in commands such as spawn, openDoor, startSoundShader, devMap and setcvar.
  • 513 of 516 (99.4%) 'call' links from map entities point to a real script function; the 3 misses are retail defects (one commented out, two missing entirely).
  • Correction from pass 1: entities bind behaviour templates through a scriptObject block, 173 times in 20 of 25 maps, with 24 template types.
  • Not tested in game: the recipe for custom events and whether the unused cvar trigger works; also open is whether a moved trigger volume's shape moves with it.

Dieser Bericht hat untersucht, wie Wolfenstein: The New Order (TNO) in seinen Leveln Ereignisse auslöst. Er fand zwei Ebenen eines Systems: Entities in Klartext in jeder Karte, die sich über Ziellisten gegenseitig auslösen, und eine echte kompilierte Skriptsprache aus der Doom-3-Familie, die diese Trigger aufrufen können. Neue Relais und neue Skriptfunktionen ließen sich mit den vorhandenen Werkzeugen schreiben, ganz neue Trigger-Volumen aber nicht, weil ihre Form in fest gebackenen Dateien für die Kollision liegt, die sich nicht neu hinzufügen lassen. Alles stammt aus dem Lesen von Spieldateien und Textstellen im Programm (TNOs Code war damals noch verschlüsselt); im laufenden Spiel wurde nichts getestet.

  • Das Programm enthält 197 Trigger- und Target-Entity-Typen; 112 davon kommen in den 25 Karten der Verkaufsversion vor, 85 nie, darunter ein ungenutzter Typ, der Cvars setzt.
  • Die Skriptsprache hat 211 Bytecode-Befehle (in 3 identischen Kopien gefunden) und 913 eingebaute Befehle wie spawn, openDoor, startSoundShader, devMap und setcvar.
  • 513 von 516 (99,4 %) „call“-Verknüpfungen von Karten-Entities zeigen auf eine echte Skriptfunktion; die 3 Fehltreffer sind Fehler der Verkaufsversion (eine auskommentiert, zwei fehlen ganz).
  • Korrektur aus Durchgang 1: Entities binden Verhaltensvorlagen über einen scriptObject-Block, 173-mal in 20 von 25 Karten, mit 24 Vorlagentypen.
  • Nicht im Spiel getestet: das Rezept für eigene Ereignisse und ob der ungenutzte Cvar-Trigger funktioniert; offen ist auch, ob die Form eines verschobenen Trigger-Volumens mitwandert.

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 research pass on how The New Order drives map events. The answer is two layers of one system: a plain-text entity graph of idTarget_*/idTrigger_* classes wired by targets lists (plus scriptObject template bindings), and a real, compiled idTech4/Doom3-lineage script VM. New relays and script functions can be authored with the existing tools; new trigger volumes cannot. String- and data-level evidence only (no TNO runtime image existed yet), and nothing was tested in game. The code-level follow-up is The DoomScript VM (R7).

Read-only against <Steam library>/steamapps/common/Wolfenstein.The.New.Order (archives read from base/_wolfsdk_backup/ via wolfsdk.patch.Installation/pristine(), exactly the pattern tools/entities.py and tools/probe_tno_maps.py already use) and the EXE's .rdata/.data as a flat ASCII string scan (.text is SteamStub-encrypted and unreadable statically — confirmed again here, consistent with docs/ghidra.md; TNO has no runtime image, unlike TNC's re_probes/runtime_87400/module.bin, so every EXE-side claim below is a string/offset in the file, not a disassembly or a decompile). No game was launched, no process attached. All writes are under this track's own re_probes/tnoscript/ (extraction scripts + the 25 .entities and 37 .script files pulled from the archives for reading, ~44 MB total, nothing outside that folder and this report).

Builds on and does not redo: docs/moddingflaeche-tno.md §4.1–4.2 (first-pass identification of .entities/.script as the two map-event layers, the in-place-patch budget of 0–428 bytes forcing a full chunk rebuild for both, marked "vermutlich, ungeprüft", i.e. "probable, unchecked"), docs/ui-assets.md §7.3 ("the most interesting unfollowed thread" — named script_events.script's 913 signatures as the most powerful unused lever and explicitly stopped there because following it needs a launched game), docs/kiscule.md (TNC's node-graph equivalent — cited throughout for contrast, not reused), docs/ghidra.md / docs/ghidra/klassen_tno.csv (TNO RTTI: 2 368 classes with inheritance, used here for the unused-class cross-check).

Pass 1 errors (fixed in this pass)

The verifier refuted 3 claims in pass 1, all now fixed below with re-measured data (same extraction re-run from the archives, out/ deleted again afterward):

  1. "scriptobject never appears as an entity field" — wrong. scriptObject = { type = "X"; <params>; } is a common, structured entity block: 173 instances across 20 of the 25 .entities files, naming 24 distinct object template types. This isn't a minor correction — it answers §9's own "genuinely open" question ("how is a script_util.script object bound to an entity"): scriptObject.type is the binding. See new §4.1a.
  2. "moveSelf/rotateSelf/bobSelf/randomActivator bound to zero entities" — wrong, same root cause as #1: the grep looked for the bare template name as a spawnarg key, not inside a scriptObject.type value. Re-measured: rotateSelf=36, moveSelf=22, bobSelf=6, randomActivator=3 (67 instances). See §4.1a.
  3. "211-opcode table at 0xdb6db8–0xdb7ee8" was presented as the table — it's one of 3 byte-identical copies (0xdb6db8, 0xdf49e8, 0x1030e80, each exactly 211 entries), and 92 more OP_-prefixed strings exist elsewhere for two unrelated systems (an NFSv4 protocol op set, a shader/face-blend op set) that a naive re-scan would conflate with the VM's opcode set. Re-measured exactly, not just acknowledged — see new §3.1.

Also folded in from the verifier's "missed" list: the script_scriptEventsPath / script_debug / script_suppressPrints cvars (§3.2a), the c16p0 script-less-map converse case (§5, confirmed consistent), and the full 24-type scriptObject census (§4.1a). The 116-vs-112 class-count regex wording was imprecise, not the numbers — fixed in §4.3's footnote. idTarget_Cvar reachability remains genuinely open (§6) — no runtime image, no way to resolve it statically; a TNO-specific cheat-cvar gate was found this pass (§6a) and is relevant context, not a resolution.

Question

TNO level scripting: how does id Tech 5 Wolfenstein drive events in maps (script decls / entity targets / triggers / a script VM?), its format and command set (from data files and EXE strings), how do TNO map entities reference scripts, and can custom events (trigger → spawn/door/sound/objective) be authored for TNO maps with the existing tools?

Answer in one paragraph

All three guesses in the question are correct at once — they are the same system in two layers, not alternatives. Layer 1 is the entity graph already partly covered by moddingflaeche-tno.md: 25 .entities maps carry 116 distinct idTarget_*/idTrigger_* classes as plain decl-text entities (the New Colossus "Kiscule" node-graph system does not exist in this engine generation at all — there is no binary graph format; the whole graph is the decl text), wired together by a targets = { item[0] = "entityname"; } list exactly like classic idTech4 target/targetname, with volumes (idTrigger) distinguished from pure logic relays (idTarget_Count, 8 029 instances — the most common class in the game) by whether they carry a clipModelInfo.clipModelName pointing at a separately baked, binary cm collision model. A third, named-but-templated authoring lever sits inside the entity graph too: scriptObject = { type = "X"; } blocks (173 instances, 24 distinct template names) bind a named object behavior — a mover, rotator, or one of 20 other per-map templates — directly to an entity (§4.1a, corrected this pass from pass 1's wrong "never found" claim). Layer 2 is a real, compiled, C-like scripting VM, the unmodified idTech4/Doom3 "DoomScript" lineage confirmed by 211 distinct OP_* bytecode mnemonics found verbatim in .rdata, in 3 byte-identical copies (OP_PUSH_ENT at file offset 0xdb7de0, OP_GOTO at 0xdb7ec8, full table 0xdb6db8–0xdb7ee8 and two more copies at 0xdf49e8/0x1030e80, see §3.1 — corrected this pass from pass 1's single-copy framing) plus the idCompiler's own error strings ("Compile failed in file %s.", "scriptEvents can only defined in the global namespace", w:\tech5\tungsten\game\script\Program.cpp(1270)), a stack-based thread scheduler (idInterpreter, idThread — present in the RTTI, §2), and a 913-signature native command table in script/script_events.script (giveInventoryItem, spawn, teleport, respawn, kill, radiusDamage, openDoor, startSoundShader, nextMap, devMap, doCheckpoint, setcvar, drawText, wait, …). The bridge between the two layers is proven, not inferred: idTarget_Count relay entities carry an optional call = "functionName" field, and across all 25 maps, 513 of 516 such fields (99.4 %) resolve case-insensitively to an actual top-level function with the fixed signature void name(entity activatedEntity, entity activator) defined in that map's own <mapid>.script or one of its same-chapter sidecar scripts (measured below, §5 — the same kind of cross-check kiscule.md ran for TNC, with a comparable ~99 % hit rate and a handful of named retail defects, not parser failures). Map scripts are not referenced by name anywhere in the map data — the engine loads maps/<id>.script by a fixed naming convention identical to <id>.entities (32 of 32 map scripts match a map 1:1; confirmed indirectly, §5). Authoring custom events with the existing tools: yes for logic (new or edited relay chains — spawn/door/sound/objective targets, new script functions, new call wiring — all plain text, all inside already-indexed file assets that already tolerate arbitrary-length rewrites via a full chunk rebuild), no for new physical trigger volumes (their shape lives in a separately baked, binary, unwriteable cm asset that cannot be newly indexed — see §7). None of this was run in the game; every claim below is format/string-level, and the recipe in §8 is the next, concrete, unperformed step.

1. What this track measured, reproducibly

python re_probes/tnoscript/extract.py            # all 37 .script files -> out/scripts/
python re_probes/tnoscript/extract.py entities    # all 25 .entities maps -> out/entities/
python re_probes/tnoscript/maplink.py             # the call="..." -> function cross-check (§5)
python re_probes/tnoscript/strings_scan.py <text> # ASCII scan over WolfNewOrder_x64.exe (.rdata/.data only)

All four reuse existing, already-proven infrastructure only: wolfsdk.patch.Installation + tools/build_gore_deep.pristine() for archive access (the same call tools/entities.py/tools/probe_tno_maps.py use), and a flat regex string scan for the EXE (no disassembler, no Ghidra project — not needed for .rdata literals).

2. The two layers, by RTTI class name

docs/ghidra/klassen_tno.csv confirms both layers are real engine classes, not an artifact of the decl text:

Layer Classes (examples) RTTI base chain
Entity graph idTarget_Count, idTrigger, idTrigger_Hurt, … idEntity;idEventReceiver;idClass
Script VM idScriptObject, idThread idClass / idEventReceiver;idClass
AI↔script glue AC_DoomscriptDone, AC_ExecutingScript, AC_NoActionScript, idAIScriptProxy, idAIScriptedAnimWeb idAIStateTransition… / idEventReceiver;idClass

(idProgram, idCompiler, idInterpreter, idVarDef, idEventDef — the classic idTech4 compiler/VM classes — are not in the RTTI dump because they are not polymorphic idClass descendants, so MSVC never emits a type_info for them; their existence is instead confirmed directly by the string evidence in §3, which is the stronger proof for a compiler anyway — error messages, not just a name.)

3. The script VM, read straight from .rdata/.data

3.1 Bytecode: 211 opcodes — in 3 identical copies, plus 92 decoys from unrelated systems

0x00db6db8  OP_RETURN  ...  0x00db7ee8  OP_CONTINUE   (211 entries)
0x00df49e8  OP_RETURN  ...  0x00df56d8  OP_CONTINUE   (211 entries, byte-identical)
0x01030e80  OP_RETURN  ...  0x01032e08  OP_CONTINUE   (211 entries, byte-identical,
                                                        immediately adjacent to the
                                                        idCompiler string block at
                                                        0x01032e00, §3.2)

Corrected from pass 1, which cited only the first copy as if it were the table. Re-scanned this pass with a case-sensitive, prefix-anchored match (" OP_[A-Z]", 725 hits total over the whole EXE, up from pass 1's looser case-insensitive substring query that also pulled in unrelated idProp_* class names): the 211-mnemonic table exists 3 times, byte-identical, at the three offsets above (3 × 211 = 633 of the 725 hits). The remaining 92 are genuine OP_-prefixed strings belonging to two unrelated systems that happen to share the OP_ prefix convention — confirmed by reading them, not assumed:

  • 38 + 27 + 22 = 87 at 0xd84360/0xdd5b68/0x1066db8 are either an NFSv4 protocol operation set (OP_ACCESS, OP_CLOSE, OP_COMMIT, OP_CREATE, OP_DELEGPURGE, OP_DELEGRETURN, …) or a shader/material expression opcode set (OP_TYPE_MOVE/OP_TYPE_SWIZZLE/OP_TYPE_ADD/OP_TYPE_UNMASKED_MOVE_CONSTANT, present in 2 near-identical copies of its own).
  • 5 at 0xf3d334 are a facial-animation blend opcode set (OP_DECODE_FACECOMPOSITE_AND_BLEND, OP_DECODE_AND_PUSH, OP_POP_AND_BLEND, OP_END, OP_NO).

None of these 92 overlap the DoomScript mnemonic vocabulary (no OP_PUSH_*, OP_CALL, OP_THREAD, OP_GOTO, etc. among them) — the 211-mnemonic vocabulary count from pass 1 is unaffected and independently re-confirmed exact, but a plain grep OP_ without the exact offset window (which is what pass 1's own §1 commands implicitly invited a re-run to do) would silently mix in 92 unrelated strings from two other engine subsystems. Fixed tooling note: re_probes/tnoscript/strings_scan.py <query> does case-insensitive substring matching by design (so idProp_* matches a query of "OP_") — anyone re-running §1's commands for the opcode table specifically should grep the output for " OP_[A-Z]" (case-sensitive, prefixed by two spaces = start of the string field), not just the raw query match.

Interleaved with the opcode entries (in all 3 copies) are per-opcode type-tag strings (int, <PUSH>, <CALL>, <THREAD>, <GOTO> …) that are debug-print metadata, not opcodes themselves (excluded from the 211). This is the exact Doom3/Quake4 idProgram bytecode set unmodified (arithmetic per-type: OP_MUL_F/_V/_I/_FV/_VF/_IV/_VI/_FI/_IF; "unary"/compound-assign variants OP_UADD_*/OP_USUB_*/OP_UMUL_*; array indexing OP_INDEXARRAY_FF…DI; the dispatch triad OP_CALL/OP_EVENTCALL/OP_OBJECTCALL/ OP_SYSCALL/OP_LISTEVENTCALL). Only the mnemonic strings are proven here — the numeric opcode values and the dispatch table that maps them to handlers live in the encrypted .text, so the byte-level bytecode format (operand widths, instruction size) is not recovered, only the complete instruction vocabulary. Why the table is triplicated is not determined (3 separate static const arrays compiled into 3 translation units is the ordinary explanation for a linked C++ binary; not pursued further, cosmetic to the research question).

3.2 The compiler: idCompiler, confirmed by its own diagnostics

Contiguous block 0x01032e00–0x01036000 is the compiler's string table. Selected, exact, at their file offsets:

0x01033108  Divide by zero
0x01033118  Script referenced decl '%s' but decl does not exist
0x010331a8  Unknown token '%s'
0x01033240  Function '%s' not found
0x01033280  Unexpected end of file inside function '%s'.  Missing closing braces.
0x010332e0  Unexpected end of file inside object '%s'.  Missing closing braces.
0x01033330  Unexpected end of file inside namespace '%s'.  Missing closing braces.
0x01033568  scriptEvents can only defined in the global namespace
0x01033b30  Objects cannot be defined within functions or other objects
0x01033ba0  Objects may only inherit from objects.
0x01033c18  Functions may not be defined within other functions
0x01034100  /script/script_events.script          (auto-#include, confirmed)
0x01034130  /script/script_defs.script             (auto-#include, confirmed)
0x01034188  Compiled '%s': %.1f ms
0x01035048  w:\tech5\tungsten\game\script\Program.cpp(1270) : TAG_SCRIPT
0x010353a0  Exceeded maximum allowed number of functions (%d)
0x010353d8  Exceeded maximum allowed number of statements (%d)
0x01035460  script/disasm.txt                      (a disassembly dump path — undocumented
                                                       cvar/command gates it, name not found
                                                       in this scan)
0x010354b0  ---------- Compile stats ----------
0x010355f8  Compile failed.
0x01035638  Compile failed in file %s.
0x01035678  Initializing scripts

This nails down what moddingflaeche-tno.md §4.2 only inferred from the two auto-include comments in script_main.script: compilation happens once, at map load ("Initializing scripts" / "Compiled '%s': %.1f ms"), is fatal on error ("Compile failed in file %s." — no fallback), and the leaked build-tree path confirms the TNO engine fork's internal name "tungsten" (also the asset-path prefix seen throughout moddingflaeche-tno.md's AI examples, e.g. ai/tungsten/c06/... — same codename, now tied to the exact source file).

A second, separate block right before it (0x01032e14–0x01032fff) is a generic HTML-table doc writer shared by at least two features: a "Wolfenstein The New Order CVar List" page and, immediately above it, ../docs/Editing/Script/script_operators.html with "<p>This file has been automatically generated with the '%s' console command.</p>" — the engine can dump its own operator-precedence table to HTML via a console command, whose exact name this scan did not resolve (format strings only name it as %s, filled at runtime; .text is encrypted so the AddCommand call site is unreachable statically). Flagged as an open lead, not used further here.

3.2a Three script-subsystem cvars found in .rdata (new this pass, from the verifier's "missed" list, re-confirmed independently)

Immediately next to the script_operators.html doc-writer block (§3.2):

0x01032e20  If set, tells engine to load script_events.script from specified location
0x01032e70  script_scriptEventsPath

A real cvar the engine checks at script-compile time to redirect where it loads script_events.script from — this is the most directly relevant lever this pass found for "can custom events be authored": if settable, it could point the compiler at a mod-extended event table without touching the encrypted .text's native dispatch. Whether it is settable from a loader-replaced cfile (the TNC-style resourceExec <file> -s route, docs/moddingflaeche-tnc.md) is unverified for TNO — flagged, not tested (no TNO console-restriction work exists yet, same gap pass 1 already flagged).

Two more, found and read in context (not just named):

0x00ffdd50 / 0x010351a8 / 0x010351f8 / 0x01035228 / 0x01035dc8  script_debug
  (5 separate GetBool()-style check sites, gating different VM debug prints: "Script: Init
  List %s to %d", "Script: Create List %d", "Script: REMOVE LIST %d | TOTAL : %d" — a single
  verbose-logging toggle checked at several call sites, not 5 different cvars)
0x010361b0  suppress script tty prints
0x010361d0  script_suppressPrints

No default-value or CVAR_* flag string sits adjacent to either (unlike script_suppressPrints, whose description string "suppress script tty prints" sits directly before its name — the ordinary idCVar-registration string layout), so their flags/defaults are not recovered here, only their existence and purpose strings.

3.3 Thread/stack model — confirms the thread keyword seen in script_util.script

0x010344a8  %s(%d): Thread '%s': %s
0x010345c8  call stack overflow
0x01035f98  idStateThread::GetScriptFunction : Null entity

Scripts run as cooperative threads with their own call stack ("Stack depth: %d bytes, %d max", "Call depth: %d, %d max"), and idStateThread ties the identical thread machinery into the AI state system (AC_ExecutingScript/AC_DoomscriptDone, §2) — the same VM drives both map-trigger callbacks and AI state-machine script hooks, one interpreter, two call sites.

4. The data format, parsed directly (37/37 .script, 25/25 .entities)

4.1 .script — C-like source, unchanged from the leaked idTech4/Doom3 dialect

37 files (32 map-specific + 5 shared: script_main.script, script_util.script, script_defs.script, script_events.script, debug_drawtext.script), all plain ASCII text, sizes 415 B (c02p0.script) to 246 921 B (script_events.script). Confirmed constructs, read directly out of the files (not reconstructed from memory of the format):

// #define / #ifndef preprocessor (script_defs.script)
#define BIT( num )  ( 1 << ( num ) )
#define waitUntil( x )  while( !( x ) ) { sys.waitFrame(); }

// top-level function, the fixed callback signature every "call" target needs:
void call_freeLook( entity activatedEntity, entity activator ) { ... }

// an "object" — a named, strongly-typed bundle of persistent members + methods,
// one of which (`onTrigger`) the engine calls back directly:
object moveSelf {
    void init();
    void onTrigger( entity activator );
    float moveTime; float moveAccelTime; float moveDecelTime;
    float distance; int direction; boolean toggle;
};
void moveSelf::onTrigger( entity activator ) {
    self.move( direction, distance );
    if( toggle ) distance = -distance;
}

// threads, entity-method calls, sys.* built-ins:
thread delayRemoveThread( ent, mytime );
$player1.getUserButtons();
sys.wait( 0.01 );

script_util.script additionally declares four more object templates: moveSelf, rotateSelf, bobSelf, randomActivator — bound to retail entities, and the binding mechanism itself, both corrected in §4.1a below (pass 1 grepped for the bare template name as a spawnarg key and found nothing, because the real field is one level deeper).

4.1a scriptObject = { type = "X"; } — the entity↔script-object binding (corrected this pass, was wrongly called absent/unresolved)

Pass 1 claimed the string "scriptobject" never appears as an entity field and left "how is a script_util.script object bound to an entity" as a genuinely open question (§9). Both were wrong, from the same miss: the real field is a structured block, not a bare key:

entity { entityDef gp_func_movers_mover_corridor_blocker {
    ...
    clipModelInfo = { clipModelName = "maps/.../gp_func_movers_mover_corridor_blocker"; }
    scriptObject = {
        type = "moveSelf";
        moveTime = "3"; moveAccelTime = "0"; moveDecelTime = "0";
    }
} }                                                        -- c01p4.entities:23839

entity { entityDef gp_mover_... {
    scriptObject = {
        type = "rotateSelf";
        moveTime = "2"; moveAccelTime = "1"; moveDecelTime = "1";
        rotationVector = "0 150 0"; toggle = "0";
    }
} }                                                        -- c02p1.entities:32900

This is the binding: scriptObject.type names an object template by exact name, and the fields alongside type are that object's own public members (moveTime, rotationVector, toggle, … — the identical field names script_util.script's object moveSelf { ...; float moveTime; ...; } declares, §4.1). Measured directly:

grep -c 'scriptObject = {' out/entities/*.entities  -> 173 blocks total, 20 of 25 files
grep -A1 'scriptObject = {' ... | grep 'type ='      -> 24 distinct type values:
  flameburstprefab=45  rotateSelf=36  moveSelf=22  brakeblock=20  missilepoddamage=6
  chainEvent=6  bobSelf=6  elevator=5  breakable_poster_trigger=5  randomActivator=3
  holding_door=3  grenaderespawner=3  playerPusher=2  (+11 singletons: waterlevel,
  wallCrushMover, supersoldat, ripOffMaskObject, prototyperobot, mechadeathshead_stage2/3,
  londonmonitorturret, londonMonitorParams, londonMonitor, heavyrobot)

The 4 script_util.script-shared templates account for 67 of the 173 blocks (rotateSelf=36, moveSelf=22, bobSelf=6, randomActivator=3 — directly refuting pass 1's "zero" claim). The other 20 distinct template names are not in script_util.script at all — they are object definitions living in individual per-map .script files, a second tier of the same mechanism pass 1 didn't enumerate:

grep -rhoE '^object\s+[A-Za-z_][A-Za-z0-9_]*' out/scripts/ | sort -u
-> Drone, RandomObjectRotate, bobSelf, brakeblock, breakable_poster_trigger, bus_move,
   chainEvent, debug_drawtext, dynamicPlane, dynamicSun, elevator, explodeObject,
   flameburstprefab, floatingBodies, grenaderespawner, hatch, heavyrobot, holding_door,
   londonMonitor, londonMonitorParams, londonmonitorturret, mechadeathshead_stage2/3,
   missilepoddamage, moveSelf, objectFollowSpline, panzerhund_move, pendulum, playerPusher,
   prototyperobot, randomActivator, ripOffMaskObject, rotateSelf, searchLight, splineCar,
   spotMover, spotTarget, supersoldat, swingingObject, wallCrushMover, waterlevel

Every scriptObject.type value measured above matches one of these object template names exactly. This widens §8's authorable-object surface: a custom scriptObject block on an entity, naming either a shared template (script_util.script) or a new per-map object definition added to that map's own .script, is a third authoring lever alongside the idTarget_* relay graph and the call="X" bridge — not run in-game, same caveat as the rest of §8, but no longer an open question about whether the mechanism exists at all.

4.2 script_events.script — 913 native scriptEvent signatures, the command set

$ grep -c "^scriptEvent" out/scripts/script/script_events.script
913

This is the full native API surface a map script can call — sys.* globals and entity.*/object.* methods, auto-generated (comment: "This defines the function prototypes for all events..." per moddingflaeche-tno.md §4.2) and auto-#included in every script (confirmed string 0x01034100 /script/script_events.script). A sample directly answering "spawn/door/sound/objective":

scriptEvent entity  spawn( string classname );
scriptEvent void    respawn();
scriptEvent void    respawnWithFade( string spawnSpot, int fadeTimeMS );
scriptEvent void    nextMap( string mapName, string spawnSpot, string layersActive, string layersDeactive );
scriptEvent void    devMap( string mapName, string spawnSpot, string layersActive, string layersDeactive );
scriptEvent void    openDoor();
scriptEvent void    linkSoundDoor();
scriptEvent vector  getCenterOfDoor();
scriptEvent float   startSoundShader( decl shader, int channel );
scriptEvent void    stopSound( int channel, int netSync );
scriptEvent void    doCheckpoint( entity activator );
scriptEvent void    setcvar( string name, string value );
scriptEvent void    executeCommandText( string command );
scriptEvent void    drawText( string text, vector origin, float scale, vector color, int align, float lifetime );

(Full list: out/scripts/script/script_events.script, 913 lines, already extracted by this track.) devMap as a scriptEvent (not just a console command) is new relative to prior tracks' console-command work — it is reachable from inside a compiled map script without touching the restriction-level machinery r4_consolecmds.md/r5_kisculecmd.md measured for TNC at all (different engine, different gate — not re-checked here, flagged in §9).

4.3 .entities — the target/trigger graph, 116 classes measured, spawnargs read directly

idTrigger (volume, needs a baked collision model):

entity { entityDef sound_trigger_condor9_enter {
    inherit = "triggers/trigger"; class = "idTrigger"; expandInheritance = false;
    edit = {
        spawnPosition = { x=-1072; y=-408; z=-528; }
        targets = { num=5; item[0]="sound_tr_enter_cockpit"; ... }
        clipModelInfo = { clipModelName = "maps/game/wolf/c01/c01p1/sound_trigger_condor9_enter"; }
    }
} }

idTarget_Count used as target/relay (pure logic, no clipModelInfo — proof that relays are not volume-bound, §7), with the call bridge into the script VM:

entity { entityDef call_boattest {
    inherit = "target/relay"; class = "idTarget_Count"; expandInheritance = false;
    edit = {
        "int" count = 1;
        targets = { num=1; item[0]="gp_target_show_36"; }
        delay = 0.0199999996;
        call = "call_boattest";
        repeat = true;
    }
} }

Fields measured directly, across many instances: count (activations needed before firing — idTarget_Count_Random additionally randomizes which target in the list fires, 454 retail instances, e.g. a bank of flak-impact speakers picked at random), delay (seconds before the chained targets/call fire), repeat (re-arms instead of one-shot), triggerOnce (seen on idTrigger volumes), call (the script bridge, §5).

116 distinct classes whose name contains Target or Trigger anywhere appear across the 25 maps (corrected this pass: pass 1's text described this as the anchored regex id(Target|Trigger)[A-Za-z_]*, which does not actually match 3 of the 4 gap classes below — that regex requires Target/Trigger immediately after id, which idSoundTrigger, idPlayerHealthSoundTrigger and idInfo_Trigger_Facing_Target don't satisfy; the true matcher is the broader "contains" grep, grep -h -o 'class = "id[A-Za-z_]*"' ... | grep -E 'Target|Trigger', independently re-run this pass and confirmed to give exactly 116). §6 below counts 112 of these because it deliberately narrows to the idTarget_/idTrigger_ (with underscore) naming convention to line up one-to-one against the RTTI dump — the 4-class gap is idTrigger itself (the bare volume base class), idSoundTrigger, idPlayerHealthSoundTrigger and idInfo_Trigger_Facing_Target, which don't follow that convention. Same number (116), two different matching rules producing it and the narrower one (112) — not a contradiction, just two footnoted counts (the same kind of footnote docs/kiscule.md needed for its own two counting rules), but pass 1's prose named the wrong rule for the 116 figure. Directly relevant to the "spawn/door/sound/objective" part of the question, all already used in retail, all plain decl text, all editable:

Need Retail class Example edit field
spawn idTarget_Spawn entityDefs.item[0].text = "generated/maps/.../name.decl", spawnEditable.spawnAt
door idTarget_Door, idTarget_LockDoor/idTarget_UnlockDoor, idTrigger_OpenDoor —
sound idTarget_StartSoundShader, idTarget_SoundStart/_SoundStop, idSoundTrigger soundShader name
objective (automap icon) idTarget_Show/idTarget_Hide targeting a gp_misc_automap_objective_* entity targets
objective (mission end) idTarget_CompleteGame, idTarget_NextMap —
inventory/secrets idTarget_GiveItems, idTarget_GiveSecrets, idTarget_RemoveItems itemList
checkpoint idTarget_PlayerCheckpoint (+ scriptEvent doCheckpoint) —
AI direct event idTarget_ActionScript (eventName + typed parms, e.g. action_ClearPerceptionFlag) separate from the .script VM — a data-driven AI-event dispatch, not a call

idTarget_Spawn's referenced decl (generated/maps/.../<name>.decl) is itself a shipped, already-indexed asset — re-pointing an existing idTarget_Spawn at a different, already-existing generated decl is plausible with the already-proven set-style decl edit; authoring a brand-new spawn prefab is not (same "no new (type,name) index entry" constraint moddingflaeche-tno.md §6.4 already established for decls in general, inherited here unchanged — not re-measured, cited).

5. How entities reference scripts: a measured, not assumed, answer

Two separate mechanisms, both measured directly over the extracted files, not inferred from a single example:

(a) The whole map script loads by naming convention, never by an explicit reference. No field anywhere in any .entities file, mapInfo decl, or the .mapresources preload list (python tools/probe_tno_maps.py mapres — rows are opaque index-entry IDs, no filenames, checked directly) names a .script file. Instead, every one of the 32 map-specific .script files shares its exact basename with exactly one .entities map (c01p1.script↔c01p1.entities, c04p2_3.script↔c04p2_3.entities, …, 1:1, confirmed by listing both sets). Combined with script_main.script's own comment ("the main script that is loaded before any level scripts load") this is naming-convention loading, the same pattern Doom3/Quake4 use (<mapname>.script auto-loads with <mapname>.map) — inferred from the 1:1 naming match and the compiler's per-map "Compiled '%s'" log line, not observed at runtime (no game launch).

The converse was flagged as unchecked by the verifier and is checked now: of the 25 .entities maps, exactly one, c16p0, has no matching .script file anywhere in the archive at all (confirmed: comm -23 of the 25 .entities basenames against the 32 map-script basenames). This is consistent with, not a counter-example to, the model above: c16p0.entities also has zero call="X" fields (confirmed), so the per-map compile step would have nothing to call even if a c16p0.script existed — a map's script file is presumably only emitted/shipped when that map actually uses the call bridge, matching the "auto-#includes the two shared scripts regardless, loads the per-map one if present" model exactly.

(b) Individual relay entities reference individual script functions via call, and this is the part actually cross-checked, not just read off one example:

python re_probes/tnoscript/maplink.py

For every .entities file, every call = "X" field is matched (case-insensitively — the DoomScript identifier rule, confirmed by call_freelook/call_freeLook differing only in case in c01p1.entities/c01p1.script) against every top-level function defined in that map's own script and every other .script in the same chapter folder (map scripts are not self-contained — c06p1.entities also calls into c06p1_parking.script, c06p1_vo.script, shaft.script, shaft_drones.script; c14p1.entities also calls into c14p1_boss.script/c14p1_elevator.script — these sidecar scripts are a second, smaller mechanism this track found and had not been named before):

TOTAL: 513/516 call="..." fields resolve to a same-map top-level function (case-insensitive)

The 3 unresolved are genuine retail defects, not a model error — checked individually:

  • c06p1: call="call_chase_to_ruble" — the function exists in c06p1_parking.script line 247, but is wrapped in /* ... */ — commented out in shipped retail data. The relay entity still exists and still names it; at runtime this call can only be a silent no-op (or, if the compiler still errors on an unresolved call string at trigger time rather than compile time, a dead branch — not determined, no launch).
  • c06p1: call="spawn_shaft_gp_drone_1" / "spawn_shaft_gp_drone_3" — no function of this name exists anywhere in the extracted 37 scripts. Genuinely dangling, same defect class as TNC Kiscule's 43 dead edges in 29 files (docs/kiscule.md §3.3) — a known, harmless shape of retail data, not unique to this track's method.

This 99.4% figure, with 3 named and individually explained exceptions, is the strongest available substitute for the game-only "does it actually run" test the rules otherwise require and explicitly permit here (the game cannot be launched by this track).

6. Unused classes: the RTTI cross-check (same method as kiscule.md §4, reused on purpose)

grep -oE '^id(Target_|Trigger_)[A-Za-z0-9_]*' docs/ghidra/klassen_tno.csv | sort -u   # 197
grep -h -o 'class = "id[A-Za-z_]*"' out/entities/*.entities | sort -u                 # 112 of those 197 appear

197 distinct idTarget_*/idTrigger_* RTTI classes exist in the shipped EXE; 112 are used across the 25 retail maps; 85 are not used anywhere in retail. Unlike TNC's 113 unused Kiscule node classes (which were mostly generic logic ops — LogicGateAnd, CompareBool, …), a meaningful chunk of TNO's 85 look purpose-built and specific: idTarget_Cvar, idTarget_Print, idTarget_UnlockMap, idTarget_UnlockSecret, idTarget_SetHelp, idTarget_GameStateIntSet/_GameStateIntInc, idTarget_PlayAnim, idTarget_HideShowMesh, idTarget_LevelTransition, idTarget_SoundDuck/_SoundSync, plus a visible block of QA-only entities (idTarget_Test*, idTarget_TestScreenCompare, idTarget_TestShotsFired) and coop/multiplayer leftovers (idTarget_CoopGiveScore, idTarget_CoopObjectiveTracker, idTrigger_Multiplayer, idTrigger_GameChallenge — TNO retail has no multiplayer, so these are dead weight from a shared engine build, not a cut TNO feature). idTarget_Cvar is the standout for this project's stated goal ("engine ausreizen"): a trigger-driven cvar setter would be a second, independent route around the command-restriction gate r4_consolecmds.md found for TNC's different engine — whether the class still spawns correctly when referenced from a hand-edited .entities file is completely untested, zero evidence either way beyond "the RTTI entry and vtable exist." Flagged as the single most promising next experiment, not claimed as working.

6a. idTarget_Cvar reachability — pushed as far as static evidence allows, still open

Two new pieces of evidence this pass, neither resolves it, both relevant:

  • idTarget_Cvar's RTTI entry has the same shape as every used class: idTarget_Cvar, idEntity;idEventReceiver;idClass,0x140C18C28,0x1410A6E28 (vtable + type_info addresses both present, docs/ghidra/klassen_tno.csv). idTech4-lineage engines spawn entities by name lookup into a reflection table (idClass::CreateInstance/idTypeInfo) populated by static initializers at process start — not a hand-written per-class switch statement — so architecturally there is no obvious reason a fully-RTTI'd-but-unused class like idTarget_Cvar would be rejected by the spawn path differently from a used one. This is an architectural inference from the known idTech4 pattern, not a TNO-specific proof — the actual spawn dispatch is in the encrypted .text and was not read.
  • TNO has its own cheat-cvar restriction system, confirmed by strings near the CVAR_CHEAT/CVAR_NOCHEAT/CVAR_ARCHIVE flag-name table (0xd7d0a8 block) and a separate function's diagnostics: "...T be set in retail!" (truncated "CANNOT be set in retail!"), "Archived CVar '%s' in production mode", "Resetting cheat cvar: %s" (0x01050400–0x01050480). This is a different, TNO-native gate, not TNC's resourceExec mechanism (r4_consolecmds.md/r5_kisculecmd.md are TNC-only, never re-checked for TNO, still true). Whether the setcvar/executeCommandText scriptEvents (§4.2) go through this same gate, a different one, or none, is not determined — the call site is encrypted .text. Named here because it's concrete, reproducible string evidence where pass 1 had none, not because it answers the question.

7. What is provably NOT authorable with the existing tools

New physical trigger volumes — no. Every idTrigger-derived entity with a shape (not a pure relay) carries clipModelInfo.clipModelName, a reference to a separately baked, binary cm decl (8 661 such entries across the game per docs/engine-surface.md, listed there as flatly binary, unextracted/unwriteable). Adding a new named cm asset hits the same wall moddingflaeche-tno.md §6.4 already proved for decls in general — Installation.apply replaces existing (type,name) index entries, it cannot insert a new one. This means a brand-new trigger volume with new geometry is not reachable. The only moves available are: reusing an existing volume's clipModelName for different logic (same shape, new behavior), or repositioning an existing volume via its own spawnPosition (not measured here whether the baked cm shape follows spawnPosition or is already world-baked — open, §9).

New top-level (type,name) assets of any kind — no, unchanged from the general finding already on record; not re-derived here.

8. What IS authorable, and the recipe — all steps individually proven as possible, the combination untested end-to-end

  1. Edit an existing relay's logic without adding anything — change delay, count, repeat, the targets list, or redirect an existing call to a different, already defined function in the same chapter. Pure set-style value edits, same mechanism the project has already proven in-game for TNC decl edits (per memory: tnc-decl-edits.md, magazine 30) — the TNO analogue is unproven in-game but structurally identical (same decl-text grammar, same patch pipeline).
  2. Add a brand-new relay + a brand-new script function, both inside the existing .entities/.script text blobs for one map (growing an already-indexed file asset's payload, not creating a new index entry — the distinction §7 turns on). Concretely: add entity { entityDef my_new_relay { inherit="target/relay"; class="idTarget_Count"; edit = { targets={num=1;item[0]="<existing door/sound/spawn target>";} call="my_new_func"; } } } to the map's .entities, and void my_new_func( entity activatedEntity, entity activator ) { <any scriptEvent from script_events.script> } to the matching .script. Both files already tolerate 0–428 bytes in place (moddingflaeche-tno.md §4.1) and a full chunk rebuild for anything larger — the rebuild path already exists (Installation.apply's rebuild branch, cited there, not re-derived here).
  3. Wire it to an existing trigger volume by adding the new relay's name to that volume's own targets list — no new collision geometry needed, §7.
  4. For an objective marker, target an existing gp_misc_automap_objective_*-style entity's idTarget_Show/idTarget_Hide the same way §4.3's table shows retail doing.
  5. (new this pass) Attach a scriptObject block to an entity for templated behavior (mover/rotator/trigger-prefab logic) instead of a bespoke relay+function pair — either reuse a shared template (scriptObject = { type = "moveSelf"; moveTime = "2"; };, §4.1a) or add a new object definition to the map's own .script and reference it by name the same way the 20 per-map templates (flameburstprefab, brakeblock, elevator, …) do.

None of steps 1–5 has been run against the game. This is the recipe this track's research converges on, not a verified result.

PROVEN (format/string level, this track, reproducible with the commands in §1)

  • The entity-trigger graph and the script VM are two measured, distinct layers, both real engine systems (RTTI classes + compiler/VM strings), not an assumption from the question.
  • 211 distinct OP_* DoomScript bytecode mnemonics exist, in 3 byte-identical copies in .rdata (0xdb6db8, 0xdf49e8, 0x1030e80), including OP_PUSH_ENT/OP_GOTO that moddingflaeche-tno.md only knew as unlocated examples; a further 92 OP_-prefixed strings elsewhere belong to two unrelated systems (NFSv4 protocol ops, a shader/face-blend opcode set) and do not overlap the DoomScript vocabulary (§3.1, corrected this pass).
  • scriptObject = { type = "X"; <params>; } is the entity↔script-object binding: 173 instances across 20/25 maps, 24 distinct template types, 4 of them (moveSelf=22, rotateSelf=36, bobSelf=6, randomActivator=3) the shared script_util.script templates, the other 20 defined per-map in that map's own .script (§4.1a, new/corrected this pass — resolves what pass 1 called a genuinely open question).
  • c16p0.entities is the one retail map with no matching .script file at all, consistent with it also having zero call="X" fields (§5, confirmed this pass).
  • The compiler is idCompiler (not named in RTTI, but in its own diagnostic strings), compiles once per map at load, is fatal on error, auto-includes script_events.script+script_defs.script, and its engine fork is internally named "tungsten" (leaked build path).
  • 913 native scriptEvent signatures in script/script_events.script, including spawn, openDoor, startSoundShader, nextMap/devMap, doCheckpoint — directly answering the "spawn/door/sound/objective" part of the command-set question.
  • 116 distinct idTarget_*/idTrigger_* classes used across the 25 retail maps; 197 exist in the RTTI; 85 unused, named individually (§6).
  • The call="X" bridge from relay entity to script function: 513/516 (99.4 %) resolve to a real, same-chapter function, case-insensitively, with the 3 exceptions individually explained (1 commented-out retail function, 2 genuinely dangling — both harmless defect classes, not a method error).
  • .script files load per map by a 1:1 filename convention with .entities, with a secondary same-folder sidecar-script mechanism (newly named by this track, not previously documented) needed to resolve 41 of the 516 call fields.
  • New physical trigger volumes cannot be authored (binary cm dependency + the existing "no new index entry" wall); new logic relays and new script functions can be added inside already-indexed files without that restriction.

INFERRED (plausible, not run)

  • The full recipe in §8 (steps 1–5) works end-to-end in a launched game.
  • idTarget_Cvar (unused in retail, RTTI-present with the same vtable/type_info shape as used classes, §6a) could set cvars from a trigger, possibly bypassing TNO's own cheat-cvar/"production mode" gate (strings found this pass, §6a) the way resourceExec bypasses TNC's — architecturally plausible given idTech4's reflection-based entity spawn, but the actual spawn dispatch and the setcvar/executeCommandText scriptEvent call sites are both in encrypted .text and were not read; still completely unverified.
  • A re-pointed idTarget_Spawn (existing relay, different existing generated/....decl target) would spawn a different already-shipped prefab.
  • A new scriptObject-bound entity (new object added to a map's own .script, referenced by a new entity's scriptObject.type, §4.1a/§8.5) would run its init/onTrigger callbacks the same way the 173 retail instances do — not run in-game.

FAILED HYPOTHESES / dead ends (named, not hidden)

  • Assumed TNC's Kiscule node-graph binary format might have a TNO-side analogue reachable through the same archive layer — no, confirmed structurally: there is no binary graph container in TNO at all; the trigger graph is plain decl text from the start. Not a bug, a different engine generation.
  • Removed this pass (was wrong, see "Pass 1 errors" above and §4.1a): pass 1 claimed the spawnarg scriptobject never appears as an entity field. It does, as the structured block scriptObject = { type = "X"; }, 173 times — the grep that produced "zero" was looking for the wrong shape (a bare key, not a nested block). superScriptObjects (a separate, unrelated music-modifier field) is still real and still distinct from this mechanism, that part of the pass-1 text was not wrong, only the "never appears" claim around it.
  • Tried to resolve the exact console-command name behind the auto-generated script_operators.html/CVar-list HTML dump (§3.2) — the format strings only hold a %s placeholder for the command name; the AddCommand call site is in the encrypted .text. Capped at this one string-level attempt per the project's "~3 identical failures" rule (this was the only attempt, not three — stopped because further attempts would need disassembly, which TNO's read-only string-level scope here does not have).

Open questions (§9)

  • Does a repositioned idTrigger's baked cm shape follow a changed spawnPosition, or is the collision mesh already world-baked at build time? Decides whether "move an existing trigger volume" is a real option. (Still open, not pushed further this pass — needs the binary cm format read, out of this track's string-level scope.)
  • ~~How is a script_util.script object bound to a specific entity~~ — resolved this pass, see §4.1a (scriptObject.type). No longer open.
  • Whether idTarget_Cvar/idTarget_Print (RTTI-present, zero retail instances) still spawn correctly if written into a hand-edited .entities file — still the single most promising next experiment; pushed as far as static evidence allows this pass (§6a: same RTTI shape as used classes, a TNO-native cheat-cvar gate exists) without resolving it — needs a runtime image or an actual in-game test, neither available to this track.
  • The exact console command that dumps script_operators.html (cosmetic, not blocking).
  • Whether devMap/setcvar/executeCommandText as scriptEvents reach the same restriction-level gate TNC's console does, TNO's own cheat-cvar/"production mode" gate found this pass (§6a), or neither — still not determined, the call sites are in encrypted .text; would need the same kind of work r4_consolecmds.md did for TNC, from scratch, for TNO, or a runtime image TNO does not have.

Smallest test for the maintainer

(Translated from German.)

The safest first, with no new file length and no new trigger volume: in a copy of c01p1.entities, change the entry call_boattest (delay = 0.0199999996;) to delay = 5; (pure numeric value, no length change, rebuild probably not needed) and load c01p1 — if the boat test fires noticeably later than in the unpatched version, the idTarget_Count→call mechanism is confirmed live in the game, not only in the text. The next, more meaningful step: in the same copy, append a new, own idTarget_Count entry that points at a newly written function void call_wolfsdk_test( entity activatedEntity, entity activator ) { sys.print("wolfsdk r6 alive\n"); } in the corresponding c01p1.script, and attach it to an already existing, early-reachable trigger (e.g. a door or sound trigger from chapter 1) as an additional targets target — that needs a full chunk rebuild (the budget according to moddingflaeche-tno.md is not enough), but it is the first real test of whether new script functions and new relays are accepted by the game at all.

New this round (§4.1a): as an alternative to a new call function, a scriptObject = { type = "moveSelf"; moveTime = "2"; }; block can experimentally be appended to an already existing, unused entity (a pure value addition, known field) — if the entity then actually moves in the game, the second, previously unrecognised authoring route (template objects instead of relay + function) is also confirmed live.

Related: Research Report: R4 Console Commands, Research Report: R5 Kiscule Command, Research Report: R7 Tno Script Vm, Ghidra Analysis, TNO Modding Surface, UI Assets, Kiscule Scripting, TNC Modding Surface, Engine Modding Surface, TNO Maps.