Wolfenstein Modloader Buy me a coffeeKaffee spendieren

Wiki / Latest research roundsNeueste Forschungsrunden

Engine Limits: Entities, AI, Lights, Map Size, Streaming, Decls, Physics, Strings

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

In plain wordsIn einfachen Worten

This report looks for the built-in limits that matter to modders in Wolfenstein II: The New Colossus (TNC) and Wolfenstein: The New Order (TNO). For TNC, reading the program code found fixed caps, such as 16 projectiles per shot, 64 members per AI group and 8,196 visible lights per frame, and showed which ones only print a warning and which ones crash the game. Many other limits are settings with default values, and the AI population limit is set per spawner by the map author, not by the engine. For TNO only text strings could be scanned, so no numbers are confirmed there. Everything is static analysis; neither game was launched.

  • The command and setting lists have a fixed size: 234 of 256 commands and 3,256 of 4,096 settings are used, but no current mod route can add new ones.
  • Caps read from the code: 16 projectiles per shot (warning, clamped), 64 members per AI group (crash), 128 dynamic reflection volumes and 8,196 visible lights per frame (both warnings).
  • Running out of entity slots crashes the game, but the exact entity limit could not be found.
  • AI count is map data: each spawner has its own maxActiveAIs value (-1 = unlimited, most common 12, median 8).
  • Open: physics limits sit inside the Havok middleware with no number found, and TNO's values are unknown; the suggested in-game tests (such as a shotgun set above 16 pellets) have not been run.

Dieser Bericht sucht die eingebauten Grenzen, die für Modder zählen, in Wolfenstein II: The New Colossus (TNC) und Wolfenstein: The New Order (TNO). Für TNC hat das Lesen des Programmcodes feste Obergrenzen gefunden, etwa 16 Projektile pro Schuss, 64 Mitglieder pro KI-Gruppe und 8.196 sichtbare Lichter pro Bild, und gezeigt, welche nur eine Warnung ausgeben und welche das Spiel abstürzen lassen. Viele andere Grenzen sind Einstellungen mit Standardwerten, und die Obergrenze für KI-Gegner legt der Kartenautor pro Spawner fest, nicht die Engine. Für TNO ließen sich nur Textzeichenketten durchsuchen, dort ist also keine Zahl bestätigt. Alles ist statische Analyse; keines der beiden Spiele wurde gestartet.

  • Befehls- und Einstellungslisten haben eine feste Größe: 234 von 256 Befehlen und 3.256 von 4.096 Einstellungen sind belegt, aber kein heutiger Mod-Weg kann neue hinzufügen.
  • Aus dem Code gelesene Grenzen: 16 Projektile pro Schuss (Warnung, wird gekappt), 64 Mitglieder pro KI-Gruppe (Absturz), 128 dynamische Reflexionsvolumen und 8.196 sichtbare Lichter pro Bild (beides Warnungen).
  • Gehen die Entity-Plätze aus, stürzt das Spiel ab, aber die genaue Entity-Grenze wurde nicht gefunden.
  • Die KI-Anzahl ist Kartendaten: Jeder Spawner hat seinen eigenen Wert maxActiveAIs (-1 = unbegrenzt, am häufigsten 12, Median 8).
  • Offen: Physikgrenzen stecken in der Havok-Middleware ohne gefundene Zahl, und die Werte von TNO sind unbekannt; die vorgeschlagenen Tests im Spiel (etwa eine Schrotflinte mit mehr als 16 Kugeln) liefen noch nicht.

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 the hard limits of both engines that matter to modders — entities, AI, lights, map size, streaming pools, decl counts, physics bodies and string lengths — on which of them are raisable cvars and which are compiled constants, and on what happens at the limit (warning, cull or fatal error). For TNC (decompiled), the command and cvar registries themselves are fixed-size arrays, a few literal caps were pulled out of disassembly, and most named MAX_* constants exist only as names whose values could not be extracted. TNO had no runtime image at the time, so its side is a string scan only; the decompiled TNO follow-up is TNO engine limits (R7). Static evidence only; neither game was launched. The verifier's corrections (pass-1 errors) and the PROVEN/INFERRED split are kept as written.

