18 KiB
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) |
| – renamed opcodes | former names (flagEC10000 …, op-aliases.tsv) still assemble |
– 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], unit:Name[+off] (custom types, see below), @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) |
| jump out of an outer loop (to its end / head) | break name / continue name, the loop shown as name: loop { (while, do) |
| jump to a return / end / remove | return / end / remove (the same instruction) |
| conditional jumps separated only by expression code, to a common false target / the body | one condition with && / || (short-circuit; also what the compiler generates) |
| if (a) { if (b) {…} } with nothing else in the outer block | if (a && b) {…}; leading if (c) break lines of a loop → while (!c && …) |
Everything else stays goto label. Over all 1461 units: 11 019 → 400 → 31 gotos (2026-11-07,
second pass; 138 labeled break/continue). The 31 left are one pattern in the Llama strat of every
level: a jump from inside a then-part into the else part (if (a) { call; if (!b) goto else; … } else {…}); earlier versions showed these as a nested if without the else, which was wrong
(fits now refuses an inner if that targets the outer if's else label). Guarded by
StratPseudoTest (balanced blocks, every goto has its label, every break name names a loop).
Inline operands in <>: spawn<3, 6, 0, 0, 0>(10.0, 204r, 450r, unit:35+0x28). Raw integers
pushed on the stack are written with an r suffix (204r), as in the script language (below);
1.0-style and deg values as in the assembly.
Example (Coin):
strat Coin:
setModel(model:5)
collRadius(409r)
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 + 204r
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). Language version 2 (2026-11-07): strats, subs,
aliases, parameter names, assignments, expressions with constant folding, short-circuit &&/||,
if/else if/else, while, loop, do-while, switch, break/continue, yield, event handlers, calls of any
opcode by name. Tests: StratCompilerTest. In game (village 1, apptest, 2026-11-07): version 1
scripts img/mods/strats_script_game.jpg; version 2 (StratsGameLevel … v2: a custom type, then
an imported Spider in the same session, the custom type edited afterwards, compiled Toucher/Ticker
with on touched / on event(0x40, 30), switch, &&/||) loads and plays without errors
(img/mods/strats2_game_import.jpg); the handlers' visible effect was not confirmed (the test
objects were out of the camera's view under the slow harness).
// 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 // 1 = one cell
phase = 0
on touched { // event handler (type 4), runs as a subroutine
soundPlay(sound:6)
}
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; event handler code behind it |
sub name { … } / call name / return |
label, body, return / call name / return |
x = e, x += e (-=, *=, /=) |
e, pop op of x; x += 1 / x -= 1 (= 1.0) → address + inc/dec |
| places | vN, alias, parameter name, gN, self.prop, player.prop, parent.prop, parent.vN, camera./target./target2./boss./dialog.prop (props.tsv names or propN) |
| numbers | plain integer = game units: 10 = 10.0 (40960 raw); 1.5, 90deg as in the assembly; raw words: 215r or hex 0x10 |
| references | model:N, anim:N, unit:N / unit:Name (address), sound:N (soundAddress), tex:N (push) |
| operators (low → high) | ` |
| constant folding | operators over constants are computed with the VM's rules (20.12 * = a·b>>12, / = (a<<8)/b<<4, shifts by b>>12, comparisons 0/1): 2 * 3 + 0.5 → pushShort 6.5 |
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. Inline operands <…> are raw words as in the assembly |
conditions (if, while, do … while) |
short-circuit: a && b → a; jz F; b; jz F, `a |
if (c) {…} else if … else {…} |
c → else; …; jmp end; else: … |
while (c) {…} / loop {…} |
head label, c → end, body, jmp/jmpYield head |
do {…} while (c) |
body, c → head |
switch (e) { case 1: … break … default: … } |
e; switch tab; jmp default/end, case bodies in order (C rules: fall through, break leaves the switch, continue the enclosing loop); table .switch n + .case [imm] v, label (short form when v fits 16 bits). Case values are constants |
on touched { … } / on event(type[, param]) [slot n] { … } |
onEvent ev, slot where it stands (slots 0, 1, … in order unless given); entry .event type, param, handler; the handler body (ends with return; remove/end allowed) behind the strat. type/param/slot are raw words; type 4 = touched (collisionOn + collRadius needed, like Coin), 0x40 = timer, 0x80/0x100 = player distance |
break / continue / yield / end |
jmp end / jmp head (do: the condition) / jmpYield next / end |
use rawNumbers |
the version-1 number rule (plain integers are raw words) for the rest of the script |
Layout: event entries and switch tables go to the start of the unit (like the original code):
data behind a terminating instruction would be decoded as dead code by StratCode's
terminator-follower pass and break the listing.
Parameter names: StratCompiler.compile(src, names) takes predefined names → vN; the editor
passes the parameter names of the object schema of the type the custom type's fields come from
(StratEdits.paramNames, the same names as the script view's aliases, e.g. snapToFloor = v0
for Coin). The script's own alias wins.
Numbers, compatibility: language version 1 (before 2026-11-07) read 10 as 10 raw units. The
compiler now writes the marker line ; script language 2 behind the embedded source;
embeddedSource of older output (no marker) puts use rawNumbers in front of the script, so an
old type opened with Edit type… compiles to the same code. Stored types are assembly anyway
(the script is a comment), so loading never recompiles.
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.
Not implemented: for, spawn with typed arguments, typed variables (int vs fixed), a view of
event handlers as on … { } (the script view shows onEvent + the handler label).
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
addressoperands 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.jsonstratEdits.constantsas{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 [, fieldsFrom]}(fieldsFrom= type of the fields/parameter names when it differs from basedOn). Verification: the merged STPC is disassembled again (no decode errors, entry decoded, everyaddressoperand 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.deleteof the users + the type list change, one undo step). Creating is undoable too (TypeEdits.create). In edit mode Type fields / parameters from can be switched to another type (new placements start with its fields; placed objects keep theirs);(keep current fields)is the default. Renaming a type also renamesunit:Oldin the other custom types. - References between custom types:
unit:Noperands that point at a custom type are stored asunit:TypeName(StratEdits.symbolicon create/edit; on load for older projects, with the savedsession.firstUnit); the assembler resolves names throughLayout.withUnitNames, the listing prints them (Names.unitName). Deleting a referenced type is refused ("unknown unit name"). - Imports in the same session (CrossImport
CodeHook): the merge is computed on the code without the custom units (mergeBase), and the commit cuts the custom units off, merges, and appends them again behind the imported code (StratEdits.aroundImport; objects follow by type index and offset from the entry) – the same order as on load, so editing/deleting/undoable creating keep working without reopening. (Imports themselves still clear the undo history.) - 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). Projects saved by older versions with imports behind the custom types are reordered on load as before. Constants of custom types are changed through their source.
pushShortholds 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).