Skip to content

Research Report R7 Tno Limits

LopeKinz edited this page Oct 4, 2026 · 1 revision

TNO Engine Limits from Decompiled Code: Entities, AI, Script VM, Physics, Decls, Audio, Render

Round-7 research pass on The New Order's hard engine limits, read from decompiled code — the TNO counterpart of Engine limits (R6). It finds a proven entity-slot ceiling with a separate spawn counter, a fatal AI "Group" member cap identical to TNC's, a fatal script call-stack depth, and hard and soft physics caps; the AI population limit is a per-spawner field, not a global constant, as in TNC. Static evidence only; the game was never launched.

Track tnolimits. Read-only on both game installs and on every project file outside re_probes/tnolimits/ (unused — no scratch file was needed) and this report. The game was never launched, no process was attached. This track's sole new capability is tools/decomp.py --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf, querying re_probes/runtime_tno_186352/module.bin (a read-only memory snapshot of TNO, PID 186352, base 0x7FF74A390000, captured earlier and already analyzed into D:/wolf-decomp/ghidra/tno_runtime.gzf — see D:/wolf-decomp/tno_import_log.txt: 50,884 functions from .pdata, 2,369 RTTI vtables labeled, no curated/command names yet, unlike TNC's tnc_runtime.gzf). Every RVA below is a file offset into that module.bin (rva = address - 0x7FF74A390000), computed and cross-checked with Python, never by hand. Builds directly on r6_enginelimits.md (same question, TNC side; TNO side there was string-only, no runtime image existed yet) and does not redo r6_tnoconsole.md/docs/limits.md (retail cvar lock, cheat-reset mechanism — a different kind of limit).

Question

TNO's hard engine limits from code: entity/AI/light/physics/decl/string/ memory caps (MAX_* constants, asserts, "overflow"/"exceeded" errors); which are cvars and which are compiled; what happens at the limit (warn vs fatal); compare with TNC's r6_enginelimits.md.

Answer (short)

Headline: TNO's decompiled runtime resolves one of TNC's own standing open questions, in TNO's own code. r6_enginelimits.md §2.1 could not find any caller of TNC's entity-registration overflow branch (Ghidra's static xref scan found zero callers for the fatal path itself) and explicitly recorded the real MAX_GENTITIES-equivalent numeric value as unrecoverable. TNO's decompiled idGameLocal::RegisterEntity (3 independent inlined copies, RVAs 0x8609db/0x860870/0x865440, §1) does have found callers and a directly-readable literal: a managed-object pointer array of 8192 (0x2000) slots, of which indices above 0x1ffd (8189) are refused ("no free entities", fatal), with 0x1ffe/0x1fff used as reserved sentinel ids (the world entity is explicitly assigned 0x1ffe in one of the three copies) — and a separate monotonic "ever spawned" counter capped at 0x7ffff (524,287), whose overflow is the actual "idGameLocal::RegisterEntity: spawn count overflow" message both games share verbatim. This is TNO's own number, not proof TNC's is identical (r6_enginelimits.md itself cautions against that assumption) — but it is the first PROVEN, not just named-but-unreachable, entity-slot ceiling found in this project for either game.

Second confirmation: the "Group has too many members" cap is identical in both games, and now proven fatal in both. r6_enginelimits.md §3.2 found the format string byte-identical in TNO's static .rdata but could not read .text to confirm the number. Decompiled TNO (RVA 0x57cecf): literal 0x40 = 64, followed immediately by swi(3) (an int3 trap — an unconditional crash, the same "print then trap" idiom as every other fatal cap found this round) — exactly TNC's value and TNC's fatal behaviour, now independently proven on both sides rather than inferred from a shared string.

Everything else found this round is new ground r6 did not cover (TNC side not reproduced here either): the TNO script VM has a hard 64-deep call stack (RVA 0x96e7d0, fatal) and a hard 32 KiB global-variable memory pool (RVA 0x9791e0, fatal); physics has a hard 100-particle cloth cap (RVA 0x29d480, fatal) and a soft 32-vertex/32-edge/16-polygon cap on trace-model-from-submodel generation (RVA 0xc8ae0, warn-and-reject, not fatal); an idDeclEnv (environment decl) is capped at 80 renderParms (RVA 0x113720, fatal); the render layer soft-caps 256 GUI surfaces (RVA 0x313810) and 128 dynamic-environment probes (RVA 0x3179b9), both reject-and-continue, not fatal; audio soft-caps 2 sound listeners (RVA 0x4a3260); AI pathfinding's Dijkstra search soft-caps at 180 visited nodes (RVA 0x7902e0) with a verbose diagnostic dump instead of a crash. Three subsystems (AI blackboard variable pools, script max-functions/max-statements, the "too many weapon stats"/"too many touched triggers" registries) have a capacity held in a data field, not a literal in the instruction stream — same class of dead end r6_enginelimits.md hit for ~440 MAX_* names; confirmed, not resolved, this round.

Which are cvars and which are compiled, for TNO specifically: every single cap found this round is compiled or data-driven-but-not-cvar-backed — not one of the ~20 decompiled functions below reads a cvar before comparing against its limit. This is a materially different picture from TNC's §4 (131 cvars with engine-clamped min/max, several of them exactly the kind of streaming/world-geometry/physics pool a modder would want to raise) — but it is not proof TNO has no such cvars anywhere; this round inspected the ~20 functions behind the error strings found, not TNO's full cvar registry (TNO has no cvars_tnc_v2.json-equivalent catalogue — see open questions). The one direct attempt to reuse TNC's approach (finding idCVarSystemLocal's singleton the way tools/probe_cvartable.py does for TNC) failed outright this round (see Failed hypotheses) — the TNC-side cvar/command registry array-size ceiling (§1 of r6_enginelimits.md: 256/64 commands, 4096/512 cvars) and the cvar min/max table (§4) have no TNO counterpart in this project yet.

