Files
llamamod/notes/strat-language.md
T

13 KiB
Raw Blame History

Strat language (LlamaMod's assembly for the strat VM)

Object behaviour ("strats") is bytecode for the VM at groove.exe FUN_0054d180, stored per level in the STPC chunk as code units (one per compiled source file; notes/editor-cross-import.md). format.strat turns a unit into text and back:

Class Role
OpTable opcode/property names and docs (resources ops.tsv, props.tsv; notes/strat-opcodes.md)
StratListing unit → assembly text (+ per-line metadata: offsets, editable constants)
StratAssembler text → unit words; layout = where the unit goes + model/anim/unit offsets
StratPseudo read-only structured view (expressions, if/else, loops, switch, break/continue)
StratCompiler script language → assembly (new behaviour)
Num literals: 42, 0x2a, 1.5 (20.12), 90deg

Round trip (golden test StratRoundTripTest): disassemble → assemble is byte-identical for all 1461 units of all 34 level WADs (670 555 words), with no instruction emitted as raw words. Own syntax; the style is loosely inspired by Stratigise (knowledge only, no code used).

Syntax

One statement per line; ; starts a comment; name: defines a label (several per line allowed).

; unit 4 (139 words at STPC 0x422fce): Coin
.unit Coin
ev_0000:
        .event 4, 0, L_0084                  ; handler entry
L_000c:
        .string "Coin", "WARNING: Coin "
        .word 0x6f200020
L_0084:
        pushProp damageTaken
        push 2048.0                          ; = 8388608
        notEqual
        jz L_013c
        ...
Coin:
        debugName "Coin"
        address model:5                      ; model
        setModel
        onEvent ev_0000, 0                   ; event type 4 → L_0084
        pushVar snapToFloor                  ; v0 (alias from the object schema)
        jz L_018c
        ...
L_0218:
        push 5.625deg                        ; = 64.0
        turnLeft
        jmpYield L_0218
        end

Directives

Directive Words Meaning
.unit NAME 0 unit name (informational)
.alias name = vN 0 variable alias; the listing declares one per schema parameter
.word a, b, … n raw words (numbers or @label = absolute address of a label)
.string "a", "b" ⌈(bytes)/4⌉ packed NUL-terminated strings, zero padded to the next word (\", \\ escapes)
.event type, param, target 3 event table entry; rel is computed against the onEvent that names this entry's label (exactly one must)
.switch n + n × .case v, target / .case imm v, target 1 + 2/3 each switch table (case as 0x44 short or 0x45 immediate); targets relative to the switch naming the table's label
.at name, k 0 defines name = next statement + k bytes (labels inside an instruction; not needed by the CD levels)

Instructions

mnemonic operand, operand… – mnemonics from notes/strat-opcodes.md (case-insensitive) or op_XX for any opcode.

Opcode kind Operand syntax
short ops (< 0x45) one operand, encoded in the high 16 bits (−32768…32767)
– variables (pushVar, popVar, pushVarAddr, parent vars) v3 or an alias
– globals (pushGlobal, popGlobal, …) g12
– properties (pushProp, popProp, pushPlayerProp, …) name from props.tsv (x, rotY, floorHeight) or prop17
– branches/calls (jz jnz jzYield jnzYield jmp jmpYield call callYield pushCodeWord pushCodeAddr popCodeWord) label (or a number = raw word offset)
– switch label of the .switch table
– onEvent entry, slot[, hi=N] label of the .event entry, slot number
– skipArgs/print (0x02/0x03) raw words: count, items… (hi=N first if the opcode word has high bits)
push v (0x45) number; tex:N marks a texture index (before shadowTexture/spawnParticle/…)
address ref (0xb2) model:N[+off], anim:N[+off], unit:N[+off], @label[+off] (own unit), or a number (negative = DEFANIM.WAD animation)
soundAddress sound:N (0xd7), topSay text:N … (0xf0/0xfa) level sound / text index
debugName "Name"[, k] (0xd4 0xea rel) name string: the k-th (default first) occurrence of the string + NUL in the unit that starts a word or follows a NUL; or a label
other long ops as many numbers as inline words (spawn 3, 6, 0, 0, 0)

Numbers: decimal, 0x…/-0x…, fixed point with a dot (1.5 → 6144), angles 90deg (360deg = 0x1000000). The listing prints fixed point when the value reads naturally as one (≥ 64 raw, ≤ 3 decimals), degrees for constants consumed by turn/tilt/sin/cos/popProp rot* (only when exact).

Assembler rules

Two passes (sizes are fixed per mnemonic/directive, then encoding). Errors carry line numbers (unknown mnemonic/label, operand count, 16-bit overflow, missing onEvent/switch for a table, unknown model/anim/unit index). Layout: base = STPC offset of the unit's first word (@label and own-unit addresses), plus the model/animation/unit offsets of the target STPC for model:N etc.

Script view (StratPseudo)

Read-only (not parsed back). Stack code folded into expressions (self.y = self.floorHeight + 204), properties as self.x, player.y, parent.v3, camera.…. Control flow is rebuilt from the jumps where the blocks nest:

Jumps Shown as
forward jz/jnz over a block (+ jmp over a second block) if (…) {…} else {…}; an if that is the whole else part → } else if (…) {; an empty then-part is negated
backward jmp/jmpYield loop {… yield}; first statement if (c) break → while (!c) {…}
backward conditional jump do {…} while (c) (/*yield*/ for jzYield/jnzYield; empty body: do {} while (c) /*yield*/)
jump to the end / head of the innermost loop break / continue (also as if (c) break)
switch + jmp end with the cases in between switch (v) { case 1.0: … break … } (default: when the jmp goes to a case-like block)

Everything else stays goto label (mostly multi-level breaks out of nested loops and || chains that jump to a common far target). Over all 1461 units: 11 019 → 400 gotos (2026-11-07; guarded by StratPseudoTest, which also checks balanced blocks and that every remaining goto has its label). Inline operands in <>: spawn<3, 6, 0, 0, 0>(10.0, 204, 450, unit:35+0x28). Example (Coin):

strat Coin:
setModel(model:5)
collRadius(409)
onEvent ev_0000, 0  // event type 4 → L_0084
if (v1) { collisionOn<32768>() ... }
if (v0) {
    if (self.floorMaterial == -1.0) { skipArgs … } else {
        self.y = self.floorHeight + 204
        self.rotY = rnd(4096.0)
    }
}
loop { turnLeft(5.625deg); yield }

Script language (StratCompiler) – writing NEW behaviour

A small structured language in the style of the script view, compiled to the assembly above (so everything the assembler checks still applies). Implemented slice (2026-11-07): strats, subroutines, aliases, assignments, expressions, if/else if/else, while, loop, do-while, break/continue, yield, calls of any opcode by name. Tests: StratCompilerTest; verified in game (village 1: img/mods/strats_script_game.jpg).

// bobs one cell up and down and turns
strat Bobber {
    alias base = v0              // .alias base = v0
    alias phase = v1
    setModel(model:5)            // any opcode: name<inline operands>(stack arguments)
    base = self.y + 1.0
    phase = 0
    loop {
        phase = phase + 4deg
        self.y = base + sin(phase)
        turnLeft(4deg)
        yield                    // last statement of a loop → the loop's jmpYield back edge
    }
}

sub wobble {                     // called with "call wobble", ends with return
    self.rotZ = sin(self.rotY) * 0.1
}
Construct Compiles to
strat Name { … } .string "Name", label Name:, debugName "Name", body, end unless the body cannot fall through
sub name { … } / call name / return label, body, return / call name / return
x = e, x += e (-=, *=, /=) e, pop op of x; x += 1.0 / x -= 1.0 → address + inc/dec
places vN, alias, gN, self.prop, player.prop, parent.prop, parent.vN, camera./target./target2./boss./dialog.prop (props.tsv names or propN)
literals as in the assembly: 10.0 = ten, 10 = 10 raw units (1/4096), 90deg, 0x10; 16-bit values use pushShort, others push
references model:N, anim:N, unit:N (address), sound:N (soundAddress), tex:N (push)
operators (low → high) `
op<a, b>(e1, e2) e1, e2, op a, b; the argument count must match the opcode's pops; a value-returning op used as a statement is followed by pop
if (c) {…} else if … else {…} c; jz else; …; jmp end; else: …
while (c) {…} / loop {…} head label, c; jz end, body, jmp/jmpYield head
do {…} while (c) body, c; jnz head
break / continue / yield / end jmp end / jmp head (do: the condition) / jmpYield next / end

The generated assembly starts with the script as ;| comment lines, so the editor can show the script again (Edit type…). Comments: //, /* */; ; and newlines separate statements.

Next steps (not implemented): event handlers (on touched { … } → .event table + onEvent), switch, spawn with typed arguments, local names for parameters from the object schema, for, constant folding, typed numbers (int vs fixed) instead of the assembly's literal rules, short-circuit &&/|| (now: both sides are evaluated, like the original and/or opcodes).

Editing in the level editor (editor.tools.strats)

  • Script panel (bottom dock, View → Panels → Script, Ctrl+Shift+K): unit chooser (all code units with their type names, custom ones marked), Follow selection, Script/Assembly view, Find, Uses (all lines with the selected operand), help for the selected line (opcode semantics, property docs, constant values), side panel with parameters (schema aliases + use counts), "referenced from" (units whose address operands point into this unit = who spawns it) and instance count. Inspector section "Script" with Open script.
  • Constants (double-click / Edit value…): push, pushShort, sound, texture and text operands. Undoable (ConstantEdit), applied to reference + scene, stored in level.json stratEdits.constants as {unit, name, at (byte offset in the unit), old, new, short}; on load the old value is checked first (mismatch → warning, skipped). Edited lines are orange.
  • New types (Clone as new type… in the panel, Level → New object type from script…): source text (the cloned unit's listing with the strat renamed, or a template), entry label, type fields/parameters taken from an existing type. Check assembles and verifies; Create appends the unit at the end of the STPC code region (nothing else moves), adds the type to the catalogue (Assets → Objects), stores stratEdits.types {name, basedOn, entry, source, proto}. Verification: the merged STPC is disassembled again (no decode errors, entry decoded, every address operand of the new unit hits a model, animation set or code, and the new unit's listing re-assembles to the same words).
  • Edit type… / Delete type… (Script panel, Inspector Script section of a custom object): edit opens the same dialog with the type's source (script or assembly), Apply rebuilds all custom units (TypeEdits.edit, one undo step) and the placed objects move to the new entry. Delete warns with the number of objects that use the type and removes them with it (TypeEdits.delete: Edits.delete of the users + the type list change, one undo step). Creating is undoable too (TypeEdits.create), except when imported types follow the custom block (then appended as before).
  • Language (dialog): Assembly or Script (compiled by StratCompiler, see above); switching Script → Assembly shows the generated code.
  • Load order (ProjectIO.LoadHook.order()): constants of base units (−10) → imported types (CrossImport, 0) → constants of imported units and custom types (+10). Custom units are always last on load; if types were imported after a custom type in the session, the stored strat codes are remapped with the saved custom block position (stratEdits.session).

Limits

  • Custom types are edited and deleted as a whole list: the custom units are cut off the code and appended again (objects follow their type's new entry). Not possible while object types imported after the custom ones sit behind them in the code (save and reopen the project first; on load the custom types always come last). A custom type that addresses another custom type's unit with unit:N breaks when that index changes. Constants of custom types are changed through their source.
  • pushShort holds only −8.0…7.99; bigger values need a source edit (push).
  • The script view is a reading aid (gotos where blocks do not nest; expressions assume the documented pop/push counts; ops with variable stack use are shown raw).
  • Names/semantics of low-confidence opcodes and properties are guesses (see confidence column).