Track enginelimits. Read-only on both game installs and on every project file outside re_probes/enginelimits/ and this report. Neither game was launched, no process attached. Builds on and does not redo: r4_consolecmds.md/ cmdtable.md/cvartable.md (the cvar/command restriction-level gate and its two hard-capacity arrays, reused directly in §1), r4_aispawnwaves.md (maxActiveAIs is per-spawner data, not a global AI cap — reused in §2), r5_kisculecmd.md (the resourceExec -s / Kiscule-Command route non-exposed cvars need), docs/limits.md (TNO's retail console lock and archive-offset invariants — a different limit, not duplicated here), docs/engine-surface.md (TNC asset-type counts, reused in §5). Tools: python tools/decomp.py <rva|name> [--xrefs] [--callers] [--strings PATTERN] (TNC only, Ghidra over re_probes/runtime_87400/module.bin) and a new, small read-only scanner, re_probes/enginelimits/tno_strings.py <regex>, written this track because TNO has no runtime image and no decompiled code (.text is SteamStub- encrypted, confirmed again in docs/ghidra.md) — it is a plain ASCII-string scan over WolfNewOrder_x64.exe's .rdata/.data, the only static-analysis option available for TNO. All TNC addresses below are RVAs into re_probes/runtime_87400/module.bin (base 0x7FF62C210000); all TNO addresses are RVAs into WolfNewOrder_x64.exe (base 0x140000000).

Pass 1 errors (verifier round)

A verifier independently re-checked every claim in the first pass and found 5 concrete errors, all confirmed by re-running the exact same read-only tools this round. Fixed in place below; marked here for the record:

  1. False PROVEN claim: idAAS2File::MAX_AREAS was claimed to exist "verbatim in both TNC and TNO". Re-ran decomp.py --strings idAAS2File, --strings AAS2, --strings MAX_AREAS — zero hits in TNC, all three. The symbol exists only in TNO (RVA 0xd95778). §6 table and the PROVEN list corrected below.
  2. False "TNO only" claim: §6 claimed MAX_RENDER_LIGHTS/MAX_RENDER_MODELS were "not found in TNC's --strings MAX_ sweep". Re-ran decomp.py --strings MAX_RENDER — both exist in TNC (RVA 0x279f100 / 0x279f0e8). The "may use different naming post-refactor" speculation built on this false premise is dropped.
  3. Statistic mislabeled: §2 called the maxActiveAIs corpus's 12 a "median". Recomputed directly from r4_aispawnwaves.md's own cited n=504 distribution: the median is 8 (ranks 252/253 both fall in the 8 bucket); 12 is the mode (177/504, the single most common value) — r4_aispawnwaves.md itself already says "mode 12" correctly; this report's prose conflated the two. Fixed below.
  4. Citation error: the cuttable/fracture-model [-16, 16] string was cited at RVA 0x2652620; re-running decomp.py --strings "16, 16" finds it at RVA 0x26526c0 (0xA0 off). String content and existence were correct, only the address was wrong. Fixed below.
  5. Unflagged tooling artifact: Ghidra's decompiler tags the call site for the (confirmed non-fatal) 16-projectile clamp with /* WARNING: Subroutine does not return */, the same tag used on the genuinely fatal calls — the report never flagged that this tag is unreliable in this snapshot. Investigated further this round (§2.2 below): the tag turns out to be wrong on every non-fatal call site found so far, and there is a reliable alternative discriminator.

The verifier also flagged 4 missed items — physics body limits, a real raisable poi_ArtificialPOILimit cvar, a 4th hard numeric MAX_* constant (MAX_DYNAMIC_ENVIRONMENT = 128), and partial resolution of the two light/shadow caps §4/§8 left open. All four are addressed below (§2.2, §3.4, §3.5, §4 shadows/lights subsection, §7).

Question

Hard limits of both engines for modders: max entities, max AI, max active lights, map size/coordinate limits, streaming/memory pools, decl/resource count limits, physics body limits, string/name length limits. Which limits are cvars (raisable) and which are compiled constants; what happens at the limit (warning, cull, fatal).

Answer (short)

TNC (decompiled, strong evidence): two completely different kinds of limit coexist, and conflating them is the easiest way to get this wrong.

  1. Hard, fixed-size C arrays with no cvar at all, found by direct disassembly, not by name-matching: the command and cvar registries themselves are capped — 234/256 commands, 34/64 exposed commands, 3256/4096 cvars, 304/512 exposed cvars (§1, from r4_consolecmds.md
    • cvars_tnc_v2.json's own runtime_lists metadata, independently re-verified here). This is the one limit that directly bounds "how many new commands/cvars can a mod register" — headroom: 22 commands, 30 exposed commands, 840 cvars, 208 exposed cvars before the engine's own array caps out. No cvar raises this; it is a compiled array size.
  2. ~400 named MAX_*/*_MAX_ID C++ static-const symbols compiled into both EXEs as RTTI/debug-string literals (§6) — MAX_GENTITIES, ENTITYNUM_MAX_NORMAL, MAX_RENDER_LIGHTS, MAX_WORLD_AREAS, etc. Proven to exist (the strings are real, referenced by RTTI/type tables) in both TNC and TNO verbatim for most of the family — itself evidence the two engines share code lineage — but for the overwhelming majority the literal numeric value could not be extracted this round: they are compiled directly into comparison instructions at each call site, not stored as a single addressable constant, and digging out "the" value would mean finding and disassembling every use site individually. (Not every name is shared — idAAS2File::MAX_AREAS is TNO-only, corrected from pass 1's wrong "both" claim, §6.) Five exceptions where the value was pulled out of disassembly (§3): 16 (max projectiles spawned per weapon-fire call, TNC), 64 (max members of an AI "Group", TNC, string-identical warning exists in TNO too), 128 (MAX_DYNAMIC_ENVIRONMENT, TNC, found this round, §3.4), 8196 (max visible lights per frame, TNC, found this round, §4), and the [-16,16] world-unit cube a cuttable/fracture model's geometry must fit inside (TNC).
  3. 131 cvars with an engine-enforced min/max clamp range and 309 more whose name suggests a cap (not independently verified as clamped) — full table extracted from re_probes/cvartable/cvars_tnc_v2.json (§4), covering AI/physics/streaming/render/world-geometry caps with their defaults, ranges, CVAR_ flags and whether they are console-reachable (exposed) or need the resourceExec -s / Kiscule-Command route proven in r4_consolecmds.md/r5_kisculecmd.md.
  4. AI population caps are level-design data, not an engine constant. idTarget_AISpawn's "Too many AIs active!" warning (disassembled, §2) reads a per-entity field m_maxActiveAIs set by the map author (-1 = unlimited, corpus mode 12/177 of 504, median 8, per r4_aispawnwaves.md) and compares it to a live AI counter — there is no global "max AI in world" engine constant gating this; a custom map is free to set maxActiveAIs = -1 on every spawner.
  5. What happens at the limit differs by subsystem, now proven across 6 disassembled call sites and traced to a single reliable discriminator (§2.2): every fatal-vs-warn call in this report routes through one of two shared logger trampolines, and the trampoline's own first argument to the underlying log function (5 = warn, 7 = fatal) decides the outcome — not Ghidra's own "does not return" tag, which this round found to be wrong on every one of the 4 confirmed-non-fatal call sites. FATAL (severity 7, terminates first): the entity registry overflow (idGameLocal::RegisterManagedObject, §2.1) and the AI "Group"-size cap (§3.2). Warn, logged, execution continues (severity 5): the per- spawner AI cap (§2), the multi-projectile clamp (§3.1), the MAX_DYNAMIC_ENVIRONMENT cap (§3.4), and the "too many visible lights" cap (§4).
  6. Physics body limits exist but are Havok-internal, not id Tech-level (§7, new this round): a "shape instances exceeded maximum" string and a Havok motion-solver rotation/position-clamp-event pair were traced to real call sites, but both numeric ceilings live inside Havok middleware's own budget/threshold system rather than a single findable idLib-style comparison — a structurally different, harder static-analysis target than every cap in points 1–5, and still numerically unresolved.

TNO (no runtime image, strings only — honestly limited): the exact same MAX_* symbol family exists verbatim in .rdata (MAX_GENTITIES, ENTITYNUM_MAX_NORMAL, MAX_PLAYERS, idAAS2File::MAX_AREAS, …, §6), and the exact same "Too many AIs active!"/"Group '%s' has too many members. The max is %d."/idGameLocal::RegisterEntity: spawn count overflow message family exists too — strong evidence TNO and TNC are the same engine lineage with the same limit architecture — but no numeric value, no cvar min/max, no flag byte, and no fatal-vs-warning behaviour could be confirmed for TNO this round: .text is encrypted, there is no runtime snapshot, and this project's rule against unpacking SteamStub was respected. Everything TNO-side in this report is string evidence only, marked INFERRED, never PROVEN.

1. The one limit that is neither a cvar nor a MAX_* name: the registry arrays themselves (TNC)

From r4_consolecmds.md §1 (disassembled idCmdSystemLocal/idCVarSystemLocal, re-verified against the live snapshot this run) and cvars_tnc_v2.json's own runtime_lists block (machine-readable, produced by walking the same two idList heads at runtime):

registry used capacity headroom
all commands (idCmdSystemLocal+8, RVA 0x4104428) 234 256 22
exposed commands (idCmdSystemLocal+0x28, RVA 0x4104448) 34 64 30
all cvars (idCVarSystemLocal, array ptr 0x2f4f8c0) 3256 4096 840
exposed cvars (array ptr 0x2f578f0) 304 512 208

This is a hard, compiled array size — not a cvar, nothing in the CVAR_/CMD_ flag set changes it. It matters for modding because it is the ceiling on adding new commands/cvars at all (e.g. via a hypothetical AddCommand/ RegisterStaticVars hook): retail already uses 79.5% of the cvar slots and 91.4% of the command slots, but there is real headroom (840 cvars, 22 commands) before the engine would need the backing array reallocated — something no proven mechanism in this project currently does. Not tested in this round (would need a working AddCommand/RegisterStaticVar injection point, which no track has built); recorded as a hard ceiling to keep in mind, not as "headroom available to mods today".

2. AI population: per-spawner data field, not a global constant (TNC)

idTarget_AISpawn's Exec/Think body at RVA 0x96d290 (tools/decomp.py 0x96d290) is the function behind the string "idTarget_AISpawn: ( Spawn ScenePoint execution ) Too many AIs active! Number of AIs active: %i, limit set to: %i, number shown: %i out of: %i " (RVA 0x26a19c0, confirmed as this function's own referenced string via --strings "too many"). The decompiled body reads three named fields off the entity through the generic decl-parameter accessor (same reflection mechanism r4_aispawnwaves.md §3 inferred from the lack of direct code xrefs to the field name):

puVar10 = FUN_7ff62cb86060(param_1, s_m_maxActiveAIs_..., param_2);
uVar6 = *puVar10;                              // m_maxActiveAIs (per-entity, map-author-set)
puVar10 = FUN_7ff62cb86060(param_1, s_m_maxNumOfSpawn_..., param_2);
uVar8 = *puVar10;                              // m_maxNumOfSpawn
pcVar11 = FUN_7ff62cb85ec0(param_1, s_m_randomSpawn_..., param_2);
cVar4 = *pcVar11;                              // m_randomSpawn (bool)
...
if ((-1 < (int)uVar6) && ((int)uVar6 <= iVar17)) {
    return;                                    // cap not exceeded (or -1 = unlimited) -> fall through, no warning
}

iVar17 is read earlier from *(int*)(*(longlong*)(lVar12+8) + 0x56a20) — a live counter field, not a constant. This confirms r4_aispawnwaves.md's inference directly in code: maxActiveAIs is a per-idTarget_AISpawn entity parameter (map/decl data), compared against a live count, with no engine-wide ceiling anywhere in this function. A custom map setting maxActiveAIs = -1 on every spawner is not fighting any compiled limit here (it may still hit AI performance limits — pathfinding, nav-mesh job count, ai_visAreaNodesPerFrame/ai_visAreaTracesPerFrame cvars §4 — but not this hard-coded population gate). (Corpus distribution note, corrected from pass 1: the value 12 quoted above is the mode — 177 of 504 spawners that set the field — not the median, which is 8; see "Pass 1 errors" item 3.)

2.2 How to tell warn from fatal reliably: the logger's severity argument, not Ghidra's NORETURN tag

Pass 1 used Ghidra's /* WARNING: Subroutine does not return */ tag as a rough fatal/warn signal and got it right by luck; the verifier caught that the tag is also present on the confirmed-non-fatal 16-projectile clamp call site. This round traced every warn- and fatal-style call found across the whole track (§2.1, §3.1, §3.2, §3.4, §4) down one more level, into the two shared logger trampolines they all call through:

void FUN_7ff62d4485c0(a,b,c,d) { ...; FUN_7ff62d446a60(5, a, &local); return; }   // WARN path
void FUN_7ff62d4486b0(a,b,c,d) { ...; FUN_7ff62d4481e0();                        // FATAL path
                                       FUN_7ff62d446a60(7, a, &local); return; }

Both wrappers are tagged NORETURN by Ghidra at their call sites, and both decompile to a normal return; themselves — the tag is simply wrong/ unreliable in this GZF snapshot (likely because FUN_7ff62d446a60 itself, or its own leaf FUN_7ff62df567c0, genuinely never returns for some severity level, and Ghidra's static analysis applied that NORETURN-ness to the trampoline regardless of which severity it is called with). The real discriminator is the first argument to FUN_7ff62d446a60: severity 5 is a log-and-continue warning (the wrapper always falls through to its own return;); severity 7 first tail-calls a dedicated terminate chain (FUN_7ff62d4481e0, itself genuinely NORETURN) before logging — i.e. the process is already being torn down by the time the message prints. This holds across every call site traced this track: fatal (severity 7, FUN_7ff62d4486b0) — idGameLocal::RegisterManagedObject overflow (§2.1), the Group-member cap (§3.2); warn (severity 5, FUN_7ff62d4485c0) — the 16-projectile clamp (§3.1), the idTarget_AISpawn AI-cap check (§2), the new MAX_DYNAMIC_ENVIRONMENT cap (§3.4), and the new "too many visible lights"/ "max visible shadow slices" caps (§4). Recipe for future passes: when classifying a new MAX_*/limit call site in this snapshot, find the call into FUN_7ff62d446a60 (directly or through one of the two wrappers above) and read its first argument — do not trust the NORETURN tag alone.

2.1 What overflow actually does: FATAL for entities, not for AI count

idGameLocal::RegisterManagedObject's overflow branch (RVA 0x11c8a91, tools/decomp.py 0x11c8a91) is a one-instruction, no-return tail call:

void FUN_7ff62d3d8a91(void) {
    /* WARNING: Subroutine does not return */
    FUN_7ff62d4486b0(s_idGameLocal__RegisterManagedObje_7ff62eb80810);
    // "idGameLocal::RegisterManagedObject: spawn count overflow"
}

FUN_7ff62d4486b0 is the engine's fatal-error entry point (same call shape used by every other no-return assert-style error string found in this track, e.g. s_Tried_to_spawn_too_many_projecti_... in §3.1, and matches the idCommonLocal/idLib::FatalError idiom this project's other tracks already established). Exceeding the managed-object/entity registration slot count is FATAL, not a soft cull. The companion string, "Could not register '%s'. Managed Object number %d already used." (RVA 0x2970880, function 0x11c8a9e), is a related but distinct error — a slot-reuse conflict, also a no-return fatal call — not the same condition as the pure overflow. Open question, not resolved this round: the actual numeric capacity behind MAX_GENTITIES/MAX_G_MANAGED_OBJECTS (both exist as RTTI/debug-string names, §6, in both games) was not extracted — no caller of the overflow function was found by Ghidra's static xref scan (likely an indirect/ optimized call site), so the comparison that triggers it was not located.

3. Compiled numeric constants actually pulled out of disassembly (TNC)

Three cases where a MAX_*-style cap's literal value, not just its name, was confirmed:

3.1 Multi-projectile spawn: hard-coded 16

tools/decomp.py 0x105a280 (the function behind "Tried to spawn too many projectiles (%d). Max allowed is (%d)", RVA 0x2955758):

iVar1 = *(int *)(param_2[0x1d] + 0x3bc);   // requested projectile count (weapon/ammo decl field)
if (iVar1 < 1) { iVar6 = 1; }
else {
    iVar6 = iVar1;
    if (0x10 < iVar1) { iVar6 = 0x10; }     // clamp to 16
}
FUN_7ff62d448640(iVar1 != iVar6, s_Tried_to_spawn_too_many_projecti_...);
                                             // assert(iVar1==iVar6) -- warns+continues (severity 5, see §2.2),
                                             // despite Ghidra's misleading NORETURN tag on this call site

This clamps (not fatals) to 16 simultaneous projectiles per fire call — relevant to any weapon mod that sets a decl's pellet/burst count above 16 (e.g. a shotgun with more than 16 pellets per shot): the engine will silently cap it to 16 and print a warning, not crash.

3.2 AI/encounter "Group" size: hard-coded 64

tools/decomp.py 0xdae38b (behind "Group '%s' has too many members. The max is %d.", RVA 0x28b9430):

FUN_7ff62d4486b0(s_Group___s__has_too_many_members__..., *(undefined8*)(unaff_RBP+0x20), 0x40);

The format's %d is filled from the literal 0x40 = 64 — a fatal no-return call (same idiom as §2.1). This exact message string exists verbatim in TNO ("Group '%s' has too many members. The max is %d.", RVA 0xf327d0, found by tno_strings.py) — the number itself could not be confirmed for TNO (encrypted .text), but the shared, byte-identical format string is itself evidence the cap is the same architectural mechanism (possibly the same 64) in both games.

3.3 Cuttable/fracture model geometry: a literal world-space bound

"DynamicFracture : Model '<name>' is too large to use as a cuttable, ensure model is contained inside a [-16, 16] world boundary axis box." (RVA 0x26526c0, corrected from pass 1's 0x2652620 — see "Pass 1 errors" item 4) — a plain-text literal bound, no disassembly needed: any cuttable/destructible model's local geometry must fit inside a 32×32×32 world-unit cube or DynamicFracture rejects it. Relevant to any mod adding a new breakable prop.

3.4 Dynamic environment probe population: hard-coded 128

tools/decomp.py --strings "MAX_DYNAMIC_ENVIRONMENT" finds the string "MAX_DYNAMIC_ENVIRONMENT exceeded" (RVA 0x2605538) referenced from FUN_7ff62c589120 (RVA 0x379120) — a function that registers/looks up a dynamic-environment (reflection-probe-style) volume by its world-space bounding box. A live population counter is read at the top of the function (iVar1 = *(int*)(lVar2 + 0xc510)) and, further down, compared directly:

if ((param_3 != 0) && (param_6 < 0x11)) {
    if (iVar1 == 0x80) {                       // 0x80 = 128
                    /* WARNING: Subroutine does not return */
        FUN_7ff62d4485c0(s_MAX_DYNAMIC_ENVIRONMENT_exceeded_...);  // severity 5 -- warn, not fatal (§2.2)
    }
    /* ... falls through and continues registering the probe ... */

A 4th confirmed hard numeric cap: 128 simultaneous dynamic-environment probes, warn-and-continue (same non-fatal severity-5 path as §3.1/§2), despite carrying the same misleading NORETURN tag. Relevant to any mod/map that spawns an unusually large number of reflection-probe-style volumes.

4. Cvar-governed limits: the full min/max-clamped set, plus named max* cvars (TNC)

re_probes/cvartable/cvars_tnc_v2.json (3256 cvars, each with default, type, flags_decoded, exposed/settable_in_retail, and — for 131 of them — an engine-enforced min/max). Flag meanings (from tools/cvar_table.py's own cross-checked bit table, selbsttest()): CVAR_CHEAT = reset-on-cheat-path candidate (TNC has no proven TNO-style reset mechanism found so far, unlike the TNO behaviour docs/limits.md documents); CVAR_INIT = only takes effect if set before that subsystem initializes (startup .cfg/+set, not mid-session console); CVAR_ROM = never settable; CVAR_EXPOSE = reachable by the normal, restricted (level-1) console — everything not EXPOSE needs the resourceExec <file> -s / Kiscule-Command-node route r4_consolecmds.md/r5_kisculecmd.md already proved reaches non-exposed cvars with no code patch.

World geometry / joints (all CHEAT+INIT, none EXPOSE — startup-config only):

cvar default notes
r_worldGeometryManagerMaxVerts 11 million static vertices, engine-wide cap for mergedworld-class geometry
r_worldGeometryManagerMaxIndices 36 million static indices (~12M triangles)
r_worldGeometryVertexPaintMaxVerts 8 million, vertex-painted subset
r_worldJointsManagerMaxJoints 300000 max joints usable for skinned geometry engine-wide
r_LODSkipOnLargeModelsMaxVerts 500000 per-model; skip auto-LOD generation above this

Collision/kd-tree (CHEAT, not EXPOSE; engine-clamped min/max):

cvar default min–max
kd_maxTrisPerLeaf 8192 64–16384
kd_maxVertsPerLeaf 8192 64–16384
kd_maxLeafSize 800 200–2000

AI / entity population & physics (CHEAT, not EXPOSE):

cvar default notes
g_maxEffectPhysicsBodies 64 max active effect (non-gameplay) physics bodies at once
g_maxSpawnLocations 150 approx. limit of generated MP-style spawn locations
gore_maxLivingGoreEnts 10 max simultaneous gore-piece entities in the world
ai_maxTurnRate 360 deg/s
g_parallelMaxThreads 4 threads for parallel game jobs
aimtarget_maxDistance 500 aim-assist target distance
poi_PVPEnemyNameMaxAlpha/...Deg — leftover PvP/MP scaffolding (TNC ships SP-only)
poi_ArtificialPOILimit 25 CHEAT, NETWORKSYNC (not EXPOSE — needs resourceExec -s). Description verbatim: "Although we allocate space for additional POIs past this, we will throw an error if we get this many POIs — so we can evaluate how many we actually need." Directly backs the string "idPOIManager::UpdatePOIInfo had too many POIs - exceeded artificial limit of %d, had %d. Increase CVAR 'poi_ArtificialPOILimit' to raise this." — a named, raisable POI (point-of-interest / HUD marker) population cap, found this round (missed in pass 1 despite being reachable from the same --strings "exceeded" sweep)

Streaming / memory pools (mixed EXPOSE status — check before assuming a route):

cvar default min–max exposed?
is_poolSize 1024 MiB — yes (NOCHEAT, LOCAL_SETTING, EXPOSE) — plain console-settable
is_maxUploadsPerFrame 24 — yes
is_maxTextureSize 32768 KB — no (CHEAT, INIT)
is_maxAllocationsPerFrame 32 — no
fc_maxCacheMemoryMB 1024 1–2048 no
fc_minCacheMemoryMB 4 0–96 no
fc_leaveFreeMapMB 128 1–1024 no
fc_leaveFreePersistMB 8 1–1024 no
resource_diskStreamingBufferSize 8 MiB 1–1024 no
resource_diskStreamingScratchBufferSize 4 MiB 1–1024 no
s_initStreamPoolSizeMB 40 — no (INIT — startup only)
s_initApuMemoryHeapSize 370 MB — no
vt_maxLockedPagesPercent 20% 0–50 yes
vt_maxPPF (pages/frame, virtual texture) 32 0–64 yes

Strings / names (cvar-level only — the C++ MAX_* string-length constants in §6 have no retrievable numeric value this round):

cvar default notes
swf_textMaxInputLength 512 max chars in a UI text-input field
snapEdit_scriptVarNameMaxLength 20 max script-variable name length in the SnapMap-derived editor

Shadows / lights (compiled caps, CHEAT, not EXPOSE):

cvar default notes
r_staticShadowsMaxOpaqueVertices 33 554 432 32M, static shadow cache
r_staticShadowsMaxAlphaVertices 1 048 576 1M, transparent subset
r_umbraMaxShadowQueriesVisible/...Invisible 6 / 1 0–512 clamp

No cvar named "max visible lights"/"max active lights" exists. Pass 1 left both compiled caps unresolved; this round finished reading both to their comparison line:

  • Visible lights per frame: hard-coded 8196. The light-gather function (RVA 0x113549b) guards its "Too many visible lights.\n" warning with a direct literal comparison: if (unaff_R12 == 0x2004) { FUN_7ff62d4485c0(...) } — 0x2004 = 8196. Severity 5, i.e. warn-and-continue (§2.2), same misleading NORETURN tag. unaff_R12 is a live visible-light counter (its increment site was not traced this round, but the comparison itself is a direct literal, not table- or cvar-driven).
  • "Max visible shadow slices" is a table, not one number. GatherShadowCasters (RVA 0x1136efd) reads its cap from *(uint*)(&DAT_7ff62e878100 + index*4) — a small int32 array indexed by a live value (uVar29, whose origin — most likely a quality-tier or light-type enum — was not traced back this round) — then warns (severity 5, "Max visible shadow slices exceeded") if the live count exceeds table[index] + some_float. Reading the raw bytes directly from re_probes/runtime_87400/module.bin at file offset 0x2668100 (= RVA of DAT_7ff62e878100, same file-offset-as-RVA convention tools/decomp.py uses) gives the first 6 int32 entries as {6, 1, 4, 0, 6, 0} before the data starts looking like 8-byte-aligned pointers (plausible table end, not confirmed by a bounds check in code). So this is a per-context cap table with small values (0–6), not a single large constant — a materially different shape of limit than every other cap in this report, which is itself a useful finding: not every MAX_*-adjacent check is a flat array bound.

5. Decls / resources: no fixed-array cap found — idList-shaped, not idArray-shaped

Unlike the cvar/command registries (§1, a genuine fixed C array), nothing in this track found a compiled ceiling on decl or resource counts. Circumstantial but consistent evidence: docs/engine-surface.md already measured TNC's live install at 203 843 assets across 105 types, with single types running into the tens of thousands (sample 61 318 in the TNO measurement in the same doc, sound 43 782) — if a small fixed array like the cvar one existed here, retail content would already be bumping into it, and it isn't. declManager itself is referenced in TNC's strings only via generic "type already registered"/"decl not found" messages (§6), never a capacity message, and its RTTI name (idDeclManagerLocal) uses the generic idList-backed container idiom this project's other tracks have repeatedly found to be dynamically growable (unlike idCmdSystemLocal's/idCVarSystemLocal's static arrays). Inferred, not proven: decl/resource counts are bounded by available memory and by the archive/cfile container format (csize/offset invariants, docs/limits.md §3–4), not by a small compiled array — the practical ceiling for a modder is "the container format and streaming budget", not an entity-style hard count.

6. Shared MAX_* symbol family: present (named) in both games, numeric value mostly out of reach

tools/decomp.py --strings "MAX_" (TNC, ~450 hits) and re_probes/enginelimits/tno_strings.py "MAX_[A-Z_]+" (TNO, ~450 hits, reproducible on demand) both surface the same C++-mangled static-const-member symbol family, e.g. (TNC left / TNO right, both confirmed present in the respective binary, values below are each binary's own string, not compared numerically — neither is readable):

MAX_GENTITIES                     MAX_GENTITIES
ENTITYNUM_MAX_NORMAL              ENTITYNUM_MAX_NORMAL
ENTITYNUM_WORLD / _NONE / _ALL_BUT_WORLD   (TNO has the same trio)
MAX_PLAYERS / MAX_COOP_PLAYERS / MAX_BOTS / MAX_LOCAL_PLAYERS   (both)
MAX_WORLD_AREAS / MAX_WORLD_VIEWS                               (both)
idCmdArgs::MAX_COMMAND_STRING / MAX_COMMAND_ARGS                 (both)
MAX_RENDER_LIGHTS / MAX_RENDER_MODELS                             (both — corrected from pass 1, which wrongly said "TNO only"; re-run `decomp.py --strings MAX_RENDER` confirms TNC RVA 0x279f100/0x279f0e8)
MAX_ENTITY_COORDINATE                                             (both)

Corrected from pass 1 (see "Pass 1 errors" item 1): idAAS2File::MAX_AREAS was wrongly claimed above and in PROVEN to exist in both games. Re-checked three ways this round (--strings idAAS2File, --strings AAS2, --strings MAX_AREAS) — zero hits in TNC. It exists only in TNO (RVA 0xd95778, via tno_strings.py). Dropped from the "both" list.

Why the numbers can't be read even for TNC (where .text is decompilable this round): almost all of these strings show "(no references found)" under tools/decomp.py's xref scan — they are consumed by a reflection/RTTI name table (likely per-type static-member descriptors for the save/serialization or script-binding system), not by a single, findable comparison instruction. The 3 exceptions in §3 were found precisely because their error-message siblings (format strings passed to a fatal/warn call) sit right next to a literal integer argument in the same short function — that pattern does not exist for most of the MAX_* family, which are structural/reflection metadata rather than runtime-checked limits with a logged error path.

TNO-specific finding: idGameLocal::RegisterEntity: spawn count overflow (TNO's name for TNC's RegisterManagedObject, same message shape, RVA 0xffeb60) confirms TNO has the identical entity-overflow mechanism — further lineage evidence — but, again, no numeric value, no code path, and no fatal-vs-warning confirmation is possible without a decompiled TNO image, which does not exist in this project.

7. Physics body limits (TNC) — missed entirely in pass 1, traced further this round

The question explicitly asked about physics body limits; pass 1's report had only the g_maxEffectPhysicsBodies=64 cvar (§4) and no mention of the Havok (hknp) engine's own internal limits, despite the relevant strings being directly reachable from the report's own --strings "exceeded" sweep. This round traced two separate Havok-side limit families as far as static analysis allows:

Shape-instance budget. "The number of shape instances (%i) exceeded the maximum number (%i) by %i. Number of triangle meshes used: %i. Number of polytopes used: %i" (RVA 0x26544e0). Pass 1's xref scan found only a raw reference address (~0x7ff62c931487) that tools/decomp.py would not resolve to a function start — reproduced here and confirmed still true. This round went one level deeper with tools/tnc_runtime_code.py's Capstone disassembler (reads module.bin directly, independent of Ghidra's function boundaries) and disassembled the actual instructions at that address:

0x721487  lea rdx, [rip + 0x1f33052]   ; -> 0x26544e0, the shape-instance string
0x72148e  mov rcx, qword ptr [rbp+0x1e8]
0x721495  call 0x120fb40

FUN_7ff62d41fb40 (the call target) is a thin trampoline into FUN_7ff62d41fa90 — a different logging path than §2.2's FUN_7ff62d446a60/severity pair, consistent with this being a Havok middleware-internal error reporter, not an id Tech-level MAX_* check. The message's own two %i fields (current count, max count) are filled from runtime arguments passed into this call, not from a literal immediate at this site, so the numeric ceiling itself remains unresolved — but this round establishes why: it is a Havok-internal configured budget (most likely set once at physics-world init from a Havok config/tuning asset, not a single compiled constant anywhere in idGameLocal-level code), which is a materially different, harder static-analysis target than every idLib-level cap in §2–§4. tools/decomp.py 0x7210c0-area and tools/tnc_runtime_code.py disagree on this function's boundaries (the .pdata-derived range 0x720100–0x7218e0 that tnc_runtime_code.py reports contains the target address, but tools/decomp.py's Ghidra-derived function at that same start address decompiles to an unrelated 6-line stub) — recorded as a tooling discrepancy for whoever picks this up next, not re-investigated further this round (capped per this track's "~3 identical dead ends" rule).

Motion/constraint clamp events. "MimaxRotationExceeded" / "MimaxPositionExceeded" (RVA 0x1f8b310/0x1f8b328) are referenced by three real functions, not reflection metadata — decompiled one, FUN_7ff62db9e250 (RVA 0x198e250): it is a debug-telemetry event emitter (writes named events — YtDebris, YtGetClosestPoint, YtCheckContactCache, then conditionally MimaxRotationExceeded/ MimaxPositionExceeded — into a ring buffer for an external physics visual-debugger), not the threshold check itself. It forwards a live exceeded-flag/magnitude (*(int*)(param_1+9) / *(int*)(param_1+0x4c), non-zero = exceeded) that was computed earlier by Havok's own motion solver; the actual rotation/position thresholds that set that flag are Havok-internal and were not located this round.

Net assessment: physics body/shape limits are real, stringed, and now traced one concrete level deeper than pass 1 (an actual disassembled call site for the shape-instance cap, a decompiled telemetry emitter for the motion-clamp events) — but, unlike §2–§4's id Tech-level caps, they live inside Havok middleware's own internal budget/threshold system rather than a single findable idLib-style comparison, so no numeric value could be extracted this round either. This is a different kind of dead end than §2.1's "no callers found" (an architectural one, not a search-coverage one), worth recording as such for the next pass rather than re-attempting the same xref search a 4th time.

PROVEN

  • TNC's command/cvar registries are fixed-size C arrays with the exact capacities in §1 (r4_consolecmds.md's disassembly + cvars_tnc_v2.json's own runtime_lists metadata — two independent sources agreeing).
  • idTarget_AISpawn's "Too many AIs active" check (RVA 0x96d290) reads a per-entity m_maxActiveAIs field, not a global constant, and the function returns normally (no fatal, no assert) when the cap is not exceeded or is -1 — confirms r4_aispawnwaves.md's data-level inference directly in disassembly (§2).
  • idGameLocal::RegisterManagedObject's overflow path (RVA 0x11c8a91) is an unconditional, no-return fatal-error call — exceeding it crashes, it does not cull or warn-and-continue (§2.1).
  • The multi-projectile spawn cap is a hard-coded 16 (RVA 0x105a280, §3.1) — a warn-and-clamp, not fatal.
  • The "Group has too many members" cap is a hard-coded 64 (RVA 0xdae38b, §3.2) — fatal, no-return.
  • The cuttable/fracture model bound is a literal [-16, 16] world-unit cube per model (string, RVA 0x26526c0, §3.3; corrected from pass 1's 0x2652620, see "Pass 1 errors" item 4).
  • MAX_DYNAMIC_ENVIRONMENT is a hard-coded 128 (RVA 0x379120, §3.4) — warn-and-continue (severity 5, §2.2), not fatal.
  • The light-gather function's "too many visible lights" cap is a hard-coded 8196 (0x2004, RVA 0x113549b, §4 shadows/lights) — warn, not fatal. GatherShadowCasters's "max visible shadow slices" cap (RVA 0x1136efd) is not a single literal but a small int32 lookup table at DAT_7ff62e878100, first 6 entries read directly from module.bin as {6, 1, 4, 0, 6, 0} (§4).
  • The logger FUN_7ff62d446a60's first argument is the real fatal/warn discriminator (5 = warn/returns, 7 = fatal/terminates first), independently reproducible across all 6 warn/fatal call sites traced this track (§2.2) — more reliable than Ghidra's own NORETURN tag, which is wrong on every one of the 4 warn-path call sites.
  • poi_ArtificialPOILimit (default 25, CHEAT, NETWORKSYNC, not EXPOSE) exists in cvars_tnc_v2.json exactly as described and backs the idPOIManager::UpdatePOIInfo "exceeded artificial limit" string (§4).
  • The 131-entry min/max-clamped cvar table and the broader max*-named cvar set in §4, with exact defaults, ranges and CVAR_ flags, all read directly from cvars_tnc_v2.json (produced by walking the live cvar registry, per that file's own runtime_lists/hinweis metadata).
  • The CVAR_ flag bit table used to interpret exposed/INIT/CHEAT above is tools/cvar_table.py's own cross-checked table (selbsttest() asserts it against two independently-read reflection tables in the EXE and confirms TNO uses a different bit layout — proof the table is measured, not assumed).
  • The MAX_*/*_MAX_ID string family exists, by name, in both TNC (tools/decomp.py --strings "MAX_") and TNO (re_probes/enginelimits/tno_strings.py "MAX_[A-Z_]+"), including several identical fully-qualified names (MAX_GENTITIES, ENTITYNUM_MAX_NORMAL, idCmdArgs::MAX_COMMAND_STRING, MAX_RENDER_LIGHTS, MAX_RENDER_MODELS, §6) — both runs are reproducible on demand, output included inline above. (idAAS2File::MAX_AREAS is not in this "both" list — pass 1 wrongly included it; it is TNO-only, see "Pass 1 errors" item 1 and §6.)
  • The error-message strings "Too many AIs active!" (as idTarget_AISpawn in TNC / idTarget_ShowAI in TNO) and "Group '%s' has too many members. The max is %d." exist verbatim in both binaries' string tables (§3.2, §6) — found independently via tools/decomp.py --strings and tno_strings.py respectively.

INFERRED (plausible, not independently nailed down this round)

  • That decl/resource counts have no small fixed-array ceiling (§5) — based on the absence of any capacity-style error message for declManager and on retail's own very high per-type counts, not on a disassembled bounds check (none was found to disassemble).
  • That TNO's identical MAX_*/error-string family implies the same numeric caps as TNC — plausible given shared code lineage but explicitly unconfirmed; TNO could easily have different compiled values even with identical symbol names (different game, different build).
  • That MAX_GENTITIES/MAX_G_MANAGED_OBJECTS's actual numeric value governs the fatal overflow in §2.1 — the string names exist and the overflow function exists, but the comparison site tying the two together was not located (no callers found by Ghidra's static xref scan for the overflow function itself, §2.1).
  • That GatherShadowCasters's shadow-slice cap table (DAT_7ff62e878100, §4) is indexed by a quality-tier or light-type enum — the index register (uVar29) is plausibly one of those given the surrounding light-culling code, but its origin was not traced back to a named source this round (resolved from pass 1's open question: the light-gather cap itself, 0x2004, is now PROVEN, not inferred — only this table's index semantics remain inferred).
  • That the Havok shape-instance budget (§7) is a configured-at-init value rather than a compiled constant — based on the call site passing both the current count and the max as runtime arguments (not literals) into a distinct Havok-internal logging path, not on having located the actual init-time assignment.

Failed hypotheses / dead ends

  • Tried to find a caller of idGameLocal::RegisterManagedObject's overflow branch (tools/decomp.py 0x11c8a91 --callers) to recover the actual MAX_GENTITIES-equivalent numeric comparison — zero callers found by Ghidra's static xref scan (consistent with an optimized/indirect call, same class of dead end r4_aispawnwaves.md §3 already hit trying to xref maxActiveAIs). Not pursued further this round; would need either a broader caller search strategy or runtime breakpoint data this project's rules don't allow (no process attach).
  • Searched TNO's .rdata/.data for any literal small-integer array or table that might sit adjacent to the MAX_GENTITIES string and encode its value directly — none found; C++ static const int members at this era are near-universally inlined as immediate operands at each use site rather than stored as data, which is exactly why they show up as debug-string labels with "no references" in TNC's own decompiled xref scan too. Not a promising lead for either game; abandoned after one check.
  • Looked for a single max visible lights/max active lights cvar by name pattern in cvars_tnc_v2.json (ai_-, r_-, g_-prefixed candidates) — none exists; the only light-count-adjacent cvars found (r_umbraMaxShadowQueriesVisible/Invisible) govern occlusion-query job counts, not a light population cap. Confirmed by full-table grep, not abandoned early.
  • Tried to find the real function start for the Havok shape-instance-exceeded call site (§7) three ways: tools/decomp.py's own xref scan (pass 1, no callers resolved), tools/decomp.py on the .pdata-derived containing range reported by tnc_runtime_code.py (decompiles to an unrelated 6-line stub — the two tools' function boundaries disagree at this address), and direct Capstone disassembly at the exact byte offset (succeeded at the instruction level, §7, but the call target is a thin trampoline whose numeric argument is a runtime value, not a literal). Capped here per this track's "~3 identical dead ends" rule; a working caller-boundary reconciliation between the two tools would be needed to go further.
  • Traced one of the three MimaxRotationExceeded/MimaxPositionExceeded referencing functions (§7, FUN_7ff62db9e250) to completion — it is a debug-telemetry emitter, not a threshold check; did not decompile the other two referencing functions (FUN_7ff62db9e77a, FUN_7ff62db9e49a) since the first already established the pattern (event emission, not the solver threshold itself) and this track's dead-end cap applies.

Recipe: what a modder can actually do with this, today

  1. Raise streaming/cache pools with no code patch: is_poolSize (image streaming) and vt_maxPPF/vt_maxLockedPagesPercent (virtual texture) are EXPOSEd — settable from the plain, unrestricted console or any .cfg, no resourceExec -s needed. fc_maxCacheMemoryMB (file cache, 1–2048 MB) and s_initStreamPoolSizeMB (WWise sound pool) are not exposed but have no other barrier — the resourceExec <modded cfile> -s route already proven in r4_consolecmds.md/r5_kisculecmd.md reaches them the same way it reached God/g_infiniteAmmo. s_initStreamPoolSizeMB is additionally CVAR_INIT — it must be set in the config that runs before the audio subsystem initializes (i.e. early in default.cfg's own exec chain, not a late in-level resourceExec), or it has no effect until the next full restart.
  2. World-geometry ceilings are a startup-config matter, not a console one: r_worldGeometryManagerMaxVerts/...Indices/ r_worldJointsManagerMaxJoints are all CHEAT+INIT, never EXPOSE — raising them for an unusually large custom map means writing them into whatever .cfg the loader gets exec'd before the renderer's world geometry manager initializes, not setting them mid-session. Retail's defaults (11M verts / 36M indices / 300k joints) are generous for a single custom map, so this only matters for an exceptionally large build.
  3. Don't fight the per-spawner AI cap — it isn't the engine's. §2 proves maxActiveAIs is authored per idTarget_AISpawn entity; a mod wanting "unlimited AI" just needs maxActiveAIs = -1 (or omit the field) on its own spawners, exactly as 83/504 retail spawners already do (r4_aispawnwaves.md). This is a data change, no cvar, no patch.
  4. Respect the two confirmed hard numeric caps (§3): a weapon/ammo decl asking for more than 16 simultaneous projectiles gets silently clamped with a warning (not fatal) — if a mod wants a "32-pellet shotgun" effect, it needs 2 fire events or 2 weapon stages, not one 32-count decl. A single AI "Group" (encounter roster) is capped at 64 members and this one is fatal — a mod building an unusually large single encounter group should stay under 64 distinct group members.
  5. The cvar/command registry headroom (§1, 840 cvars / 22 commands) is real but currently unreachable by any proven mechanism in this project — no track has built a working AddCommand/RegisterStaticVar injection. Recorded for whoever picks that up next, not usable today.
  6. New this round: a named, raisable POI cap. poi_ArtificialPOILimit (default 25, §4) is not EXPOSEd but needs no code patch — the resourceExec -s route raises it like any other non-exposed cvar; useful for a mod/map that places more than 25 simultaneous HUD points-of-interest (collectibles, objective markers) before hitting its logged "exceeded artificial limit" message.
  7. New this round: two more hard numeric caps to design around. MAX_DYNAMIC_ENVIRONMENT (§3.4) caps simultaneous reflection-probe-style volumes at 128, warn-and-continue. The light-gather function's "too many visible lights" check (§4) caps at 8196, also warn-and-continue — generous enough that no normal custom map should hit it, but a mod programmatically spawning dynamic lights should know the ceiling exists. GatherShadowCasters's shadow-slice cap (§4) is a small per-context table ({6, 1, 4, 0, 6, 0, ...}), not one number — a mod relying on many simultaneous shadow-casting slices in one context could plausibly hit a cap as low as single digits depending on which table index applies; worth keeping in mind for any "many dynamic shadows at once" effect.

Open questions

  • The actual numeric value of MAX_GENTITIES/MAX_G_MANAGED_OBJECTS (TNC) — blocked on finding the (apparently indirect/optimized) call site that compares the live count against it before calling the fatal overflow path (§2.1, "Failed hypotheses").
  • The light-gather function's cap is now resolved (8196, PROVEN, §4). Still open: GatherShadowCasters's shadow-slice cap table's index semantics — which live value selects the DAT_7ff62e878100[index] entry (quality tier? light type? both were plausible from the surrounding code, neither confirmed) — and whether the 6 values read this round ({6, 1, 4, 0, 6, 0}) are the table's full extent or just its first 6 entries (the following bytes look like pointers, suggesting the table itself is that short, but no explicit bounds check was found to confirm it).
  • The Havok-internal shape-instance budget (§7) and the Havok motion-solver's rotation/position-clamp thresholds behind MimaxRotationExceeded/ MimaxPositionExceeded (§7) — both are now traced one concrete level deeper than pass 1 (an actual call site for the former, a decompiled telemetry emitter for the latter) but neither numeric value was recovered; likely needs either locating Havok's own init-time config read or a different search strategy than the xref/disassembly approach this track used throughout.
  • Whether TNO's identical symbol/message family carries the same numeric values as TNC — fundamentally unanswerable without a TNO runtime image, which this project's rules (no process attach, no .text unpacking) keep out of reach; flagging it as a standing limitation of any future TNO-side limits work, not something the next pass should expect to solve with static analysis alone.
  • The remaining ~440 unexplored MAX_* names in TNC and TNO (§6) — most are reflection/debug metadata with "no references found", so a blanket attempt to resolve all of them is likely low-yield; worth doing only for specific ones a future track actually needs (e.g. if someone hits MAX_PARMBLOCK_OPS/MAX_NODE_TYPES in practice while scripting).

Reproduction

# TNC — the exact queries this report's evidence came from, each ~15 s:
python tools/decomp.py --strings "exceeded"
python tools/decomp.py --strings "overflow"
python tools/decomp.py --strings "too many"
python tools/decomp.py --strings "MAX_"
python tools/decomp.py 0x96d290            # idTarget_AISpawn AI-cap check
python tools/decomp.py 0x11c8a91 --callers # RegisterManagedObject overflow (fatal, no callers found)
python tools/decomp.py 0x105a280           # multi-projectile clamp (16)
python tools/decomp.py 0xdae38b            # Group member cap (64)
python tools/decomp.py --strings "16, 16"              # cuttable-model bound, correct RVA 0x26526c0
python tools/decomp.py --strings "MAX_RENDER"          # MAX_RENDER_LIGHTS/MODELS exist in TNC (pass 1 said no)
python tools/decomp.py --strings "idAAS2File"          # zero hits in TNC (pass 1 wrongly said "both")
python tools/decomp.py --strings "MAX_DYNAMIC_ENVIRONMENT"  # new this round
python tools/decomp.py 0x379120            # MAX_DYNAMIC_ENVIRONMENT cap (128), warn not fatal
python tools/decomp.py 0x113549b           # light-gather "too many visible lights" cap (0x2004=8196)
python tools/decomp.py 0x1136efd           # GatherShadowCasters shadow-slice cap table lookup
python tools/decomp.py 0x12385c0           # warn logger (severity 5) -- §2.2
python tools/decomp.py 0x12386b0           # fatal logger (severity 7) -- §2.2
python -c "import struct; d=open('re_probes/runtime_87400/module.bin','rb').read(); print(struct.unpack_from('<6i', d, 0x2668100))"  # shadow-slice table raw values

# TNC — physics (§7), new this round:
python tools/decomp.py --strings "exceeded the maximum"   # Havok shape-instance string, RVA 0x26544e0
python tools/decomp.py --strings "MimaxRotationExceeded|MimaxPositionExceeded"
python tools/decomp.py 0x198e250           # one of the 3 referencing functions (debug-telemetry emitter)
python -c "
import sys; sys.path.insert(0,'tools')
from tnc_runtime_code import Image
img = Image('re_probes/runtime_87400')
rva = 0x721487
for insn in img.cs.disasm(img.data[rva-32:rva+32], rva-32):
    print(hex(insn.address), insn.mnemonic, insn.op_str)
"                                           # direct Capstone disasm of the shape-instance call site

# TNC cvar table (no decompiler needed, pure data read):
python -c "import json; d=json.load(open('re_probes/cvartable/cvars_tnc_v2.json',encoding='utf-8')); print(d['runtime_lists'])"
python -c "import json; d=json.load(open('re_probes/cvartable/cvars_tnc_v2.json',encoding='utf-8')); print([c for c in d['cvars'] if c['name']=='poi_ArtificialPOILimit'])"

# TNO — read-only PE string scan, new this track:
python re_probes/enginelimits/tno_strings.py "MAX_[A-Z_]+"
python re_probes/enginelimits/tno_strings.py "exceeded|overflow"
python re_probes/enginelimits/tno_strings.py "too many"
python re_probes/enginelimits/tno_strings.py "idAAS2File"  # TNO-only confirmation

Smallest in-game test

(Translated from German.)

The most robust, most easily checkable findings here are the 16-projectile clamp and the two free (EXPOSE'd) streaming cvars — neither needs the resourceExec -s detour, only an own weapon decl or the normal console, respectively:

  1. 16-projectile clamp (TNC, §3.1): In an own weapon/ammo decl (e.g. a copy of example_fast_shotgun), set the field that sets the projectile count per shot (find it by a decl diff against a shotgun decl, if the field name is not already known) experimentally to 24 or 32 and load it. Expectation according to this report: the weapon still fires only 16 projectiles per shot (visible from the spread/hit count on a wall at short distance), and in the qconsole.log equivalent (TNC log path not yet checked in this track) the warning "Tried to spawn too many projectiles" should appear. A decl with <=16 as a control should fire exactly the set number, without a warning.
  2. Free streaming cvar (TNC, §4/Recipe 1): Via the normal, unrestricted console (no resourceExec -s needed, because of the EXPOSE flag) set is_poolSize 2048 (is_restart afterwards, according to the cvar's description), then load a texture-heavy scene. Expectation: if the game noticeably reloaded/popped before, there should be less reloading/ popping with the larger pool — not a hard pass/fail test, but an immediately observable difference that shows the cvar actually takes effect and is not reset at the next start (unlike TNO's cheat-reset mechanism from docs/limits.md, which has not been shown for TNC in any track so far).
  3. New this round — poi_ArtificialPOILimit (TNC, §4/Recipe 6): Via the already-proven resourceExec <modded cfile> -s route, set poi_ArtificialPOILimit 5 (instead of the default 25), then load a scene with more than 5 simultaneous POI markers (e.g. several collectibles/objective markers in view at the same time). Expectation from the disassembly: from the 6th POI on, the engine should log the error message "idPOIManager::UpdatePOIInfo had too many POIs - exceeded artificial limit of %d, had %d." — the simplest evidence that the resourceExec -s route also reaches non-EXPOSE'd NETWORKSYNC cvars like this one, not only the already-confirmed God/g_infiniteAmmo.

Related: Research Report: R4 Console Commands, Research Report: Cmdtable, Research Report: Cvartable, Research Report: R4 Ai Spawn Waves, Research Report: R5 Kiscule Command, Research Report: R7 Tno Limits, Modding Limits, Engine Modding Surface, Ghidra Analysis.