Negative evidence against two of TNC's own cross-game guesses: TNC's hard-coded 16-projectile multi-fire clamp message ("Tried to spawn too many projectiles") does not exist anywhere in TNO's string table (full "too many"/"projectil" scans, zero hits) — not an unconfirmed number this time, a confirmed absent feature/message. TNC's unnamed light-count caps (GatherShadowCasters's "Maximum allowed per frame", "Too many visible lights") also do not exist in TNO's string table — r6_enginelimits.md's own open question about those two functions' literal values cannot be settled from the TNO side; they appear to be TNC-only code (later engine revision only, or renamed beyond a substring match).

1. Entities: the slot array, the spawn-id counter, and what each overflow does

Three independent, differently-inlined copies of idGameLocal::RegisterEntity all reference "idGameLocal::RegisterEntity: spawn count overflow" (RVA 0xffeb60) and all agree on the same two literals:

// FUN_7ff74abf09db, rva 0x8609db (one of three inlined copies)
if (0x7ffff < *(int *)(unaff_RBX + 0x170598)) {       // monotonic spawn-id counter
    FUN_7ff74ad5ab70(s_idGameLocal__RegisterEntity__spa_7ff74b38eb60);
    pcVar2 = (code *)swi(3); (*pcVar2)();             // int3 trap -- FATAL
    return;
}
if (unaff_ESI < 0) {                                   // caller passed "pick a free slot"
    lVar1 = *(longlong *)(unaff_RBX + 0x161d0 + (longlong)*(int*)(unaff_RBX+0x2e1d0)*8);
    while (lVar1 != 0 && (*(int*)(unaff_RBX + 0x2e1d0) < 0x1ffe)) {  // linear free-slot search, capped
        ... *(int*)(unaff_RBX + 0x2e1d0) = iVar3; ...
    }
    unaff_ESI = *(int *)(unaff_RBX + 0x2e1d0);
    if (0x1ffd < unaff_ESI) {                          // 8189 -- no slot left
        FUN_7ff74ad5ab70(s_no_free_entities_7ff74b38eb98);
        pcVar2 = (code *)swi(3); (*pcVar2)();          // FATAL
        return;
    }
    ...
}

A third copy (RVA 0x865440, the largest/least-inlined of the three — grep for the same two constants inside it) additionally contains *(undefined4 *)(lVar15 + 0x218) = 0x1ffe; — assigning the world entity its id directly as the top-of-range sentinel 0x1ffe (8190), consistent with the classic id Tech ENTITYNUM_WORLD/ENTITYNUM_NONE convention of reserving the last few ids below the array size. The int array parallel to the pointer array runs 0x261d0–0x2e1d0 (exactly 0x8000 bytes = 8192 * 4), independently confirming the pointer array's declared capacity is 8192 (0x2000) entries, of which indices 0–0x1ffd (8189) are the actual free-list search space and 0x1ffe/0x1fff are reserved. Separately, FUN_7ff74aa69ef0 ("touched triggers", §5) uses literal 0x1fff (8191) as its own "no entity" sentinel default — a fourth, independent confirmation of the same 0x2000-entry array size.

The "spawn count" field (+0x170598) is a different counter from the slot-array index — it only increments, it is not decremented on entity removal (unlike the free-slot index, which is explicitly reused), and its own cap (0x7ffff = 524,287) is roughly 64× the slot array size. This explains the message's own wording ("spawn count", not "entity count"): it is almost certainly a lifetime spawn-id generator (every entity ever created in a level, including ones since freed, gets a unique id up to ~524k), separate from "how many entities can be alive at once" (~8189). Modding relevance: a level that spawns and frees an unusually large number of entities over its lifetime (not simultaneously alive) is the one that could theoretically hit the 0x7ffff ceiling; a level with too many simultaneously alive entities hits the 0x1ffd ceiling instead — two different failure modes, two different literal numbers, same source string family.

2. AI population cap: per-spawner field, not a global constant — confirmed identical in TNO

FUN_7ff74ab478a0 (name resolved directly by decomp.py, RVA 0x7b78a0) is the function behind all three "idTarget_ShowAI ... Too many AIs active!" strings (TNO's name for TNC's idTarget_AISpawn, same message family, r6_enginelimits.md §2 already flagged the naming parallel but had no TNO code to check it against):

iVar2 = func_0x7ff74abe4880(DAT_7ff74c1360e0);      // live active-AI count
iVar15 = *(int *)(param_1 + 0x37c);                 // per-entity cap field (-1 = unlimited)
bVar17 = iVar15 < 0;
if ((bVar17) || (iVar2 < iVar15)) {
    ... /* cap not exceeded, or unlimited: normal spawn logic follows */
}
else {
    uVar6 = func_0x7ff74abcdac0(param_1 + 0x18);
    FUN_7ff74ad5a7b0(s_idTarget_ShowAI___entity___s__To_7ff74b343e30, uVar6, iVar2,
                     *(undefined4 *)(param_1 + 0x37c));   // warn, NOT fatal
    ...
}

FUN_7ff74ad5a7b0 is a thin wrapper around FUN_7ff74ad5a270(3, fmt, args) — a leveled print/log call, not a no-return trap (unlike every fatal case in this report, which goes through a different function, FUN_7ff74ad5ab70, immediately followed by swi(3)). Execution continues normally after every "Too many AIs" call site in this function (three call sites inside one function body, all reached via break/fallthrough, never return into a crash). This independently confirms, in TNO's own disassembly, exactly what r6_enginelimits.md §2 proved for TNC: the cap is a per-entity, map-author-set field (TNO reads it at a fixed struct offset +0x37c directly, rather than through TNC's generic decl-field-by-name-hash accessor — a build/inlining difference, not a semantic one), compared against a live counter, with -1 meaning unlimited, and exceeding it is a logged warning, not a crash. No global "max AI in world" constant exists in this function, same as TNC.

