229 lines
13 KiB
Markdown
229 lines
13 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) |
|
||
| – `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) | `||` `|` (or) · `&&` (and) · `&` · `==` `!=` · `<` `>` `<=` `>=` · `<<` `>>` · `+` `-` · `*` `/` · unary `-` `!` |
|
||
| `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).
|