Repository navigation
Research Report R5 Kiscule Command
Follow-up question to the project's console-restriction research: a mission script (kiscule) can contain an idKisculeNodeCommand node that runs a raw command string (e.g. "r_dof 0", "triggernextmap") when triggered. Does that node call the restricted console dispatcher the same way typing a command does, or does it reach the same unrestricted path resourceExec is already known to use? Found by direct disassembly: the unrestricted path. The node's handler never calls the dispatcher itself — it queues the command text into one of the engine's two command buffers, selected by whatever the restriction level happens to be at that instant, and that level sits at the unrestricted value for essentially the entire session. This closes the single open question the earlier console-restriction research flagged as its top priority. The code path is proven by disassembly end to end; using it from an actual kiscule node in a running game has not been tested. It should behave the same as the already-proven resourceExec route, which the project has confirmed live: Godmode and g_infiniteAmmo both took effect in game, while Notarget printed as set but enemies already engaged in combat kept attacking — the unlock reaches the command/cvar, not necessarily every AI behavior that reads it.
The engine registers 234 commands total, of which only 34 are exposed to the normal console — commands like map, devmap, God, killMonsters, teleport are registered but not on the exposed list, so typing them normally returns "Unknown command". A single global integer (not a CVar) decides which list the dispatcher searches: level 0 searches all 234, level 1 searches only the 34 exposed ones. resourceExec is one of the 34 exposed commands, and it deliberately sets the level to 0 for the duration of the script it runs — already proven and already confirmed live for Godmode/g_infiniteAmmo. This report does not re-derive that mechanism; it asks whether a kiscule Command node reaches the same unrestricted window.
Note for anyone cross-checking older notes: SpawnAI, give, spawn, and noclip are not among the 234 registered commands in the retail build at all — they fail identically at any restriction level, exposed or not. They are not part of this report's chain.
Found via the class's RTTI vtable (two slots unique to idKisculeNodeCommand among five sibling node types checked): the registered Exec callback reads the command parameter, appends a trailing newline, and calls idCmdSystemLocal::AppendCommandText — not the command dispatcher. Condensed from the disassembly:
read "command" parameter, append '\n'
rax = singleton's current vtable pointer
cmp rax, <the real, un-hooked vtable> ; defensive check, always true in retail
je <call directly into the selected buffer>
cmp [restriction_level], 0
cmovne rcx, <restricted buffer> ; level != 0 -> restricted buffer
; level == 0 (default) -> unrestricted buffer
append(buffer, text)
jne <call through the vtable instead — same destination, defensive fallback only>
No call to the dispatcher and no write to the restriction-level global appears anywhere in this function. A Command node's Exec pulse only ever queues text; it neither executes immediately nor changes the ambient level itself.
The generic buffer-drain helper (already known to run once at startup for default.cfg/package.cfg) turns out to have a second call site: the engine's own main per-frame function (identified by its own embedded assert string and independently cross-checked against its position in the class's vtable). It drains both command buffers, twice every single frame:
drain(level=0) ; unrestricted buffer against the full 234-command/3256-cvar catalog
drain(level=1) ; restricted buffer against the 34/304 exposed ones
— twice, in two separate places inside the same function. So: whatever a Command node queues while the ambient restriction level sits at 0 (true for essentially the whole session, per the prior research's exhaustive save/restore trace) is executed against the full, unrestricted catalog on the very next frame, with no patch, no resourceExec, and no DevGUI needed. A second confirmation that this is a general engine idiom, not a one-off for kiscule: a key-binding deserialization routine builds command strings ("unbindall\n", "bindset %d\n") and appends them into the exact same unrestricted buffer using the identical pattern.
The task behind this research suggested that a retail Command node using a non-exposed command name would itself be evidence for the level-0 path. That does not hold for the actual retail data, reported honestly rather than cherry-picked: of the 7 retail idKisculeNodeCommand instances that exist, r_dof and print (6 of the 7 uses) are both already exposed commands that would work at either restriction level, and the 7th (triggernextmap) is not a registered command under any name or case at all — it would fail with "Unknown command" regardless of level. The yes/no verdict in this report rests entirely on the code-path proof above, not on an empirical retail example.
- The Command node's Exec handler (found via RTTI vtable) calls the devirtualized inline of
AppendCommandText, never the dispatcher, confirmed via raw disassembly of both the common and the defensive-fallback branch. - The buffer-select logic matches the already-known
resourceExecaddresses exactly. - The per-frame drain function is
idCommonLocal::Frame()(confirmed by its own embedded assert string and independently by its vtable slot position), and it drains the unrestricted buffer at level 0 then the restricted buffer at level 1, twice per frame. - None of the 7 retail Command-node instances actually needs the level-0 path to work (6 are already-exposed commands, 1 isn't a valid command at all).
- That the per-frame drain function is reached every simulated frame during actual gameplay — not independently traced from the top-level message loop, but the standard, unambiguous meaning of an engine's own
Frame()-named function that runs this exact "drain both command buffers" pair, matching the universal idTech-family pattern. - That a trigger firing a Command node during gameplay is never synchronous with the brief window where a console keystroke/
exec/resourceExecholds the level at 1 — not formally proven impossible, just never documented or plausible as a reachable interaction. The one theoretical edge case would instead land in the restricted buffer and execute at level 1 on the same frame's second drain.
In a test map, wire a trigger volume directly to a Command node with command = "God 1" (capitalized — the lowercase god is not registered at all). Enter the trigger volume, then take damage from an enemy without touching the console. If no damage is taken, God 1 reached the dispatcher through the full, level-0 catalog — it is not among the 34 exposed commands and cannot be reached through the normal console (level 1) at all. As a falsifiable control, run a second instance with command = "triggernextmap" in parallel: since that name is not a registered command under any case, nothing should happen regardless of level; if it does have an effect, the chain documented here is wrong or incomplete. A sharper, damage-test-free variant: use the non-exposed cvar g_infiniteAmmo 1 instead and fire without reloading — matches the project's already-confirmed-live resourceExec result for the same cvar.
Related: Research Report: Cmdtable (the 234/34 command catalog this builds on), Research Report: Execcarrier (the resourceExec carrier route, confirmed live), Developer Mode, Kiscule Scripting, Mission Kiscule Scripting.
English Wiki generated from the measured research notes in docs/. Evidence grades and uncertainty are preserved intentionally.
- WolfSDK Loader · DE
- WolfSDK Studio · DE
- FAQ · DE
- Mod format · DE
- Modding Guide
- Modding Surface
- CVar Catalog
- CVar Validation (old scrape)
- Texture Database
- Developer Mode
- Dev Menu
- Custom-Map Status