3. The two literals extracted with full confidence: Group cap (64, fatal) and script call-stack depth (64, fatal)

// FUN_7ff74a90cecf, rva 0x57cecf -- "Group '%s' has too many members"
void FUN_7ff74a90cecf(void) {
  undefined8 uVar2 = func_0x7ff74abcdac0();
  FUN_7ff74ad5ab70(s_Group___s__has_too_many_members__7ff74b2c27d0, uVar2, 0x40);  // 0x40 = 64
  pcVar1 = (code *)swi(3); (*pcVar1)();                                            // FATAL
}

r6_enginelimits.md §3.2 found TNC's own value for this same cap (0x40, also fatal, also via the "print then no-return call" idiom) and the byte-identical TNO string, but explicitly could not confirm the TNO number. It is confirmed now: 64, fatal, in both games.

// FUN_7ff74acfe7d0, rva 0x96e7d0 -- idThread/idProgram-style VM call-stack push
if (0x3f < *(int *)(param_1 + 0x318)) {                 // 0x3f = 63 -> cap is 64 deep
    FUN_7ff74acfe320(param_1, s_call_stack_overflow_7ff74b3c5bc8);
    pcVar3 = (code *)swi(3); (*pcVar3)();               // FATAL
    return;
}

This is the literal behind "call stack overflow" (RVA 0x1035bc8, one of the "locals stack overflow"/"EnterFuncton: locals stack overflow" family r6_tnoscript.md §3.3 already found by name but had not decompiled — that report also surfaced "Stack depth: %d bytes, %d max\n"/"Call depth: %d, %d max\n", which Ghidra's xref scan shows "no references found" for (pure debug-dump strings, not the check itself) — the actual bound was recovered by decompiling the function behind the sibling, found-reference error string instead, the same successful pattern r6_enginelimits.md §3 used for TNC. A script that nests 65 or more function calls (thread calls, event handlers calling into other event handlers, etc.) crashes TNO outright.

// FUN_7ff74ad091e0, rva 0x9791e0 -- script global/AST memory accounting
*(int *)(lVar15 + 0xfc) = *(int *)(lVar15 + 0xfc) + iVar14;
if (0x8000 < *(int *)(lVar15 + 0xfc)) {                 // 0x8000 = 32768 bytes
    uVar10 = FUN_7ff74a395990(auStack_4028, s_Exceeded_global_memory_size___d_b_7ff74b3c6938, 0x8000);
    ...
    pcVar2 = (code *)swi(3); (*pcVar2)();               // FATAL
    return;
}

TNO's script compiler hard-caps a level's (or shared .script's) global variable/constant memory at exactly 32 KiB, fatal on overflow. The two sibling caps in the same compiler — "Exceeded maximum allowed number of functions (%d)" (RVA 0x97ab10) and "...statements (%d)" (RVA 0x979a60) — are real, fatal (swi(3), same idiom), and found (unlike most of r6's MAX_* family), but their actual capacity is read from a struct field (param_1+0x10120/+0x10124 and +0x2c138/+0x2c13c respectively) rather than a bare immediate — the literal that initializes that field was not traced this round (see Open questions).

4. Physics: one hard fatal cap, one soft warn-and-reject cap

// FUN_7ff74a62d480, rva 0x29d480 -- cloth-sheet constructor
iVar4 = param_2 * param_3;             // width * height
if (iVar4 < 0x65) {                    // must stay under 101 particles
    ... /* build the cloth system */
    return param_1;
}
FUN_7ff74ad5ab70(s_Cloth_system___s__has_too_many_p_7ff74b226020, ..., iVar4, 100);
pcVar6 = (code *)swi(3); (*pcVar1)();  // FATAL

A cloth sheet (width × height particle grid) over 100 particles crashes TNO (message literally states "Decrease ... to be less than %d" with 100 as the argument). Any mod authoring a new cloth-sim prop (flag, curtain, cape) must keep width*height ≤ 100.

// FUN_7ff74a458ae0, rva 0xc8ae0 -- TrmFromSubModel (build a physics trace model from a render submodel)
if (0x20 < *(int *)(param_2 + 0x54))      // > 32 verts: WARN, continues (non-fatal)
    FUN_7ff74ad5a710(...vertices(%d) (max %d)..., 0x20);
if (0x20 < *(int *)(param_2 + 0x4c))      // > 32 edges: WARN, continues (non-fatal)
    FUN_7ff74ad5a710(...edges(%d) (max %d)..., 0x20);
if (0x10 < *(int *)(param_2 + 0x3c)) {    // > 16 polygons: WARN *and* reject
    FUN_7ff74ad5a710(...polygons(%d) (max %d)..., 0x10);
    FUN_7ff74a458530(param_1); return 0;  // abort, NOT a crash
}
if (iVar4 < 0x21 && iVar3 < 0x21) { ... }  // only proceeds if verts AND edges both <= 32

Unlike every other cap in this report, overflowing this one never crashes: the vertex/edge checks only warn (and the function still bails out a few lines later via the same <= 0x20 gate), and the polygon check explicitly warns-then-returns-zero. A destructible/physics-trace submodel is limited to 32 vertices, 32 edges and 16 polygons; exceeding any of them silently produces no trace model (the object becomes non-collidable via this path) rather than crashing the game — a materially different failure mode from TNC's one disassembled physics-adjacent bound (r6_enginelimits.md §3.3's [-16,16] cuttable-model cube, also non-fatal but a spatial bound, not a count).

