278 lines
18 KiB
Markdown
278 lines
18 KiB
Markdown
# 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) | `||` · `&&` · `|` · `&` · `==` `!=` · `<` `>` `<=` `>=` · `<<` `>>` · `+` `-` · `*` `/` · unary `-` `!` |
|
||
| 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 || b` → `a; jnz T; b; jz F; T:`; `!` swaps the jump; constant conditions need no code. In a value (`x = a && b`) both sides are evaluated (`and`/`or` opcodes) |
|
||
| `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 `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
|
||
[, 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, 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`). 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 renames `unit:Old` in the other
|
||
custom types.
|
||
* **References between custom types**: `unit:N` operands that point at a custom type are stored
|
||
as `unit:TypeName` (`StratEdits.symbolic` on create/edit; on load for older projects, with the
|
||
saved `session.firstUnit`); the assembler resolves names through `Layout.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.
|
||
* `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).
|