5. Decl, audio, AI-nav and render soft/hard caps found incidentally

subsystem cap value enforcement RVA source function
idDeclEnv (environment decl) renderParms hard 80 (0x50) FATAL (swi(3)) 0x113720 idDeclEnv::Parse-equivalent
sound-world listeners hard 2 soft (warns, resets to 0) 0x4a3260 sound-world AddListener-equiv
AAS/nav Search_Dijkstra visited-node limit hard 180 (0xb4) soft (verbose diagnostic dump, falls back, no crash) 0x7902e0 Search_Dijkstra-equiv
GUI surface stack (MAX_GUI_SURFACES) hard 256 (0x100) soft (warn, silently drops the new surface) 0x313810 idRenderModelGui::AdvanceSurf-equiv
dynamic environment probes (MAX_DYNAMIC_ENVIRONMENT) hard 128 (0x80) soft (warn, caller gets -1) 0x3179b9 dynamic-environment-probe allocator
approximated light-model buffer data field, not literal — soft, rate-limited (only every 500th overflow is logged) 0x475bfc light-model gather
AI blackboard int/float/string variable pools (3 parallel) data field, not literal — soft (returns null, caller must handle) 0x54b700/0x54b7c0/0x54b880 AI blackboard Set*-equivalents
weapon-stats registry dynamically resized (idList-shaped) — (capacity is a runtime-configured field, grows) soft warn only if that external capacity is hit 0x8e79d0 weapon-stats add
touched-triggers-per-frame registry dynamically resized — soft warn 0x6d9ef0 trigger-touch add

The last two generalize r6_enginelimits.md §5's TNC finding (decl/resource counts are idList-shaped, not a small fixed idArray) to two more TNO subsystems: neither has a small compiled ceiling, both only warn if an externally supplied capacity (set elsewhere, not found this round) is reached.

6. The shared MAX_* RTTI-name family: same dead end in TNO as in TNC

python tools/decomp.py --strings "MAX_GENTITIES|MAX_RENDER_LIGHTS|ENTITYNUM_MAX_NORMAL" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
  MAX_RENDER_LIGHTS       (no references found)
  MAX_GENTITIES           (no references found)
  ENTITYNUM_MAX_NORMAL    (no references found)

Exactly as r6_enginelimits.md §6 found for TNC: these are RTTI/reflection debug-name literals, not strings fed to a comparison+warn/fatal call site, so Ghidra's static xref scan finds no referencing instruction — the pattern that did work everywhere else in this report (find the error-message sibling string instead, which — unlike the bare MAX_* name — sits next to the actual literal at its one call site) does not apply to this family in either game. Confirms, does not newly resolve, r6_enginelimits.md's own conclusion that the ~440 MAX_* names are structural metadata, not runtime-checked bounds with a logged path.

PROVEN

  • TNO's idGameLocal::RegisterEntity has a managed-object array of 8192 (0x2000) entries, of which indices 0–0x1ffd (8189) are the usable free-slot search space and 0x1ffe/0x1fff are reserved sentinels (one of three decompiled copies assigns 0x1ffe to the world entity directly, RVA 0x865440); a separate lifetime spawn-id counter caps at 524,287 — both confirmed across 3 independent inlined call sites (RVAs 0x8609db, 0x860870, 0x865440) plus a 4th independent confirmation of the 0x2000-entry size from an unrelated function's sentinel constant (0x1fff, RVA 0x6d9ef0) — both overflows are FATAL (swi(3) trap).
  • idTarget_ShowAI's "Too many AIs active" check (RVA 0x7b78a0) reads a per-entity field at param_1+0x37c, -1 = unlimited, compares to a live count, and is a soft warning, not fatal — independently confirms r6_enginelimits.md §2's TNC finding, in TNO's own disassembly this time.
  • The "Group has too many members" cap is 64 (0x40), FATAL in TNO (RVA 0x57cecf) — matches TNC's value exactly (r6_enginelimits.md §3.2), resolving that report's own "could not confirm for TNO" caveat.
  • TNO's script VM call-stack depth cap is 64 (0x3f+1), FATAL (RVA 0x96e7d0).
  • TNO's script global/constant memory pool cap is 32,768 bytes (0x8000), FATAL (RVA 0x9791e0).
  • TNO's cloth-sheet particle-count cap is 100 (0x64), FATAL (RVA 0x29d480).
  • TrmFromSubModel's vertex/edge cap is 32 each (soft warn, continues) and its polygon cap is 16 (soft warn + reject, not fatal) (RVA 0xc8ae0) — the one cap family in this report that never crashes.
  • idDeclEnv's renderParms cap is 80 (0x50), FATAL (RVA 0x113720).
  • Sound-world listener cap is 2 (soft, resets to 0) (RVA 0x4a3260).
  • AAS/nav Dijkstra search node-visit cap is 180 (0xb4, soft, diagnostic dump + fallback) (RVA 0x7902e0).
  • MAX_GUI_SURFACES = 256 (soft, drop) (RVA 0x313810); MAX_DYNAMIC_ENVIRONMENT = 128 (soft, reject) (RVA 0x3179b9).
  • The MAX_*/RTTI-name family (MAX_GENTITIES, MAX_RENDER_LIGHTS, ENTITYNUM_MAX_NORMAL) shows zero referencing instructions in TNO's decompiled runtime, same as TNC — reconfirms r6_enginelimits.md §6's conclusion with TNO's own decompiler this time, not string-matching alone.
  • TNC's 16-projectile multi-fire clamp message and its light-count-cap messages (GatherShadowCasters, "Too many visible lights") are absent from TNO's entire string table (zero matches on full-text scans) — a confirmed feature/message absence, not an unconfirmed number.

INFERRED (plausible, not nailed down this round)

  • That the script compiler's max-functions/max-statements caps (RVAs 0x97ab10/0x979a60) have some specific literal value — the check and its fatal enforcement are proven, but the capacity is read from a struct field whose own initializer was not traced back to a constant this round (same class of gap as TNC's MAX_GENTITIES numeric value before this track, just not resolved here either).
  • That the approximated-light-model buffer and the three AI-blackboard variable pools have a genuinely fixed capacity at all, as opposed to one computed at level-load from decl/config data — the enforcement (soft reject) is proven, the capacity's origin is not traced.
  • That TNO's apparent total absence of cvar-gated caps in the ~20 functions inspected generalizes to "TNO has no cvar-controlled engine limits at all" — more likely this round simply didn't sample any cvar-backed subsystem; no TNO equivalent of TNC's cvars_tnc_v2.json live-registry walk exists to check this properly (see Open questions).

Failed hypotheses / dead ends

  • Tried resolving vtbl_idCVarSystemLocal/vtbl_idCmdSystemLocal by name through decomp.py the same way TNC's curated function names work — "no function matches" for both. decomp.py's name resolver only walks FunctionManager.getFunctions(); RTTI vtable labels are data symbols in this snapshot (per D:/wolf-decomp/tno_import_log.txt: "labeled 2369 RTTI vtables", distinct from the function list), not reachable this way. Reusing TNC's probe_cvartable.py/cmdtable.md approach for TNO (walking the live cvar/command registry the way those tools read TNC's module.bin directly, entirely read-only, no process attach) would need the TNO singleton's RVA found by hand first — not attempted further this round given the time budget; a real next step, not a dead end in principle.
  • Searched TNO's full string table for TNC's "Tried to spawn too many projectiles" family and for "Too many visible lights"/"Maximum allowed per frame" — zero matches for either, confirmed by two independent --strings passes ("too many"/"projectil" and "visible light|Maximum allowed per frame|shadow caster"). Not a decompiler limitation — the messages are not present in TNO's .rdata at all.
  • "Stack depth: %d bytes, %d max\n"/"Call depth: %d, %d max\n" (flagged by name only in r6_tnoscript.md §3.3) — Ghidra's xref scan: no references found for either, exactly the class of miss r6_enginelimits.md hit for most of the MAX_* family. Worked around, not solved: found the actual bound via the sibling "call stack overflow" string instead (§3), which did have a found reference.
  • idMemLocal::Alloc: exceeded MAX_TRACKED_POINTERS, idWinding::Split: points exceeded estimate, idFile_Memory::TruncateData: ... exceeded allocated size — all no references found; not pursued further, same indirect/optimized-call-site class of dead end r6_enginelimits.md already catalogued for TNC.

Recipe: what this means for modding TNO, today

  1. Don't build an encounter "Group" (shared AI roster/TargetList) bigger than 64 members — confirmed fatal in TNO now, not just inferred from a shared string. Split a larger set-piece into two or more groups instead.
  2. AI population caps (maxActiveAIs-equivalent, idTarget_ShowAI) are per-spawner data, exactly like TNC — a custom map wanting unlimited AI just sets the field to -1 (or omits it); this is a data change, not a cvar, not a patch, and failing to do so only prints a warning, never crashes.
  3. Script mods should stay well under 64 nested calls and 32 KiB of global script memory. A thread-heavy .script file that calls deep chains of helper threads/functions, or declares a very large number of global constants/arrays, risks the two fatal VM caps in §3 — both are hard crashes with no warning beforehand.
  4. A cloth-sim prop (flag/cape/curtain) must stay at width*height ≤ 100 particles or the game crashes outright on spawn (§4) — this is a genuinely new, previously-undocumented hard limit for anyone adding cloth to a custom prop.
  5. A destructible/physics-trace submodel should stay at ≤32 vertices, ≤32 edges, ≤16 polygons — the only cap family in this report that fails soft (object just becomes non-collidable) instead of crashing, so this one is forgiving to get wrong during iteration, unlike every other cap here.
  6. An env decl (environment/skybox-style decl) is capped at 80 renderParms — fatal if exceeded (§5); relevant only to anyone editing env decls directly, which is a narrow but real part of TNO's decl surface.
  7. Entity population: a single TNO level is extremely unlikely to hit either entity ceiling found in §1 (~8189 simultaneously alive, ~524k lifetime spawns) through normal level design — recorded for completeness and because it resolves r6_enginelimits.md's own open question, not because it is a practical modding constraint today.

Open questions

  • TNO's cvar/command registry array sizes (TNC's own §1: 256/64 commands, 4096/512 cvars) — no TNO counterpart was attempted this round beyond the one failed name-lookup (see Failed hypotheses). Finding TNO's idCVarSystemLocal singleton RVA and its RegisterStaticVars the way TNC's cvartable.md/cmdtable.md tracks did (a dedicated disassembly pass, not a quick side quest) is the natural next step and would also make a live cvar min/max catalogue possible for TNO for the first time.
  • The literal values behind the script compiler's max-functions/ max-statements caps (§3) — the fatal check is proven, the capacity field's own origin was not traced to a constant.
  • Whether TNO has any cvar-gated engine limit at all — this round sampled ~20 cap-enforcing functions and found none, but that is not an exhaustive search of TNO's cvar surface (no live-registry walk exists for TNO yet, see above).
  • The AI blackboard pools, the light-model-approximation buffer, and the weapon-stats/touched-triggers registries' actual capacities — all confirmed data-driven, none traced to their initializing constant/decl field this round.
  • Whether TNC has equivalents of the new TNO-only caps found here (script call-stack depth, script global memory, cloth particles, env decl renderParms, GUI surfaces, dynamic environment probes, Dijkstra node limit, sound listeners) — not cross-checked against TNC's own runtime this round; tools/decomp.py's default --gzf already points at TNC's snapshot, so this is a same-method, same-tool follow-up for whoever picks it up next, not a new capability gap.

Reproduction

# All queries below ran against the TNO runtime snapshot, ~15-90s each:
python tools/decomp.py --strings "overflow" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
python tools/decomp.py --strings "exceeded" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
python tools/decomp.py --strings "too many" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
python tools/decomp.py --strings "projectil" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
python tools/decomp.py --strings "visible light|Maximum allowed per frame|shadow caster" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
python tools/decomp.py --strings "MAX_GENTITIES|MAX_RENDER_LIGHTS|ENTITYNUM_MAX_NORMAL" --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf

# Entity registration (3 inlined copies):
python tools/decomp.py 0x8609db 0x860870 0x865440 --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
# AI population per-spawner cap:
python tools/decomp.py 0x7b78a0 --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
# "Group has too many members" (64, fatal):
python tools/decomp.py 0x57cecf --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
# Script VM call-stack depth (64, fatal) + global memory (32KiB, fatal):
python tools/decomp.py 0x96e7d0 0x9791e0 --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
# Physics: cloth cap (100, fatal) + TrmFromSubModel (32/32/16, soft):
python tools/decomp.py 0x29d480 0xc8ae0 --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf
# Decl / audio / AI-nav / render soft+hard caps:
python tools/decomp.py 0x113720 0x4a3260 0x7902e0 0x313810 0x3179b9 --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf

# Negative control (vtable name lookup fails -- data symbol, not a function):
python tools/decomp.py vtbl_idCVarSystemLocal vtbl_idCmdSystemLocal --gzf D:/wolf-decomp/ghidra/tno_runtime.gzf

Smallest in-game test

(Translated from German.)

Everything in this report is a compiled limit, not a cvar — so there is no console command to try out, only a scenario that actually reaches the limit. By far the safest test (no crash to expect, only a log line):

  1. AI population limit, harmless (§2): In an own test map/an own .script spawn setup, set an idTarget_ShowAI (or the decl/entity field behind it, presumably analogous to TNC's m_maxActiveAIs — exact field name in TNO's own .entities/decls still to be verified) to a small value (e.g. 2) and let more AIs spawn at the same time. Expectation according to this report: the surplus AIs are not spawned, a line "Too many AIs active! Number of AIs active: ..., limit set to: ..." appears in qconsole.log, and the game keeps running normally — no crash, nothing breaks.

  2. Riskier, but unambiguous proof of a hard crash (§3): If a deliberate crash test is wanted (e.g. to confirm that the 64 "Group" cap really is fatal): extend an own AI encounter group (shared TargetList/Group field, as the retail maps use for mass encounters) to 65 or more members and load the scene. Expectation according to this report: the game crashes immediately on reaching the 65th member (int3 trap, no graceful exit) — save beforehand, this test is deliberately destructive and serves only as a control, not for normal modding.

Related: Research Report: R6 Engine Limits, Research Report: R6 Tno Console, Research Report: R6 Tno Script, Research Report: Cmdtable, Research Report: Cvartable, Modding Limits.

Clone this wiki locally