diff --git a/docs/manual/03-editor-window.md b/docs/manual/03-editor-window.md index e9c848e..3de8a40 100644 --- a/docs/manual/03-editor-window.md +++ b/docs/manual/03-editor-window.md @@ -57,6 +57,7 @@ a saved `.llamalevel` file. | **Problems** | bottom | yes | | **Environment** | left | yes | | **Visibility** | right | yes | +| **Script** | bottom | yes | ### Outliner diff --git a/docs/manual/19-shortcuts.md b/docs/manual/19-shortcuts.md index a002c59..26de492 100644 --- a/docs/manual/19-shortcuts.md +++ b/docs/manual/19-shortcuts.md @@ -106,6 +106,7 @@ built-in tools and every plugin): | **C** | Show collision | View | | **Ctrl+Shift+T** | Texture manager | View → Panels | | **Shift+V** | Vertex colours only | View | +| **Ctrl+Shift+K** | Script | View → Panels | | **Ctrl+B** | Build | Level | | **F5** | Play | Level | | **Shift+F5** | Play from camera position | Level | @@ -209,6 +210,7 @@ Every menu command with its shortcut and a short description. | Light objects (LGHT preview) (on/off) | | | | Vertex colours only (on/off) | **Shift+V** | Show the baked/painted vertex colours without textures | | Panels → Texture manager | **Ctrl+Shift+T** | Pages, textures, usage and animations | +| Panels → Script | **Ctrl+Shift+K** | Strat script of the selected object type | ### Level menu @@ -226,6 +228,7 @@ Every menu command with its shortcut and a short description. | Visibility regions | | | | Import objects of a world… | **Ctrl+Shift+I** | Copy object types (enemies, pickups, platforms …) of another world into this level | | Rebuild object catalogue | | Scan all levels of the CD again (thumbnails, sizes) | +| New object type from script… | | Create a custom object type from strat source text | ### Geometry menu diff --git a/docs/manual/22-strats.md b/docs/manual/22-strats.md new file mode 100644 index 0000000..7232bd0 --- /dev/null +++ b/docs/manual/22-strats.md @@ -0,0 +1,150 @@ +# Strat scripts: reading and changing object behaviour + +Every object in the game – a coin, a squirrel, a moving platform, a camera – is driven by a small +program, its **strat** (Argonaut's "strategy" scripts). The level editor shows these programs in +the **Script** panel, lets you change their numbers (speeds, heights, timers, damage) and lets you +create **your own object types** by copying a script and editing it. + +![The Script panel showing Bucky's script](img/strat_script_panel.jpg) + +## The Script panel + +Open it with **View → Panels → Script** (**Ctrl+Shift+K**), the **Open script** button in the +Inspector's *Script* section, or click its tab in the bottom dock. + +| Part | What it does | +|---|---| +| **Type** | the code unit to show. A unit is one compiled script file and can hold several strats (Bucky + ThrowAcorn). Custom types are marked *[custom]* | +| **Script / Assembly** | *Script* is a readable view with expressions, `if`/`else` and `loop`; *Assembly* is the exact instruction list (the language you write new types in) | +| **Follow selection** | shows the script of the object you select in the viewport or the Outliner | +| **Find**, **Next** | search the text (Enter = next match) | +| **Uses** | lists every line that uses the selected line's variable, property or label | +| **Edit value…** | changes the numbers of the selected line (also: double-click) | +| **Clone as new type…** | copies this type into a new, editable object type | + +The side panel explains the selected line: what the instruction does, how many values it takes +from the stack, its Argonaut name where known, and what properties like `floorHeight` mean. With +nothing selected it lists the unit's parameters, which other scripts **spawn or reference** this +one, and how many objects of it are placed. + +### Reading a script + +* `self.x`, `self.rotY`, `self.floorHeight` are properties of the object itself; `player.…`, + `parent.…` (the object that spawned it) and `camera.…` those of others. +* `v0`, `v1`, … are the object's variables. The first ones are the **parameters** you set in the + Inspector (*Object parameters*): parameter 0 = `v0`. Where the editor knows a parameter's + meaning, the script uses its name (`snapToFloor`). +* `g12` is a global variable shared by all scripts. +* Numbers are in game units: `1.0` = one cell (or "true"), angles are written like `5.625deg`. + Speeds and turn rates are **per frame** (the game runs at 30 frames per second). +* `yield` ends the object's turn for this frame; a `loop { … yield }` runs once per frame. +* `onEvent` installs a handler, for example "when the player touches me" (event type 4). +* `spawn<…>(…)` creates another object, `address model:5` names a 3D model of the level. + +## Changing numbers + +Double-click a line with a number (or select it and press **Edit value…**). Lines whose numbers +can be edited show a tooltip; edited lines turn **orange**. + +![Editing a constant](img/strat_edit_constant.jpg) + +* Type the new value as `1.5` (fixed point), `90deg` (angle), `42` or `0x2a`. The dialog shows + the value in all three readings and refuses impossible ones (a `pushShort` holds only + −8.0 … 7.99). +* The change can be undone (**Ctrl+Z**) like every other edit. +* It applies to **every object of that type** in the level, also to objects other scripts spawn. +* It is saved in the project and written into the level when you build or play. + +Example: in the Coin's script, `self.y = self.floorHeight + 204` puts snapping coins just above +the floor. Changing `204` to `1.5` makes them float one and a half cells high: + +![Original and edited Coin script in the game](img/strat_coin_constant_ab.jpg) + +## Your own object types + +**Clone as new type…** in the Script panel (or **Level → New object type from script…** for an +empty template) opens the type editor: + +![Creating a custom type](img/strat_clone_dialog.jpg) + +1. **Name** – the name of the new type (letters, digits, `_`). +2. **Entry label** – where the script starts (for clones: the renamed strat). +3. **Type fields / parameters from** – which existing type's settings (parameters, waypoints, + flags) new objects of this type start with. +4. Edit the source. **Check** assembles it and verifies the result; errors name the line. +5. **Create type**. The type appears in **Assets → Objects** and can be placed like any other. + +The new script is added to the level's code; the original type is not changed. Custom types are +stored in the project (as text) and rebuilt every time it is opened. + +Language reference: every instruction is one line, labels end with `:`, comments start with `;`. +`push 4deg` / `turnLeft` pushes a number and lets an instruction use it; `jmpYield label` waits +for the next frame and jumps. The full syntax and the list of all 434 instructions are in the +LlamaMod notes (`notes/strat-language.md`, `notes/strat-opcodes.md`). + +## Tutorial: make the squirrel run in a circle + +Bucky, the nut-throwing squirrel of *Jungle at Night*, can run in a circle in two ways. Both are +in one test level below: on the left the waypoint way, on the right the script way. + +![Two squirrels running in circles, eight frames](img/strat_bucky_circle.jpg) + +### Find out how Bucky moves + +1. Import Bucky (**Assets → All worlds**, search "Bucky", drag it into the level). +2. Select it and open the **Script** panel. The script shows `wpFirst()`, `wpNext()` and + `wpTurnToY(v1)` / `moveForwardQ(215)`: Bucky **runs along its waypoint chain**, turning + with the rate in parameter 1 and moving 215/4096 cells per frame. +3. At every node it reads `self.wpParam` (the node's field **+26**): the low part chooses run + (`0`) or jump (`1.0`, `2.0`), the high part `128.0` means "run on", `64.0` "wait until the + player comes close". At the end: `if (!(!v0 && (self.wpEndFlags & 2))) goto …` – with + **parameter 0** set the chain repeats instead of ending. + +### Way 1: a circular waypoint chain (no script change) + +1. With Bucky selected, delete its imported path (**Delete path** in the Inspector) and draw a + new one with the waypoint tool (**N**): 12 nodes on a circle (radius 1.5–3 cells). +2. Select all nodes of the chain and set **Field +26** to `524288` (= 128.0, "run on without + waiting"). +3. Set Bucky's **Parameter 0** to `1.0` (repeat the chain). Parameter 1 is the turn rate + (`64.0` = 5.6° per frame; larger turns faster). +4. Play. After the last node Bucky continues with the first one – forever. + +### Way 2: a scripted circle (custom type) + +1. Select Bucky, **Clone as new type…**, name it `BuckyCircle`. +2. In the source, find the label `L_05b0:` (the start of the main loop) and insert before it: + +``` + push 3.0 + popVar v9 ; animation state: running + push 3.0 + popVar v22 +circle: + push 4deg ; turn 4° per frame + turnLeft + pushShort 215 ; forward speed per frame + moveForwardQ + forceCollision + pushProp floorHeight ; stay on the ground + popProp y + call L_0180 ; Bucky's animation routine + jmpYield circle ; next frame +``` + +3. **Check**, **Create type**, place a BuckyCircle from **Assets → Objects** and play. The + circle's size is speed ÷ turn rate: 215/4096 cells × 90 frames per turn ≈ 4.7 cells around. + +## Limits + +* Custom types cannot be deleted or edited in place yet – clone the custom type again with the + changes and replace the placed objects. +* Big structural changes are up to you: the editor checks that the code is well-formed and that + every model/animation/code reference points somewhere valid, not that the object behaves well. +* Names of rarely used instructions are educated guesses (the opcode list shows a confidence for + each). +* Small constants stored as short numbers only accept −8.0 … 7.99 (see *Changing numbers*). +* The Script view shows `goto` where the original jumps don't form nested blocks; the Assembly + view is always exact. +* In the clone dialog, renaming the type doesn't yet rename the entry label or the name inside + the script text – edit those by hand if you want them to match. diff --git a/docs/manual/img/strat_bucky_circle.jpg b/docs/manual/img/strat_bucky_circle.jpg new file mode 100644 index 0000000..5239ed4 Binary files /dev/null and b/docs/manual/img/strat_bucky_circle.jpg differ diff --git a/docs/manual/img/strat_clone_dialog.jpg b/docs/manual/img/strat_clone_dialog.jpg new file mode 100644 index 0000000..cdbf68e Binary files /dev/null and b/docs/manual/img/strat_clone_dialog.jpg differ diff --git a/docs/manual/img/strat_coin_constant_ab.jpg b/docs/manual/img/strat_coin_constant_ab.jpg new file mode 100644 index 0000000..e2439b1 Binary files /dev/null and b/docs/manual/img/strat_coin_constant_ab.jpg differ diff --git a/docs/manual/img/strat_edit_constant.jpg b/docs/manual/img/strat_edit_constant.jpg new file mode 100644 index 0000000..f07812d Binary files /dev/null and b/docs/manual/img/strat_edit_constant.jpg differ diff --git a/docs/manual/img/strat_script_panel.jpg b/docs/manual/img/strat_script_panel.jpg new file mode 100644 index 0000000..5fd0046 Binary files /dev/null and b/docs/manual/img/strat_script_panel.jpg differ diff --git a/docs/manual/index.md b/docs/manual/index.md index 3d287c0..e48a91f 100644 --- a/docs/manual/index.md +++ b/docs/manual/index.md @@ -46,6 +46,7 @@ open the chapter about the panel, tool or dialog under the mouse pointer. * [Environment: zones, fog, music and light](12-environment.md) * [Visibility regions](13-visibility.md) * [Level settings](14-level-settings.md) +* [Strat scripts: reading and changing object behaviour](22-strats.md) ## Testing and sharing diff --git a/notes/img/editor/strat_bucky_circle_sequence.jpg b/notes/img/editor/strat_bucky_circle_sequence.jpg new file mode 100644 index 0000000..5239ed4 Binary files /dev/null and b/notes/img/editor/strat_bucky_circle_sequence.jpg differ diff --git a/notes/img/editor/strat_clone_dialog.jpg b/notes/img/editor/strat_clone_dialog.jpg new file mode 100644 index 0000000..cdbf68e Binary files /dev/null and b/notes/img/editor/strat_clone_dialog.jpg differ diff --git a/notes/img/editor/strat_coin_constant_ab.jpg b/notes/img/editor/strat_coin_constant_ab.jpg new file mode 100644 index 0000000..e2439b1 Binary files /dev/null and b/notes/img/editor/strat_coin_constant_ab.jpg differ diff --git a/notes/img/editor/strat_edit_constant.jpg b/notes/img/editor/strat_edit_constant.jpg new file mode 100644 index 0000000..f07812d Binary files /dev/null and b/notes/img/editor/strat_edit_constant.jpg differ diff --git a/notes/img/editor/strat_script_panel.jpg b/notes/img/editor/strat_script_panel.jpg new file mode 100644 index 0000000..5fd0046 Binary files /dev/null and b/notes/img/editor/strat_script_panel.jpg differ diff --git a/notes/strat-language.md b/notes/strat-language.md new file mode 100644 index 0000000..1892622 --- /dev/null +++ b/notes/strat-language.md @@ -0,0 +1,151 @@ +# 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) | +| `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. Stack code folded into expressions (`self.y = self.floorHeight + 204`), properties as +`self.x`, `player.y`, `parent.v3`, `camera.…`; forward `jz/jnz` → `if {…}` (with `else` when the +block ends in a forward `jmp`), backward `jmp`/`jmpYield` → `loop {… yield}`; blocks are only +built when they nest, everything else stays `goto 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 } +``` + +## 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). +* **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 cannot be removed or re-edited in place (clone again; the old unit stays as dead + code until the project is recreated). 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). diff --git a/notes/strat-opcodes.md b/notes/strat-opcodes.md new file mode 100644 index 0000000..b2f88d8 --- /dev/null +++ b/notes/strat-opcodes.md @@ -0,0 +1,697 @@ +# Strat VM opcodes (groove.exe, "Argonaut Strategy Language") + +Machine-readable tables (the source of the editor's names and help): +`src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv` (opcodes) and `props.tsv` (strat +properties). Language/syntax: `notes/strat-language.md`. Disassembler/assembler: `format.strat`. + +## Summary + +* **434 opcodes** (0x000–0x1b1; 0x00–0x44 "short" with a signed 16-bit argument in the high half + of the word, 0x45+ "long" with inline operand words). All of them have a name and a one-line + semantics from reading every handler (headless Ghidra decompile of the two handler tables + 0x5796bc / 0x578ff4, plus the helpers they call). +* Confidence: **329 high, 71 medium, 34 low**. Of the **222 opcodes the 34 level WADs use**: 170 high, + 37 medium, 15 low; high-confidence opcodes cover **96.8 %** of the 556314 instructions in the levels. +* **252 opcodes matched to Argonaut names** (column *ref*: Croc 2 PSX demo jump table / Croc 1 + handler names), by handler behaviour and table position. 99 slots are no-ops, error stubs or + pop-only stubs (dropped Croc features). +* Strat properties (short ops 0x07/0x40 own, 0x1c/0x37 player, …): **194 ids** named (`props.tsv`), + getter FUN_00550e60 and setter FUN_005509f0 share the numbering; ids without a setter are read-only + (a write would go to address 0). + +## Sources + +* groove.exe handler tables, decompiled with Ghidra 12.2 (headless, all 0x1b2 + 0x45 entries and + their callees). Usage counts: our disassembler over all 34 level WADs. +* Knowledge only (no code copied – Stratigise is GPL-2.0): the opcode name lists of + [Stratigise](https://github.com/Argonaut-PS1-Reverse/Stratigise) (fork: + [knot126/Stratigise](https://github.com/knot126/Stratigise)) – especially + `doc/Croc2/Croc 2 psx demo strat_jump_table.txt` (319 handler names of the Croc 2 PSX demo) and + `doc/Croc1/Opcodes.md` – and the Croc 1 decompilation + [Xeeynamo/croc](https://github.com/Xeeynamo/croc) (`src/croc/3038.c`, `st*` handler names). + Croc 1 uses a different, expression-tree encoding; Croc 2 is the direct ancestor: our long + opcodes are the Croc 2 table index + 0x3b at the start (0x0a stNumber = our 0x45 push, + 0x0e stAdd = 0x49 add), the offset shrinks stepwise where Croc 2 entries were dropped and grows + where Groove added opcodes (0x50 shl, 0x51 shr, 0x52 animToFrame, 0x9b stream play, most of + 0x140+: particles, trails, tint, update order, level end). + +## Conventions + +* Stack: `PUSH` = FUN_0054bbd0, `POP` = FUN_0054bc00 (strat+0x104 = stack pointer). Binary ops + pop **b** (top) then **a** and push `a op b`. Comparisons push raw 0/1. +* Numbers are 20.12 fixed point (0x1000 = 1.0); `mul` = a·b>>12, `div` = (a<<8)/b<<4. +* Angles: a full turn is **0x1000000** (4096.0 in 20.12, i.e. 4096 sin-table units << 12); + getters mask angles to 24 bits. The language writes them as `90deg`. +* Variables: strat+0xF4 array; the first n are the MAP parameters of the instance. + Event handlers (0x0c): 16-byte slots at strat+0xF8; entry {type, param, rel}; type 4 = touched / + collision (pickups), 0x40 = timer, 0x80/0x100 = player distance. +* Special strats: player 0x6D9E1C (set by 0xb5 isPlayer), camera 0x6D9E28, camera targets + 0x6D9E30/34, boss 0x6D9E24, dialog strat 0x6D9E2C, "parent" = strat+0xC (the spawner). + Short-op families: push value / push address / pop for own vars (0x04/0x08/0x41), own props + (0x07/0x0b/0x40), parent vars (0x18/0x19/0x3e) and props (0x1a/0x1b/0x3f), player + (0x1c/0x1d/0x37), camera (0x1e/0x1f/0x38), target (0x20/0x21/0x39), target2 (0x24/0x25/0x3a), + boss (0x26/0x27/0x3d), dialog (0x28/0x29/0x3b), globals 0x06/0x0a/0x43 (array 0x6D9E8C; the exe + never indexes it – meanings only from script use). +* Calls: 0x11 `call` pushes the return address (0x10 is the same with a yield, unused); + `return` (0xb3) pops it. Yield = flag 2 in strat+0xE8 ends the strat's frame. + +## Opcodes + +ops = operands (inline words for long ops, the 16-bit argument for short ops); pops/pushes +(? = variable); uses = instructions in the 34 level WADs; conf = confidence. + +| op | name | operands | pops | pushes | uses | conf | ref | semantics | +|---|---|---|---|---|---|---|---|---| +| 0x00 | `nop_0x00` | - | 0 | 0 | | high | stCommandError | No-op (shared empty handler 00426500); unused in levels. 426500 | +| 0x01 | `hide` | imm | 0 | 0 | 294 | medium | stHide | arg!=0 sets, arg==0 clears flag 0x1000000 in +0xE8; renderer and shadow pass skip strats with this bit, so it hides the strat. 54fa50 | +| 0x02 | `skipArgs` | count | 0 | 0 | 1523 | medium | - | Inline block: count word, then count push instructions (0x44 one word, 0x45/0xea two words) that are skipped without executing; data block read by 0x0c event descriptors (same use count as 0x0c). 553850 | +| 0x03 | `print` | count | 0 | 0 | 26 | medium | stPrint | Debug print with output stripped: skips count inline push instructions like 0x02 and advances debug text line y DAT_006d9db8 by 12 (reset to 0x20/0x48 each frame). 5537f0 | +| 0x04 | `pushVar` | var | 0 | 1 | 50710 | high | stLocal | Pushes own variable n (+0xF4 array). 553710 | +| 0x05 | `pushCodeWord` | rel | 0 | 1 | 438 | high | - | Pushes the 32-bit word at pc+arg*4 (inline data in the code stream, pc = next instruction). 5537b0 | +| 0x06 | `pushGlobal` | global | 0 | 1 | 27756 | high | stGlobal | Pushes global n (0x6D9E8C[n]). 5536b0 | +| 0x07 | `pushProp` | prop | 0 | 1 | 38636 | high | stAlienVar | Pushes own property n via FUN_00550E60(self,self,n). 552ab0 | +| 0x08 | `pushVarAddr` | var | 0 | 1 | 2074 | high | stLocalAddress | Pushes address of own variable n (for 0x47/0x48/0x4d). 5536f0 | +| 0x09 | `pushCodeAddr` | rel | 0 | 1 | 28 | high | - | Pushes address pc+arg*4 (address of inline code-stream data word). 553790 | +| 0x0a | `pushGlobalAddr` | global | 0 | 1 | 1087 | high | stGlobalAddress | Pushes address of global n. 553690 | +| 0x0b | `pushPropAddr` | prop | 0 | 1 | 248 | high | stAlienVarAddress | Pushes address of own property n (FUN_005509F0(self,self,n)). 552ad0 | +| 0x0c | `onEvent` | rel,imm | 0 | 0 | 1523 | high | - | Installs event handler slot (2nd word) in table +0xF8 (16-byte slots): descriptor at word pc+1+off holds type, param, rel code address of the handler; type 0x40 = timer (param converted to frames), types 0x80/0x100 enable player-distance tracking (+0xEC bit 0x4000). 54fc40 | +| 0x0d | `clearEvent` | imm | 0 | 0 | 35 | high | - | Clears event handler slot arg (type word := 0). 54dbe0 | +| 0x0e | `disableEvent` | imm | 0 | 0 | | high | - | Disables event handler slot arg (sets bit 31 of its type word). 54dc20 | +| 0x0f | `enableEvent` | imm | 0 | 0 | | high | - | Re-enables event handler slot arg (clears bit 31 of its type word). 54dc00 | +| 0x10 | `callYield` | rel | 0 | 1 | | high | stJsr | Pushes return address (next pc), jumps to next+arg*4 and yields (+0xE8 bit 2); unused in levels. 553d40 | +| 0x11 | `call` | rel | 0 | 1 | 8471 | high | stJsrImm | Pushes return address (next pc) and jumps to next+arg*4 without yielding; the call form actually used by the compiler. 553d10 | +| 0x12 | `jzYield` | rel | 1 | 0 | 3012 | high | stBeq | Pops cond; if 0 jumps to next+arg*4; always yields this frame. 553e10 | +| 0x13 | `jnzYield` | rel | 1 | 0 | 202 | high | stBne | Pops cond; if !=0 jumps to next+arg*4; always yields this frame. 553eb0 | +| 0x14 | `jz` | rel | 1 | 0 | 32026 | high | stBeqImm | Pops cond; if 0 jumps to next+arg*4. 553e50 | +| 0x15 | `jnz` | rel | 1 | 0 | | high | stBneImm | Pops cond; if !=0 jumps to next+arg*4; unused. 553e80 | +| 0x16 | `jmp` | rel | 0 | 0 | 15125 | high | stJumpImm | Jumps to next+arg*4. 553d80 | +| 0x17 | `switch` | rel | 1 | 0 | 597 | high | stIndex_Jump | Pops value; table at next+arg*4+4: count, then entries (case value as 0x44 short or 0x45 imm, then rel offset); on match jumps to next+offset*4, otherwise falls through. 550060 | +| 0x18 | `pushParentVar` | var | 0 | 1 | 711 | high | stExt_Local | Pushes variable n of the linked strat at +0xC (parent/creator). 553750 | +| 0x19 | `pushParentVarAddr` | var | 0 | 1 | 135 | high | stExt_LocalAddress | Pushes address of variable n of the +0xC strat. 553770 | +| 0x1a | `pushParentProp` | prop | 0 | 1 | 1362 | high | stExt_AlienVar | Pushes property n of the +0xC strat. 552af0 | +| 0x1b | `pushParentPropAddr` | prop | 0 | 1 | | high | stExt_AlienVarAddress | Pushes address of property n of the +0xC strat; unused. 553730 | +| 0x1c | `pushPlayerProp` | playerProp | 0 | 1 | 4320 | high | stPlayer_AlienVar | Pushes property n of the player strat (DAT_006D9E1C, set by 0xb5), 0 if none. 552d20 | +| 0x1d | `pushPlayerPropAddr` | playerProp | 0 | 1 | | high | stPlayer_AlienVarAddress | Pushes address of player property n (0 if no player); unused. 552d50 | +| 0x1e | `pushCameraProp` | prop | 0 | 1 | 855 | high | stCamera_AlienVar | Pushes property n of the camera strat (DAT_006D9E28, set by 0xc8), 0 if none. 552b60 | +| 0x1f | `pushCameraPropAddr` | prop | 0 | 1 | | high | stCamera_AlienVarAddress | Pushes address of camera property n (0 if none); unused. 552b90 | +| 0x20 | `pushTargetProp` | prop | 0 | 1 | 1484 | medium | stTarget_AlienVar | Pushes property n of the camera target strat (DAT_006D9E30, set by 0xc9 from the player character strats), 0 if none. 552c40 | +| 0x21 | `pushTargetPropAddr` | prop | 0 | 1 | | medium | stTarget_AlienVarAddress | Pushes address of target property n (0 if none); unused. 552c70 | +| 0x22 | `error_0x22` | - | 0 | 0 | | high | stCollide_AlienVar | Prints "stCollide_AlienVar" not-implemented error; unused. 54d200 | +| 0x23 | `error_0x23` | - | 0 | 0 | | high | stCollide_AlienVarAddress | Prints "stCollide_AlienVarAddress" not-implemented error; unused. 54d220 | +| 0x24 | `pushTarget2Prop` | prop | 0 | 1 | | medium | stTarget_AlienVar | Pushes property n of the second target strat (DAT_006D9E34, set by 0xca), 0 if none; unused. 552bd0 | +| 0x25 | `pushTarget2PropAddr` | prop | 0 | 1 | | medium | stTarget_AlienVarAddress | Pushes address of target2 property n (0 if none); unused. 552c00 | +| 0x26 | `pushBossProp` | prop | 0 | 1 | | low | stBoss_AlienVar | Pushes property n of the strat in DAT_006D9E24 (set by 0xef), no null check; unused. 552b10 | +| 0x27 | `pushBossPropAddr` | prop | 0 | 1 | | low | stBoss_AlienVarAddress | Pushes address of property n of the DAT_006D9E24 strat; unused. 552b30 | +| 0x28 | `pushDialogProp` | prop | 0 | 1 | 1678 | low | stDialog_AlienVar | Pushes property n of the strat in DAT_006D9E2C (set by 0x113, used by NPC/puzzle strats), 0 if none. 552cb0 | +| 0x29 | `pushDialogPropAddr` | prop | 0 | 1 | | low | stDialog_AlienVarAddress | Pushes address of property n of the DAT_006D9E2C strat (0 if none); unused. 552ce0 | +| 0x2a | `shadow` | imm | 0 | 0 | 887 | medium | stShadow | arg!=0 sets, 0 clears +0xE8 bit 0x20 (shadow on; shadow pass requires it plus size from 0x59 and texture from 0x5a). 54e8e0 | +| 0x2b | `jmpYield` | rel | 0 | 0 | 2958 | high | - | Jumps to next+arg*4 and yields this frame (loop back-edge / wait one frame). 553da0 | +| 0x2c | `nop_0x2c` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | +| 0x2d | `nop_0x2d` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | +| 0x2e | `nop_0x2e` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | +| 0x2f | `flagEC10000` | imm | 0 | 0 | 1888 | low | - | arg!=0 sets, 0 clears +0xEC bit 0x10000 (with 0x8000 both set the strat skips player-distance updates; property 0xf2 reads it); meaning unclear. 54f720 | +| 0x30 | `trackPlayer` | imm | 0 | 0 | | medium | - | If no player yet: re-executes itself next frame (pc-=4, yield); else arg sets/clears +0xEC bit 0x4000 = update distance to player each frame (+0x148). 54fa80 | +| 0x31 | `flagEC2` | imm | 0 | 0 | 410 | low | - | arg sets/clears +0xEC bit 2 (checked by collision/floor code via +0xC0, readable as property 0x1f). 54e090 | +| 0x32 | `flagE8_80000000` | imm | 0 | 0 | | low | - | arg sets/clears +0xE8 bit 31; unused. 54dd90 | +| 0x33 | `flagEC100` | imm | 0 | 0 | 60 | low | - | arg sets/clears +0xEC bit 0x100 (tested in the collision response, skips floor handling; used by drops/projectiles/rollercoaster). 54dd30 | +| 0x34 | `flagEC200000` | imm | 0 | 0 | 50 | low | - | arg sets/clears +0xEC bit 0x200000 (used by NPC helpers/canoe/llama). 54dd60 | +| 0x35 | `nop_0x35` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | +| 0x36 | `nop_0x36` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | +| 0x37 | `popPlayerProp` | playerProp | 1 | 0 | 1131 | high | - | Pops value and stores it into player property n (discarded if no player). 553920 | +| 0x38 | `popCameraProp` | prop | 1 | 0 | 564 | high | - | Pops value into camera strat property n (discarded if none). 553950 | +| 0x39 | `popTargetProp` | prop | 1 | 0 | 186 | medium | - | Pops value into camera target (DAT_006D9E30) property n (discarded if none). 553980 | +| 0x3a | `popTarget2Prop` | prop | 1 | 0 | | medium | - | Pops value into DAT_006D9E34 strat property n (discarded if none); unused. 5539b0 | +| 0x3b | `popDialogProp` | prop | 1 | 0 | | low | - | Pops value into DAT_006D9E2C strat property n (discarded if none); unused. 5539e0 | +| 0x3c | `error_0x3c` | - | 0 | 0 | | high | - | Prints "stCollide_AlienVar_Equals" not-implemented error; unused. 54d240 | +| 0x3d | `popBossProp` | prop | 1 | 0 | | low | - | Pops value into DAT_006D9E24 strat property n (no null check); unused. 553a10 | +| 0x3e | `popParentVar` | var | 1 | 0 | 204 | high | - | Pops value into variable n of the +0xC strat. 553a40 | +| 0x3f | `popParentProp` | prop | 1 | 0 | | high | - | Pops value into property n of the +0xC strat; unused. 553a60 | +| 0x40 | `popProp` | prop | 1 | 0 | 15730 | high | - | Pops value into own property n (via FUN_005509F0 address). 553a90 | +| 0x41 | `popVar` | var | 1 | 0 | 30379 | high | - | Pops value into own variable n. 553ac0 | +| 0x42 | `popCodeWord` | rel | 1 | 0 | 372 | high | - | Pops value into the code-stream word at pc+arg*4 (self-modifying inline data, pairs with 0x05). 553ae0 | +| 0x43 | `popGlobal` | global | 1 | 0 | 20214 | high | - | Pops value into global n. 553b00 | +| 0x44 | `pushShort` | imm | 0 | 1 | 82749 | high | stNumber | Pushes the sign-extended 16-bit arg (raw, not shifted to 20.12). 553610 | +| 0x45 | `push` | imm | 0 | 1 | 25870 | high | stNumber | Pushes inline 32-bit word (handler shared with 0xd7). 5535f0 | +| 0x46 | `neg` | - | 1 | 1 | 923 | high | stUMinus | Pops a, pushes -a. 5508b0 | +| 0x47 | `inc` | - | 1 | 0 | 1648 | high | stIncrease | Pops address, adds 1.0 (0x1000) to the word there. 553890 | +| 0x48 | `dec` | - | 1 | 0 | 1924 | high | stDecrease | Pops address, subtracts 1.0 (0x1000) from the word there. 5538b0 | +| 0x49 | `add` | - | 2 | 1 | 15901 | high | stAdd | Pops b, a; pushes a+b. 5538d0 | +| 0x4a | `sub` | - | 2 | 1 | 10042 | high | stSub | Pops b, a; pushes a-b. 552d90 | +| 0x4b | `mul` | - | 2 | 1 | 11877 | high | stMul | Pops b, a; pushes fixed-point a*b>>12. 552dc0 | +| 0x4c | `div` | - | 2 | 1 | 468 | high | stDiv | Pops b, a; pushes fixed-point (a<<8)/b<<4. 552e00 | +| 0x4d | `store` | - | 2 | 0 | | high | stEquals | Pops value, then address; stores value at address; unused (compiler uses pop ops). 553900 | +| 0x4e | `eq` | - | 2 | 1 | 10075 | high | stCompare | Pops b, a; pushes a==b (0/1, raw). 553be0 | +| 0x4f | `lt` | - | 2 | 1 | 5556 | high | stLessThan | Pops b, a; pushes a553bb0 | +| 0x50 | `shl` | - | 2 | 1 | 1162 | high | stShiftLeft | Pops b, a; pushes a << (b>>12) (b fixed shift count). 54fb70 | +| 0x51 | `shr` | - | 2 | 1 | 2496 | high | stShiftRight | Pops b, a; pushes a >> (b>>12) arithmetic (also stashes a in 0x6DA2D8). 54fba0 | +| 0x52 | `animToFrame` | - | 1 | 0 | 91 | medium | - | Pops frame (fixed); steps current anim frame +0x110 forward (wrapping) until it reaches frame (clamped to last frame of anim +0x14), applying each frame to the model. 54fad0 | +| 0x53 | `gt` | - | 2 | 1 | 3662 | high | stGreaterThan | Pops b, a; pushes a>b. 553b20 | +| 0x54 | `setModel` | - | 1 | 0 | 1439 | high | stSetModel | Pops model address; sets +0x10 and updates +0xEC bit 0x20 / +0xE8 bit 0x80 from model header fields. 553c10 | +| 0x55 | `scale` | - | 1 | 0 | 376 | high | stScale | Pops scale (fixed); sets x/y/z scale +0x60/+0x64/+0x68. 54ffe0 | +| 0x56 | `scaleX` | - | 1 | 0 | 102 | high | stScaleX | Pops scale (fixed) into +0x60. 550000 | +| 0x57 | `scaleY` | - | 1 | 0 | 242 | high | stScaleY | Pops scale (fixed) into +0x64. 550020 | +| 0x58 | `scaleZ` | - | 1 | 0 | 24 | high | stScalez | Pops scale (fixed) into +0x68. 550040 | +| 0x59 | `shadowSize` | - | 1 | 0 | 548 | high | stShadowSize | Pops shadow size (fixed) into +0x12C (shadow drawn only if >=0x80). 54e840 | +| 0x5a | `shadowTexture` | - | 1 | 0 | 548 | medium | stShadowType | Pops shadow texture index (TEXT texture) into +0x128 (short). 54e860 | +| 0x5b | `error_0x5b` | - | 0 | 0 | | high | stFlash | Prints "stFlash" not-implemented error; unused. 54d260 | +| 0x5c | `error_0x5c` | - | 0 | 0 | | high | stTrans | Prints "stTrans" not-implemented error; unused. 54d280 | +| 0x5d | `moveUp` | - | 1 | 0 | 50 | high | stMoveUp | Pops dist (fixed); moves strat by local (0,+d,0) via FUN_005501D0. 550910 | +| 0x5e | `moveDown` | - | 1 | 0 | 29 | high | stMoveDown | Pops dist; moves by local (0,-d,0). 550960 | +| 0x5f | `moveForward` | - | 1 | 0 | 95 | high | stMoveForward | Pops dist; moves by local (0,0,+d) along its facing. 550590 | +| 0x60 | `moveBackward` | - | 1 | 0 | 12 | high | stMoveBackward | Pops dist; moves by local (0,0,-d). 5505c0 | +| 0x61 | `moveLeft` | - | 1 | 0 | 20 | high | stMoveLeft | Pops dist; moves by local (+d,0,0). 550720 | +| 0x62 | `moveRight` | - | 1 | 0 | 4 | high | stMoveRight | Pops dist; moves by local (-d,0,0). 550750 | +| 0x63 | `turnRight` | - | 1 | 0 | 380 | high | stTurnRight | Pops angle; yaw +0x24 -= angle (24-bit wrap; 4096.0 = full turn). 553c90 | +| 0x64 | `turnLeft` | - | 1 | 0 | 775 | high | stTurnLeft | Pops angle; yaw +0x24 += angle. 553cb0 | +| 0x65 | `tiltLeft` | - | 1 | 0 | 4 | high | stTiltLeft | Pops angle; roll +0x28 -= angle. 54e9e0 | +| 0x66 | `tiltRight` | - | 1 | 0 | 6 | high | stTiltRight | Pops angle; roll +0x28 += angle. 54ea00 | +| 0x67 | `tiltForward` | - | 1 | 0 | 26 | high | stTiltForward | Pops angle; pitch +0x20 += angle. 553cd0 | +| 0x68 | `tiltBackward` | - | 1 | 0 | | high | stTiltBackward | Pops angle; pitch +0x20 -= angle; unused. 553cf0 | +| 0x69 | `turnToPlayerX` | - | 1 | 0 | | high | stTurnToPlayerX | Pops max step (angle); turns pitch +0x20 toward the player by at most step; unused. 54ef50 | +| 0x6a | `turnToPlayerY` | - | 1 | 0 | 345 | high | stTurnToPlayerY | Pops max step (angle); turns yaw +0x24 toward the player by at most step. 54f0d0 | +| 0x6b | `turnToPlayerXY` | - | 1 | 0 | 1 | high | stTurnToPlayerXY | Pops max step (angle); turns yaw and pitch toward the player. 54eff0 | +| 0x6c | `turnToX` | - | 4 | 0 | 2 | high | stTurnToX | Pops step, z, y, x; turns pitch toward point (x,y,z) by at most step. 54eb10 | +| 0x6d | `turnToY` | - | 4 | 0 | 209 | high | stTurnToY | Pops step, z, y, x; turns yaw toward point (x,y,z) by at most step. 54ebc0 | +| 0x6e | `turnToXY` | - | 4 | 0 | 28 | high | stTurnToXY | Pops step, z, y, x; turns yaw and pitch toward point (x,y,z). 54ec60 | +| 0x6f | `wobble` | - | 1 | 0 | | medium | stWobble | Pops and discards one value (unimplemented; shared pop handler); unused. 554fb0 | +| 0x70 | `resetPos` | - | 0 | 0 | 613 | high | stReSetPos | Restores 8 words (angles+position, +0x20..+0x3C) from the saved copy at +0xA0. 54fa10 | +| 0x71 | `savePos` | - | 0 | 0 | 566 | high | stSetPos | Saves 8 words (+0x20..+0x3C angles+position) to +0xA0 for 0x70. 54f9f0 | +| 0x72 | `nop_0x72` | - | 0 | 0 | | medium | stJump | No-op (shared empty handler); unused. 426500 | +| 0x73 | `wpFirst` | - | 0 | 0 | 833 | high | stWPFirst | Current waypoint +0x120 := first waypoint +0x118. 54ea60 | +| 0x74 | `wpLast` | - | 0 | 0 | 83 | high | stWPLast | Current waypoint +0x120 := last waypoint +0x11C. 54f240 | +| 0x75 | `wpNext` | - | 0 | 0 | 1129 | high | stWPNext | Current waypoint := its next link; wraps to first. 54f260 | +| 0x76 | `wpPrev` | - | 0 | 0 | 177 | high | stWPPrev | Current waypoint := its prev link; wraps to last. 54f550 | +| 0x77 | `error_0x77` | - | 0 | 0 | | high | stWPDel | Prints "stWpdel" not-implemented error; unused. 54d2a0 | +| 0x78 | `error_0x78` | - | 0 | 0 | | high | stWPNew | Prints "stWpnew" not-implemented error; unused. 54d2c0 | +| 0x79 | `wpNearest` | - | 0 | 0 | | high | stWPNearest | Sets current waypoint to the one nearest the strat position; unused. 54f3f0 | +| 0x7a | `wpFurthest` | - | 0 | 0 | | high | stWPFurthest | Sets current waypoint to the one furthest from the strat (excluding last); unused. 54f500 | +| 0x7b | `wpTurnToX` | - | 1 | 0 | 12 | high | stWPTurnToX | Pops max step (angle); turns pitch toward the current waypoint (pos at wp+0x18). 54ee40 | +| 0x7c | `wpTurnToY` | - | 1 | 0 | 230 | high | stWPTurnToY | Pops max step (angle); turns yaw toward the current waypoint (if any). 54eed0 | +| 0x7d | `wpTurnToXY` | - | 1 | 0 | 25 | high | stWPTurnToXY | Pops max step (angle); turns yaw and pitch toward the current waypoint. 54ed60 | +| 0x7e | `animPlay` | - | 1 | 0 | 3847 | high | stAnimPlay | Pops anim address; sets +0x14, frame +0x110 := 0, sets +0xE8 bit 8 (playing) and applies frame 0 to the model. 550160 | +| 0x7f | `animStop` | - | 0 | 0 | 458 | high | stAnimStop | Clears +0xE8 bit 8 (anim playing). 54f7c0 | +| 0x80 | `animClear` | - | 0 | 0 | | medium | stAnimClear | Clears the current anim pointer (+0x14) and flag 0x8 in +0xE8 (no anim playing). 54f7d0 | +| 0x81 | `animSetSpeed` | - | 0 | 0 | | high | stAnimSetSpeed | Unimplemented on PC: only logs "stAnimsetspeed" via the debug print; no stack effect. 54d2e0 | +| 0x82 | `collisionType` | - | 1 | 0 | 179 | high | stCollisionType | pops type (raw int); clears bits 0x42C of +0xEC then sets 1->0x8, 2->0x400, 3->0x20, 4->none, other->0x4 (collision shape/type flags). 54fd60 | +| 0x83 | `collRadius` | - | 1 | 0 | 821 | high | stCollRadius | pops radius; stores its low 16 bits raw into collision radius short +0xD2. 54e910 | +| 0x84 | `collHeight` | - | 0 | 0 | | high | stCollHeight | Unimplemented: logs "stCollheight" only. 54d300 | +| 0x85 | `collExtent` | - | 1 | 0 | | medium | stCollextent | pops extent; stores its low 16 bits raw into short +0xC4. 54e930 | +| 0x86 | `collView` | - | 0 | 0 | | high | stCollView | Unimplemented: logs "stCollview" only. 54d320 | +| 0x87 | `nop_0x87` | - | 0 | 0 | | low | stCollPoints | Shared no-op handler (0x426500); unused. Slot of Croc2 stCollPoints. 426500 | +| 0x88 | `nop_0x88` | - | 0 | 0 | | low | stCollSetPoint | Shared no-op handler (0x426500); unused. Slot of Croc2 stCollSetPoint. 426500 | +| 0x89 | `createTrigger` | - | 0 | 0 | 45 | medium | stCreateTrigger | Sets flag 0x40 in +0xE8 (strat becomes a trigger/touchable, used by pickups like Health, Carrot). 54e4c0 | +| 0x8a | `killTrigger` | - | 0 | 0 | | medium | stKillTrigger | Clears flags 0x40 and 0x4000000 in +0xE8 (undoes createTrigger). 54e4b0 | +| 0x8b | `wait` | - | 1 | 0 | 1671 | high | stWait | pops frames (fixed, >>12) into wait counter short +0xFE and sets flag 0x2 in +0xE8 (strat sleeps until the counter runs out). 54fb40 | +| 0x8c | `hold` | - | 0 | 0 | 110 | medium | stHold | Sets flags 0x12 (0x2 wait + 0x10 stopped) in +0xE8: suspends the strat indefinitely until released. 54fce0 | +| 0x8d | `release` | - | 0 | 0 | | high | stRelease | Unimplemented: logs "stRelease" only. 54d340 | +| 0x8e | `remove` | - | 0 | 0 | 985 | high | stRemove | Sets flags 0x6 in +0xE8 (yield + kill); same handler as 0xb4 end. 5535e0 | +| 0x8f | `nop_0x8f` | - | 0 | 0 | | low | stMapRemove | Shared no-op handler (0x426500); unused. Slot of Croc2 stMapRemove. 426500 | +| 0x90 | `mapAdd` | - | 0 | 0 | | high | stMapAdd | Unimplemented: logs "stMapadd" only. 54d360 | +| 0x91 | `mapReplace` | - | 0 | 0 | | high | stMapReplace | Unimplemented: logs "stMapreplace" only. 54d380 | +| 0x92 | `activated` | - | 0 | 0 | | medium | stActivated | Sets bit 0x8 in byte +0x44 of the strat's map-instance record (+0xF0): marks the map object as activated (persistent state). 54db40 | +| 0x93 | `collected` | - | 0 | 0 | | medium | stCollected | Sets bit 0x4 in byte +0x44 of the map-instance record (+0xF0): marks the map object as collected so it does not respawn. 54db50 | +| 0x94 | `spawnLegacy` | count,imm,imm,flags,imm | ? | 0 | | medium | stSpawn | pops code address of the new strat, then argCount (first inline word) argument values (popped inside FUN_0054bfc0); spawns a child strat at this strat's position (+0x20 block); inline words 3-5 are spawn parameters/flags (word 3 tested for 0x40000/0x80000/0x8000 collision-info allocation). Unused in shipped levels. 5531d0 | +| 0x95 | `spawnFrom` | count,imm,imm,flags,imm,imm | ? | 0 | | medium | stSpawnFrom | Like spawn (pops code address + args) but with an extra inline block giving a local offset (x/y/z) that is rotated by the strat's matrix (+0x70..) and added to its translation (+0x94..): spawn at an offset relative to this strat. Unused. 5533f0 | +| 0x96 | `link` | - | 0 | 0 | | high | stLink | Unimplemented: logs "stLink" only. 54d3a0 | +| 0x97 | `unlink` | - | 0 | 0 | | high | stUnlink | Unimplemented: logs "stUnlink" only. 54d3c0 | +| 0x98 | `soundShift` | - | 2 | 0 | 2 | medium | stSoundShift | pops pitch (fixed, >>12), then sound channel; changes pitch of a playing sound channel owned by this strat (FUN_00546400). 54e0c0 | +| 0x99 | `soundStop` | - | 1 | 0 | 209 | high | stSoundStop | pops sound channel; stops that channel if owned by this strat, or (channel <0/invalid) all sounds of this strat. 54e1c0 | +| 0x9a | `musicPlay` | - | 1 | 0 | 108 | high | stCdPlay | pops music track index (fixed, >>12); if different from the current track (0x586430) starts that ASF music stream (FUN_00546b70). 54d4c0 | +| 0x9b | `streamPlay` | - | 0 | 0 | | high | - | Unimplemented: logs "stStreamPlay" only. 54d3e0 | +| 0x9c | `speechSetPos` | - | 1 | 0 | 31 | low | - | pops a value (fixed, >>12) into global 0x6DA2B8 (meaning unknown, likely speech volume/range) and moves the speech/voice stream (handle 0x6D94B4, started by '#' text) to this strat's position. 54d530 | +| 0x9d | `discard_0x9d` | - | 1 | 0 | 28 | low | stMidiVolume | pops a value (>>12) and passes it to the no-op debug print; effectively drop. Croc2 slot midiVolume/cdFade. 54d4f0 | +| 0x9e | `speechStop` | - | 0 | 0 | 141 | high | stMidiStop | Stops the current speech/voice stream (handle 0x6D94B4) if one is playing and resets the handle to -1. 54d510 | +| 0x9f | `streamQueue` | - | 0 | 0 | | high | stMidiQueue | Unimplemented: logs "stStreamQueue" only. 54d400 | +| 0xa0 | `isLight` | - | 0 | 0 | | high | stIsLight | Unimplemented: logs "stIsLight" only. 54d420 | +| 0xa1 | `lightCol` | - | 0 | 0 | | high | stLightCol | Unimplemented: logs "stLightCol" only. 54d440 | +| 0xa2 | `lightFade` | - | 0 | 0 | | high | stLightFade | Unimplemented: logs "stLightFade" only. 54d460 | +| 0xa3 | `lightAtten` | - | 0 | 0 | | high | stLightAtten | Unimplemented: logs "stLightAtten" only. 54d480 | +| 0xa4 | `lightType` | - | 0 | 0 | | high | stLightType | Unimplemented: logs "stLightType" only. 54d4a0 | +| 0xa5 | `collisionOn` | flags | 0 | 0 | 1591 | high | stCollisionOn | ORs the inline flag word into +0xE8; if any of 0x34000 is set also sets 0x4000 in +0xEC. 54fcf0 | +| 0xa6 | `collisionOff` | flags | 0 | 0 | 1134 | high | stCollisionOff | Clears the inline flag word's bits in +0xE8. 54fd40 | +| 0xa7 | `collisionOffAll` | - | 0 | 0 | | high | stCollisionOffAll | Clears all collision flag bits 0x7E000 in +0xE8. 54fd30 | +| 0xa8 | `soundPlayEx` | - | 3 | 0 | 5 | high | stSoundPlay | pops pitch (fixed), volume (fixed), sound id; in game states 3/4 plays the sound on this strat and sets its volume and pitch (no channel returned). 54e3a0 | +| 0xa9 | `soundPlay4_0xa9` | - | 4 | 0 | | medium | stSoundPlay | pops 4 values and discards them (stubbed sound-play variant). 54e400 | +| 0xaa | `soundPlayAssEx` | - | 3 | 1 | | high | stSoundPlayAss | pops pitch, volume, sound id; plays it like 0xa8 and pushes the channel (raw int), or -1 if not in game state 3/4. 54e320 | +| 0xab | `soundPlayAss4_0xab` | - | 4 | 0 | | medium | stSoundPlayAss | pops 4 values and discards them (same stub as 0xa9; pushes nothing). 54e400 | +| 0xac | `int` | - | 1 | 1 | 1508 | high | stInt | pops x; pushes x & 0xFFFFF000 (fixed-point floor/truncate to integer). 5501b0 | +| 0xad | `sin` | - | 1 | 1 | 2994 | high | stSin | pops angle (fixed, 4096 table units = full turn); pushes sin from table 0x574318 (20.12). 54e790 | +| 0xae | `cos` | - | 1 | 1 | 3149 | high | stCos | pops angle; pushes cos (table 0x574318 offset by quarter turn 0x400). 54e810 | +| 0xaf | `not` | - | 1 | 1 | 4952 | high | stNot | pops x; pushes 1 if x==0 else 0. 54fc20 | +| 0xb0 | `pop` | - | 1 | 0 | | high | stPop | Drops the top stack value (stack pointer +0x104 -= 4). 553dd0 | +| 0xb1 | `stkCmp` | imm | 0 | 1 | | low | stStkCmp | Peeks (does not pop) the top stack entry, dereferences it as a pointer and pushes imm minus the pointed-to int (compare helper). 553de0 | +| 0xb2 | `address` | addr | 0 | 1 | 7890 | high | stAddress | Patches itself into 0x45 (push immediate) after resolving the operand to an absolute address (negative = import table 0x6DA324, else + STPC base 0x6D9DBC) and pushes it (code/model/anim address). 553630 | +| 0xb3 | `return` | - | 1 | 0 | 4974 | high | stReturn | pops code address and jumps there (pc = popped). 5537d0 | +| 0xb4 | `end` | - | 0 | 0 | 2006 | high | stEndStrat | Sets flags 0x6 in +0xE8 (yield + kill): ends the strat. Same handler as 0x8e. 5535e0 | +| 0xb5 | `isPlayer` | - | 0 | 0 | 62 | high | stIsPlayer | Registers this strat as the player (global 0x6D9E1C = strat) and sets flag 0x800 in +0xE8. 550140 | +| 0xb6 | `and` | - | 2 | 1 | 3004 | high | stAnd | pops b, a; pushes 1 if both are non-zero else 0 (logical and). 552e60 | +| 0xb7 | `or` | - | 2 | 1 | 5411 | high | stOr | pops b, a; pushes a \| b (bitwise, used as logical or). 552ea0 | +| 0xb8 | `bitwiseAnd` | - | 2 | 1 | 6814 | high | stBitwiseAnd | pops b, a; pushes a & b. 552e30 | +| 0xb9 | `discard_0xb9` | - | 1 | 0 | | medium | - | Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 ext-var slots). 554fb0 | +| 0xba | `discard_0xba` | - | 1 | 0 | | medium | - | Shared stub 0x554fb0: pops one value and discards it. Unused. 554fb0 | +| 0xbb | `nop_0xbb` | - | 0 | 0 | | low | - | Shared no-op handler (0x426500); unused. 426500 | +| 0xbc | `notEqual` | - | 2 | 1 | 2576 | high | stNotEqual | pops b, a; pushes 1 if a != b else 0. 54fbd0 | +| 0xbd | `greaterEqual` | - | 2 | 1 | 1169 | high | stGreaterEqual | pops b, a; pushes 1 if a >= b else 0. 553b80 | +| 0xbe | `lessEqual` | - | 2 | 1 | 2129 | high | stLessEqual | pops b, a; pushes 1 if a <= b else 0. 553b50 | +| 0xbf | `rnd` | - | 1 | 1 | 4990 | high | stRnd | pops n; pushes random value (two rand() halves) modulo n, 0 if n==0; result is in the same units as n (fixed in, fixed out). 54f960 | +| 0xc0 | `loseHeart` | - | 0 | 0 | 219 | high | stLoseHeart | In game state 3, unless player invulnerable (0x584710 bit 0): health 0x584E70 -= 1.0, or if already 0 sets 0x584638 = 2 (player dies); refreshes HUD (FUN_004268c0(1)). 54f5c0 | +| 0xc1 | `nop_0xc1` | - | 0 | 0 | | low | stResettoCheckPoint | Shared no-op handler (0x426500); unused. Croc2 slot stResettoCheckPoint. 426500 | +| 0xc2 | `forceCollision` | - | 0 | 0 | 1619 | medium | stForceCollision | Runs world collision for this strat (FUN_00403020 with level geometry): updates floor/ceiling/wall info in the collision block +0xC0 (ground height, surface type). 54e9c0 | +| 0xc3 | `turnFromPlayerY` | - | 1 | 0 | | medium | stTurnFromPlayerY | pops max turn step (angle, 0x1000000 = full turn); turns yaw +0x24 toward the direction from the player (0x6D9E1C) to this strat, i.e. away from the player, by at most step. 54f1c0 | +| 0xc4 | `rumble` | - | 2 | 0 | 93 | low | stRumble | Shared stub 0x555090: pops two values and discards them (PSX pad rumble/vibrate not implemented on PC). 555090 | +| 0xc5 | `suspendIfTooFar` | - | 1 | 0 | 232 | low | stSuspendIfTooFar | Shared stub 0x554fb0: pops one value (likely distance) and discards it. 554fb0 | +| 0xc6 | `collisionBone` | - | 2 | 0 | 90 | medium | stCollisionBone | pops value (short), then bone id (fixed, >>12); sets/adds entry {id, value} in the bone table at +0xC8 (count +0xC6). 54e6c0 | +| 0xc7 | `useBone` | - | 1 | 0 | 345 | low | stUseBone | pops one value (ignored); if a model/anim is set (+0x14) rebuilds the rotation matrix and stores a transformed point (matrix * local vector + translation +0x94) into +0xD8/+0xDC/+0xE0. 54e4d0 | +| 0xc8 | `isCamera` | - | 0 | 0 | 34 | high | stIsCamera | Registers this strat as the camera (global 0x6D9E28) and sets flag 0x8000000 in +0xE8. 5500f0 | +| 0xc9 | `lookAtMe` | - | 0 | 0 | 86 | high | stLookatme | Sets camera look-at target global 0x6D9E30 = this strat. 5500e0 | +| 0xca | `lookAtMe2` | - | 0 | 0 | | medium | stLookatme | Sets secondary look-at global 0x6D9E34 = this strat. Unused. 5500d0 | +| 0xcb | `pushCamera` | - | 0 | 0 | | high | stPushCamera | Pushes current camera (0x6D9E28) and look-at (0x6D9E30) pair onto the camera stack 0x6D9DCC (index 0x6D9E40). 54dbb0 | +| 0xcc | `popCamera` | - | 0 | 0 | | high | stPopCamera | If the camera stack is not empty, restores camera and look-at from it and increments camera-change counter 0x6D9D34. 54db00 | +| 0xcd | `nop_0xcd` | - | 0 | 0 | | low | stResetCamerapos | Shared no-op handler (0x426500); unused. Croc2 slot stResetCamerapos. 426500 | +| 0xce | `gainHeart` | - | 0 | 0 | 94 | high | stGainHeart | Health 0x584E70 += 1.0 if below max 0x584E6C; refreshes HUD (FUN_004268c0(1)). 54f650 | +| 0xcf | `nop_0xcf` | - | 0 | 0 | | low | stGainHeartPot | Shared no-op handler (0x426500); unused. Croc2 slot stGainHeartPot. 426500 | +| 0xd0 | `addInv` | imm | 0 | 0 | | medium | stAddInv | Adds inline item to inventory via FUN_00426860(item,1,1,0,0), which is a stub returning 0 on PC (no effect). 54da00 | +| 0xd1 | `nop_0xd1` | - | 0 | 0 | | low | stGainCrystal | Shared no-op handler (0x426500); unused. Croc2 slot stGainCrystal. 426500 | +| 0xd2 | `discard_0xd2` | - | 1 | 0 | | low | stCutscene | Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 slot stCutscene). 554fb0 | +| 0xd3 | `inventory` | imm | 0 | 1 | | medium | stInventory | Queries inline item via stub FUN_00426860 and pushes result<<12 (always 0 on PC). 54d9d0 | +| 0xd4 | `debugName` | imm,count | 0 | 0 | 1804 | high | stDebugname | Strat name: skips the first inline word, reads the byte length of the following name string, sets the name pointer (+0x18) to that string and jumps past it. 553ef0 | +| 0xd5 | `soundPlay` | - | 1 | 0 | 1835 | high | stSoundPlay | pops sound id; plays it on this strat (FUN_00546170); outside game states 3/4 first sets game state 0x5846E4 = 5. 54e230 | +| 0xd6 | `soundPlayAss` | - | 1 | 1 | 288 | high | stSoundPlayass | pops sound id; in game states 3/4 plays it and pushes the channel (raw int), else pushes -1. 54e1e0 | +| 0xd7 | `soundAddress` | sound | 0 | 1 | 2996 | high | stSoundAddress | Pushes the inline sound id (same handler as 0x45 push immediate). 5535f0 | +| 0xd8 | `nop_0xd8` | - | 0 | 0 | | low | stOnground | Shared no-op handler (0x426500); unused. Croc2 slot stOnground/stObjectFallslow. 426500 | +| 0xd9 | `collisionOffset` | - | 3 | 0 | 403 | high | stCollisionOffset | pops z, y, x; stores low 16 bits into shorts +0xD0, +0xCE, +0xCC (collision centre offset). 54e050 | +| 0xda | `abs` | - | 1 | 1 | 1847 | high | stAbs | pops x; pushes \|x\|. 5508d0 | +| 0xdb | `pickup` | imm | 0 | 0 | | medium | stPickup | inline flag: nonzero sets bit 0x200 in +0xEC, zero clears it (strat can be picked up/carried). 54dd00 | +| 0xdc | `min` | - | 2 | 1 | 1546 | high | stMin | pops b, a; pushes the smaller (signed). 54dc80 | +| 0xdd | `max` | - | 2 | 1 | 768 | high | stMax | pops b, a; pushes the larger (signed). 54dc40 | +| 0xde | `spawnParticle` | - | 6 | 0 | 1935 | medium | stSpawnparticle | pops param (texture or colour, type-dependent), vz, vy, vx (velocity, fixed), scale (fixed, 0x1000 = default), particle type (raw int); emits a particle at this strat's position (FUN_00428ea0). 552ed0 | +| 0xdf | `sgn` | - | 1 | 1 | | high | stSgn | pops x; pushes 1.0 (0x1000), -1.0 or 0 by sign. 54e7c0 | +| 0xe0 | `spawn` | count,count,imm,flags,imm | ? | 0 | 1788 | medium | stSpawn | Spawns a child strat via FUN_0054bfc0: pops code address of the new strat (top), then pops operand1 (param count) values from the parent stack as the child's MAP parameters; operand2 = local variable count of the child, operand4 = spawn flags (ORed with own +0x138; bits 0x40000/0x80000/0x8000 select model/collision setup), operands 3/5 extra sizes/flags; child copies parent orientation/position block (+0x20) and waypoint fields; fails silently (params still popped) if strat pool full 553230 | +| 0xe1 | `nop_0xe1` | - | 0 | 0 | | high | stDontLookatme? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0xe2 | `nop_0xe2` | - | 0 | 0 | | high | stRunat? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0xe3 | `moveForwardQ` | - | 1 | 0 | 854 | high | stMoveForwardq | pops distance (fixed); x(+0x30) += d*sin(yaw), z(+0x38) += d*cos(yaw), yaw = +0x24 (4096 units per turn, table 0x574318) 550690 | +| 0xe4 | `moveBackwardQ` | - | 1 | 0 | 436 | high | stMoveBackwardq | pops distance (fixed); moves the strat backwards along its yaw (x -= d*sin, z -= d*cos) 550600 | +| 0xe5 | `playSoundVol` | - | 2 | 0 | 1631 | high | stSoundPlay | pops volume (fixed, >>12), sound id; if game state DAT_005846e4 is 3 or 4 plays the sound for this strat (FUN_00546170) and sets the channel volume (+0x6a, FUN_00546360) 54e2d0 | +| 0xe6 | `playSoundChannel` | - | 2 | 1 | 47 | high | stSoundPlayass | pops volume (fixed), sound id; if game state DAT_005846e4==3 plays the sound with that volume and pushes the channel index (raw int, not fixed), else pushes -1 54e270 | +| 0xe7 | `setWP` | - | 0 | 0 | 213 | high | stSetWP | saves the current waypoint: +0x124 = +0x120 54ea20 | +| 0xe8 | `resetWP` | - | 0 | 0 | 193 | high | stResetWP | restores the saved waypoint: +0x120 = +0x124 54ea40 | +| 0xe9 | `soundVolume` | - | 2 | 0 | 7 | high | stSoundVolume | pops volume (fixed), sound channel; sets the channel volume (+0x6a) if the channel belongs to this strat (FUN_00546360) 54e190 | +| 0xea | `nop_0xea` | - | 0 | 0 | | high | stString? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0xeb | `setBossHearts` | - | 1 | 0 | | high | stSetbossHearts | pops count (fixed); sets boss heart max DAT_00584f0c and current DAT_00584f10 to count 54f580 | +| 0xec | `loseBossHeart` | - | 0 | 0 | | high | stLosebossHeart | decrements boss hearts DAT_00584f10 (not below 0) and refreshes the HUD (FUN_004268c0(1)) 54f5a0 | +| 0xed | `soundShiftRelative` | - | 2 | 0 | 57 | medium | stSoundShiftrelative | pops shift/pitch (fixed, >>12), sound channel; sets channel +0x70 and updates a playing voice (FUN_00546460) if the channel belongs to this strat 54e0f0 | +| 0xee | `smin` | - | 2 | 1 | 42 | high | stSmin | pops limit (top), value; pushes value with its magnitude clamped to limit, keeping the sign (sign(v)*min(\|v\|,limit)) 54dcc0 | +| 0xef | `isBoss` | - | 0 | 0 | | high | stIsboss | registers this strat as the boss: DAT_006d9e24 = self 550130 | +| 0xf0 | `topSay` | text | 0 | 0 | 1308 | high | stTopsay | shows dialog text (inline text index) in the top text box: sets DAT_006d9858=1 and formats via FUN_0040d4c0 40d480 | +| 0xf1 | `getParentPos` | - | 0 | 0 | 48 | high | stGetparentpos | copies parent's (+0xC) orientation+position block (+0x20..+0x3C, 8 words) into this strat 54e030 | +| 0xf2 | `setDrawMode` | - | 1 | 0 | | low | - | pops mode (raw int): 1 sets flag 0x80000 in +0xEC, 2 sets 0x100000, other clears both (exact effect of the bits unknown); unused 54fe40 | +| 0xf3 | `afterBoss` | - | 0 | 0 | | high | stAfterboss | if no boss (DAT_006d9e24) rewinds pc and yields (waits); else relinks this strat in the process list right after the boss 553290 | +| 0xf4 | `afterPlayer` | - | 0 | 0 | 144 | high | stAfterPlayer | if no player (DAT_006d9e1c) rewinds pc and yields (waits); else relinks this strat in the process list right after the player 553380 | +| 0xf5 | `beforePlayer` | - | 0 | 0 | 22 | high | stBeforePlayer | if no player rewinds pc and yields (waits); else relinks this strat in the process list right before the player 553300 | +| 0xf6 | `nop_0xf6` | - | 0 | 0 | | high | stBeforeboss?/stNohang? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0xf7 | `pushZero` | - | 0 | 1 | | high | stZero | pushes 0 (same handler as 0x1a7); unused 54e880 | +| 0xf8 | `nop_0xf8` | - | 0 | 0 | | high | stTophead? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0xf9 | `popDiscard_0xf9` | - | 1 | 0 | | high | stTopdialog? | pops and discards one value (stub FUN_00554fb0, shared with 0x6f etc.); unused 554fb0 | +| 0xfa | `bottomSay` | text | 0 | 0 | 891 | high | stBottomsay | shows dialog text (inline text index) in the bottom text box: sets DAT_006d94e8=2 and formats via FUN_0040d4c0 40d8d0 | +| 0xfb | `nop_0xfb` | - | 0 | 0 | | high | stBottomhead? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0xfc | `popDiscard_0xfc` | - | 1 | 0 | | high | stBottomdialog? | pops and discards one value (stub FUN_00554fb0); unused 554fb0 | +| 0xfd | `getPlayerPos` | - | 0 | 0 | 193 | high | stGetPlayerpos | if a player exists copies its orientation+position block (+0x20..+0x3C) into this strat 54dde0 | +| 0xfe | `getWPPos` | - | 0 | 0 | 406 | high | stGetWPpos | sets rotation x/y/z (+0x20..+0x28) from the current waypoint's (+0x120) ints <<12 and position (+0x30..+0x3C) from waypoint +0x18..+0x24 54dfe0 | +| 0xff | `getBossPos` | - | 0 | 0 | | high | stGetbosspos | copies the boss's (DAT_006d9e24) orientation+position block (+0x20..+0x3C) into this strat (no null check); unused 54ddc0 | +| 0x100 | `getDoorPos` | - | 0 | 0 | 31 | medium | stGetdoorpos | finds the level door record (DAT_00584648+0x24) in this strat's x/z cell nearest in y; if found sets current door DAT_00584644, places the strat at the door position/yaw (FUN_0054de00) and, unless door flag 2, moves it forward by the door's distance (+0x38) 54df30 | +| 0x101 | `fadeOut` | - | 1 | 0 | 181 | medium | stFadeout | pops speed selector (fixed, 1/2/3/other) and starts a screen fade-out (FUN_00415390 mode 1/5/0xb/3); sets DAT_00584638=6 unless it is 7 or 10 54d910 | +| 0x102 | `fadeIn` | - | 1 | 0 | 150 | medium | stFadein | pops speed selector (fixed, 1/3/other) and starts a screen fade-in (FUN_00415390 mode 2/10/4); DAT_00584638=0 54d8a0 | +| 0x103 | `moveUpQ` | - | 1 | 0 | 1018 | high | stMoveUpq | pops distance (fixed); y(+0x34) += d 5508f0 | +| 0x104 | `moveDownQ` | - | 1 | 0 | 314 | high | stMoveDownq | pops distance (fixed); y(+0x34) -= d 550940 | +| 0x105 | `forcePlayerDist` | - | 0 | 0 | | high | stForcePlayerdist | recomputes the cached squared player distance +0x148 (coords >>6) and clears +0x1C; 0x200000 if no player; unused 54e950 | +| 0x106 | `nop_0x106` | - | 0 | 0 | | high | stShadeType? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x107 | `setAnimSpeed` | - | 1 | 0 | 645 | low | stSetanimSpeed? | pops value and stores its low 16 bits (raw, not >>12) in halfword +0x114 (animation timing field, zeroed when the anim frame is set) 54d880 | +| 0x108 | `nop_0x108` | - | 0 | 0 | | high | stCheckleveldoor? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x109 | `nop_0x109` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x10a | `nop_0x10a` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x10b | `nop_0x10b` | - | 0 | 0 | | high | stGainjigsaw? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x10c | `nop_0x10c` | - | 0 | 0 | | high | stGaingoldengobbo? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x10d | `gainSecret` | - | 0 | 0 | 26 | high | stGainCrystal? | increments the collected-secrets counter DAT_00584e64 (copied to the HUD/stats at level reset); used only by strat Secret 54f710 | +| 0x10e | `nop_0x10e` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x10f | `nop_0x10f` | - | 0 | 0 | 1 | high | stCheckPoint? | no-op handler FUN_00426500 (does nothing, no inline operands); used once (LlamaChase) 426500 | +| 0x110 | `setMainCameraFlag` | - | 0 | 0 | 23 | medium | stIsmainCamera? | sets flag 0x8000000 in own flags +0xE8 (same flag the camera-register opcode FUN_005500f0 sets, without registering DAT_006d9e28); used by CameraCollisionInFront 550120 | +| 0x111 | `nop_0x111` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x112 | `nop_0x112` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x113 | `isDialog` | - | 0 | 0 | 157 | high | stIsdialog | registers this strat as the dialog partner: DAT_006d9e2c = self 550110 | +| 0x114 | `distance` | - | 3 | 1 | 20 | high | stDistance | pops z (top), y, x; pushes the 3D distance from the strat's position (+0x30) to (x,y,z) (FUN_00404bb0, float sqrt -> int) 54f840 | +| 0x115 | `showLevelStartInfo` | imm | 0 | 0 | 60 | medium | - | inline word: low byte stored in DAT_006da294 (level start info / title card index), logs 'show level start info %d' 54e8b0 | +| 0x116 | `topCloseDialog` | - | 0 | 0 | 32 | high | stTopclosedialog | closes the top dialog text box: DAT_006d9858 = 0 40d900 | +| 0x117 | `bottomCloseDialog` | - | 0 | 0 | 4 | high | stBottomclosedialog | closes the bottom dialog text box: DAT_006d94e8 = 0 40d910 | +| 0x118 | `nop_0x118` | - | 0 | 0 | | high | stNextinventory? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x119 | `nop_0x119` | - | 0 | 0 | | high | stPrevinventory? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x11a | `otherPiece` | - | 0 | 0 | 266 | medium | stOtherpiece | finds the switchable map piece at the strat's grid cell (FUN_00553f10, table DAT_00584648+0x50) and sets it to its alternate state (*piece[5] = piece[4]), swapping its texture/collision table entries if DAT_006da330 bit 0x10000000 553f70 | +| 0x11b | `normalPiece` | - | 0 | 0 | 264 | medium | stNormalpiece | same as otherPiece but restores the piece's normal state (*piece[5] = piece[3]) 554090 | +| 0x11c | `delInv` | imm | 0 | 0 | | medium | stDelinv | inline word (inventory item id) passed to FUN_00426860(item,1,-1,0,0), which is a stub returning 0 in this build: effectively no-op; unused 54da20 | +| 0x11d | `nop_0x11d` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x11e | `nop_0x11e` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x11f | `nop_0x11f` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x120 | `nop_0x120` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x121 | `nop_0x121` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x122 | `nop_0x122` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x123 | `nop_0x123` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x124 | `nop_0x124` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x125 | `moveLeftQ` | - | 1 | 0 | 131 | high | stMoveLeftq | pops distance (fixed); strafes: x += d*cos(yaw), z += d*sin(yaw-half turn) (= -d*sin) 550790 | +| 0x126 | `moveRightQ` | - | 1 | 0 | 299 | high | stMoveRightq | pops distance (fixed); strafes opposite to moveLeft (x -= d*cos(yaw), z += d*sin(yaw)) 550820 | +| 0x127 | `bitwiseNot` | - | 1 | 1 | 1 | high | stBitwisenot | pops v; pushes ~v 54fc00 | +| 0x128 | `bordersOn` | - | 0 | 0 | 157 | high | stBorderson | enables cinematic letterbox borders: DAT_005fd090 = 1 54dac0 | +| 0x129 | `bordersOff` | - | 0 | 0 | 176 | high | stBordersoff | disables letterbox borders: DAT_005fd090 = 0 54dad0 | +| 0x12a | `soundAdsr` | - | 5 | 0 | | high | stSoundadsr | pops and discards 5 values (stubbed); unused 54e120 | +| 0x12b | `soundAdsrRelative` | - | 3 | 0 | 74 | medium | stSoundadsrrelative | pops a (top), b (fixed), sound channel; if the channel belongs to this strat sets channel +0x34 = b>>12, +0x38 = a>>12 (FUN_005463b0, envelope/range params) 54e150 | +| 0x12c | `nop_0x12c` | - | 0 | 0 | | high | stRotatepiece? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x12d | `setAmbient` | - | 3 | 0 | 273 | high | stSet_ambient | pops b, g, r (fixed, >>12, top = b) into bytes +0x135/+0x134/+0x133 and enables the own ambient light colour (+0x136 = 1) 54fea0 | +| 0x12e | `resetAmbient` | - | 0 | 0 | | high | stReset_ambient | disables the own ambient colour: +0x136 = 0; unused 54fee0 | +| 0x12f | `invActive` | - | 0 | 0 | | high | stInvactive | calls inventory stub FUN_00426860(0,1,0,1,0) (returns 0, no effect in this build); unused 54da60 | +| 0x130 | `invInactive` | - | 0 | 0 | | high | stInvinactive | calls inventory stub FUN_00426860(0,1,0,1,1) (no effect); unused 54da40 | +| 0x131 | `sampleStatus` | - | 1 | 1 | | high | stSamplestatus | pops sound id; pushes (fixed) the channel index on which this strat is playing that sound, or -1.0 if not playing (FUN_005461e0); unused 54e480 | +| 0x132 | `nop_0x132` | - | 0 | 0 | | high | stResettoCheckPointnlh? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x133 | `resetDoor` | - | 0 | 0 | 31 | high | stResetdoor | restores the stored door (DAT_006da28c -> DAT_00584644) and, if a player exists, places the player at that door (FUN_0054de00) and restores its saved orientation/position block from DAT_006d9e48 54def0 | +| 0x134 | `storeDoor` | - | 0 | 0 | 62 | high | stStoredoor | stores the current door (DAT_00584644 -> DAT_006da28c) and the player's orientation/position block (+0x20..+0x3C) into DAT_006d9e48 54dec0 | +| 0x135 | `cameraModified` | - | 0 | 0 | | medium | stCamera_modified | if a camera strat (DAT_006d9e28) exists copies this strat's orientation/position block into it and its position into globals DAT_006d9d20..2c; unused 54d830 | +| 0x136 | `pushPlayer` | - | 0 | 0 | | high | stPushPlayer | saves the current player pointer DAT_006d9e1c into DAT_006d9e20 (logs an error if no player); unused 54db60 | +| 0x137 | `popPlayer` | - | 0 | 0 | | high | stPopPlayer | restores the player pointer (DAT_006d9e1c and DAT_006d9e30) from DAT_006d9e20 and clears it; unused 54db90 | +| 0x138 | `resetToStart` | - | 0 | 0 | | high | stReSetPostrn | restores position +0x30..+0x38 from the saved (start) position +0xB0..+0xB8; unused 54fa30 | +| 0x139 | `gainItem` | - | 0 | 0 | | high | stGainitem | increments item counter DAT_005fd09c up to the maximum DAT_005fd0a4; unused 54f680 | +| 0x13a | `setItem` | - | 1 | 0 | | high | stSetitem | pops maximum (fixed); DAT_005fd0a4 = max, counter DAT_005fd09c = 0, enables item counter display (DAT_005fd0a0 = 1); unused 54f6a0 | +| 0x13b | `setTimer` | - | 1 | 0 | | high | stSettimer | pops time (fixed, >>12) into DAT_005fd0ac and enables the timer (DAT_005fd0a8 = 1); unused 54f6d0 | +| 0x13c | `timerOff` | - | 0 | 0 | | high | stTimeroff | disables the timer: DAT_005fd0a8 = 0; unused 54f6f0 | +| 0x13d | `distanceNoY` | - | 2 | 1 | 170 | high | stDistancenoy | pops z (top), x; pushes the horizontal (x/z) distance from the strat's position to (x,z) (FUN_00404c50) 54f890 | +| 0x13e | `nop_0x13e` | - | 0 | 0 | | high | stSwim? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x13f | `nop_0x13f` | - | 0 | 0 | | high | stLoseCrystal? | no-op handler FUN_00426500 (does nothing, no inline operands); unused 426500 | +| 0x140 | `nop_0x140` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x141 | `setDebugFlag5849a0On` | - | 0 | 0 | | medium | - | Writes short 1 to global 0x5849A0; that global is never read anywhere in groove.exe (dead PSX leftover). 54dab0 | +| 0x142 | `setDebugFlag5849a0Off` | - | 0 | 0 | | medium | - | Writes short 0xFFFF to global 0x5849A0 (never read; dead leftover). 54daa0 | +| 0x143 | `clearFlag5fd0b8` | - | 0 | 0 | | medium | - | Sets global 0x5FD0B8 = 0; global is never read elsewhere (dead leftover). 54da80 | +| 0x144 | `setFlag5fd0b8` | - | 0 | 0 | | medium | - | Sets global 0x5FD0B8 = 1; global is never read elsewhere (dead leftover). 54da90 | +| 0x145 | `nop_0x145` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x146 | `nop_0x146` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x147 | `nop_0x147` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x148 | `gotoLevel4_1` | - | 0 | 0 | | medium | - | Hardcoded level change: next level (0x584EB4/EB8/EBC/EC0) = 4/1/1/0, previous level (0x585008..14) = 4/1/4/0, game state 0x584638 = 7 (load level). 54e430 | +| 0x149 | `nop_0x149` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x14a | `hudReset2` | - | 0 | 0 | | low | - | Calls HUD/panel init FUN_004268c0(2): re-initialises the on-screen counter panels (coins, lives, etc.) for the current level. 54f700 | +| 0x14b | `setGlobal5fd086` | imm | 0 | 0 | | medium | - | Stores the low 16 bits of the inline word in global 0x5FD086 (never read elsewhere; dead leftover). 54e890 | +| 0x14c | `removeCollPoint` | - | 1 | 0 | 49 | medium | stSetCollPoint (inverse) | pops id (fixed, >>12); removes the collision point with that id from the strat's collision point list (+0xC8 array of 4-byte entries, count +0xC6). Counterpart of 0xc6 (add/set collision point). 54e720 | +| 0x14d | `setFlagEC_400000` | flags | 0 | 0 | | high | - | Inline word: nonzero sets, zero clears bit 0x400000 of strat flags2 (+0xEC); the bit selects which height constant own property 0xF2 returns. 54f780 | +| 0x14e | `atan2` | - | 2 | 1 | 457 | high | - | pops b (top), a; pushes angle atan2(a, b) as (0..0xFFF)<<12, i.e. full turn = 0x1000000 (4096.0); used with a=dx, b=dz like yaw-to-target. 54f7f0 | +| 0x14f | `nop_0x14f` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x150 | `nop_0x150` | - | 0 | 0 | 1 | high | - | Empty handler FUN_00426500 (shared no-op); does nothing (used once in HelpGirlSkip). 426500 | +| 0x151 | `nop_0x151` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x152 | `nop_0x152` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x153 | `findNearestLight` | - | 3 | 1 | 1 | medium | - | pops z (top), y, x; searches the level light table (0x586598, 0x1C-byte entries, count 0x5865D0) for the nearest light of type 2 (point light) to (x,y,z) and pushes its pointer (0 if none). 554280 | +| 0x154 | `setLightIntensity` | - | 2 | 0 | 3 | medium | - | pops intensity (fixed; top), light pointer (from 0x153); stores intensity in light+0x18, rebuilds the light (FUN_0042c4a0) and re-flags/relights the map polygons within its radius (0 = off, 0x1000 = full). 554310 | +| 0x155 | `nop_0x155` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x156 | `nop_0x156` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x157 | `nop_0x157` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x158 | `nop_0x158` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x159 | `nop_0x159` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x15a | `setPosToPlayer` | - | 0 | 0 | 27 | high | - | Copies the player's position x/y/z (+0x30..+0x38) and +0x3C into the strat (player strat = 0x6D9E1C). 554480 | +| 0x15b | `setPosToPlayerXZ` | - | 0 | 0 | | high | - | Copies the player's x and z (+0x30, +0x38) into the strat; y unchanged. 5544b0 | +| 0x15c | `setShadowHeight` | - | 1 | 0 | 1 | low | - | pops v; stores it in strat +0x14C, used by the shadow renderer as the shadow/ground height override (-0x1000 = use default 0x586424). 5544d0 | +| 0x15d | `packRgb` | - | 3 | 1 | 98 | high | - | pops c (top), b, a (fixed 0..255); pushes (c<<16)\|(b<<8)\|a as a raw packed colour int (push order r,g,b gives 0x00BBGGRR). 5544f0 | +| 0x15e | `trailCreate` | - | 2 | 1 | 6 | medium | - | pops vertB (top), vertA (fixed vertex indices of the strat's model/anim frame); allocates a free trail/ribbon slot (0x5FD180, 0x42C bytes each) owned by this strat and pushes its handle (0 if none free). 554530 | +| 0x15f | `trailAddPoint` | - | 3 | 0 | 6 | medium | - | pops colourB (top), colourA, trail handle; transforms the two trail vertices of the current anim frame into world space and appends them to the trail history (20 points) with the given colours; no-op if handle 0. 5545c0 | +| 0x160 | `trailFree` | - | 1 | 0 | 6 | high | - | pops trail handle; releases it (clears owner +0x414) if nonzero. 554950 | +| 0x161 | `setPauseState` | - | 1 | 0 | | medium | - | pops v (fixed); sets global pause state 0x584640 = v>>12 (0 = not paused, 1 = paused, 2 = pausing disabled). 554970 | +| 0x162 | `setFlagE8_1000` | flags | 0 | 0 | | high | - | Inline word: nonzero sets, zero clears bit 0x1000 of strat flags (+0xE8). 54fdd0 | +| 0x163 | `setCollOffset` | - | 3 | 0 | 189 | medium | - | pops z (top), y, x; stores them as 16-bit shorts into the strat's collision descriptor (+0xC0) at +0x32/+0x34/+0x36 = collision sphere centre offset. 54fe00 | +| 0x164 | `angleDiff` | - | 2 | 1 | 1507 | high | - | pops b (top), a (angles, full turn 0x1000000); pushes (a-b) wrapped to the signed half-turn range (-0x800000..0x7FF000). 554990 | +| 0x165 | `collideAsPlayer` | - | 0 | 0 | 27 | medium | - | Sets flag 0x800 on self (excludes self), temporarily makes self the player (0x6D9E1C) and runs the player-vs-strat collision pass FUN_004051d0, then restores the player; lets projectiles (LlamaSpit) hit strats like the player does. 554bc0 | +| 0x166 | `setGlobalTint` | - | 3 | 0 | | high | - | pops b (top), g, r (fixed, clamped 0..255); sets the global strat tint 0x6D9E14..16 and enables it, and applies it to every strat that has no own tint yet (+0x133..+0x136). 554bf0 | +| 0x167 | `screenOverlay` | flags | 0 | 0 | 18 | high | - | Inline word: nonzero enables the full-screen colour overlay (0x583740 = 1, colour reset to 0), zero disables it. 54d970 | +| 0x168 | `setScreenOverlayColour` | - | 4 | 0 | 18 | medium | - | pops d (top), c, b, a (fixed 0..255, >>12); sets the full-screen overlay colour to a<<24 \| d<<16 \| b<<8 \| c (a = alpha; d,b,c land in the R,G,B byte positions of the D3D colour). 54d990 | +| 0x169 | `nop_0x169` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x16a | `turnTowardStratY` | - | 2 | 0 | | high | stTurnTowardY | pops maxStep (top, angle), target strat pointer; turns own yaw (+0x24) toward the target's position by at most maxStep (full turn 0x1000000). 554cd0 | +| 0x16b | `pushSelf` | - | 0 | 1 | | high | - | Pushes the pointer of the executing strat (this). 554cc0 | +| 0x16c | `nop_0x16c` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x16d | `nop_0x16d` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x16e | `lerp` | - | 3 | 1 | 2167 | high | - | pops t (top), b, a; pushes b + (a-b)*t (fixed): t=0 gives b, t=1.0 gives a. 5549c0 | +| 0x16f | `clamp` | - | 3 | 1 | 459 | high | - | pops hi (top), x, lo; pushes max(lo, min(x, hi)) (push order lo, x, hi). 554a10 | +| 0x170 | `distSqToPos` | - | 3 | 1 | 166 | high | - | pops z (top), y, x; pushes squared 3D distance from own position, computed on coordinates >>6 (dx>>6)^2+(dy>>6)^2+(dz>>6)^2. 54f8d0 | +| 0x171 | `distSqToPosXZ` | - | 2 | 1 | 394 | high | - | pops z (top), x; pushes squared horizontal distance from own position, (dx>>6)^2+(dz>>6)^2. 54f920 | +| 0x172 | `approach` | - | 3 | 1 | 365 | high | - | pops step (top), target, value; pushes value moved toward target by step without overshooting. 554a70 | +| 0x173 | `distSqXZ` | - | 4 | 1 | 28 | high | - | pops z2 (top), x2, z1, x1; pushes (x2-x1)^2 + (z2-z1)^2 in fixed point. 554ad0 | +| 0x174 | `turnTowardAngleY` | - | 2 | 0 | 466 | high | stTurnTowardY | pops maxStep (top), targetYaw; turns own yaw (+0x24) toward targetYaw by at most maxStep, wrapping (full turn 0x1000000). 54f150 | +| 0x175 | `setFlagEC_80` | flags | 0 | 0 | | high | - | Inline word: nonzero sets, zero clears bit 0x80 of strat flags2 (+0xEC). 54f750 | +| 0x176 | `inRectXZ` | - | 6 | 1 | 7 | high | - | pops maxZ (top), maxX, minZ, minX, z, x; pushes integer 1 (not 0x1000) if minX<=x<=maxX and minZ<=z<=maxZ, else 0. 554b40 | +| 0x177 | `waypointStep` | - | 1 | 0 | 402 | high | stNextWaypoint/stPrevWaypoint | pops n (fixed); moves current waypoint (+0x120) n steps forward via next links (wraps to first +0x118) or -n steps back via prev links (wraps to last +0x11C). 54f290 | +| 0x178 | `inWaypointBoxXZ` | - | 2 | 1 | 319 | high | - | pops z (top), x; pushes integer 1 if (x,z) lies inside the axis-aligned XZ box spanned by the current waypoint and the next one (wrapping to first), else 0. 54f2f0 | +| 0x179 | `nearestWaypoint` | - | 0 | 0 | | high | stNearestWaypointNext | Sets current waypoint (+0x120) to the waypoint of the list (+0x118) nearest (XZ) to the strat. 54f430 | +| 0x17a | `nearestWaypointToPlayer` | - | 0 | 0 | | high | - | Sets current waypoint to the waypoint nearest (XZ) to the player. 54f470 | +| 0x17b | `nearestOtherWaypoint` | - | 0 | 0 | | high | - | Sets current waypoint to the waypoint nearest (XZ) to the strat, excluding the current one. 54f4b0 | +| 0x17c | `nop_0x17c` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x17d | `nop_0x17d` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x17e | `nop_0x17e` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x17f | `nop_0x17f` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x180 | `addTint` | - | 3 | 0 | | high | - | pops b (top), g, r (fixed deltas); adds them to the strat's tint (own +0x133..135, or the level ambient at 0x584644+0x4C if none), clamps 0..255, stores and enables own tint (+0x136). 54fef0 | +| 0x181 | `setAnimFrame` | - | 1 | 0 | | high | - | pops frame (fixed); if an anim is set (+0x14) sets current frame +0x110 (out of range/negative -> 0), clears +0x114 and sets flag 8 (anim changed). 554d50 | +| 0x182 | `nop_0x182` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x183 | `showSaveSlotInfo` | - | 1 | 0 | 1 | medium | - | pops slot (fixed 1..3); draws the save-game slot info text ('Gespeichertes Spiel n', chapter, etc.) for the main menu (FUN_0040db90). 554da0 | +| 0x184 | `pushTintR` | - | 0 | 1 | | high | - | Pushes own tint red byte (+0x133) as fixed. 554dc0 | +| 0x185 | `pushTintG` | - | 0 | 1 | | high | - | Pushes own tint green byte (+0x134) as fixed. 554de0 | +| 0x186 | `pushTintB` | - | 0 | 1 | | high | - | Pushes own tint blue byte (+0x135) as fixed. 554e00 | +| 0x187 | `emitterInit` | - | 7 | 0 | 113 | medium | stSpawnparticle | pops colour/texture (top, raw), z, y, x (vector/offset), type (fixed 1..0x2B preset, or >=0x1000 type flags), size/speed (fixed, 0x1000 default), slot (0..2, >=3 = extra slot +0x153); (re)initialises the strat's particle emitter in that slot (records at 0x5FE230, 0x34 bytes; texture/colour stored per type). 552f40 | +| 0x188 | `emitParticles` | - | 3 | 0 | | medium | - | pops yStep (top, fixed), count, slot; emits count particles from the slot's emitter at own position with random XZ velocity, each raised by yStep. 552fb0 | +| 0x189 | `randSigned` | - | 1 | 1 | 922 | high | - | pops n; pushes a random value in (-n, n) ((rand32 - rand32) % n, same fixed scale as n). 54f9a0 | +| 0x18a | `emitterSetParams` | - | 5 | 0 | 113 | low | - | pops e (top), d, c, b, slot; sets emitter slot params: +0x254 = b (rate/speed), +0x24A = c>>12 (life); if e==0 +0x25C = (d>>12)*32-32 else +0x246 = e, +0x23C = d. Allocates the slot if empty. 5530b0 | +| 0x18b | `emitterSetColour` | - | 5 | 0 | 9 | low | - | pops colour (top, raw packed, see 0x15d), b, g, r, slot; sets emitter colour (+0x250) and fade/alpha (+0x240..243): if r has no fraction it is an alpha value only, else r,g,b are per-channel fade values. 553100 | +| 0x18c | `emitParticlesStill` | - | 3 | 0 | 50 | medium | - | pops yStep (top, raw fixed), count, slot; emits count particles from the slot's emitter at own position without velocity, stacked by yStep. 552ff0 | +| 0x18d | `stubPop2_0x18d` | - | 2 | 0 | | high | - | Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). 555090 | +| 0x18e | `stubPop2_0x18e` | - | 2 | 0 | | high | - | Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). 555090 | +| 0x18f | `clampToWaypointBox` | - | 0 | 0 | 155 | high | - | Clamps own x/z (+0x30/+0x38) into the XZ box spanned by the current waypoint and the next one. 54f390 | +| 0x190 | `processBeforeCamera` | - | 0 | 0 | 107 | high | - | If no camera strat (0x6D9E28) yet: re-executes next frame (pc-=4, yield). Else unlinks self from the strat list (0x6D9E38) and reinserts it directly before the camera so it updates before the camera. 554e60 | +| 0x191 | `processBeforeCamera2` | - | 0 | 0 | 84 | high | - | Same as 0x190 (waits for camera, then moves self just before the camera in the update list); functionally identical alias. 554f30 | +| 0x192 | `endLevel` | - | 3 | 0 | 91 | medium | stEndlevel | pops sub (top), level, world (fixed); saves current level as previous, sets next level, sets 'all coins collected'/completion flags, updates furthest-progress 0x585018, state 0x584638 = 10 and starts the fade-out (world=level=sub=7 means return to the level map). 54d620 | +| 0x193 | `stubPop1Push0_0x193` | - | 1 | 1 | | high | - | Stub FUN_005550b0: pops 1 value and pushes 0. 5550b0 | +| 0x194 | `stubPop1Push0_0x194` | - | 1 | 1 | | high | - | Stub FUN_005550b0: pops 1 value and pushes 0. 5550b0 | +| 0x195 | `stubPop2_0x195` | - | 2 | 0 | | high | - | Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). 555090 | +| 0x196 | `stubPop2_0x196` | - | 2 | 0 | | high | - | Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). 555090 | +| 0x197 | `nop_0x197` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x198 | `nop_0x198` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x199 | `nop_0x199` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x19a | `nop_0x19a` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x19b | `nop_0x19b` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x19c | `nop_0x19c` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x19d | `sqrt` | - | 1 | 1 | 67 | high | - | pops v (fixed); pushes fixed sqrt(v) (fsqrt of v/4096, rescaled). 54f610 | +| 0x19e | `nop_0x19e` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x19f | `nop_0x19f` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x1a0 | `setWaypointParam1` | - | 1 | 0 | 167 | high | - | pops v; sets current waypoint +0x08 = (v>>12)&0xFFF (readable as own property 9). 54ea80 | +| 0x1a1 | `setWaypointParam2` | - | 1 | 0 | 245 | high | - | pops v; sets current waypoint +0x0C = (v>>12)&0xFFF (readable as own property 10). 54eab0 | +| 0x1a2 | `setWaypointParam3` | - | 1 | 0 | | high | - | pops v; sets current waypoint +0x10 = (v>>12)&0xFFF (readable as own property 11). 54eae0 | +| 0x1a3 | `stubPop1_0x1a3` | - | 1 | 0 | | high | - | Stub FUN_00554fb0: pops and discards 1 value. 554fb0 | +| 0x1a4 | `stubPop1_0x1a4` | - | 1 | 0 | | high | - | Stub FUN_00554fb0: pops and discards 1 value. 554fb0 | +| 0x1a5 | `nop_0x1a5` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | +| 0x1a6 | `emitterKill` | - | 1 | 0 | | high | - | pops slot (fixed); frees the strat's particle emitter in that slot (+0x150+slot = 0xFF) and resets the record. 553150 | +| 0x1a7 | `stubPush0_0x1a7` | - | 0 | 1 | | high | - | Stub FUN_0054e880: pushes 0. 54e880 | +| 0x1a8 | `processFirst` | - | 0 | 0 | 1 | high | - | Moves self to the head of the strat update list (0x6D9E38) if not already there. 554e20 | +| 0x1a9 | `setRain` | - | 4 | 0 | 4 | low | - | pops colour (top, raw -> 0x6D9BE8), b (->0x578EF4), a2 (->0x6D9BF4), a1 (->0x578EF0) (all >>12); if the three counts/params are 0 rain is stopped and cleared, else rain is enabled (0x6D9BE4 = 1). 554fc0 | +| 0x1aa | `stubPop1Push0_0x1aa` | - | 1 | 1 | | high | - | Stub FUN_005550b0: pops 1 value and pushes 0. 5550b0 | +| 0x1ab | `stubPop2_0x1ab` | - | 2 | 0 | | high | - | Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). 555090 | +| 0x1ac | `stubPop1Push0_0x1ac` | - | 1 | 1 | | high | - | Stub FUN_005550b0: pops 1 value and pushes 0. 5550b0 | +| 0x1ad | `stubPop2Push0_0x1ad` | - | 2 | 1 | | high | - | Stub FUN_005550d0: pops 2 values and pushes 0. 5550d0 | +| 0x1ae | `stubPop3_0x1ae` | - | 3 | 0 | | high | - | Stub FUN_005550f0: pops and discards 3 values. 5550f0 | +| 0x1af | `setWeatherRender` | - | 1 | 0 | | medium | - | pops mode (fixed): 0 installs the normal rain/weather update+render callbacks (0x5FF034/38), 1 installs no-op callbacks and clears 0x6D94D0 (weather off). 555040 | +| 0x1b0 | `emitParticlesVel` | - | 6 | 0 | 159 | medium | - | pops vz (top), vy, vx (velocity), yStep, count, slot (last three fixed >>12); emits count particles from the slot's emitter at own position with the given velocity, stacked by yStep. 553030 | +| 0x1b1 | `nop_0x1b1` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | + +## Properties (`pushProp`/`popProp` id; same ids for player/parent/camera/… variants) + +| id | name | get | set | conf | semantics | +|---|---|---|---|---|---| +| 0 | `zero_0` | yes | no | high | Always pushes 0 (unused id; also 0x13, 0x15, 0x47, 0x75, 0x76, 0x77, 0x98, 0x9e, 0xc1, 0xd8, 0xed, 0xee, 0x102 push 0). | +| 1 | `unk_1` | yes | no | low | DAT_006d94d0 << 12 (int global, cleared in FUN_00426... init; meaning unknown). | +| 3 | `wpEndFlags` | yes | no | high | Current waypoint (+0x120) link bits: bit0 = no previous node (node[0]==0, first), bit1 = no next node (node[1]==0, last). Raw int. | +| 4 | `wpParam` | yes | yes | medium | Current waypoint field +0x28 (raw 32-bit, per-node parameter). | +| 5 | `distToWp` | yes | no | high | 3D distance from strat position (+0x30) to current waypoint position (wp+0x18), 20.12 (FUN_00404bb0, sqrt). | +| 6 | `wpX` | yes | yes | high | Current waypoint x (wp+0x18), 20.12. | +| 7 | `wpY` | yes | yes | high | Current waypoint y (wp+0x1C), 20.12. | +| 8 | `wpZ` | yes | yes | high | Current waypoint z (wp+0x20), 20.12. | +| 9 | `wpRotX` | yes | no | medium | Current waypoint int field +0x08 << 12 (node rotation x in 4096 table units, per notes). | +| 10 | `wpRotY` | yes | no | medium | Current waypoint int field +0x0C << 12 (node yaw in 4096 table units; e.g. checkpoint respawn yaw). | +| 11 | `wpRotZ` | yes | no | low | Current waypoint int field +0x10 << 12 (third node angle/param). | +| 12 | `playerDistToWp` | yes | no | high | 3D distance from the player (0x6D9E1C) to this strat's current waypoint, 20.12; 100.0 (0x64000) if no player. | +| 13 | `angleToWp` | yes | no | high | Yaw from the strat to its current waypoint: atan2(dx,dz) (FUN_00404d60), 24-bit angle (0x1000000 = full turn, 4096 table units <<12). | +| 14 | `animFrame` | yes | no | high | Current anim frame +0x110 << 12. | +| 15 | `animPlaying` | yes | no | high | Flag +0xE8 bit 3 (0x8, anim playing) as 0/1. | +| 16 | `floorHeight` | yes | yes | high | Floor height under the strat, collision record (+0xC0)+0x00, 20.12 (0xFFFF0000 = no floor). Get returns 0 if no record. | +| 17 | `floorMaterial` | yes | no | high | Floor surface/material id, short at (+0xC0)+0x04, << 12 (0xFFFF = none; 16 = level door trigger). 0 if no record. | +| 18 | `ceilingMaterial` | yes | no | medium | Short at (+0xC0)+0x30 << 12: material of the surface above (set together with ceiling height +0x28 in the collision code, 0xFFFF none). | +| 19 | `zero_19` | yes | no | high | Always 0. | +| 20 | `slideAngle` | yes | no | low | Short at (+0xC0)+0x2C << 12: direction (atan2 of old->new pos) written when the strat is pushed back off an edge/wall; 4096 units. | +| 21 | `zero_21` | yes | no | high | Always 0 (both branches push 0). | +| 22 | `collisionRadius` | yes | yes | high | Collision radius, short at +0xD2 (raw, not shifted). Set writes a full 32-bit word at +0xD2 (also clobbers the short at +0xD4). | +| 29 | `isActivated` | yes | no | high | Map-instance record (+0xF0) byte +0x44 bit 0x8 (persistent "activated" state, set by op 0x92). Returns 8 or 0. No null check. | +| 30 | `isCollected` | yes | no | high | Map-instance record (+0xF0) byte +0x44 bit 0x4 (persistent "collected", set by op 0x93). Returns 4 or 0. No null check. | +| 31 | `onGround` | yes | no | medium | Flag +0xEC & 0x2 (standing on a floor; slope/platform code FUN_004054c0 requires it). Returns 2 or 0. | +| 32 | `flagE8_400000` | yes | no | low | Flag +0xE8 bit 22 (0x400000) as 0/1. | +| 33 | `flagE8_800000` | yes | no | low | Flag +0xE8 bit 23 (0x800000) as 0/1. | +| 34 | `x` | yes | yes | high | Position x +0x30, 20.12. | +| 35 | `y` | yes | yes | high | Position y +0x34, 20.12. | +| 36 | `z` | yes | yes | high | Position z +0x38, 20.12. | +| 37 | `rotX` | yes | yes | high | Rotation x (pitch) +0x20; get masks to 24 bits (0x1000000 = full turn), set stores raw. | +| 38 | `rotY` | yes | yes | high | Rotation y (yaw) +0x24; 24-bit angle, 0x1000000 = full turn. | +| 39 | `rotZ` | yes | yes | high | Rotation z (roll) +0x28; 24-bit angle. | +| 41 | `angleToPlayer` | yes | no | high | Yaw from this strat to the player: atan2(px-x, pz-z) (FUN_00404d60(player+0x30, self+0x30)), 24-bit angle; 0 if no player. (Notes called 41 "distance to player" - code is an angle.) | +| 42 | `padHeld` | yes | no | high | Joypad buttons currently held, DAT_005FCF20 (bit mask: 0x10 up, 0x40 down, 0x80 left?, 0x20 right?, 0x1/0x8/0x400.. buttons; 0x10000-0x80000 = direction relative to player yaw). | +| 43 | `padReleased` | yes | no | high | Buttons released this frame, DAT_005FCF34 (= old & ~new). | +| 44 | `padPressed` | yes | no | high | Buttons newly pressed this frame, DAT_005FCF30 (= new & ~old). | +| 45 | `distToPlayer` | yes | no | high | 3D distance to the player, cached in +0x1C (computed lazily as sqrt of +0x148, the squared distance updated per frame), 20.12. | +| 47 | `scale` | yes | no | high | Scale y +0x64 (used as the uniform scale value), 20.12; see op 0x55. | +| 48 | `scaleX` | yes | no | high | Scale x +0x60, 20.12. | +| 49 | `scaleY` | yes | no | high | Scale y +0x64, 20.12. | +| 50 | `scaleZ` | yes | no | high | Scale z +0x68, 20.12. | +| 51 | `stratCount` | yes | no | medium | Number of strats in use DAT_006D9DC8 << 12. | +| 52 | `stratCapacity` | yes | no | medium | Level header (DAT_00584648)+0x08 << 12 (max strat count of the level; compared against stratCount on spawn). | +| 53 | `distToWpXZ` | yes | no | high | Horizontal (x/z) distance from strat to current waypoint, 20.12 (FUN_00404c50). | +| 54 | `boneX` | yes | no | medium | +0xD8: x of the bone/attachment point computed by op 0xc7 (useBone), 20.12 world. | +| 55 | `boneY` | yes | no | medium | +0xDC bone point y. | +| 56 | `boneZ` | yes | no | medium | +0xE0 bone point z. | +| 57 | `playerPtr` | yes | no | high | Pointer to the player strat (DAT_006D9E1C); 0 if none (usable as "player exists"). | +| 58 | `camLookAtX` | yes | yes | medium | Camera look-at point x DAT_006D9D80, 20.12 (default 0; cut-scene cameras write it). | +| 59 | `camLookAtY` | yes | yes | medium | Camera look-at point y DAT_006D9D84 (default 1.0). | +| 60 | `camLookAtZ` | yes | yes | medium | Camera look-at point z DAT_006D9D88. | +| 61 | `tribe` | yes | no | high | Current level tribe DAT_00584EB4 << 12 (level id part 1, %d_%d_%d file names). | +| 62 | `world` | yes | no | high | Current level world DAT_00584EB8 << 12. | +| 63 | `chapter` | yes | no | high | Current level chapter DAT_00584EBC << 12. | +| 64 | `map` | yes | no | high | Current map/sub-level DAT_00584EC0 << 12. | +| 65 | `hasCamTarget` | yes | no | medium | -1 if a camera target strat (DAT_006D9E30, op 0xc9) is set, else 0. | +| 66 | `hasCamTarget2` | yes | no | medium | -1 if the second camera target (DAT_006D9E34, op 0xca) is set, else 0. | +| 67 | `padType` | yes | no | low | DAT_005FCF18 << 12: 3 = digital pad, 2 = direction/analog input active this frame. | +| 68 | `camDistToTarget` | yes | no | high | 3D distance camera strat (0x6D9E28) -> camera target (0x6D9E30), 20.12; 0 if no camera. | +| 69 | `camDistToTarget2` | yes | no | high | 3D distance camera -> second target (0x6D9E34), 20.12; 0 if no camera. | +| 70 | `camTargetYawDiff` | yes | no | high | Signed yaw difference target.rotY - camera.rotY (wrapped to +-0x800000, FUN_0054dae0). No null checks. | +| 72 | `camDistToTargetXZ` | yes | no | high | Horizontal distance camera -> target (FUN_00404cc0), 20.12. No null check. | +| 73 | `camDistToTarget2XZ` | yes | no | high | Horizontal distance camera -> second target, 20.12. No null check. | +| 75 | `stickX` | yes | no | high | Analog/digital stick x DAT_005FCF1C (-0x2000..0x1FC0, i.e. about +-2.0 fixed). | +| 76 | `stickY` | yes | no | high | Stick y DAT_005FCF2C (same range). | +| 77 | `stickMagnitude` | yes | no | high | Stick deflection DAT_005FCEFC, raw int 0..0xB5 (thresholds: 42 deadzone, 84 direction bits, see ids 94-97/130-131). | +| 78 | `stickAngle` | yes | no | high | Stick direction DAT_005FCF4E (short, 4096 units) << 12. | +| 79 | `stickAngleWorld` | yes | no | high | (stick angle + camera yaw DAT_005FCF00) << 12, masked to 24 bits: stick direction in world yaw. | +| 80 | `stick2X` | yes | no | low | DAT_005FCF28, never written by the exe (second stick set, always 0). | +| 81 | `stick2Y` | yes | no | low | DAT_005FCF24, never written (always 0). | +| 82 | `stick2Magnitude` | yes | no | low | DAT_005FCEF8, never written (always 0). | +| 83 | `stick2Angle` | yes | no | low | Short DAT_005FCF4C << 12, never written. | +| 84 | `stick2AngleWorld` | yes | no | low | (camera yaw + DAT_005FCF4C) << 12 masked 24 bits. | +| 85 | `slopeForward` | yes | no | medium | Floor normal rotated into the strat's yaw frame, forward component DAT_0057DDA8 (FUN_004054c0; 0 unless onGround), 20.12. | +| 86 | `slopeSide` | yes | no | medium | Floor normal, sideways component in the strat's frame DAT_0057DD90, 20.12. | +| 87 | `slopeAngle` | yes | no | medium | Downhill direction relative to the strat's yaw DAT_0057DD94 (0..0xFFF) << 12. | +| 88 | `padAux` | yes | no | low | DAT_005FCF58 (extra input bits, cleared when >1 pads). | +| 90 | `padAuxPressed` | yes | no | low | DAT_005FCF60 = new & ~old of padAux. | +| 91 | `unk_5b` | yes | no | low | DAT_00584F10 << 12 (game state global, unknown). | +| 92 | `health` | yes | no | medium | Player health DAT_00584E70 (fixed, starts at 5.0, HUD shows it; reset to 5.0 on respawn). Same value as id 189 (settable there). | +| 93 | `unk_5d` | yes | no | low | DAT_0058464C (raw, game-state pointer/flag). | +| 94 | `stickWalkThreshold` | yes | no | medium | Constant 84 (0x54, raw int): stick magnitude above which direction bits are generated. | +| 95 | `stickRunThreshold` | yes | no | medium | Constant 123 (0x7B, raw int). | +| 96 | `stickWalkThreshold2` | yes | no | medium | Constant 84 (duplicate of 94). | +| 97 | `stickRunThreshold2` | yes | no | medium | Constant 123 (duplicate of 95). | +| 98 | `hasWaypoints` | yes | no | high | 1.0 if the EXECUTING strat (not the queried one) has a first waypoint (+0x118), else 0. For 0x1c (player prop) this still checks the caller. | +| 99 | `distToBoss` | yes | no | high | 3D distance from the boss strat (DAT_006D9E24, op 0xef) to this strat, 20.12; 100.0 if none. | +| 100 | `distToBossXZ` | yes | no | high | Horizontal distance boss -> this strat, 20.12; 100.0 if none. | +| 101 | `zoneX` | yes | no | low | Map zone/cell record containing the strat (FUN_0054d560, same x/z grid cell, nearest y) field +0x0C, integer part (& 0xFFFFF000); -1.0 if none. | +| 102 | `zoneY` | yes | no | low | Zone record field +0x10 integer part; -1.0 if none. | +| 103 | `zoneZ` | yes | no | low | Zone record field +0x14 integer part; -1.0 if none. | +| 104 | `distToWpSq` | yes | no | high | Squared 3D distance to current waypoint ((d>>6)^2 summed, i.e. 20.12 of world units squared). Same as 194. | +| 105 | `distToWpSqXZ` | yes | no | high | Squared horizontal distance to current waypoint. | +| 106 | `flag83730Clear` | yes | no | low | 1.0 if DAT_00583730 == 0 (FUN_00414fb0), else 0. | +| 107 | `distToPlayerXZ` | yes | no | high | Horizontal distance player -> this strat, 20.12; 100.0 if no player. | +| 108 | `saveByte5219` | yes | no | low | DAT_00585219 << 12 (per-save-slot byte, reset to 0 on new game). | +| 110 | `queueAEmpty` | yes | no | low | 1.0 if DAT_006D9858 < 1 (message/stream queue A empty), else 0. | +| 111 | `queueBEmpty` | yes | no | low | 1.0 if DAT_006D94E8 < 1 (queue B empty; also blocks HUD counters), else 0. | +| 120 | `camFloorHeight` | yes | yes | medium | Get: floor height of the map cell under the camera DAT_00586424 (-16.0 if outside the map). Set: writes the OVERRIDE DAT_0058641C (non-zero replaces the computed value). Get and set use different variables. | +| 121 | `camX` | yes | yes | high | Camera position x DAT_006D9D20 (copied from the camera strat each frame), 20.12. | +| 122 | `camY` | yes | yes | high | Camera position y DAT_006D9D24. | +| 123 | `camZ` | yes | yes | high | Camera position z DAT_006D9D28. | +| 124 | `flagEC_1000000` | yes | no | low | Flag +0xEC bit 24 as 0/1. | +| 125 | `flagEC_2000000` | yes | no | low | Flag +0xEC bit 25 as 0/1. | +| 126 | `hitDepth` | yes | no | medium | Short +0xD4 (raw): penetration depth of the last player-vs-strat sphere hit (FUN_004051d0), 0xFFFF none. | +| 127 | `hitAngle` | yes | no | medium | Short +0xD6 << 12: direction of the last sphere hit (4096 units; player gets the opposite of the strat). | +| 128 | `unk_80` | yes | no | low | DAT_00585004 << 12 (unknown game global). | +| 129 | `zoneFlags` | yes | no | low | Zone record (FUN_0054d560) +0x20 & 0xC00, << 12; -1.0 if none. | +| 130 | `stickDeadzone` | yes | no | medium | Constant 42 (0x2A, raw int). | +| 131 | `stickDeadzone2` | yes | no | medium | Constant 42 (duplicate). | +| 132 | `oldX` | yes | yes | medium | Previous-frame position x +0x50, 20.12 (collision code restores it on edge push-back). | +| 133 | `oldY` | yes | yes | medium | Previous position y +0x54. | +| 134 | `oldZ` | yes | yes | medium | Previous position z +0x58. | +| 135 | `unk_6da2c0` | yes | no | low | DAT_006DA2C0, no other reference (always 0). | +| 136 | `unk_6da2c4` | yes | no | low | DAT_006DA2C4, no other reference (always 0). | +| 137 | `queuesEmpty` | yes | no | low | 1.0 if both queue A and queue B are empty (ids 110 and 111), else 0. | +| 138 | `alpha` | yes | yes | medium | Transparency +0x10C (renderer: < 0x1000 = translucent, 1.0 = opaque), 20.12. | +| 139 | `oldRotX` | yes | yes | medium | Previous rotation x +0x40 (24-bit masked on get). | +| 140 | `oldRotY` | yes | yes | medium | Previous yaw +0x44 (compared with +0x24 by the collision code). | +| 141 | `oldRotZ` | yes | yes | medium | Previous rotation z +0x48. | +| 142 | `optionFlag80000` | yes | no | low | DAT_006DA330 & 0x80000 (global option/debug flag word). | +| 143 | `camScale` | yes | yes | low | DAT_006D9D30, camera value initialised to 1.0 (zoom/fov factor?). | +| 144 | `levelHeaderC` | yes | no | low | Level header (DAT_00584648)+0x0C ushort << 12. | +| 145 | `slopeSteepness` | yes | no | medium | 1.0 - floor normal y (DAT_0057DDA4) << 12: 0 on flat ground (FUN_004054c0). | +| 147 | `levelTableByte` | yes | no | low | Byte table 0x585024[map + (chapter + tribe*10)*4] << 12 (per-level constant). | +| 149 | `flagEC_1` | yes | no | low | Flag +0xEC & 0x1. | +| 150 | `flagEC_8000000` | yes | no | low | Flag +0xEC & 0x8000000. | +| 151 | `cheat5` | yes | no | medium | Cheat toggle bits DAT_00584710 & 0x20 (button-sequence cheats, FUN_0041a0xx). | +| 153 | `frameCounter` | yes | no | high | Strat-update frame counter DAT_006D9E10 (+1.0 per frame). | +| 154 | `hitSphere` | yes | no | medium | Byte +0x130 << 12: index of the collision sphere hit by the player (FUN_004051d0). | +| 155 | `unk_6d9d38` | yes | no | low | DAT_006D9D38, no other reference. | +| 156 | `unk_6d9cf0` | yes | no | low | DAT_006D9CF0, no other reference. | +| 157 | `isFloorCollisionSolid` | yes | no | low | 1 (raw) if +0xEC has both 0x8000 and 0x10000 set, else 0. | +| 159 | `scratch6d9e68` | yes | yes | low | DAT_006D9E68 (only accessed by strats). | +| 160 | `saveEnabled` | yes | yes | low | DAT_006D9E74 (1.0 enables the save-game prompt, reset to 0 on level start). | +| 161 | `hudValueA` | yes | yes | low | DAT_006DA300 (HUD element value, shown via >>12, reset per level). | +| 162 | `hudValueB` | yes | yes | low | DAT_006DA2F8 (HUD element value, reset per level). | +| 163 | `audioMode` | yes | yes | low | DAT_006DA2FC: 3.0/4.0/5.0 from the sound output menu (Stereo/Quatro/Surround); also written (slot+1) by save code. | +| 164 | `instanceData0` | yes | yes | low | Map-instance record (+0xF0)+0x28 -> word 0. Set returns NULL (crash) unless record+0x24 != 0. Get pushes nothing if no record. | +| 165 | `instanceData1` | yes | yes | low | Map-instance record (+0xF0)+0x28 -> word 1. | +| 166 | `floorCollisionOn` | yes | no | medium | Flag +0xEC & 0x8000 (floor collision, ops 0xa5/0xa6). | +| 169 | `lives` | yes | yes | low | DAT_00584E6C (fixed, starts at 5.0, saved per slot) - lives or max health. | +| 171 | `saveProgressA` | yes | no | low | DAT_00585008 << 12 (saved progress byte). | +| 172 | `progressWorld` | yes | no | low | DAT_0058500C << 12 (saved; world of the furthest/selected level, 8 = finale). | +| 173 | `progressChapter` | yes | no | low | DAT_00585010 << 12 (saved; chapter, 6 with world 8 = last level). | +| 174 | `saveProgressB` | yes | no | low | DAT_00585014 << 12 (saved progress byte). | +| 175 | `unk_6da308` | yes | no | low | DAT_006DA308, no other reference. | +| 178 | `cheat4` | yes | no | medium | DAT_00584710 & 0x10 (cheat toggle). | +| 179 | `hudFlags` | yes | yes | medium | DAT_006DA2E0 HUD element enable mask (default 0x3F000; 0x2000 timer, 0x8000/0x10000/0x20000/0x40000 counters). | +| 181 | `gameState1d74` | yes | yes | low | DAT_00581D74 (game/menu state, checked == 0 for normal play). | +| 182 | `camFlag800000` | yes | no | low | Camera strat +0xEC & 0x800000 (0 if no camera). | +| 183 | `slopeNormalX` | yes | no | medium | Floor normal x DAT_0057DD9C (world, 20.12; FUN_004054c0). | +| 184 | `slopeNormalZ` | yes | no | medium | Floor normal z DAT_0057DDA0. | +| 185 | `wpParam2` | yes | yes | medium | Current waypoint field +0x2C (raw); 0 / NULL if no waypoint. | +| 186 | `hitRadius` | yes | yes | medium | +0x13C: player interaction radius (player collides when squared player distance +0x148 <= r*r), 20.12. | +| 187 | `hitDamage` | yes | yes | medium | +0x140: damage dealt on contact (copied into the other strat's +0x144). | +| 188 | `damageTaken` | yes | no | medium | +0x144: damage received from the last contact (player cleared to 0 each frame). | +| 189 | `health2` | yes | yes | medium | DAT_00584E70 = health (same as 92, settable). | +| 190 | `levelRecord` | yes | yes | low | Per-level saved word DAT_00584F1C[chapter + world*6] (best/collected value for the current level; HUD shows it). | +| 191 | `counterC` | yes | yes | low | DAT_00584FFC: persistent HUD counter (icon 0x150), saved. | +| 192 | `counterA` | yes | yes | low | DAT_00584FF4: persistent HUD counter (icon 0x184, displayed up to 999) - probably coins. | +| 201 | `distToPlayerSq` | yes | no | high | +0x148: cached squared distance to the player ((d>>6)^2 sum), updated each frame when tracking (op 0x30). | +| 202 | `distToPlayerSqXZ` | yes | no | high | Squared horizontal distance player -> strat (FUN_00404c90); 100.0 if no player. | +| 203 | `soundVecX` | yes | yes | low | DAT_006DA2CC; on the next set of 203/204 the magnitude sqrt(x^2+y^2)*4 is passed to FUN_00546140 (sound/volume parameter). | +| 204 | `soundVecY` | yes | yes | low | DAT_006DA2D0, see 203. | +| 210 | `collisionHeight` | yes | yes | medium | Collision record (+0xC0)+0x18: strat height/size used for ceiling tests (ceiling hit if ceiling <= y + this), 20.12. Get 0 if no record. | +| 211 | `mapFloorHeight` | yes | no | low | (+0xC0)+0x1C: floor height from the landscape test (FUN_004036d0), 0xFFFF0000 none. No null check. | +| 212 | `mapFloorMaterial` | yes | no | low | Short (+0xC0)+0x24 << 12: material of that floor (0xFFFF none). | +| 213 | `objFloorHeight` | yes | no | low | (+0xC0)+0x20: floor height from the strat-object test (FUN_00403ad0, skipped with +0xE8 bit 0x400). | +| 214 | `objFloorMaterial` | yes | no | low | Short (+0xC0)+0x26 << 12: material of the object floor. | +| 215 | `wallMaterial` | yes | no | low | Short (+0xC0)+0x3A << 12 (material of the wall/side surface hit, set with object/face +0x3C/+0x3E). | +| 217 | `collisionField38` | yes | yes | low | Short (+0xC0)+0x38 raw on get; set writes 32 bits at +0x38 (also clobbers +0x3A). | +| 218 | `hudDigit` | yes | yes | low | DAT_006DA290: HUD single-digit counter (shown as '0'+value>>12). | +| 226 | `animFrameCount` | yes | no | high | Anim header (+0x14)+0x08 << 12 (number of frames). No null check. | +| 227 | `angleToCamLookAt` | yes | no | medium | Yaw from the strat to the camera look-at point (DAT_006D9D80), 24-bit angle. | +| 228 | `skyParamA` | yes | yes | low | DAT_0057D830 (render global, sky/background). | +| 229 | `skyParamB` | yes | yes | low | DAT_0057D83C. | +| 230 | `skyParamC` | yes | yes | low | DAT_0057D840. | +| 231 | `skyRotation` | yes | yes | low | DAT_0057D834: rotation added to a background model's yaw (>>12 & 0xFFF). | +| 232 | `hudTimer` | yes | yes | low | DAT_006DA304: HUD timer/counter value (>>12 displayed, element bit 0x2000). | +| 233 | `animPtr` | yes | no | high | Current anim address +0x14 (0 = none). | +| 234 | `cheat1` | yes | no | medium | DAT_00584710 & 0x2 (cheat toggle). | +| 236 | `hudRefresh` | yes | yes | low | DAT_006DA2DC (forces HUD counter redraw when non-zero). | +| 239 | `unk_57ddb0` | yes | no | low | DAT_0057DDB0, no other reference. | +| 240 | `unk_57ddb4` | yes | no | low | DAT_0057DDB4, no other reference. | +| 241 | `unk_57ddb8` | yes | no | low | DAT_0057DDB8, no other reference. | +| 242 | `drawDistance` | yes | no | low | 1000.0 unless +0xEC bit 0x10000; else (bit 0x400000 ? DAT_0058658C : DAT_00586590) + 0.5 (view/clip distance globals). | +| 243 | `ceilingHeight` | yes | no | medium | (+0xC0)+0x28: height of the surface above (1000.0 default when ceiling test enabled by +0xE8 0x200000). No null check. | +| 244 | `zoneParam` | yes | no | low | DAT_006DA298: short +0x38 of the zone record the strat entered (op FUN_0054df30, used as conveyor/offset). | +| 249 | `wpPtr` | yes | yes | high | Current waypoint pointer +0x120 (set allows jumping to a stored node). | +| 250 | `counterB` | yes | yes | low | DAT_00584E60: HUD counter (icon 0x11C), reset when entering a different level. | +| 253 | `musicBusy` | yes | no | low | 1 (raw) if FUN_00546600(DAT_006D94B4,0) == 2 (stream/voice state), else 0. | +| 265 | `musicTrack` | yes | no | medium | Current music track DAT_00586430 << 12 (level header +0x58, 0x23/0x27 for special modes). | diff --git a/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectIO.java b/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectIO.java index e605a33..210b011 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectIO.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/io/ProjectIO.java @@ -56,15 +56,24 @@ public final class ProjectIO { public interface LoadHook { void beforeChanges(Map json, Bases cd, Scene reference, Scene scene, List warnings) throws IOException; + + /** Hooks run in ascending order (imports = 0). */ + default int order() { + return 0; + } } private static final List HOOKS = new CopyOnWriteArrayList<>(); static { - try { - Class.forName("com.eliasmueller.llamamod.editor.tools.catalog.CrossImport"); - } catch (ClassNotFoundException | LinkageError e) { - // optional extension + for (String c : List.of( + "com.eliasmueller.llamamod.editor.tools.catalog.CrossImport", + "com.eliasmueller.llamamod.editor.tools.strats.StratEdits")) { + try { + Class.forName(c); + } catch (ClassNotFoundException | LinkageError e) { + // optional extension + } } } @@ -114,7 +123,8 @@ public final class ProjectIO { cur.assets().put(e.getKey(), e.getValue()); } } - for (LoadHook h : HOOKS) { + for (LoadHook h : + HOOKS.stream().sorted(Comparator.comparingInt(LoadHook::order)).toList()) { h.beforeChanges(json, cd, ref, cur, p.warnings()); } cur.setNextIdCounter(intOr(json, "nextId", 1)); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/ConstantDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/ConstantDialog.java new file mode 100644 index 0000000..8ab0412 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/ConstantDialog.java @@ -0,0 +1,123 @@ +package com.eliasmueller.llamamod.editor.tools.strats; + +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.format.strat.Num; +import com.eliasmueller.llamamod.format.strat.StratListing; +import com.eliasmueller.llamamod.util.SwingListeners; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JTextField; + +/** Edits the constants of one script line: literals 1.5 (fixed), 90deg, 0x10, 42; validated. */ +final class ConstantDialog { + private ConstantDialog() {} + + static String describe(int v, StratListing.ConstKind k) { + if (k == StratListing.ConstKind.SOUND) { + return "sound sample " + v; + } + if (k == StratListing.ConstKind.TEXTURE) { + return "texture " + v; + } + if (k == StratListing.ConstKind.TEXT) { + return "text line " + v; + } + return Num.fixed(v) + " (20.12) = " + v + " raw = " + Num.degrees(v) + " as angle"; + } + + /** Problem with a new value, or null. */ + static String validate(StratListing.Line l, Integer v) { + if (v == null) { + return "not a number (examples: 1.5, -0.25, 90deg, 0x2000, 42)"; + } + if (l.constShort() && (v < Short.MIN_VALUE || v > Short.MAX_VALUE)) { + return "pushShort holds −32768…32767 raw (−8.0…7.99); for larger values the code would need a 32-bit push"; + } + if (l.constKind() == StratListing.ConstKind.SOUND && (v < 0 || v > 255)) { + return "sound ids are 0…127 (bit 0x80 = front-end sounds)"; + } + if ((l.constKind() == StratListing.ConstKind.TEXTURE || l.constKind() == StratListing.ConstKind.TEXT) + && (v < 0 || v > 0xffff)) { + return "index out of range"; + } + return null; + } + + static String literal(StratListing.Line l) { + if (l.constKind() != StratListing.ConstKind.NUMBER) { + return Integer.toString(l.value()); + } + String t = l.text(); + String op = t.substring(t.indexOf(' ') + 1).trim(); + return op.endsWith("deg") ? op : Num.literal(l.value()); + } + + /** New values by line (only changed ones), or null when cancelled. */ + static Map show(EditorContext ctx, List consts) { + JPanel p = new JPanel(new GridBagLayout()); + GridBagConstraints g = new GridBagConstraints(); + g.insets = new Insets(3, 4, 3, 4); + g.anchor = GridBagConstraints.WEST; + List fields = new ArrayList<>(); + List notes = new ArrayList<>(); + int row = 0; + for (StratListing.Line l : consts) { + g.gridy = row++; + g.gridx = 0; + p.add(new JLabel(l.text()), g); + g.gridx = 1; + JTextField f = new JTextField(literal(l), 12); + p.add(f, g); + g.gridx = 2; + JLabel n = new JLabel(describe(l.value(), l.constKind())); + p.add(n, g); + fields.add(f); + notes.add(n); + SwingListeners.onTextChange(f, () -> { + Integer v = Num.parse(f.getText()); + String e = validate(l, v); + n.setText(e != null ? "⚠ " + e : describe(v, l.constKind())); + }); + } + g.gridy = row; + g.gridx = 0; + g.gridwidth = 3; + p.add( + new JLabel( + "Fixed point: 1.0 = 4096 · angles: 360deg = 4096.0 · speeds are per frame (30 fps)." + + "
The change is stored in the project and applied to the level's code when it is built.
"), + g); + while (true) { + int r = JOptionPane.showConfirmDialog( + ctx.frame(), p, "Edit script constant", JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE); + if (r != JOptionPane.OK_OPTION) { + return null; + } + Map out = new LinkedHashMap<>(); + String err = null; + for (int i = 0; i < consts.size(); i++) { + Integer v = Num.parse(fields.get(i).getText()); + String e = validate(consts.get(i), v); + if (e != null) { + err = consts.get(i).text() + ": " + e; + break; + } + if (v != consts.get(i).value()) { + out.put(consts.get(i), v); + } + } + if (err == null) { + return out; + } + JOptionPane.showMessageDialog(ctx.frame(), err, "Edit script constant", JOptionPane.WARNING_MESSAGE); + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/ScriptPanel.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/ScriptPanel.java new file mode 100644 index 0000000..34301f4 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/ScriptPanel.java @@ -0,0 +1,588 @@ +package com.eliasmueller.llamamod.editor.tools.strats; + +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.RawChunk; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.Widgets; +import com.eliasmueller.llamamod.format.stpcmerge.StratCode; +import com.eliasmueller.llamamod.format.strat.OpTable; +import com.eliasmueller.llamamod.format.strat.StratListing; +import com.eliasmueller.llamamod.format.strat.StratPseudo; +import java.awt.BorderLayout; +import java.awt.Color; +import java.awt.Component; +import java.awt.FlowLayout; +import java.awt.Font; +import java.awt.event.MouseAdapter; +import java.awt.event.MouseEvent; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import javax.swing.BorderFactory; +import javax.swing.DefaultListCellRenderer; +import javax.swing.DefaultListModel; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JSplitPane; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.ListSelectionModel; +import javax.swing.SwingUtilities; +import javax.swing.UIManager; + +/** + * Script panel: the strat code unit of the selected object type as structured script or as + * assembly, with search, cross-references, opcode help and editing of constants (undoable, + * stored in the project). + */ +final class ScriptPanel extends JPanel { + /** A list row: text, STPC offset of its first instruction (−1 = none), editable constants. */ + record Row(String text, int offset, List consts, StratListing.Line source) {} + + private record UnitItem(int unit, String label) { + @Override + public String toString() { + return label; + } + } + + private final EditorContext ctx; + private final JComboBox units = new JComboBox<>(); + private final JComboBox mode = new JComboBox<>(new String[] {"Script", "Assembly"}); + private final JCheckBox follow = new JCheckBox("Follow selection", true); + private final JTextField search = new JTextField(14); + private final DefaultListModel model = new DefaultListModel<>(); + private final JList list = new JList<>(model); + private final JTextArea info = new JTextArea(); + private final JLabel status = new JLabel(" "); + private StratListing listing; + private Set editedAt = new HashSet<>(); + private boolean updating; + private Scene shownScene; + private byte[] shownData; + + ScriptPanel(EditorContext ctx) { + super(new BorderLayout()); + this.ctx = ctx; + JPanel row1 = new JPanel(new FlowLayout(FlowLayout.LEADING, 4, 1)); + row1.add(new JLabel("Type")); + units.setMaximumRowCount(30); + units.setPrototypeDisplayValue(new UnitItem(0, "MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM")); + row1.add(units); + row1.add(mode); + row1.add(follow); + JPanel row2 = new JPanel(new FlowLayout(FlowLayout.LEADING, 4, 1)); + row2.add(new JLabel("Find")); + row2.add(search); + row2.add(Widgets.button("Next", "search", "Find next (Enter)", () -> find(1))); + row2.add(Widgets.button("Uses", null, "Cross-references of the selected line's operand", this::references)); + row2.add(Widgets.button("Edit value…", null, "Change the constants of the selected line", this::editSelected)); + row2.add(Widgets.button( + "Clone as new type…", + "duplicate", + "Copy this type into a custom type and edit its script", + () -> TypeDialog.showClone(ctx, primaryType()))); + JPanel top = new JPanel(new java.awt.GridLayout(2, 1)); + top.add(row1); + top.add(row2); + add(top, BorderLayout.NORTH); + list.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + list.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); + list.setCellRenderer(new RowRenderer()); + info.setEditable(false); + info.setLineWrap(true); + info.setWrapStyleWord(true); + info.setFont(UIManager.getFont("Label.font")); + JSplitPane split = new JSplitPane(JSplitPane.HORIZONTAL_SPLIT, new JScrollPane(list), new JScrollPane(info)); + split.setResizeWeight(0.72); + add(split, BorderLayout.CENTER); + status.setBorder(BorderFactory.createEmptyBorder(2, 6, 2, 6)); + add(status, BorderLayout.SOUTH); + + units.addActionListener(e -> { + if (!updating) { + show(selectedUnit(), -1); + } + }); + mode.addActionListener(e -> show(selectedUnit(), -1)); + search.addActionListener(e -> find(1)); + list.addListSelectionListener(e -> { + if (!e.getValueIsAdjusting()) { + describe(list.getSelectedValue()); + } + }); + list.addMouseListener(new MouseAdapter() { + @Override + public void mouseClicked(MouseEvent e) { + if (e.getClickCount() == 2) { + editSelected(); + } + } + }); + ctx.addDocumentListener(d -> reload()); + ctx.selection().addListener(ch -> { + if (follow.isSelected()) { + followSelection(); + } + }); + ctx.addSceneListener(e -> { + if (e.type() == SceneEvent.Type.RELOADED || e.node() instanceof RawChunk) { + SwingUtilities.invokeLater(this::refreshIfChanged); + } + }); + reload(); + } + + private int selectedUnit() { + UnitItem u = (UnitItem) units.getSelectedItem(); + return u == null ? -1 : u.unit; + } + + private String primaryType() { + Scene s = ctx.scene(); + if (s == null || listing == null) { + return null; + } + for (Map.Entry e : s.catalog().names().entrySet()) { + if (StratEdits.analysis(s).img().unitOf(e.getKey()) == listing.unit + && s.catalog().has(e.getValue())) { + return e.getValue(); + } + } + return null; + } + + private void refreshIfChanged() { + Scene s = ctx.scene(); + RawChunk c = s == null ? null : StratEdits.chunk(s); + if (s != shownScene || c == null || c.data() != shownData) { + int keep = list.getSelectedIndex(); + int u = selectedUnit(); + reloadUnits(); + if (u >= 0) { + selectUnit(u); + show(u, -1); + if (keep >= 0 && keep < model.size()) { + list.setSelectedIndex(keep); + list.ensureIndexIsVisible(keep); + } + } + } + } + + private void reload() { + reloadUnits(); + if (follow.isSelected()) { + followSelection(); + } + if (listing == null && units.getItemCount() > 0) { + show(selectedUnit(), -1); + } + } + + private void reloadUnits() { + updating = true; + try { + units.removeAllItems(); + Scene s = ctx.scene(); + StratEdits.Analysis a = s == null ? null : StratEdits.analysis(s); + shownScene = s; + shownData = s == null || StratEdits.chunk(s) == null + ? null + : StratEdits.chunk(s).data(); + if (a == null) { + model.clear(); + listing = null; + status.setText("No level open."); + return; + } + Map> names = new LinkedHashMap<>(); + for (int u = 0; u < a.img().units.size(); u++) { + names.put(u, new ArrayList<>()); + } + for (var e : s.catalog().names().entrySet()) { + int u = a.img().unitOf(e.getKey()); + if (u >= 0 && !names.get(u).contains(e.getValue())) { + names.get(u).add(e.getValue()); + } + } + List items = new ArrayList<>(); + for (var e : names.entrySet()) { + String n = + e.getValue().isEmpty() ? StratEdits.unitName(s, e.getKey()) : String.join(", ", e.getValue()); + if (n.length() > 60) { + n = n.substring(0, 57) + "…"; + } + items.add(new UnitItem( + e.getKey(), + n + (StratEdits.isCustomUnit(s, e.getKey()) ? " [custom]" : "") + " · unit " + e.getKey())); + } + items.sort((x, y) -> x.label.compareToIgnoreCase(y.label)); + items.forEach(units::addItem); + } finally { + updating = false; + } + } + + private void selectUnit(int u) { + updating = true; + for (int i = 0; i < units.getItemCount(); i++) { + if (units.getItemAt(i).unit == u) { + units.setSelectedIndex(i); + } + } + updating = false; + } + + private void followSelection() { + Scene s = ctx.scene(); + Node n = ctx.selection().primary(); + if (s == null || !(n instanceof Strat st)) { + return; + } + StratEdits.Analysis a = StratEdits.analysis(s); + int u = a == null ? -1 : a.img().unitOf(st.code()); + if (u >= 0) { + selectUnit(u); + show(u, st.code()); + } + } + + /** Shows a unit, scrolled to the row of STPC offset at (−1 = top). */ + void show(int unit, int at) { + Scene s = ctx.scene(); + model.clear(); + if (s == null || unit < 0) { + listing = null; + return; + } + listing = StratEdits.listing(s, unit); + editedAt = new HashSet<>(); + Project p = ctx.document().project(); + int start = listing.start; + for (StratEdits.Constant c : StratEdits.records(p).constants()) { + if (c.unit() == unit) { + editedAt.add(start + c.at()); + } + } + List rows = new ArrayList<>(); + if ("Script".equals(mode.getSelectedItem())) { + StratPseudo ps = StratPseudo.of(listing); + List> cs = ps.constants(); + for (int i = 0; i < ps.lines.size(); i++) { + StratPseudo.Line l = ps.lines.get(i); + rows.add(new Row(" ".repeat(l.depth()) + l.text(), l.offset(), cs.get(i), null)); + } + } else { + for (StratListing.Line l : listing.lines) { + rows.add(new Row(l.render(), l.offset(), l.constAt() >= 0 ? List.of(l) : List.of(), l)); + } + } + rows.forEach(model::addElement); + int sel = -1; + if (at >= 0) { + for (int i = 0; i < rows.size(); i++) { + if (rows.get(i).offset == at) { + sel = i; + break; + } + } + } + if (sel >= 0) { + list.setSelectedIndex(sel); + int vis = Math.min(model.size() - 1, sel + list.getVisibleRowCount()); + list.ensureIndexIsVisible(vis); + list.ensureIndexIsVisible(sel); + } + int nConst = (int) listing.lines.stream().filter(l -> l.constAt() >= 0).count(); + status.setText(String.join(", ", listing.strats.values()) + " · unit " + unit + " · " + + (listing.end - listing.start) / 4 + " words · " + nConst + " editable constants" + + (editedAt.isEmpty() ? "" : " · " + editedAt.size() + " edited") + + (StratEdits.isCustomUnit(s, unit) ? " · custom type" : "") + + " · double-click a line to edit its value"); + describeUnit(); + } + + private void describeUnit() { + Scene s = ctx.scene(); + if (listing == null || s == null) { + info.setText(""); + return; + } + StringBuilder b = new StringBuilder(); + b.append("Strats in this unit: ") + .append(String.join(", ", listing.strats.values())) + .append('\n'); + if (!listing.aliases.isEmpty()) { + b.append("\nParameters (from the object schema):\n"); + listing.aliases.forEach((i, n) -> b.append(" v") + .append(i) + .append(" = ") + .append(n) + .append(" (") + .append(uses("\\b" + Pattern.quote(n) + "\\b")) + .append(" uses)\n")); + } + b.append("\nReferenced from other code (spawned / addressed by):\n"); + StratEdits.Analysis a = StratEdits.analysis(s); + Map by = new LinkedHashMap<>(); + for (StratCode.Site site : a.code().addrSites) { + int from = a.img().unitOf(site.offset()); + if (a.img().unitOf(site.value()) == listing.unit && from != listing.unit && from >= 0) { + by.merge(StratEdits.unitName(s, from) + " (unit " + from + ")", 1, Integer::sum); + } + } + if (by.isEmpty()) { + b.append(" – (only placed in the level)\n"); + } + by.forEach( + (k, v) -> b.append(" ").append(k).append(v > 1 ? " ×" + v : "").append('\n')); + b.append("\nPlaced instances: "); + int n = 0; + for (Strat st : s.strats()) { + n += a.img().unitOf(st.code()) == listing.unit ? 1 : 0; + } + b.append(n).append('\n'); + b.append("\nSelect a line for help on its instruction."); + info.setText(b.toString()); + info.setCaretPosition(0); + } + + private int uses(String regex) { + Pattern p = Pattern.compile(regex); + int n = 0; + for (int i = 0; i < model.size(); i++) { + n += p.matcher(model.get(i).text).find() ? 1 : 0; + } + return n; + } + + private static final Pattern WORD = Pattern.compile("[A-Za-z_][A-Za-z0-9_]*"); + + private void describe(Row r) { + if (r == null) { + return; + } + StringBuilder b = new StringBuilder(); + Set seen = new HashSet<>(); + Matcher m = WORD.matcher(r.text.replaceAll("\"[^\"]*\"", "").replaceAll("//.*|;.*", "")); + OpTable t = OpTable.get(); + while (m.find()) { + String w = m.group(); + if (!seen.add(w)) { + continue; + } + OpTable.Op op = t.byName(w); + if (op != null && !w.startsWith("v")) { + b.append(op.name()) + .append(String.format(Locale.ROOT, " (opcode 0x%02x", op.code())) + .append(op.pops() >= 0 ? ", pops " + op.pops() : "") + .append(op.pushes() >= 0 ? ", pushes " + op.pushes() : "") + .append(", ") + .append(op.confidence()) + .append(" confidence") + .append(op.ref().equals("-") || op.ref().isEmpty() ? "" : ", Argonaut name " + op.ref()) + .append(")\n") + .append(op.doc()) + .append("\n\n"); + } + OpTable.Named pr = t.propByName(w); + if (pr != null && r.text.contains("." + w) || pr != null && r.text.matches(".*[Pp]rop " + w + "\\b.*")) { + b.append("property ") + .append(w) + .append(" (") + .append(pr.id()) + .append(pr.readOnly() ? ", read-only" : "") + .append(")\n") + .append(pr.doc()) + .append("\n\n"); + } + } + for (StratListing.Line c : r.consts) { + b.append("Constant ") + .append(c.constKind() == null ? "" : c.constKind().name().toLowerCase(Locale.ROOT)) + .append(": ") + .append(ConstantDialog.describe(c.value(), c.constKind())) + .append(editedAt.contains(c.constAt()) ? " (edited)" : "") + .append('\n'); + } + if (b.isEmpty()) { + describeUnit(); + return; + } + info.setText(b.toString()); + info.setCaretPosition(0); + } + + private void find(int dir) { + String q = search.getText().trim().toLowerCase(Locale.ROOT); + if (q.isEmpty() || model.isEmpty()) { + return; + } + int n = model.size(); + int from = list.getSelectedIndex(); + for (int k = 1; k <= n; k++) { + int i = Math.floorMod(from + dir * k, n); + if (model.get(i).text.toLowerCase(Locale.ROOT).contains(q)) { + list.setSelectedIndex(i); + list.ensureIndexIsVisible(i); + status.setText("'" + search.getText().trim() + "': " + + uses(Pattern.quote(search.getText().trim())) + " lines"); + return; + } + } + status.setText("'" + search.getText().trim() + "' not found in this unit"); + } + + /** Uses of the selected line's main operand inside the unit (variable, property, label …). */ + private void references() { + Row r = list.getSelectedValue(); + if (r == null) { + return; + } + String text = + r.text.replaceAll("\"[^\"]*\"", "").replaceAll("//.*|;.*", "").trim(); + Matcher m = WORD.matcher(text); + String best = null; + while (m.find()) { + String w = m.group(); + if (OpTable.get().byName(w) == null + && !w.matches( + "if|goto|loop|else|strat|push|self|player|parent|camera|target|target2|boss|dialog|return|yield|call")) { + best = w; + break; + } + } + if (best == null) { + status.setText("No operand to look up on this line"); + return; + } + search.setText(best); + Pattern p = Pattern.compile("\\b" + Pattern.quote(best) + "\\b"); + StringBuilder b = new StringBuilder("Uses of " + best + " in this unit:\n"); + int n = 0; + for (int i = 0; i < model.size(); i++) { + if (p.matcher(model.get(i).text).find()) { + n++; + if (n <= 200) { + b.append(String.format( + Locale.ROOT, "%5d %s%n", i + 1, model.get(i).text.trim())); + } + } + } + info.setText(b.toString()); + info.setCaretPosition(0); + status.setText(best + ": " + n + " lines (Find → Next to step through them)"); + } + + private void editSelected() { + Row r = list.getSelectedValue(); + if (r == null || listing == null || ctx.document() == null) { + return; + } + if (r.consts.isEmpty()) { + status.setText( + "This line has no editable constant (numbers pushed with push / pushShort, sounds, textures, texts)"); + return; + } + if (StratEdits.isCustomUnit(ctx.scene(), listing.unit)) { + status.setText("Custom types are edited through their source: clone it again with the changed script"); + return; + } + Map changes = ConstantDialog.show(ctx, r.consts); + if (changes == null || changes.isEmpty()) { + return; + } + Project p = ctx.document().project(); + int unit = listing.unit; + List cmds = new ArrayList<>(); + for (var e : changes.entrySet()) { + cmds.add(new ConstantEdit(p, unit, e.getKey(), e.getValue())); + } + for (Command c : cmds) { + try { + ctx.execute(c); + } catch (RuntimeException ex) { + ctx.error("Edit script constant", ex); + return; + } + } + ctx.status("Script constant changed – rebuild/play to see the effect"); + } + + /** Undoable constant change (reference + scene + project record). */ + static final class ConstantEdit implements Command { + private final Project p; + private final int unit, constAt, old, value; + private final boolean isShort; + private final String text; + + ConstantEdit(Project p, int unit, StratListing.Line l, int value) { + this.p = p; + this.unit = unit; + this.constAt = l.constAt(); + this.isShort = l.constShort(); + this.old = l.value(); + this.value = value; + this.text = l.text(); + } + + @Override + public String label() { + return "Edit script constant (" + text + ")"; + } + + @Override + public void apply(Scene s) { + StratEdits.setConstant(p, unit, constAt, isShort, old, value); + } + + @Override + public void revert(Scene s) { + StratEdits.setConstant(p, unit, constAt, isShort, value, old); + } + } + + private final class RowRenderer extends DefaultListCellRenderer { + @Override + public Component getListCellRendererComponent(JList l, Object v, int i, boolean sel, boolean focus) { + Row r = (Row) v; + super.getListCellRendererComponent(l, r.text.isEmpty() ? " " : r.text, i, sel, focus); + boolean edited = r.consts.stream().anyMatch(c -> editedAt.contains(c.constAt())); + if (!sel) { + String t = r.text.trim(); + if (edited) { + setForeground(new Color(0xd0, 0x70, 0x00)); + } else if (t.startsWith("//") || t.startsWith(";")) { + setForeground(Color.GRAY); + } else if (t.startsWith("strat ") || t.endsWith(":") && !t.contains(" ")) { + setForeground(new Color(0x2a, 0x7a, 0xd8)); + } else if (t.startsWith(".")) { + setForeground(new Color(0x80, 0x80, 0x80)); + } + } + setFont(r.text.trim().startsWith("strat ") ? getFont().deriveFont(Font.BOLD) : getFont()); + setToolTipText( + r.consts.isEmpty() + ? null + : "Double-click to edit " + r.consts.size() + " constant" + + (r.consts.size() == 1 ? "" : "s")); + return this; + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratEdits.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratEdits.java new file mode 100644 index 0000000..54c87ca --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratEdits.java @@ -0,0 +1,731 @@ +package com.eliasmueller.llamamod.editor.tools.strats; + +import com.eliasmueller.llamamod.editor.io.Bases; +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.io.ProjectIO; +import com.eliasmueller.llamamod.editor.model.RawChunk; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema; +import com.eliasmueller.llamamod.editor.tools.objects.Schemas; +import com.eliasmueller.llamamod.format.Bytes; +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StratCode; +import com.eliasmueller.llamamod.format.stpcmerge.StratOps; +import com.eliasmueller.llamamod.format.strat.StratAssembler; +import com.eliasmueller.llamamod.format.strat.StratListing; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.WeakHashMap; + +/** + * Script edits of a project (level.json key {@value #KEY}, notes/strat-language.md): constant + * patches of existing code units and custom strat types (source text, appended as new units at + * the end of the STPC code region). Like imported types they are applied to the reference and the + * current scene on load, so the scene diff keeps describing only placements and other edits. + * Load order: constants of base-level units, imports (CrossImport), constants of imported units, + * custom types. + */ +public final class StratEdits { + public static final String KEY = "stratEdits"; + + /** Patch of one operand word: unit index, byte offset in the unit, old/new value, short = high 16 bits. */ + public record Constant(int unit, String unitName, int at, int old, int value, boolean isShort) { + Map json() { + Map m = new LinkedHashMap<>(); + m.put("unit", (long) unit); + m.put("name", unitName); + m.put("at", (long) at); + m.put("old", (long) old); + m.put("new", (long) value); + if (isShort) { + m.put("short", true); + } + return m; + } + + static Constant of(Map m) { + return new Constant( + num(m, "unit"), + String.valueOf(m.get("name")), + num(m, "at"), + num(m, "old"), + num(m, "new"), + Boolean.TRUE.equals(m.get("short"))); + } + } + + /** + * A custom type: name, source text, the entry label in the source, type fields (MAP record + * prototype), and where it came from. + */ + public record CustomType( + String name, String basedOn, String entryLabel, String source, StratCatalog.Variant proto) { + Map json() { + Map m = new LinkedHashMap<>(); + m.put("name", name); + m.put("basedOn", basedOn == null ? "" : basedOn); + m.put("entry", entryLabel); + m.put("source", source); + Map p = new LinkedHashMap<>(); + p.put("nVars", (long) proto.nVars()); + p.put("slots", (long) proto.slots()); + p.put("typeFlags", (long) proto.typeFlags()); + p.put("extra", (long) proto.extra()); + p.put("flags", (long) proto.flags()); + List prm = new ArrayList<>(); + for (int v : proto.params()) { + prm.add((long) v); + } + p.put("params", prm); + List ch = new ArrayList<>(); + for (int[] n : proto.chain()) { + List r = new ArrayList<>(); + for (int v : n) { + r.add((long) v); + } + ch.add(r); + } + p.put("chain", ch); + m.put("proto", p); + return m; + } + + static CustomType of(Map m) { + Map p = (Map) m.get("proto"); + List prm = (List) p.get("params"); + int[] params = new int[prm.size()]; + for (int i = 0; i < params.length; i++) { + params[i] = ((Number) prm.get(i)).intValue(); + } + List chain = new ArrayList<>(); + if (p.get("chain") instanceof List ch) { + for (Object o : ch) { + List r = (List) o; + int[] n = new int[r.size()]; + for (int i = 0; i < n.length; i++) { + n[i] = ((Number) r.get(i)).intValue(); + } + chain.add(n); + } + } + StratCatalog.Variant v = new StratCatalog.Variant( + 0, + num(p, "nVars"), + num(p, "slots"), + num(p, "typeFlags"), + num(p, "extra"), + num(p, "flags"), + 0, + 0, + 0, + params, + List.copyOf(chain), + "custom"); + return new CustomType( + (String) m.get("name"), + (String) m.get("basedOn"), + (String) m.get("entry"), + (String) m.get("source"), + v); + } + } + + /** Edits of a project, in order. */ + public record Records(List constants, List types) { + static Records of(Map extra) { + List c = new ArrayList<>(); + List t = new ArrayList<>(); + if (extra.get(KEY) instanceof Map m) { + if (m.get("constants") instanceof List l) { + l.forEach(o -> c.add(Constant.of((Map) o))); + } + if (m.get("types") instanceof List l) { + l.forEach(o -> t.add(CustomType.of((Map) o))); + } + } + return new Records(c, t); + } + + void store(Map extra, Map session) { + if (constants.isEmpty() && types.isEmpty()) { + extra.remove(KEY); + return; + } + Map m = new LinkedHashMap<>(); + List c = new ArrayList<>(); + constants.forEach(x -> c.add(x.json())); + List t = new ArrayList<>(); + types.forEach(x -> t.add(x.json())); + m.put("constants", c); + m.put("types", t); + if (session != null) { + m.put("session", session); + } + extra.put(KEY, m); + } + } + + private StratEdits() {} + + /** Units of the base level (before imports) and of custom types per scene. */ + private static final class State { + int baseUnits = -1; + boolean edited; + int firstCustom = -1; + final List customEntries = new ArrayList<>(); + } + + private static final Map STATE = Collections.synchronizedMap(new WeakHashMap<>()); + + private static State state(Scene s) { + return STATE.computeIfAbsent(s, k -> new State()); + } + + static { + ProjectIO.addLoadHook(new ProjectIO.LoadHook() { + @Override + public void beforeChanges(Map json, Bases cd, Scene ref, Scene cur, List w) { + beforeImports(json, ref, cur, w); + } + + @Override + public int order() { + return -10; + } + }); + ProjectIO.addLoadHook(new ProjectIO.LoadHook() { + @Override + public void beforeChanges(Map json, Bases cd, Scene ref, Scene cur, List w) { + afterImports(json, cd, ref, cur, w); + } + + @Override + public int order() { + return 10; + } + }); + } + + /** Makes sure the load hooks are registered. */ + public static void init() {} + + private static int num(Map m, String k) { + return ((Number) m.get(k)).intValue(); + } + + // ------------------------------------------------------------------ load + + private static void beforeImports(Map json, Scene ref, Scene cur, List warnings) { + Records r = Records.of(json); + int base = units(cur); + state(ref).baseUnits = base; + state(cur).baseUnits = base; + for (Constant c : r.constants) { + if (c.unit < base) { + applyLoaded(c, ref, cur, warnings); + } + } + } + + private static void afterImports(Map json, Bases cd, Scene ref, Scene cur, List warnings) { + Records r = Records.of(json); + int base = state(cur).baseUnits; + for (Constant c : r.constants) { + if (c.unit >= base && (state(cur).firstCustom < 0 || c.unit < state(cur).firstCustom)) { + applyLoaded(c, ref, cur, warnings); + } + } + if (r.types.isEmpty()) { + return; + } + int startBefore = codeEnd(cur); + for (CustomType t : r.types) { + try { + appendType(ref, t, false); + appendType(cur, t, false); + } catch (RuntimeException e) { + warnings.add("custom strat type " + t.name + " could not be assembled: " + e.getMessage()); + } + } + if (json.get(KEY) instanceof Map m && m.get("session") instanceof Map ses) { + int cs = num(ses, "start"), ce = num(ses, "end"); + int b = startBefore - cs; + if (b != 0) { + remapCodes(json, cs, ce, b); + } + } + } + + private static void applyLoaded(Constant c, Scene ref, Scene cur, List warnings) { + String e = check(ref, c); + if (e != null) { + warnings.add( + "script constant edit skipped (" + c.unitName + " +0x" + Integer.toHexString(c.at) + "): " + e); + return; + } + for (Scene s : List.of(ref, cur)) { + chunk(s).setRaw("data", patched(s, c, c.value)); + state(s).edited = true; + } + } + + /** + * Code offsets in the stored diff were written with the custom units at [cs, ce) followed by b + * bytes of later imports; on load the imports come first. + */ + @SuppressWarnings("unchecked") + private static void remapCodes(Map json, int cs, int ce, int b) { + if (!(json.get("nodes") instanceof Map nodes) || !(nodes.get("strat") instanceof Map st)) { + return; + } + List> recs = new ArrayList<>(); + if (st.get("added") instanceof List l) { + l.forEach(o -> recs.add((Map) o)); + } + if (st.get("changed") instanceof Map ch) { + ch.values().forEach(o -> recs.add((Map) o)); + } + for (Map m : recs) { + if (m.get("code") instanceof Number n) { + int v = n.intValue(); + if (v >= cs && v < ce) { + m.put("code", (long) (v + b)); + } else if (v >= ce && v < ce + b) { + m.put("code", (long) (v - (ce - cs))); + } + } + } + } + + // ------------------------------------------------------------------ analysis + + /** Disassembly of a scene's STPC (cached per chunk data array). */ + public record Analysis(StpcImage img, StratCode code, Set entries) { + public int unitOfCode(int code) { + return img.unitOf(code); + } + } + + private record Cached(byte[] data, Analysis a) {} + + private static final Map CACHE = Collections.synchronizedMap(new WeakHashMap<>()); + + static RawChunk chunk(Scene s) { + for (RawChunk c : s.chunks()) { + if (c.tag().equals("STPC")) { + return c; + } + } + return null; + } + + public static Analysis analysis(Scene s) { + RawChunk c = chunk(s); + if (c == null || c.data() == null) { + return null; + } + Cached k = CACHE.get(s); + if (k != null && k.data == c.data()) { + return k.a; + } + StpcImage img = new StpcImage(c.data(), s.settings().wfpc()); + Set roots = new TreeSet<>(); + for (Strat x : s.strats()) { + roots.add(x.code()); + } + roots.addAll(s.catalog().names().keySet()); + roots.removeIf(o -> o < img.codeStart || img.unitOf(o) < 0); + Analysis a = new Analysis(img, StratCode.analyse(img, roots), roots); + CACHE.put(s, new Cached(c.data(), a)); + return a; + } + + /** Listing of a unit with parameter names from the object schemas. */ + public static StratListing listing(Scene s, int unit) { + Analysis a = analysis(s); + StratCatalog cat = s.catalog(); + return StratListing.of(a.img, a.code, unit, a.entries, new StratListing.Names() { + @Override + public String param(int code, int i) { + String n = cat.nameOf(code); + if (n == null) { + n = com.eliasmueller.llamamod.format.Strats.name(a.img.data, code, a.img.codeStart); + } + ParamSchema.TypeSchema ts = Schemas.of(n); + ParamSchema.ParamDef d = ts == null ? null : ts.param(i); + return d == null ? null : ident(d.name()); + } + + @Override + public String entry(int code) { + return cat.nameOf(code); + } + }); + } + + static String ident(String label) { + StringBuilder b = new StringBuilder(); + boolean up = false; + for (char c : label.toCharArray()) { + if (Character.isLetterOrDigit(c) && c < 128) { + b.append(up && !b.isEmpty() ? Character.toUpperCase(c) : b.isEmpty() ? Character.toLowerCase(c) : c); + up = false; + } else { + up = true; + } + } + if (b.isEmpty() || Character.isDigit(b.charAt(0))) { + return null; + } + return b.toString(); + } + + static int units(Scene s) { + Analysis a = analysis(s); + return a == null ? 0 : a.img.units.size(); + } + + static int codeEnd(Scene s) { + return chunk(s).data().length; + } + + /** First unit index of the custom types in this scene (−1 = none). */ + public static int firstCustomUnit(Scene s) { + return state(s).firstCustom; + } + + /** Whether constant edits or custom types were applied to this scene. */ + public static boolean hasEdits(Scene s) { + return state(s).edited || state(s).firstCustom >= 0; + } + + public static boolean isCustomUnit(Scene s, int unit) { + int f = state(s).firstCustom; + return f >= 0 && unit >= f && unit < f + state(s).customEntries.size(); + } + + /** Index into the project's custom types of a unit, or −1. */ + public static int customIndex(Scene s, int unit) { + return isCustomUnit(s, unit) ? unit - state(s).firstCustom : -1; + } + + // ------------------------------------------------------------------ constants + + /** Why a constant edit does not fit the scene's code (null = fits). */ + static String check(Scene s, Constant c) { + Analysis a = analysis(s); + if (a == null || c.unit < 0 || c.unit >= a.img.units.size()) { + return "unit " + c.unit + " does not exist"; + } + StpcImage.Span u = a.img.units.get(c.unit); + int o = u.start() + c.at; + int ins = c.isShort ? o : o - 4; + if (o < u.start() || o + 4 > u.end() || a.code.length(ins) == 0) { + return "no instruction there"; + } + int op = a.img.u32(ins) & 0xffff; + if (c.isShort + ? op != StratOps.PUSH_SHORT + : op != StratOps.PUSH_IMM + && op != StratOps.PUSH_SOUND + && op != StratOps.TEXT_A + && op != StratOps.TEXT_B) { + return "the instruction is not a constant"; + } + int cur = c.isShort ? a.img.u32(o) >> 16 : a.img.u32(o); + if (cur != c.old) { + return "the code differs (found " + cur + ", expected " + c.old + ")"; + } + if (c.isShort && (c.value < Short.MIN_VALUE || c.value > Short.MAX_VALUE)) { + return "value out of the 16-bit range"; + } + return null; + } + + static byte[] patched(Scene s, Constant c, int value) { + Analysis a = analysis(s); + byte[] d = a.img.data.clone(); + int o = a.img.units.get(c.unit).start() + c.at; + int w = c.isShort ? (value << 16) | (Bytes.i32(d, o) & 0xffff) : value; + d[o] = (byte) w; + d[o + 1] = (byte) (w >> 8); + d[o + 2] = (byte) (w >> 16); + d[o + 3] = (byte) (w >> 24); + return d; + } + + /** + * Sets the constant word at STPC offset constAt (current value cur) of a unit to value in + * reference and scene and records it (merged with an earlier edit of the same word). + */ + static void setConstant(Project p, int unit, int constAt, boolean isShort, int cur, int value) { + Scene sc = p.scene(); + Analysis a = analysis(sc); + StpcImage.Span u = a.img.units.get(unit); + int at = constAt - u.start(); + String name = unitName(sc, unit); + Records before = Records.of(p.extra()); + List cs = new ArrayList<>(before.constants); + int orig = cur; + for (int i = 0; i < cs.size(); i++) { + Constant c = cs.get(i); + if (c.unit == unit && c.at == at && c.isShort == isShort) { + orig = c.old; + cs.remove(i); + break; + } + } + Constant c0 = new Constant(unit, name, at, cur, value, isShort); + String e = check(sc, c0); + if (e != null) { + throw new IllegalArgumentException(e); + } + for (Scene s : List.of(p.reference(), sc)) { + RawChunk ch = chunk(s); + byte[] old = ch.data(); + ch.setRaw("data", patched(s, c0, value)); + state(s).edited = true; + if (s == sc) { + s.fire(SceneEvent.changed(ch, ch.prop("data"), old)); + } + } + if (value != orig) { + cs.add(new Constant(unit, name, at, orig, value, isShort)); + } + new Records(cs, before.types).store(p.extra(), session(sc)); + } + + static String unitName(Scene s, int unit) { + Analysis a = analysis(s); + List sites = a.img.nameSites(a.img.units.get(unit)); + return sites.isEmpty() + ? "unit " + unit + : Bytes.cstr(a.img.data, (int) (sites.get(0) + 12L + a.img.u32(sites.get(0) + 8))); + } + + // ------------------------------------------------------------------ custom types + + /** Result of assembling a custom type against a scene (not yet applied). */ + public record Built(byte[] stpc, int base, int entry, StratAssembler.Result asm, List problems) {} + + /** Assembles a type's source as a new unit appended to the scene's code and verifies it. */ + public static Built build(Scene s, CustomType t) { + Analysis a = analysis(s); + byte[] old = a.img.data; + int base = old.length + 4; + StratAssembler.Result r = StratAssembler.assemble(t.source, StratListing.layout(a.img, base)); + Integer e = r.labels().get(t.entryLabel); + if (e == null) { + throw new IllegalArgumentException("entry label " + t.entryLabel + " not found in the source"); + } + byte[] words = r.bytes(); + byte[] d = new byte[old.length + 4 + words.length]; + System.arraycopy(old, 0, d, 0, old.length); + int n = words.length / 4; + d[old.length] = (byte) n; + d[old.length + 1] = (byte) (n >> 8); + d[old.length + 2] = (byte) (n >> 16); + d[old.length + 3] = (byte) (n >> 24); + System.arraycopy(words, 0, d, old.length + 4, words.length); + int nu = Bytes.i32(d, a.img.codeStart) + 1; + d[a.img.codeStart] = (byte) nu; + d[a.img.codeStart + 1] = (byte) (nu >> 8); + d[a.img.codeStart + 2] = (byte) (nu >> 16); + d[a.img.codeStart + 3] = (byte) (nu >> 24); + List problems = verify(d, s.settings().wfpc(), a.entries, base, base + e, r); + return new Built(d, base, base + e, r, problems); + } + + /** + * Re-disassembly of the merged code: no decode errors, the new unit's listing assembles back + * to the same words, and every address operand of the new unit hits a model, an animation set + * or code. + */ + static List verify(byte[] d, int wfpc, Set entries, int base, int entry, StratAssembler.Result r) { + List out = new ArrayList<>(); + StpcImage img = new StpcImage(d, wfpc); + Set roots = new TreeSet<>(entries); + roots.add(entry); + for (int v : r.strats().values()) { + roots.add(base + v); + } + StratCode c = StratCode.analyse(img, roots); + int u = img.units.size() - 1; + c.errors.stream().limit(5).forEach(out::add); + if (c.length(entry) == 0) { + out.add("the entry is not decoded as code"); + } + for (StratCode.Site site : c.addrSites) { + if (img.unitOf(site.offset()) != u) { + continue; + } + int v = site.value(); + boolean ok = v < 0 || img.modelAt(v) >= 0 || img.animAt(v) >= 0 || img.unitOf(v) >= 0; + if (!ok) { + out.add("address operand at +0x" + Integer.toHexString(site.offset() - base) + " points nowhere (0x" + + Integer.toHexString(v) + ")"); + } + } + StratListing li = StratListing.of(img, c, u, roots, StratListing.Names.NONE); + int[] back = StratAssembler.assemble(li.text(), StratListing.layout(img, base)) + .words(); + if (!java.util.Arrays.equals(back, r.words())) { + out.add("re-disassembly does not reproduce the assembled code"); + } + return out; + } + + /** Appends a custom type to a scene (no events unless fire) and adds it to the catalog. */ + static int appendType(Scene s, CustomType t, boolean fire) { + Built b = build(s, t); + if (!b.problems.isEmpty()) { + throw new IllegalArgumentException(String.join("; ", b.problems)); + } + State st = state(s); + int unit = units(s); + Runnable r = () -> { + chunk(s).setRaw("data", b.stpc); + StratCatalog.Variant p = t.proto; + s.catalog() + .add( + t.name, + new StratCatalog.Variant( + b.entry, + p.nVars(), + p.slots(), + p.typeFlags(), + p.extra(), + p.flags(), + p.rx(), + p.ry(), + p.rz(), + p.params(), + p.chain(), + "custom")); + for (var e : b.asm.strats().entrySet()) { + s.catalog().addName(b.base + e.getValue(), e.getKey()); + } + }; + if (fire) { + s.bulk(r); + } else { + r.run(); + } + if (st.firstCustom < 0) { + st.firstCustom = unit; + } + st.customEntries.add(b.entry); + return b.entry; + } + + /** Adds a custom type to an open project (reference + scene) and records it. Returns the entry code. */ + public static int addType(Project p, CustomType t) { + appendType(p.reference(), t, false); + int code = appendType(p.scene(), t, true); + Records r = Records.of(p.extra()); + List ts = new ArrayList<>(r.types); + ts.add(t); + new Records(r.constants, ts).store(p.extra(), session(p.scene())); + return code; + } + + /** Current position of the custom units (for remapping codes on load), or null. */ + static Map session(Scene s) { + State st = state(s); + Analysis a = analysis(s); + if (st.firstCustom < 0 || a == null || st.firstCustom >= a.img.units.size()) { + return null; + } + int last = st.firstCustom + st.customEntries.size() - 1; + Map m = new LinkedHashMap<>(); + m.put("start", (long) (a.img.units.get(st.firstCustom).start() - 4)); + m.put("end", (long) + a.img.units.get(Math.min(last, a.img.units.size() - 1)).end()); + return m; + } + + /** Refreshes the stored custom-unit position (after imports moved nothing else, they come after). */ + static void updateSession(Project p) { + Records r = Records.of(p.extra()); + if (!r.types.isEmpty()) { + r.store(p.extra(), session(p.scene())); + } + } + + public static Records records(Project p) { + return Records.of(p.extra()); + } + + /** + * A custom type copied from a placeable type: the source of its unit with the strat renamed, + * the type fields of its first variant. + */ + public static CustomType cloneOf(Scene s, String type, String newName) { + StratCatalog.Type t = s.catalog().type(type); + if (t == null) { + throw new IllegalArgumentException("unknown type " + type); + } + StratCatalog.Variant v = t.variant(0); + Analysis a = analysis(s); + int unit = a.img.unitOf(v.code()); + StratListing li = listing(s, unit); + String entry = li.labels.get(v.code()); + String oldName = null; + for (var e : li.strats.entrySet()) { + if (StratListing.ident(e.getValue()).equals(entry) || e.getKey() == v.code()) { + oldName = e.getValue(); + } + } + String src = li.text(); + String newEntry = entry; + if (oldName != null && entry != null && entry.equals(identOf(oldName))) { + src = rename(src, oldName, newName); + newEntry = identOf(newName); + } + if (entry == null) { + throw new IllegalArgumentException(type + " has no entry label"); + } + return new CustomType(newName, type, newEntry, src, v); + } + + static String identOf(String name) { + StringBuilder b = new StringBuilder(); + for (char c : name.toCharArray()) { + b.append(Character.isLetterOrDigit(c) && c < 128 ? c : '_'); + } + return b.toString(); + } + + /** Renames a strat in a listing: its label, references to it and its name string. */ + static String rename(String src, String oldName, String newName) { + String ol = java.util.regex.Pattern.quote(identOf(oldName)); + String q = java.util.regex.Pattern.quote("\"" + oldName + "\""); + StringBuilder b = new StringBuilder(); + for (String line : src.split("\n", -1)) { + String t = line.trim(); + if (t.startsWith(".string") || t.startsWith("debugName")) { + line = line.replaceAll( + q + "(?=\\s*(,|$|;))", java.util.regex.Matcher.quoteReplacement("\"" + newName + "\"")); + } else if (!t.startsWith(";")) { + line = line.replaceAll( + "(? customNames(Project p) { + return records(p).types.stream().map(CustomType::name).toList(); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratsEditorPlugin.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratsEditorPlugin.java new file mode 100644 index 0000000..9c6aba2 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratsEditorPlugin.java @@ -0,0 +1,133 @@ +package com.eliasmueller.llamamod.editor.tools.strats; + +import com.eliasmueller.llamamod.editor.model.Node; +import com.eliasmueller.llamamod.editor.model.RawChunk; +import com.eliasmueller.llamamod.editor.model.SceneEvent; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.ui.ActionSpec; +import com.eliasmueller.llamamod.editor.ui.Dock; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.editor.ui.EditorExtensions; +import com.eliasmueller.llamamod.editor.ui.EditorPlugin; +import com.eliasmueller.llamamod.editor.ui.Icons; +import com.eliasmueller.llamamod.editor.ui.InspectorSection; +import com.eliasmueller.llamamod.editor.ui.PanelSpec; +import com.eliasmueller.llamamod.editor.ui.Problem; +import com.eliasmueller.llamamod.editor.ui.Widgets; +import java.awt.BasicStroke; +import java.awt.BorderLayout; +import java.awt.geom.Line2D; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JComponent; +import javax.swing.JLabel; +import javax.swing.JPanel; + +/** Script view and editing of strat code (notes/strat-language.md). */ +public final class StratsEditorPlugin implements EditorPlugin { + static final String PANEL = "strats.script"; + + @Override + public void install(EditorExtensions x) { + StratEdits.init(); + Icons.register("script", (g, c) -> { + g.setStroke(new BasicStroke(1.4f, BasicStroke.CAP_ROUND, BasicStroke.JOIN_ROUND)); + g.draw(new Line2D.Double(5.5, 3.5, 2.5, 8)); + g.draw(new Line2D.Double(2.5, 8, 5.5, 12.5)); + g.draw(new Line2D.Double(10.5, 3.5, 13.5, 8)); + g.draw(new Line2D.Double(13.5, 8, 10.5, 12.5)); + g.draw(new Line2D.Double(9, 3, 7, 13)); + }); + x.panel(new PanelSpec(PANEL, "Script", "script", Dock.BOTTOM, 40, true, ScriptPanel::new)); + x.action(ActionSpec.of( + "strats.show", + "View/Panels", + 50, + 40, + "Script", + "script", + "ctrl shift K", + c -> c.showPanel(PANEL)) + .tip("Strat script of the selected object type")); + x.action(ActionSpec.of( + "strats.newType", + "Level", + 42, + 10, + "New object type from script…", + "script", + null, + c -> TypeDialog.showNew(c)) + .enabledWhen(c -> c.document() != null) + .tip("Create a custom object type from strat source text")); + x.inspector(new ScriptSection()); + x.check((scene, out) -> { + if (!StratEdits.hasEdits(scene)) { + return; + } + StratEdits.Analysis a = StratEdits.analysis(scene); + if (a != null && !a.code().errors.isEmpty()) { + out.add(Problem.error( + "Strat code does not disassemble cleanly: " + + a.code().errors.get(0), + null)); + } + }); + x.onOpen(ctx -> ctx.addSceneListener(e -> { + if (ctx.document() != null + && e.node() instanceof RawChunk c + && c.tag().equals("STPC") + || e.type() == SceneEvent.Type.RELOADED && ctx.document() != null) { + StratEdits.updateSession(ctx.document().project()); + } + })); + } + + /** Inspector: the object's script type, edited constants, button to the Script panel. */ + private static final class ScriptSection implements InspectorSection { + @Override + public String title() { + return "Script"; + } + + @Override + public int order() { + return 30; + } + + @Override + public boolean appliesTo(List sel) { + return sel.size() == 1 && sel.get(0) instanceof Strat s && s.scene() != null; + } + + @Override + public JComponent create(EditorContext ctx, List sel) { + Strat s = (Strat) sel.get(0); + StratEdits.Analysis a = StratEdits.analysis(s.scene()); + String info; + if (a == null || a.img().unitOf(s.code()) < 0) { + info = "No strat code at this object's code offset."; + } else { + int u = a.img().unitOf(s.code()); + long edited = ctx.document() == null + ? 0 + : StratEdits.records(ctx.document().project()).constants().stream() + .filter(c -> c.unit() == u) + .count(); + info = "" + s.type() + " · code unit " + u + " · " + + (a.img().units.get(u).size() / 4) + " words" + + (StratEdits.isCustomUnit(s.scene(), u) ? " · custom type" : "") + + (edited > 0 ? " · " + edited + " edited constant" + (edited == 1 ? "" : "s") : "") + + ""; + } + JPanel p = new JPanel(new BorderLayout(6, 0)); + p.setBorder(BorderFactory.createEmptyBorder(4, 6, 4, 6)); + p.add(new JLabel(info, Icons.get("script", 16), JLabel.LEADING), BorderLayout.CENTER); + p.add( + Widgets.button( + "Open script", null, "Show and edit this type's strat script", () -> ctx.showPanel(PANEL)), + BorderLayout.EAST); + return p; + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeDialog.java new file mode 100644 index 0000000..0ca1616 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeDialog.java @@ -0,0 +1,223 @@ +package com.eliasmueller.llamamod.editor.tools.strats; + +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.model.Scene; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.ui.EditorContext; +import com.eliasmueller.llamamod.format.strat.StratAssembler; +import java.awt.BorderLayout; +import java.awt.Dimension; +import java.awt.FlowLayout; +import java.awt.Font; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JButton; +import javax.swing.JComboBox; +import javax.swing.JDialog; +import javax.swing.JLabel; +import javax.swing.JOptionPane; +import javax.swing.JPanel; +import javax.swing.JScrollPane; +import javax.swing.JTextArea; +import javax.swing.JTextField; +import javax.swing.SwingUtilities; + +/** Creates a custom object type from strat source (cloned from an existing type or from a template). */ +final class TypeDialog { + private TypeDialog() {} + + static final String TEMPLATE = + """ + ; A minimal custom strat: shows a model and spins it. + .unit MyStrat + .string "MyStrat" + MyStrat: + debugName "MyStrat" + address model:0 ; first model of the level (pick one from another type's script) + setModel + spin: + push 4deg ; turn per frame + turnLeft + jmpYield spin ; wait one frame, loop + end + """; + + static void showClone(EditorContext ctx, String type) { + if (ctx.document() == null) { + return; + } + if (type == null) { + JOptionPane.showMessageDialog( + ctx.frame(), + "This code unit has no placeable type to clone.", + "Clone type", + JOptionPane.INFORMATION_MESSAGE); + return; + } + Scene s = ctx.scene(); + String name = freeName(s, type + "Custom"); + StratEdits.CustomType t = StratEdits.cloneOf(s, type, name); + open(ctx, "Clone " + type + " as a new type", t.name(), t.source(), type, t.entryLabel(), true); + } + + static void showNew(EditorContext ctx) { + if (ctx.document() == null) { + return; + } + Scene s = ctx.scene(); + String base = + s.catalog().has("Coin") ? "Coin" : s.catalog().types().get(0).name(); + open(ctx, "New object type from script", freeName(s, "MyStrat"), TEMPLATE, base, "MyStrat", false); + } + + static String freeName(Scene s, String base) { + String n = base; + for (int k = 2; s.catalog().has(n) || s.catalog().names().containsValue(n); k++) { + n = base + k; + } + return n; + } + + private static void open( + EditorContext ctx, + String title, + String name, + String source, + String fieldsFrom, + String entry, + boolean clone) { + JDialog d = new JDialog(ctx.frame(), title, true); + JTextField nameField = new JTextField(name, 18); + JTextField entryField = new JTextField(entry, 14); + JComboBox from = new JComboBox<>(); + ctx.scene().catalog().types().forEach(t -> from.addItem(t.name())); + from.setSelectedItem(fieldsFrom); + JTextArea text = new JTextArea(source, 30, 90); + text.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); + text.setTabSize(8); + JLabel result = new JLabel(" "); + JPanel top = new JPanel(new FlowLayout(FlowLayout.LEADING)); + top.add(new JLabel("Name")); + top.add(nameField); + top.add(new JLabel("Entry label")); + top.add(entryField); + top.add(new JLabel("Type fields / parameters from")); + top.add(from); + JButton check = new JButton("Check"); + JButton create = new JButton("Create type"); + JButton cancel = new JButton("Cancel"); + JPanel bottom = new JPanel(new BorderLayout()); + JPanel buttons = new JPanel(new FlowLayout(FlowLayout.TRAILING)); + buttons.add(check); + buttons.add(create); + buttons.add(cancel); + result.setBorder(BorderFactory.createEmptyBorder(4, 8, 4, 8)); + bottom.add(result, BorderLayout.CENTER); + bottom.add(buttons, BorderLayout.EAST); + JPanel help = new JPanel(new BorderLayout()); + help.add( + new JLabel( + "" + + (clone + ? "The source of the original type, renamed. Edit constants or code freely; references to models, animations and other code stay valid. " + : "") + + "Syntax: notes/strat-language.md (manual chapter “Strat scripts”). The new code is appended to the level and verified by disassembling it again.")); + JPanel north = new JPanel(new BorderLayout()); + north.add(top, BorderLayout.NORTH); + north.add(help, BorderLayout.SOUTH); + d.getContentPane().add(north, BorderLayout.NORTH); + d.getContentPane().add(new JScrollPane(text), BorderLayout.CENTER); + d.getContentPane().add(bottom, BorderLayout.SOUTH); + Runnable[] verify = new Runnable[1]; + StratEdits.CustomType[] ok = new StratEdits.CustomType[1]; + verify[0] = () -> { + ok[0] = null; + String n = nameField.getText().trim(); + Scene s = ctx.scene(); + if (!n.matches("[A-Za-z_][A-Za-z0-9_]{0,40}")) { + result.setText("⚠ The name must be letters, digits and _ (max 40)."); + return; + } + if (s.catalog().has(n) || s.catalog().names().containsValue(n)) { + result.setText("⚠ A type named " + n + " already exists."); + return; + } + String src = text.getText(); + StratCatalog.Type ft = s.catalog().type((String) from.getSelectedItem()); + if (ft == null) { + result.setText("⚠ Choose the type whose type fields and parameters the new type uses."); + return; + } + StratEdits.CustomType t = new StratEdits.CustomType( + n, clone ? fieldsFrom : null, entryField.getText().trim(), src, ft.variant(0)); + try { + StratEdits.Built b = StratEdits.build(s, t); + if (!b.problems().isEmpty()) { + result.setText("⚠ " + String.join("; ", b.problems())); + return; + } + if (!b.asm().strats().isEmpty() + && b.asm().strats().keySet().stream().noneMatch(k -> k.equals(t.entryLabel()))) { + result.setText( + "Note: the entry label is not a strat with debugName – the type gets its name only in the editor. " + + b.asm().words().length + " words OK."); + } else { + result.setText("✓ " + b.asm().words().length + " words, verified. Entry " + t.entryLabel() + "."); + } + ok[0] = t; + } catch (StratAssembler.AsmException e) { + List ps = e.problems; + result.setText("⚠ " + ps.size() + " error" + (ps.size() == 1 ? "" : "s") + ": " + ps.get(0)); + highlight(text, ps.get(0).line()); + } catch (RuntimeException e) { + result.setText("⚠ " + e.getMessage()); + } + }; + check.addActionListener(e -> verify[0].run()); + create.addActionListener(e -> { + verify[0].run(); + if (ok[0] == null) { + return; + } + Project p = ctx.document().project(); + try { + StratEdits.addType(p, ok[0]); + } catch (RuntimeException ex) { + ctx.error("Create object type", ex); + return; + } + ctx.status("Object type " + ok[0].name() + " created – place it from Assets → Objects"); + d.dispose(); + }); + cancel.addActionListener(e -> d.dispose()); + d.setMinimumSize(new Dimension(800, 500)); + d.pack(); + java.awt.Rectangle screen = ctx.frame().getGraphicsConfiguration().getBounds(); + d.setSize(Math.min(1100, screen.width - 40), Math.min(760, screen.height - 60)); + int at = text.getText().indexOf("\n" + entry + ":"); + if (at >= 0) { + text.setCaretPosition(at + 1); + SwingUtilities.invokeLater(() -> { + try { + java.awt.geom.Rectangle2D r = text.modelToView2D(at + 1); + text.scrollRectToVisible( + new java.awt.Rectangle(0, (int) r.getY(), 10, text.getVisibleRect().height)); + } catch (javax.swing.text.BadLocationException e) { + // keep the top + } + }); + } + d.setLocationRelativeTo(ctx.frame()); + d.setVisible(true); + } + + private static void highlight(JTextArea t, int line) { + try { + int a = t.getLineStartOffset(line - 1), b = t.getLineEndOffset(line - 1); + t.requestFocusInWindow(); + t.select(a, b); + } catch (javax.swing.text.BadLocationException | IllegalArgumentException e) { + // line outside the text + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/Num.java b/src/main/java/com/eliasmueller/llamamod/format/strat/Num.java new file mode 100644 index 0000000..88d71a4 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/Num.java @@ -0,0 +1,118 @@ +package com.eliasmueller.llamamod.format.strat; + +import java.math.BigDecimal; +import java.util.Locale; + +/** Number literals of the strat language: integers, hex, 20.12 fixed point ("1.5"), angles ("90deg"). */ +public final class Num { + private Num() {} + + /** 20.12 fixed point: 0x1000 = 1.0. */ + public static final int ONE = 0x1000; + + /** Full turn in angle units: 4096.0 in 20.12 (angles wrap at 24 bits). */ + public static final int TURN = 0x1000000; + + /** Shortest decimal (≤ 4 fraction digits) d with round(d·4096) = v. */ + public static String fixed(int v) { + for (int digits = 1; digits <= 4; digits++) { + BigDecimal d = + BigDecimal.valueOf(v).divide(BigDecimal.valueOf(ONE), digits, java.math.RoundingMode.HALF_EVEN); + if (Math.round(d.doubleValue() * ONE) == v) { + String s = d.stripTrailingZeros().toPlainString(); + return s.contains(".") ? s : s + ".0"; + } + } + return Double.toString(v / (double) ONE); + } + + /** Literal for a constant: fixed when it reads naturally as one, else decimal / hex. */ + public static String literal(int v) { + if (Math.abs((long) v) >= 64 && v != Integer.MIN_VALUE) { + String f = fixed(v); + int dot = f.indexOf('.'); + if (dot >= 0 && f.length() - dot - 1 <= 3 && Math.round(Double.parseDouble(f) * ONE) == v) { + return f; + } + } + return integer(v); + } + + public static String integer(int v) { + return Math.abs((long) v) < 0x10000 ? Integer.toString(v) : hex(v); + } + + public static String hex(int v) { + return v < 0 && v > -0x10000 ? "-0x" + Integer.toHexString(-v) : "0x" + Integer.toHexString(v); + } + + /** Readable value for comments: "1.5 (6144)". */ + public static String describe(int v) { + return fixed(v) + " = " + v; + } + + public static String degrees(int v) { + double d = v * 360.0 / TURN; + String t = Math.abs(d) >= 1 || d == 0 + ? String.format(Locale.ROOT, "%.2f", d) + : String.format(Locale.ROOT, "%.4f", d); + return t.replaceAll("\\.?0+$", "") + "°"; + } + + /** "90deg" if that literal (≤ 3 decimals) parses back to exactly v, else null. */ + public static String degreeLiteral(int v) { + for (int digits = 0; digits <= 3; digits++) { + String d = BigDecimal.valueOf(v * 360.0 / TURN) + .setScale(digits, java.math.RoundingMode.HALF_EVEN) + .stripTrailingZeros() + .toPlainString(); + String lit = d + "deg"; + Integer back = parse(lit); + if (back != null && back == v) { + return lit; + } + } + return null; + } + + /** + * Parses a literal: decimal, 0x hex, -0x hex, fixed with a '.', "90deg" (angle units) or 'c' + * character literal. Returns null if it is not a number. + */ + public static Integer parse(String s) { + s = s.trim().replace("_", ""); + if (s.isEmpty()) { + return null; + } + try { + if (s.endsWith("deg")) { + double d = Double.parseDouble(s.substring(0, s.length() - 3)); + return (int) Math.round(d * (TURN / 360.0)); + } + boolean neg = s.startsWith("-"); + String b = neg || s.startsWith("+") ? s.substring(1) : s; + if (b.startsWith("0x") || b.startsWith("0X")) { + long v = Long.parseLong(b.substring(2), 16); + if (v > 0xffffffffL) { + return null; + } + return (int) (neg ? -v : v); + } + if (b.contains(".")) { + double d = Double.parseDouble(s); + long v = Math.round(d * ONE); + return v < Integer.MIN_VALUE || v > Integer.MAX_VALUE ? null : (int) v; + } + if (!b.chars().allMatch(Character::isDigit)) { + return null; + } + long v = Long.parseLong(s); + if (v < Integer.MIN_VALUE || v > 0xffffffffL) { + return null; + } + return (int) v; + } catch (NumberFormatException e) { + return null; + } + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/OpTable.java b/src/main/java/com/eliasmueller/llamamod/format/strat/OpTable.java new file mode 100644 index 0000000..405685b --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/OpTable.java @@ -0,0 +1,173 @@ +package com.eliasmueller.llamamod.format.strat; + +import com.eliasmueller.llamamod.format.stpcmerge.StratOps; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * Names and documentation of the strat VM opcodes, properties and globals (resources ops.tsv, + * props.tsv, globals.tsv; notes/strat-opcodes.md). + */ +public final class OpTable { + /** One opcode. pops/pushes = -1 when variable. */ + public record Op( + int code, String name, String operands, int pops, int pushes, String doc, String confidence, String ref) { + public boolean isShort() { + return code < StratOps.PUSH_IMM; + } + } + + /** A named property or global: id, name, documentation. */ + public record Named(int id, String name, String doc, boolean readOnly) {} + + private static final OpTable INSTANCE = new OpTable(); + + private final Op[] ops = new Op[StratOps.COUNT]; + private final Map byName = new HashMap<>(); + private final Map props = new HashMap<>(); + private final Map propsByName = new HashMap<>(); + private final Map globals = new HashMap<>(); + private final Map globalsByName = new HashMap<>(); + + public static OpTable get() { + return INSTANCE; + } + + private OpTable() { + for (String[] f : rows("ops.tsv")) { + int code = Integer.decode(f[0]); + if (code < 0 || code >= ops.length || ops[code] != null) { + continue; + } + String name = f[1]; + if (name.isEmpty() || byName.containsKey(name.toLowerCase(Locale.ROOT))) { + name = String.format(Locale.ROOT, "op_%02x", code); + } + Op op = new Op(code, name, at(f, 2), count(at(f, 3)), count(at(f, 4)), at(f, 5), at(f, 6), at(f, 7)); + ops[code] = op; + byName.put(name.toLowerCase(Locale.ROOT), op); + } + for (int i = 0; i < ops.length; i++) { + if (ops[i] == null) { + String name = String.format(Locale.ROOT, "op_%02x", i); + ops[i] = new Op(i, name, "", -1, -1, "", "none", "-"); + byName.put(name, ops[i]); + } + } + named("props.tsv", props, propsByName, true); + named("globals.tsv", globals, globalsByName, false); + } + + private static void named(String res, Map m, Map byName, boolean withAccess) { + for (String[] f : rows(res)) { + int id; + try { + id = Integer.decode(f[0]); + } catch (NumberFormatException e) { + continue; + } + String name = at(f, 1); + if (name.isEmpty() || !name.matches("[A-Za-z_][A-Za-z0-9_]*") || byName.containsKey(lc(name))) { + continue; + } + boolean ro = withAccess && at(f, 3).equalsIgnoreCase("no"); + String doc = withAccess ? at(f, 4) : at(f, 2); + Named n = new Named(id, name, doc, ro); + m.put(id, n); + byName.put(lc(name), n); + } + } + + private static String lc(String s) { + return s.toLowerCase(Locale.ROOT); + } + + private static String at(String[] f, int i) { + return i < f.length ? f[i].trim() : ""; + } + + private static int count(String s) { + try { + return Integer.parseInt(s.trim()); + } catch (NumberFormatException e) { + return -1; + } + } + + private static List rows(String res) { + List out = new ArrayList<>(); + try (InputStream in = OpTable.class.getResourceAsStream(res)) { + if (in == null) { + return out; + } + BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8)); + String line; + while ((line = r.readLine()) != null) { + if (line.isBlank() || line.startsWith("#")) { + continue; + } + out.add(line.split("\t", -1)); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return out; + } + + public Op op(int code) { + return ops[code]; + } + + /** Opcode by mnemonic (case-insensitive) or "op_XX", or null. */ + public Op byName(String name) { + Op o = byName.get(lc(name)); + if (o == null && lc(name).startsWith("op_")) { + try { + int c = Integer.parseInt(name.substring(3), 16); + return c >= 0 && c < ops.length ? ops[c] : null; + } catch (NumberFormatException e) { + return null; + } + } + return o; + } + + public List all() { + return Collections.unmodifiableList(List.of(ops)); + } + + public Named prop(int id) { + return props.get(id); + } + + public Named propByName(String name) { + return propsByName.get(lc(name)); + } + + public Named global(int id) { + return globals.get(id); + } + + public Named globalByName(String name) { + return globalsByName.get(lc(name)); + } + + /** Number of opcodes with a real name and semantics (not op_XX). */ + public int namedCount() { + int n = 0; + for (Op o : ops) { + n += o.name.startsWith("op_") ? 0 : 1; + } + return n; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/StratAssembler.java b/src/main/java/com/eliasmueller/llamamod/format/strat/StratAssembler.java new file mode 100644 index 0000000..0a74d9e --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/StratAssembler.java @@ -0,0 +1,688 @@ +package com.eliasmueller.llamamod.format.strat; + +import com.eliasmueller.llamamod.format.stpcmerge.StratOps; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** Assembler for the strat language (notes/strat-language.md): text → words of one STPC code unit. */ +public final class StratAssembler { + /** + * Where the unit goes and what absolute references resolve to: base = STPC offset of the + * unit's first word; models/anims/units = STPC offsets of model headers, animation sets, unit + * starts. + */ + public record Layout(int base, List models, List anims, List units) { + public Layout withBase(int b) { + return new Layout(b, models, anims, units); + } + } + + /** A problem at a source line (1-based). */ + public record Problem(int line, String message) { + @Override + public String toString() { + return "line " + line + ": " + message; + } + } + + public static final class AsmException extends RuntimeException { + public final List problems; + + AsmException(List problems) { + super( + problems.size() == 1 + ? problems.get(0).toString() + : problems.size() + " errors, first: " + problems.get(0)); + this.problems = List.copyOf(problems); + } + } + + /** Assembly result: words, labels (byte offset in the unit), unit name, strat entry labels. */ + public record Result(int[] words, Map labels, String unitName, Map strats) { + public byte[] bytes() { + byte[] b = new byte[words.length * 4]; + for (int i = 0; i < words.length; i++) { + int v = words[i]; + b[4 * i] = (byte) v; + b[4 * i + 1] = (byte) (v >> 8); + b[4 * i + 2] = (byte) (v >> 16); + b[4 * i + 3] = (byte) (v >> 24); + } + return b; + } + } + + private record Stmt(int line, String head, List args, String rest, int size, int offset) {} + + private final OpTable ops = OpTable.get(); + private final List problems = new ArrayList<>(); + private final Map labels = new LinkedHashMap<>(); + private final Map aliases = new HashMap<>(); + private final Map eventBase = new HashMap<>(); + private final Map switchBase = new HashMap<>(); + private final List nameSites = new ArrayList<>(); + private String unitName = ""; + private final List pendingNames = new ArrayList<>(); + private final List pendingNameText = new ArrayList<>(); + + private StratAssembler() {} + + public static Result assemble(String text, Layout layout) { + return new StratAssembler().run(text, layout); + } + + private void err(int line, String m) { + problems.add(new Problem(line, m)); + } + + private Result run(String text, Layout layout) { + List stmts = new ArrayList<>(); + String[] src = text.split("\n", -1); + int off = 0; + for (int i = 0; i < src.length; i++) { + int ln = i + 1; + String s = stripComment(src[i]).trim(); + while (true) { + int c = labelEnd(s); + if (c < 0) { + break; + } + String l = s.substring(0, c).trim(); + define(ln, l, off); + s = s.substring(c + 1).trim(); + } + if (s.isEmpty()) { + continue; + } + String head, rest; + int sp = indexOfSpace(s); + head = sp < 0 ? s : s.substring(0, sp); + rest = sp < 0 ? "" : s.substring(sp + 1).trim(); + List args = head.equals(".string") ? List.of(rest) : splitArgs(rest); + int size = size(ln, head, args, rest); + if (head.equals(".at") && args.size() == 2) { + Integer k = Num.parse(args.get(1)); + if (k == null) { + err(ln, "bad offset " + args.get(1)); + } else { + define(ln, args.get(0), off + k); + } + } + if (head.equals(".alias")) { + alias(ln, rest); + } + if (head.equals(".unit")) { + unitName = rest; + } + stmts.add(new Stmt(ln, head, args, rest, size, off)); + off += size * 4; + } + int[] words = new int[off / 4]; + // bases of event entries and switch tables + for (Stmt st : stmts) { + Integer op = opcode(st.head); + if (op == null) { + continue; + } + if (op == StratOps.HANDLER && !st.args.isEmpty()) { + eventBase.put(st.args.get(0), st.offset + 8); + } else if (op == StratOps.SWITCH && !st.args.isEmpty()) { + switchBase.put(st.args.get(0), st.offset + 4); + } else if (op == StratOps.NAME && !st.args.isEmpty()) { + nameSites.add(st.offset); + } + } + Integer curSwitch = null; + Map labelAt = new HashMap<>(); + labels.forEach((k, v) -> labelAt.putIfAbsent(v, k)); + for (Stmt st : stmts) { + if (st.head.equals(".switch")) { + String l = labelAt.get(st.offset); + curSwitch = null; + for (var e : labels.entrySet()) { + if (e.getValue() == st.offset && switchBase.containsKey(e.getKey())) { + curSwitch = switchBase.get(e.getKey()); + } + } + if (curSwitch == null) { + err(st.line, "no switch instruction refers to this table" + (l == null ? " (no label)" : "")); + } + } + encode(st, words, layout, curSwitch); + } + resolveNames(words); + if (!problems.isEmpty()) { + throw new AsmException(problems); + } + Map strats = new LinkedHashMap<>(); + for (int site : nameSites) { + for (var l : labels.entrySet()) { + if (l.getValue() == site) { + strats.put(l.getKey(), l.getValue()); + break; + } + } + } + return new Result(words, Map.copyOf(labels), unitName, strats); + } + + /** debugName "x": the first occurrence of x + NUL that starts a word or follows a NUL byte. */ + private void resolveNames(int[] words) { + byte[] d = new byte[words.length * 4]; + for (int i = 0; i < words.length; i++) { + for (int k = 0; k < 4; k++) { + d[4 * i + k] = (byte) (words[i] >> (8 * k)); + } + } + for (int j = 0; j < pendingNames.size(); j++) { + int i = pendingNames.get(j)[0], ln = pendingNames.get(j)[1]; + String name = pendingNameText.get(j); + if (name == null) { + continue; + } + byte[] n = name.getBytes(StandardCharsets.ISO_8859_1); + int found = -1, skip = pendingNames.get(j)[2]; + for (int o = 0; o + n.length < d.length && found < 0; o++) { + if (o % 4 != 0 && d[o - 1] != 0) { + continue; + } + boolean m = d[o + n.length] == 0; + for (int k = 0; m && k < n.length; k++) { + m = d[o + k] == n[k]; + } + if (m && skip-- == 0) { + found = o; + } + } + if (found < 0) { + err(ln, "no .string \"" + name + "\" in the unit"); + } else { + words[i + 2] = found - (i * 4 + 12); + } + } + } + + private void define(int ln, String l, int off) { + if (!l.matches("[A-Za-z_][A-Za-z0-9_]*")) { + err(ln, "bad label name '" + l + "'"); + } else if (labels.putIfAbsent(l, off) != null) { + err(ln, "label " + l + " defined twice"); + } + } + + private void alias(int ln, String rest) { + String[] p = rest.split("="); + if (p.length != 2 || !p[1].trim().matches("v\\d+") || !p[0].trim().matches("[A-Za-z_][A-Za-z0-9_]*")) { + err(ln, "expected .alias name = vN"); + return; + } + aliases.put( + p[0].trim().toLowerCase(Locale.ROOT), + Integer.parseInt(p[1].trim().substring(1))); + } + + private static int labelEnd(String s) { + int c = s.indexOf(':'); + if (c <= 0) { + return -1; + } + String l = s.substring(0, c); + return l.matches("\\s*[A-Za-z_][A-Za-z0-9_]*\\s*") ? c : -1; + } + + private static int indexOfSpace(String s) { + for (int i = 0; i < s.length(); i++) { + if (Character.isWhitespace(s.charAt(i))) { + return i; + } + } + return -1; + } + + static String stripComment(String s) { + boolean q = false; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + if (c == '\\' && q) { + i++; + } else if (c == '"') { + q = !q; + } else if (c == ';' && !q) { + return s.substring(0, i); + } + } + return s; + } + + private static List splitArgs(String s) { + List out = new ArrayList<>(); + if (s.isBlank()) { + return out; + } + for (String p : s.split(",")) { + out.add(p.trim()); + } + return out; + } + + private Integer opcode(String head) { + if (head.startsWith(".")) { + return null; + } + OpTable.Op o = ops.byName(head); + return o == null ? null : o.code(); + } + + private int size(int ln, String head, List args, String rest) { + switch (head) { + case ".unit", ".alias", ".at" -> { + return 0; + } + case ".word" -> { + return args.size(); + } + case ".string" -> { + byte[] b = packed(ln, rest); + return b == null ? 0 : (b.length + 3) / 4; + } + case ".event" -> { + return 3; + } + case ".switch" -> { + return 1; + } + case ".case" -> { + return args.size() == 2 && args.get(0).startsWith("imm ") ? 3 : 2; + } + default -> {} + } + Integer op = opcode(head); + if (op == null) { + err(ln, "unknown instruction '" + head + "'"); + return 0; + } + if (op == StratOps.SKIP_A || op == StratOps.SKIP_B) { + int n = args.size(); + if (n > 0 && args.get(0).startsWith("hi=")) { + n--; + } + return 1 + n; + } + return 1 + StratOps.operands(op); + } + + /** Comma-separated quoted strings, each NUL-terminated, packed. */ + private byte[] packed(int ln, String rest) { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + String r = rest.trim(); + while (true) { + int e = closingQuote(r); + if (e < 0) { + err(ln, "expected a quoted string"); + return null; + } + String s = unquote(ln, r.substring(0, e + 1)); + if (s == null) { + return null; + } + out.writeBytes(s.getBytes(StandardCharsets.ISO_8859_1)); + out.write(0); + r = r.substring(e + 1).trim(); + if (r.isEmpty()) { + return out.toByteArray(); + } + if (!r.startsWith(",")) { + err(ln, "expected ',' between strings"); + return null; + } + r = r.substring(1).trim(); + } + } + + private static int closingQuote(String s) { + if (!s.startsWith("\"")) { + return -1; + } + for (int i = 1; i < s.length(); i++) { + if (s.charAt(i) == '\\') { + i++; + } else if (s.charAt(i) == '"') { + return i; + } + } + return -1; + } + + private String unquote(int ln, String s) { + s = s.trim(); + if (s.length() < 2 || !s.startsWith("\"") || !s.endsWith("\"")) { + err(ln, "expected a quoted string"); + return null; + } + StringBuilder b = new StringBuilder(); + for (int i = 1; i < s.length() - 1; i++) { + char c = s.charAt(i); + if (c == '\\' && i + 1 < s.length() - 1) { + c = s.charAt(++i); + } + if (c > 0xff || c == 0) { + err(ln, "character not allowed in a string: " + c); + return null; + } + b.append(c); + } + return b.toString(); + } + + private Integer label(int ln, String l) { + Integer v = labels.get(l); + if (v == null) { + err(ln, "unknown label '" + l + "'"); + } + return v; + } + + private Integer number(int ln, String s) { + Integer v = Num.parse(s); + if (v == null) { + err(ln, "expected a number, got '" + s + "'"); + } + return v; + } + + private int rel(int ln, int target, int base) { + int d = target - base; + if ((d & 3) != 0) { + err(ln, "target not word aligned"); + } + return d >> 2; + } + + private void encode(Stmt st, int[] w, Layout layout, Integer curSwitch) { + int ln = st.line, i = st.offset / 4; + List a = st.args; + switch (st.head) { + case ".unit", ".alias", ".at" -> { + return; + } + case ".word" -> { + for (int k = 0; k < a.size(); k++) { + Integer v = a.get(k).startsWith("@") ? addr(ln, a.get(k), layout) : number(ln, a.get(k)); + w[i + k] = v == null ? 0 : v; + } + return; + } + case ".string" -> { + byte[] b = packed(ln, st.rest); + if (b != null) { + for (int k = 0; k < b.length; k++) { + w[i + k / 4] |= (b[k] & 0xff) << (8 * (k % 4)); + } + } + return; + } + case ".event" -> { + if (a.size() != 3) { + err(ln, "expected .event type, param, label"); + return; + } + String own = null; + for (var e : labels.entrySet()) { + if (e.getValue() == st.offset && eventBase.containsKey(e.getKey())) { + own = e.getKey(); + } + } + if (own == null) { + err(ln, "no onEvent instruction refers to this entry"); + return; + } + Integer t = number(ln, a.get(0)), p = number(ln, a.get(1)), tg = label(ln, a.get(2)); + if (t != null && p != null && tg != null) { + w[i] = t; + w[i + 1] = p; + w[i + 2] = rel(ln, tg, eventBase.get(own)); + } + return; + } + case ".switch" -> { + Integer n = a.size() == 1 ? number(ln, a.get(0)) : null; + if (n == null) { + err(ln, "expected .switch count"); + return; + } + w[i] = n; + return; + } + case ".case" -> { + if (a.size() != 2 || curSwitch == null) { + err(ln, "expected .case [imm] value, label after .switch"); + return; + } + boolean imm = a.get(0).startsWith("imm "); + Integer v = number(ln, imm ? a.get(0).substring(4) : a.get(0)); + Integer tg = label(ln, a.get(1)); + if (v == null || tg == null) { + return; + } + if (imm) { + w[i] = StratOps.PUSH_IMM; + w[i + 1] = v; + w[i + 2] = rel(ln, tg, curSwitch); + } else { + if (v < Short.MIN_VALUE || v > Short.MAX_VALUE) { + err(ln, "short case value out of range (use .case imm)"); + } + w[i] = (v << 16) | StratOps.PUSH_SHORT; + w[i + 1] = rel(ln, tg, curSwitch); + } + return; + } + default -> {} + } + Integer opc = opcode(st.head); + if (opc == null) { + return; + } + int op = opc; + int next = st.offset + 4; + if (op == StratOps.HANDLER) { + if (a.size() < 2) { + err(ln, "expected onEvent entryLabel, slot"); + return; + } + int hi = a.size() > 2 ? hiArg(ln, a.get(2)) : 0; + Integer e = label(ln, a.get(0)), slot = number(ln, a.get(1)); + if (e != null && slot != null) { + w[i] = (hi << 16) | op; + w[i + 1] = rel(ln, e, st.offset + 8); + w[i + 2] = slot; + } + return; + } + if (op == StratOps.SKIP_A || op == StratOps.SKIP_B) { + int hi = 0, k0 = 0; + if (!a.isEmpty() && a.get(0).startsWith("hi=")) { + hi = hiArg(ln, a.get(0)); + k0 = 1; + } + w[i] = (hi << 16) | op; + for (int k = k0; k < a.size(); k++) { + Integer v = number(ln, a.get(k)); + w[i + 1 + k - k0] = v == null ? 0 : v; + } + return; + } + if (op < StratOps.PUSH_IMM) { + if (a.size() != 1) { + err(ln, st.head + " takes one operand"); + return; + } + String x = a.get(0); + Integer arg; + if (StratOps.relJump(op) || op == StratOps.PUSH_REL_WORD || op == StratOps.PUSH_REL_ADDR || op == 0x42) { + Integer t = labels.containsKey(x) ? labels.get(x) : null; + arg = t != null ? rel(ln, t, next) : number(ln, x); + } else if (op == StratOps.SWITCH) { + Integer t = label(ln, x); + arg = t == null ? null : rel(ln, t - 4, next); + } else { + arg = shortValue(ln, op, x); + } + if (arg == null) { + return; + } + if (arg < Short.MIN_VALUE || arg > Short.MAX_VALUE) { + err(ln, "operand out of the 16-bit range: " + arg); + return; + } + w[i] = (arg << 16) | op; + return; + } + int n = StratOps.operands(op); + w[i] = op; + switch (op) { + case StratOps.NAME -> { + if (a.size() >= 1 && a.size() <= 2 && a.get(0).startsWith("\"")) { + w[i + 1] = StratOps.NAME_TAG; + Integer occ = a.size() == 2 ? number(ln, a.get(1)) : Integer.valueOf(0); + pendingNames.add(new int[] {i, ln, occ == null ? 0 : occ}); + pendingNameText.add(unquote(ln, a.get(0))); + return; + } + Integer s = a.size() == 1 ? label(ln, a.get(0)) : null; + if (s != null) { + w[i + 1] = StratOps.NAME_TAG; + w[i + 2] = s - (st.offset + 12); + } else if (a.size() != 1) { + err(ln, "expected name stringLabel"); + } + return; + } + case StratOps.PUSH_ADDR -> { + Integer v = a.size() == 1 ? addr(ln, a.get(0), layout) : null; + if (a.size() != 1) { + err(ln, "expected one address operand"); + } + w[i + 1] = v == null ? 0 : v; + return; + } + default -> {} + } + if (a.size() != n) { + err(ln, st.head + " takes " + n + " operand" + (n == 1 ? "" : "s") + ", got " + a.size()); + return; + } + for (int k = 0; k < n; k++) { + String x = a.get(k); + int c = x.indexOf(':'); + if (c > 0 && x.substring(0, c).matches("tex|sound|text")) { + x = x.substring(c + 1); + } + Integer v = number(ln, x); + w[i + 1 + k] = v == null ? 0 : v; + } + } + + private int hiArg(int ln, String s) { + Integer v = s.startsWith("hi=") ? Num.parse(s.substring(3)) : null; + if (v == null) { + err(ln, "expected hi=N"); + return 0; + } + return v; + } + + private Integer shortValue(int ln, int op, String x) { + Integer v = Num.parse(x); + if (v != null) { + return v; + } + String kind = StratListing.shortKind(op); + String lx = x.toLowerCase(Locale.ROOT); + switch (kind) { + case "var" -> { + if (aliases.containsKey(lx)) { + return aliases.get(lx); + } + if (lx.matches("v\\d+")) { + return Integer.parseInt(lx.substring(1)); + } + } + case "global" -> { + if (lx.matches("g\\d+")) { + return Integer.parseInt(lx.substring(1)); + } + OpTable.Named n = ops.globalByName(x); + if (n != null) { + return n.id(); + } + } + case "prop", "playerProp" -> { + if (lx.matches("prop\\d+")) { + return Integer.parseInt(lx.substring(4)); + } + OpTable.Named n = ops.propByName(x); + if (n != null) { + return n.id(); + } + } + default -> {} + } + err(ln, "bad " + kind + " operand '" + x + "'"); + return null; + } + + private Integer addr(int ln, String s, Layout l) { + s = s.trim(); + if (s.startsWith("@")) { + String name = s.substring(1); + int plus = name.indexOf('+'); + int add = 0; + if (plus > 0) { + Integer d = number(ln, name.substring(plus + 1)); + add = d == null ? 0 : d; + name = name.substring(0, plus); + } + Integer v = label(ln, name.trim()); + return v == null ? null : l.base() + v + add; + } + int c = s.indexOf(':'); + if (c > 0) { + String kind = s.substring(0, c); + String r = s.substring(c + 1); + int plus = r.indexOf('+'); + int add = 0; + if (plus > 0) { + Integer d = number(ln, r.substring(plus + 1)); + add = d == null ? 0 : d; + r = r.substring(0, plus); + } + Integer idx = number(ln, r); + if (idx == null) { + return null; + } + List list = + switch (kind) { + case "model" -> l.models(); + case "anim" -> l.anims(); + case "unit" -> l.units(); + default -> null; + }; + if (list == null) { + err(ln, "unknown reference kind '" + kind + "' (model, anim, unit)"); + return null; + } + if (idx < 0 || idx >= list.size()) { + err(ln, kind + " " + idx + " does not exist (0.." + (list.size() - 1) + ")"); + return null; + } + return list.get(idx) + add; + } + return number(ln, s); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/StratListing.java b/src/main/java/com/eliasmueller/llamamod/format/strat/StratListing.java new file mode 100644 index 0000000..5a31055 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/StratListing.java @@ -0,0 +1,847 @@ +package com.eliasmueller.llamamod.format.strat; + +import com.eliasmueller.llamamod.format.Bytes; +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StratCode; +import com.eliasmueller.llamamod.format.stpcmerge.StratOps; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.function.IntFunction; + +/** + * Disassembly of one STPC code unit into the strat language (notes/strat-language.md). Every + * word of the unit is emitted either as an instruction or as data, with labels for all relative + * targets, so {@link StratAssembler} rebuilds the unit byte-identically. + */ +public final class StratListing { + /** What a line is. */ + public enum Kind { + HEADER, + LABEL, + INSTR, + DATA, + COMMENT + } + + /** Kind of an editable constant. */ + public enum ConstKind { + NUMBER, + TEXTURE, + SOUND, + TEXT + } + + /** + * One listing line. offset: STPC byte offset (−1 for header/comment lines); constAt: STPC + * offset of the editable constant word (−1 = none); constShort: the constant is the high 16 + * bits of an opcode word. + */ + public record Line( + Kind kind, + int offset, + int words, + String label, + String text, + String comment, + int op, + int constAt, + boolean constShort, + int value, + ConstKind constKind) { + public String render() { + StringBuilder b = new StringBuilder(); + switch (kind) { + case HEADER, COMMENT -> b.append(text); + case LABEL -> b.append(label).append(':'); + default -> { + b.append(" ").append(text); + if (comment != null && !comment.isEmpty()) { + while (b.length() < 44) { + b.append(' '); + } + b.append(" ; ").append(comment); + } + } + } + return b.toString(); + } + } + + /** Naming context: parameter names of the unit's strats, names of code entries elsewhere. */ + public interface Names { + /** Alias for variable i of the strat whose entry is at code (null = none). */ + default String param(int code, int i) { + return null; + } + + /** Type/entry name of a code offset outside the unit (null = unknown). */ + default String entry(int code) { + return null; + } + + Names NONE = new Names() {}; + } + + public final StpcImage img; + public final int unit; + public final int start; + public final int end; + public final List lines = new ArrayList<>(); + /** Label name by STPC offset. */ + public final Map labels = new TreeMap<>(); + /** Strat names declared in the unit by name site offset. */ + public final Map strats = new LinkedHashMap<>(); + /** Variable aliases (index → name) declared at the top. */ + public final Map aliases = new TreeMap<>(); + + private final StratCode code; + private final Map eventBase = new HashMap<>(); + private final Map switchBase = new HashMap<>(); + private final Set rawData = new HashSet<>(); + private final OpTable ops = OpTable.get(); + + private StratListing(StpcImage img, StratCode code, int unit) { + this.img = img; + this.code = code; + this.unit = unit; + this.start = img.units.get(unit).start(); + this.end = img.units.get(unit).end(); + } + + public static StratListing of(StpcImage img, StratCode code, int unit, Set entries, Names names) { + StratListing l = new StratListing(img, code, unit); + l.build(entries, names); + return l; + } + + private int w(int o) { + return Bytes.i32(img.data, o); + } + + private boolean in(int o) { + return o >= start && o < end; + } + + private void build(Set entries, Names names) { + Map eventRefs = new HashMap<>(), switchRefs = new HashMap<>(); + Set targets = new HashSet<>(); + for (int o = start; o < end; ) { + int ln = code.length(o); + if (ln == 0) { + o += 4; + continue; + } + int wd = w(o), op = wd & 0xffff, arg = wd >> 16; + int next = o + 4; + if (op == StratOps.HANDLER) { + int e = o + 8 + w(o + 4) * 4; + eventRefs.merge(e, 1, Integer::sum); + eventBase.put(e, o + 8); + targets.add(e); + } else if (op == StratOps.SWITCH) { + int t = next + arg * 4 + 4; + switchRefs.merge(t, 1, Integer::sum); + switchBase.put(t, next); + targets.add(t); + } else if (relOp(op)) { + if (in(next + arg * 4)) { + targets.add(next + arg * 4); + } + } else if (op == StratOps.NAME && w(o + 4) == StratOps.NAME_TAG) { + long s = o + 12L + w(o + 8); + if (s >= start && s < end && (s & 3) == ((start) & 3)) { + targets.add((int) s); + } + } else if (op == StratOps.PUSH_ADDR && in(w(o + 4))) { + targets.add(w(o + 4)); + } + o += ln; + } + for (var e : eventRefs.entrySet()) { + int t = e.getKey(); + if (e.getValue() != 1 + || !in(t) + || !in(t + 8) + || code.length(t) != 0 + || code.length(t + 4) != 0 + || code.length(t + 8) != 0) { + eventBase.remove(t); + } else { + targets.add(eventBase.get(t) + w(t + 8) * 4); + } + } + for (var e : switchRefs.entrySet()) { + int t = e.getKey(); + int base = switchBase.get(t); + boolean ok = e.getValue() == 1 && in(t) && code.length(t) == 0; + List caseTargets = new ArrayList<>(); + if (ok) { + int n = w(t); + int q = t + 4; + for (int k = 0; k < n && ok; k++) { + if (!in(q)) { + ok = false; + break; + } + int c = w(q) & 0xffff; + int cl = c == StratOps.PUSH_SHORT ? 4 : c == StratOps.PUSH_IMM ? 8 : -1; + if (cl < 0 || !in(q + cl) || (c == StratOps.PUSH_IMM && (w(q) >>> 16) != 0)) { + ok = false; + break; + } + for (int x = q; x <= q + cl; x += 4) { + ok &= code.length(x) == 0; + } + caseTargets.add(base + w(q + cl) * 4); + q += cl + 4; + } + ok &= n >= 0 && n < 4096; + } + if (ok) { + targets.addAll(caseTargets); + } else { + switchBase.remove(t); + } + } + targets.addAll(entries.stream().filter(this::in).toList()); + for (int s : img.nameSites(img.units.get(unit))) { + String n = Bytes.cstr(img.data, (int) (s + 12L + w(s + 8))); + strats.put(s, n); + targets.add(s); + } + nameLabels(targets, entries); + for (int s : strats.keySet()) { + for (int i = 0; i < 64; i++) { + String a = names.param(s, i); + if (a != null + && a.matches("[A-Za-z_][A-Za-z0-9_]*") + && !aliases.containsValue(a) + && !labels.containsValue(a) + && ops.byName(a) == null) { + aliases.putIfAbsent(i, a); + } + } + } + emit(names); + angleComments(); + } + + /** Constants consumed by turning/rotation ops get a comment in degrees. */ + private void angleComments() { + for (int i = 0; i + 1 < lines.size(); i++) { + Line l = lines.get(i); + if (l.constKind() != ConstKind.NUMBER || l.kind() != Kind.INSTR) { + continue; + } + Line n = null; + for (int k = i + 1; k < lines.size() && n == null; k++) { + n = lines.get(k).kind() == Kind.INSTR ? lines.get(k) : null; + if (lines.get(k).kind() != Kind.INSTR && lines.get(k).kind() != Kind.LABEL) { + break; + } + } + if (n != null && angleConsumer(n.text()) && Math.abs((long) l.value()) >= 0x100) { + String deg = Num.degreeLiteral(l.value()); + String text = l.text(); + String cm = "= " + Num.degrees(l.value()); + if (deg != null) { + text = text.substring(0, text.indexOf(' ') + 1) + deg; + cm = "= " + Num.literal(l.value()); + } + lines.set( + i, + new Line( + l.kind(), + l.offset(), + l.words(), + l.label(), + text, + cm, + l.op(), + l.constAt(), + l.constShort(), + l.value(), + l.constKind())); + } + } + } + + static boolean angleConsumer(String t) { + String s = t.toLowerCase(java.util.Locale.ROOT); + return s.startsWith("turn") + || s.startsWith("tilt") + || s.startsWith("sin") + || s.startsWith("cos") + || s.matches("pop\\w*prop rot[xyz].*") + || s.matches("pop\\w*prop \\w*(angle|yaw)\\w*"); + } + + private void nameLabels(Set targets, Set entries) { + Set used = new HashSet<>(); + for (var e : strats.entrySet()) { + String n = ident(e.getValue()); + String u = n; + for (int k = 2; used.contains(u.toLowerCase()) || OpTable.get().byName(u) != null; k++) { + u = n + "_" + k; + } + used.add(u.toLowerCase()); + labels.put(e.getKey(), u); + } + for (int t : new java.util.TreeSet<>(targets)) { + if (labels.containsKey(t) || !in(t)) { + continue; + } + String p = eventBase.containsKey(t) + ? "ev_" + : switchBase.containsKey(t) ? "sw_" : entries.contains(t) ? "entry_" : "L_"; + labels.put(t, p + String.format("%04x", t - start)); + } + } + + public static String ident(String s) { + StringBuilder b = new StringBuilder(); + for (char c : s.toCharArray()) { + b.append(Character.isLetterOrDigit(c) && c < 128 ? c : '_'); + } + if (b.isEmpty() || Character.isDigit(b.charAt(0))) { + b.insert(0, '_'); + } + return b.toString(); + } + + private String label(int o) { + String l = labels.get(o); + return l != null ? l : null; + } + + /** Reference text for an absolute STPC offset (operand of pushAddr). */ + String addrRef(int v) { + if (in(v) && labels.containsKey(v)) { + return "@" + labels.get(v); + } + int m = indexIn(img.models, v); + if (m >= 0) { + return "model:" + m + plus(v - img.models.get(m).start()); + } + int a = indexIn(img.anims, v); + if (a >= 0) { + return "anim:" + a + plus(v - img.anims.get(a).start()); + } + int u = img.unitOf(v); + if (u >= 0 && u != unit) { + return "unit:" + u + plus(v - img.units.get(u).start()); + } + return Num.integer(v); + } + + private static String plus(int d) { + return d == 0 ? "" : "+" + Num.hex(d); + } + + private static int indexIn(List l, int o) { + for (int i = 0; i < l.size(); i++) { + if (l.get(i).contains(o)) { + return i; + } + } + return -1; + } + + private void add(Kind k, int off, int words, String text, String comment) { + lines.add(new Line(k, off, words, null, text, comment, -1, -1, false, 0, null)); + } + + private void emit(Names names) { + String title = strats.isEmpty() ? "unit " + unit : String.join(", ", strats.values()); + add( + Kind.HEADER, + -1, + 0, + "; unit " + unit + " (" + (end - start) / 4 + " words at STPC 0x" + Integer.toHexString(start) + "): " + + title, + null); + add( + Kind.HEADER, + -1, + 0, + ".unit " + + ident( + strats.isEmpty() + ? "unit" + unit + : strats.values().iterator().next()), + null); + for (var a : aliases.entrySet()) { + add(Kind.HEADER, -1, 0, ".alias " + a.getValue() + " = v" + a.getKey(), null); + } + int o = start; + while (o < end) { + String lb = label(o); + if (lb != null) { + lines.add(new Line(Kind.LABEL, o, 0, lb, null, null, -1, -1, false, 0, null)); + if (strats.containsKey(o)) { + // nothing extra: the label carries the strat name + } + } + int ln = code.length(o); + if (ln > 0) { + // labels inside the instruction (rare) + for (int k = 4; k < ln; k += 4) { + if (labels.containsKey(o + k)) { + add( + Kind.DATA, + -1, + 0, + ".at " + labels.get(o + k) + ", " + k, + "label inside the next instruction"); + } + } + instr(o, ln, names); + o += ln; + continue; + } + if (eventBase.containsKey(o)) { + int base = eventBase.get(o); + int tgt = base + w(o + 8) * 4; + String tl = label(tgt); + if (tl != null && !labels.containsKey(o + 4) && !labels.containsKey(o + 8)) { + add( + Kind.DATA, + o, + 3, + ".event " + Num.integer(w(o)) + ", " + Num.integer(w(o + 4)) + ", " + tl, + "handler entry"); + o += 12; + continue; + } + } + if (switchBase.containsKey(o) && switchTable(o)) { + o = switchEnd(o); + continue; + } + o = data(o); + } + } + + private boolean switchTable(int t) { + int base = switchBase.get(t); + int n = w(t); + int q = t + 4; + List rows = new ArrayList<>(); + List spans = new ArrayList<>(); + for (int k = 0; k < n; k++) { + int c = w(q) & 0xffff; + boolean sh = c == StratOps.PUSH_SHORT; + int cl = sh ? 4 : 8; + for (int x = q + 4; x <= q + cl; x += 4) { + if (labels.containsKey(x)) { + return false; + } + } + String tl = label(base + w(q + cl) * 4); + if (tl == null) { + return false; + } + int val = sh ? w(q) >> 16 : w(q + 4); + rows.add(".case " + (sh ? "" : "imm ") + Num.literal(val) + ", " + tl); + spans.add(new int[] {q, cl / 4 + 1}); + q += cl + 4; + } + for (int x = t + 4; x < q; x += 4) { + if (labels.containsKey(x) && spans.stream().noneMatch(s -> s[0] == -1)) { + return false; + } + } + add(Kind.DATA, t, 1, ".switch " + n, "switch table"); + for (int i = 0; i < rows.size(); i++) { + add(Kind.DATA, spans.get(i)[0], spans.get(i)[1], rows.get(i), null); + } + return true; + } + + private int switchEnd(int t) { + int n = w(t); + int q = t + 4; + for (int k = 0; k < n; k++) { + q += (w(q) & 0xffff) == StratOps.PUSH_SHORT ? 8 : 12; + } + return q; + } + + /** Emits uncovered words from o up to the next label/instruction; returns the next offset. */ + private int data(int o) { + int e = o + 4; + while (e < end + && code.length(e) == 0 + && !labels.containsKey(e) + && !eventBase.containsKey(e) + && !switchBase.containsKey(e)) { + e += 4; + } + int p = o; + while (p < e) { + List strs = new ArrayList<>(); + int s = stringAt(p, e, strs); + if (s > 0) { + StringBuilder b = new StringBuilder(".string "); + for (int i = 0; i < strs.size(); i++) { + b.append(i == 0 ? "" : ", ").append(quote(strs.get(i))); + } + add(Kind.DATA, p, s / 4, b.toString(), null); + p += s; + continue; + } + int q = p; + StringBuilder b = new StringBuilder(".word "); + int n = 0; + while (q < e && n < 4 && (n == 0 || stringAt(q, e, new ArrayList<>()) == 0)) { + if (n > 0) { + b.append(", "); + } + b.append(Num.hex(w(q))); + q += 4; + n++; + } + add(Kind.DATA, p, n, b.toString(), null); + p = q; + } + return e; + } + + /** + * Length in bytes of packed NUL-terminated printable strings at o (inside [o, e)), zero padded to + * the next word, or 0. The strings are added to out. + */ + private int stringAt(int o, int e, List out) { + int k = o; + while (true) { + int s = k; + while (k < e && img.data[k] != 0) { + int c = img.data[k] & 0xff; + if (c < 0x20 || c > 0x7e) { + return 0; + } + k++; + } + if (k >= e || k - s < (out.isEmpty() ? 2 : 1)) { + return 0; + } + out.add(new String(img.data, s, k - s, StandardCharsets.ISO_8859_1)); + k++; + if (((k - start) & 3) == 0) { + return k - o; + } + if (img.data[k] == 0) { + int lim = start + ((k - start + 3) & ~3); + for (int x = k; x < lim; x++) { + if (x >= e || img.data[x] != 0) { + return 0; + } + } + return lim - o; + } + } + } + + /** + * Where the assembler finds a quoted name: the first occurrence of name + NUL in the unit that + * starts a word or follows a NUL byte (−1 = none). + */ + List findString(String name) { + List out = new ArrayList<>(); + byte[] n = name.getBytes(StandardCharsets.ISO_8859_1); + for (int o = start; o + n.length < end; o++) { + if ((o - start) % 4 != 0 && img.data[o - 1] != 0) { + continue; + } + boolean m = img.data[o + n.length] == 0; + for (int i = 0; m && i < n.length; i++) { + m = img.data[o + i] == n[i]; + } + if (m) { + out.add(o); + } + } + return out; + } + + static String quote(String s) { + StringBuilder b = new StringBuilder("\""); + for (char c : s.toCharArray()) { + if (c == '"' || c == '\\') { + b.append('\\'); + } + b.append(c); + } + return b.append('"').toString(); + } + + private void instr(int o, int ln, Names names) { + int wd = w(o), op = wd & 0xffff, arg = wd >> 16; + OpTable.Op info = ops.op(op); + String mn = info.name(); + int next = o + 4; + String text; + String comment = null; + int constAt = -1; + boolean constShort = false; + int value = 0; + ConstKind ck = null; + if (!info.isShort() && (wd >>> 16) != 0) { + raw(o, ln, "long opcode with high bits set"); + return; + } + if (op == StratOps.HANDLER) { + int e = o + 8 + w(o + 4) * 4; + String el = label(e); + if (el == null) { + raw(o, ln, "event entry outside the unit"); + return; + } + text = mn + " " + el + ", " + Num.integer(w(o + 8)); + if (arg != 0) { + text += ", hi=" + arg; + } + if (eventBase.containsKey(e)) { + comment = "event type " + Num.integer(w(e)) + " → " + label(eventBase.get(e) + w(e + 8) * 4); + } + line(o, ln, text, comment, op, -1, false, 0, null); + return; + } + if (info.isShort()) { + if (op == StratOps.SKIP_A || op == StratOps.SKIP_B) { + StringBuilder b = new StringBuilder(mn); + if (arg != 0) { + b.append(" hi=").append(arg).append(","); + } + for (int x = o + 4; x < o + ln; x += 4) { + b.append(x == o + 4 && arg == 0 ? " " : x == o + 4 ? " " : ", ") + .append(Num.hex(w(x))); + } + line(o, ln, b.toString(), "debug list (skipped)", op, -1, false, 0, null); + return; + } + if (ln != 4) { + raw(o, ln, "unexpected length"); + return; + } + if (relOp(op)) { + String t = label(next + arg * 4); + text = mn + " " + (t != null ? t : Integer.toString(arg)); + if (op == StratOps.PUSH_REL_WORD && in(next + arg * 4)) { + comment = "= " + Num.literal(w(next + arg * 4)); + } + } else if (op == StratOps.SWITCH) { + int t = next + arg * 4 + 4; + String tl = label(t); + if (tl == null) { + raw(o, ln, "switch table outside the unit"); + return; + } + text = mn + " " + tl; + } else { + Operand a = shortOperand(op, arg); + text = mn + " " + a.text; + comment = a.comment; + if (a.editable) { + constAt = o; + constShort = true; + value = arg; + ck = ConstKind.NUMBER; + } + } + line(o, ln, text, comment, op, constAt, constShort, value, ck); + return; + } + int nOps = ln / 4 - 1; + List args = new ArrayList<>(); + switch (op) { + case StratOps.PUSH_IMM -> { + int v = w(o + 4); + boolean tex = o + 8 < end && StratOps.texConsumer(w(o + 8) & 0xffff); + args.add(tex ? "tex:" + v : Num.literal(v)); + comment = tex ? "texture index" : comment(v); + constAt = o + 4; + value = v; + ck = tex ? ConstKind.TEXTURE : ConstKind.NUMBER; + } + case StratOps.PUSH_ADDR -> { + int v = w(o + 4); + args.add(addrRef(v)); + comment = addrComment(v, names); + } + case StratOps.PUSH_SOUND -> { + args.add("sound:" + w(o + 4)); + constAt = o + 4; + value = w(o + 4); + ck = ConstKind.SOUND; + } + case StratOps.TEXT_A, StratOps.TEXT_B -> { + args.add("text:" + w(o + 4)); + constAt = o + 4; + value = w(o + 4); + ck = ConstKind.TEXT; + } + case StratOps.NAME -> { + long s = o + 12L + w(o + 8); + if (w(o + 4) != StratOps.NAME_TAG || s < start || s >= end) { + raw(o, ln, "name site with unusual operands"); + return; + } + String str = Bytes.cstr(img.data, (int) s); + String sl = label((int) s); + int occ = findString(str).indexOf((int) s); + if (occ >= 0 && str.chars().allMatch(c -> c >= 0x20 && c <= 0x7e)) { + args.add(quote(str) + (occ > 0 ? ", " + occ : "")); + } else if (sl != null) { + args.add(sl); + comment = quote(str); + } else { + raw(o, ln, "name string not addressable"); + return; + } + } + default -> { + for (int k = 1; k <= nOps; k++) { + args.add(Num.integer(w(o + 4 * k))); + } + } + } + text = args.isEmpty() ? mn : mn + " " + String.join(", ", args); + if (comment == null && !info.doc().isEmpty() && nOps == 0) { + comment = null; + } + line(o, ln, text, comment, op, constAt, false, value, ck); + } + + private record Operand(String text, String comment, boolean editable) {} + + private Operand shortOperand(int op, int arg) { + String kind = shortKind(op); + switch (kind) { + case "var" -> { + String a = aliases.get(arg); + return new Operand(a != null ? a : "v" + arg, a != null ? "v" + arg : null, false); + } + case "global" -> { + OpTable.Named n = ops.global(arg); + return new Operand(n != null ? n.name() : "g" + arg, n != null ? "g" + arg : null, false); + } + case "prop", "playerProp" -> { + OpTable.Named n = ops.prop(arg); + return new Operand(n != null ? n.name() : "prop" + arg, null, false); + } + default -> { + return new Operand(Num.literal(arg), comment(arg), op == StratOps.PUSH_SHORT); + } + } + } + + /** Short opcodes whose argument is a relative code offset (target = next + arg·4). */ + static boolean relOp(int op) { + return StratOps.relJump(op) || op == StratOps.PUSH_REL_WORD || op == StratOps.PUSH_REL_ADDR || op == 0x42; + } + + /** Operand kind of a short opcode: var, global, prop, playerProp or number. */ + static String shortKind(int op) { + return switch (op) { + case 0x04, 0x41 -> "var"; + case 0x06, 0x43 -> "global"; + case 0x07, 0x40 -> "prop"; + case 0x1c, 0x37 -> "playerProp"; + default -> { + String k = OpTable.get().op(op).operands().split("[ ,]")[0]; + yield switch (k) { + case "var", "global", "prop", "playerProp" -> k; + default -> "number"; + }; + } + }; + } + + private static String comment(int v) { + if (Math.abs((long) v) >= 64 && Math.abs((long) v) < 0x10000000L) { + String f = Num.fixed(v); + String lit = Num.literal(v); + return lit.contains(".") ? "= " + v : "≈ " + f; + } + return null; + } + + private String addrComment(int v, Names names) { + if (in(v)) { + String s = strats.get(v); + return s != null ? "strat " + s : "code in this unit"; + } + if (indexIn(img.models, v) >= 0) { + return "model"; + } + if (indexIn(img.anims, v) >= 0) { + return "animation set"; + } + if (v < 0) { + return "DEFANIM.WAD animation " + (-v); + } + int u = img.unitOf(v); + if (u >= 0) { + String n = names.entry(v); + if (n == null) { + for (int s : img.nameSites(img.units.get(u))) { + if (s == v) { + n = Bytes.cstr(img.data, (int) (s + 12L + w(s + 8))); + } + } + } + return n != null ? "strat " + n : "code of unit " + u; + } + return null; + } + + private void raw(int o, int ln, String why) { + StringBuilder b = new StringBuilder(".word "); + for (int x = o; x < o + ln; x += 4) { + b.append(x == o ? "" : ", ").append(Num.hex(w(x))); + } + line(o, ln, b.toString(), ops.op(w(o) & 0xffff).name() + ": " + why, w(o) & 0xffff, -1, false, 0, null); + } + + private void line(int o, int ln, String text, String comment, int op, int constAt, boolean sh, int v, ConstKind k) { + lines.add(new Line(Kind.INSTR, o, ln / 4, null, text, comment, op, constAt, sh, v, k)); + } + + public String text() { + StringBuilder b = new StringBuilder(); + for (Line l : lines) { + b.append(l.render()).append('\n'); + } + return b.toString(); + } + + /** Word values of the unit (for comparisons). */ + public int[] words() { + int[] out = new int[(end - start) / 4]; + for (int i = 0; i < out.length; i++) { + out[i] = w(start + 4 * i); + } + return out; + } + + /** Resolves pushAddr references and other units' code for {@link StratAssembler} in the same STPC. */ + public static StratAssembler.Layout layout(StpcImage img, int base) { + List models = new ArrayList<>(), anims = new ArrayList<>(), units = new ArrayList<>(); + img.models.forEach(s -> models.add(s.start())); + img.anims.forEach(s -> anims.add(s.start())); + img.units.forEach(s -> units.add(s.start())); + return new StratAssembler.Layout(base, models, anims, units); + } + + static IntFunction none() { + return i -> null; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java b/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java new file mode 100644 index 0000000..afe2e12 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java @@ -0,0 +1,482 @@ +package com.eliasmueller.llamamod.format.strat; + +import com.eliasmueller.llamamod.format.stpcmerge.StratOps; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Read-only structured view of a {@link StratListing}: stack code folded into expressions, + * forward conditional branches into if/else blocks and backward jumps into loops where the blocks + * nest; everything else stays as labels and gotos. + */ +public final class StratPseudo { + /** One output line; offset = STPC offset of the first instruction (−1 for structure lines). */ + public record Line(int offset, int depth, String text) {} + + private static final Map BINARY = Map.ofEntries( + Map.entry("add", "+"), + Map.entry("sub", "-"), + Map.entry("mul", "*"), + Map.entry("div", "/"), + Map.entry("eq", "=="), + Map.entry("notEqual", "!="), + Map.entry("lt", "<"), + Map.entry("gt", ">"), + Map.entry("lessEqual", "<="), + Map.entry("greaterEqual", ">="), + Map.entry("and", "&&"), + Map.entry("or", "|"), + Map.entry("bitwiseAnd", "&"), + Map.entry("shl", "<<"), + Map.entry("shr", ">>")); + + /** Push/pop opcodes by target prefix ("" = own variable). */ + private static final Map PUSH = new HashMap<>(), POP = new HashMap<>(), ADDR = new HashMap<>(); + + static { + Object[][] t = { + {0x04, 0x41, 0x08, ""}, + {0x07, 0x40, 0x0b, "self."}, + {0x06, 0x43, 0x0a, ""}, + {0x18, 0x3e, 0x19, "parent."}, + {0x1a, 0x3f, 0x1b, "parent."}, + {0x1c, 0x37, 0x1d, "player."}, + {0x1e, 0x38, 0x1f, "camera."}, + {0x20, 0x39, 0x21, "target."}, + {0x24, 0x3a, 0x25, "target2."}, + {0x26, 0x3d, 0x27, "boss."}, + {0x28, 0x3b, 0x29, "dialog."} + }; + for (Object[] r : t) { + PUSH.put((Integer) r[0], (String) r[3]); + POP.put((Integer) r[1], (String) r[3]); + ADDR.put((Integer) r[2], (String) r[3]); + } + } + + private final StratListing src; + private final List ls; + private final OpTable ops = OpTable.get(); + public final List lines = new ArrayList<>(); + private final Deque stack = new ArrayDeque<>(); + private int stackAt = -1; + private int depth; + + private StratPseudo(StratListing src) { + this.src = src; + this.ls = src.lines; + } + + public static StratPseudo of(StratListing l) { + StratPseudo p = new StratPseudo(l); + p.build(); + return p; + } + + /** Editable constants per output line: those of the instructions from its offset to the next line's. */ + public List> constants() { + List consts = new ArrayList<>(); + for (StratListing.Line l : src.lines) { + if (l.constAt() >= 0) { + consts.add(l); + } + } + int[] nextOff = new int[lines.size()]; + int nxt = src.end; + for (int i = lines.size() - 1; i >= 0; i--) { + nextOff[i] = nxt; + if (lines.get(i).offset() >= 0) { + nxt = Math.min(nxt, lines.get(i).offset()); + } + } + List> out = new ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + Line l = lines.get(i); + List cs = new ArrayList<>(); + if (l.offset() >= 0) { + for (StratListing.Line c : consts) { + if (c.offset() >= l.offset() && c.offset() < nextOff[i]) { + cs.add(c); + } + } + } + out.add(cs); + } + return out; + } + + public String text() { + StringBuilder b = new StringBuilder(); + for (Line l : lines) { + b.append(" ".repeat(l.depth)).append(l.text).append('\n'); + } + return b.toString(); + } + + private record Block(int open, int close, String kind) {} + + private String mn(StratListing.Line l) { + String t = l.text(); + int sp = t.indexOf(' '); + return sp < 0 ? t : t.substring(0, sp); + } + + private String operand(StratListing.Line l) { + String t = l.text(); + int sp = t.indexOf(' '); + return sp < 0 ? "" : t.substring(sp + 1).trim(); + } + + private void build() { + Map labelIdx = new HashMap<>(); + for (int i = 0; i < ls.size(); i++) { + if (ls.get(i).kind() == StratListing.Kind.LABEL) { + labelIdx.put(ls.get(i).label(), i); + } + } + // candidate blocks + List cand = new ArrayList<>(); + Map elseOf = new HashMap<>(); + for (int i = 0; i < ls.size(); i++) { + StratListing.Line l = ls.get(i); + if (l.kind() != StratListing.Kind.INSTR) { + continue; + } + int op = l.op(); + Integer t = labelIdx.get(operand(l)); + if (t == null) { + continue; + } + if ((op == 0x14 || op == 0x15 || op == 0x12 || op == 0x13) && t > i && clean(i, t)) { + cand.add(new Block(i, t, "if")); + int pj = prevInstr(t); + if (pj > i && ls.get(pj).op() == 0x16) { + Integer m = labelIdx.get(operand(ls.get(pj))); + if (m != null && m > t && clean(t, m)) { + elseOf.put(i, m); + } + } + } else if ((op == 0x16 || op == 0x2b) && t <= i && clean(t, i)) { + cand.add(new Block(t, i, op == 0x2b ? "loopYield" : "loop")); + } + } + List accepted = new ArrayList<>(); + Set elseAccepted = new HashSet<>(); + cand.sort((a, b) -> a.open != b.open ? Integer.compare(a.open, b.open) : Integer.compare(b.close, a.close)); + for (Block b : cand) { + int end = b.kind.equals("if") && elseOf.containsKey(b.open) ? elseOf.get(b.open) : b.close; + boolean ok = true; + for (Block a : accepted) { + int ae = a.kind.equals("if") && elseAccepted.contains(a.open) ? elseOf.get(a.open) : a.close; + ok &= nests(b.open, end, a.open, ae); + if (ok && b.kind.equals("if") && elseOf.containsKey(b.open)) { + ok &= nests(b.open, b.close, a.open, ae) && nests(b.close, end, a.open, ae); + } + if (ok && a.kind.equals("if") && elseAccepted.contains(a.open)) { + ok &= nests(b.open, end, a.open, a.close) && nests(b.open, end, a.close, ae); + } + } + if (ok && accepted.stream().noneMatch(a -> a.open == b.open)) { + accepted.add(b); + if (elseOf.containsKey(b.open) && b.kind.equals("if")) { + elseAccepted.add(b.open); + } + } + } + Map ifAt = new HashMap<>(), loopAt = new HashMap<>(), loopEnd = new HashMap<>(); + Map closeCount = new HashMap<>(), elseAt = new HashMap<>(); + Set elseJumps = new HashSet<>(); + Map consumed = new HashMap<>(); + for (Block b : accepted) { + if (b.kind.equals("if")) { + ifAt.put(b.open, b); + consumed.merge(ls.get(b.close).label(), 1, Integer::sum); + if (elseAccepted.contains(b.open)) { + int m = elseOf.get(b.open); + elseAt.put(b.close, b.open); + elseJumps.add(prevInstr(b.close)); + consumed.merge(ls.get(m).label(), 1, Integer::sum); + closeCount.merge(m, 1, Integer::sum); + } else { + closeCount.merge(b.close, 1, Integer::sum); + } + } else { + loopAt.computeIfAbsent(b.open, k -> b); + loopEnd.put(b.close, b); + consumed.merge(ls.get(b.open).label(), 1, Integer::sum); + } + } + Map refs = new HashMap<>(); + for (StratListing.Line l : ls) { + if (l.kind() == StratListing.Kind.INSTR || l.kind() == StratListing.Kind.DATA) { + for (String w : l.text().split("[ ,@+]+")) { + if (labelIdx.containsKey(w)) { + refs.merge(w, 1, Integer::sum); + } + } + } + } + for (String s : src.strats.values()) { + refs.merge(StratListing.ident(s), 1, Integer::sum); + } + for (int i = 0; i < ls.size(); i++) { + StratListing.Line l = ls.get(i); + switch (l.kind()) { + case HEADER, COMMENT -> { + if (l.text().startsWith(";")) { + out(-1, "//" + l.text().substring(1)); + } + } + case LABEL -> { + flush(); + Integer c = closeCount.get(i); + for (int k = 0; c != null && k < c; k++) { + depth--; + out(-1, "}"); + } + if (elseAt.containsKey(i)) { + depth--; + out(-1, "} else {"); + depth++; + } + int r = refs.getOrDefault(l.label(), 0) - consumed.getOrDefault(l.label(), 0); + if (src.strats.containsKey(l.offset())) { + out(l.offset(), ""); + out(l.offset(), "strat " + src.strats.get(l.offset()) + ":"); + } else if (r > 0 + || l.label().startsWith("entry_") + || l.label().startsWith("ev_")) { + out(l.offset(), l.label() + ":"); + } + if (loopAt.containsKey(i)) { + out(-1, "loop {"); + depth++; + } + } + case DATA -> { + flush(); + out(l.offset(), l.text()); + } + case INSTR -> instr(i, l, ifAt, loopEnd, elseJumps); + } + } + flush(); + } + + private static boolean nests(int a0, int a1, int b0, int b1) { + return a1 <= b0 || b1 <= a0 || (a0 >= b0 && a1 <= b1) || (b0 >= a0 && b1 <= a1); + } + + /** No data lines between the two line indexes. */ + private boolean clean(int a, int b) { + for (int k = Math.min(a, b); k <= Math.max(a, b); k++) { + if (ls.get(k).kind() == StratListing.Kind.DATA) { + return false; + } + } + return true; + } + + private int prevInstr(int i) { + for (int k = i - 1; k >= 0; k--) { + if (ls.get(k).kind() == StratListing.Kind.INSTR) { + return k; + } + if (ls.get(k).kind() != StratListing.Kind.LABEL) { + return -1; + } + } + return -1; + } + + private void out(int off, String t) { + lines.add(new Line(off, Math.max(0, depth), t)); + } + + private void flush() { + while (!stack.isEmpty()) { + out(stackAt, "push " + stack.pollLast()); + } + stackAt = -1; + } + + /** Offset where the statement being emitted starts: its first pushed operand if the stack is now empty. */ + private int begin(int off) { + return stack.isEmpty() && stackAt >= 0 ? stackAt : off; + } + + private String pop() { + return stack.isEmpty() ? "pop()" : stack.pop(); + } + + private void push(int off, String e) { + if (stack.isEmpty() && stackAt < 0) { + stackAt = off; + } + stack.push(e); + } + + private static String strip(String e) { + return e.startsWith("(") && e.endsWith(")") && balanced(e.substring(1, e.length() - 1)) + ? e.substring(1, e.length() - 1) + : e; + } + + private static boolean balanced(String s) { + int d = 0; + for (char c : s.toCharArray()) { + d += c == '(' ? 1 : c == ')' ? -1 : 0; + if (d < 0) { + return false; + } + } + return d == 0; + } + + private void instr( + int i, StratListing.Line l, Map ifAt, Map loopEnd, Set elseJumps) { + int op = l.op(); + String mn = mn(l); + String a = operand(l); + String c = l.comment() == null ? "" : " // " + l.comment(); + int off = l.offset(); + if (PUSH.containsKey(op)) { + push(off, PUSH.get(op) + a); + return; + } + if (ADDR.containsKey(op)) { + push(off, "&" + ADDR.get(op) + a); + return; + } + if (POP.containsKey(op)) { + String v = pop(); + int b0 = begin(off); + flush(); + out(b0, POP.get(op) + a + " = " + strip(v) + c); + return; + } + if (op == StratOps.PUSH_SHORT + || op == StratOps.PUSH_IMM + || op == StratOps.PUSH_ADDR + || op == StratOps.PUSH_SOUND) { + push(off, a + (l.comment() != null && l.comment().startsWith("strat") ? " /*" + l.comment() + "*/" : "")); + return; + } + if (op == 0x47 || op == 0x48) { + String v = pop(); + int b0 = begin(off); + flush(); + out(b0, (v.startsWith("&") ? v.substring(1) : "*" + v) + (op == 0x47 ? " += 1.0" : " -= 1.0")); + return; + } + if (BINARY.containsKey(mn)) { + String b = pop(), x = pop(); + push(off, "(" + x + " " + BINARY.get(mn) + " " + b + ")"); + return; + } + if (op == 0x46) { + push(off, "-" + pop()); + return; + } + if (op == 0xaf) { + push(off, "!" + pop()); + return; + } + boolean cond = op >= 0x12 && op <= 0x15; + if (cond) { + String e = strip(pop()); + boolean neg = op == 0x13 || op == 0x15; + String yield = op <= 0x13 ? " /*yield*/" : ""; + int b0 = begin(off); + flush(); + if (ifAt.containsKey(i)) { + out(b0, "if (" + (neg ? "!(" + e + ")" : e) + ")" + yield + " {"); + depth++; + } else { + out(b0, "if (" + (neg ? e : "!(" + strip(e) + ")") + ") goto " + a + yield); + } + return; + } + if (op == 0x16 || op == 0x2b) { + flush(); + Block lp = loopEnd.get(i); + if (lp != null) { + if (op == 0x2b) { + out(off, "yield"); + } + depth--; + out(-1, "}"); + } else if (elseJumps.contains(i)) { + if (op == 0x2b) { + out(off, "yield"); + } + } else { + out(off, (op == 0x2b ? "yield; " : "") + "goto " + a); + } + return; + } + if (op == 0x11 || op == 0x10) { + flush(); + out(off, "call " + a + (op == 0x10 ? " /*yield*/" : "") + "()"); + return; + } + if (op == StratOps.JUMP_POP) { + String v = pop(); + flush(); + out(off, v.equals("pop()") ? "return" : "goto *" + v); + return; + } + if (op == StratOps.END || op == 0x8e) { + flush(); + out(off, mn); + return; + } + if (op == StratOps.NAME) { + flush(); + out(off, "debugName " + a); + return; + } + if (op == StratOps.HANDLER) { + flush(); + out(off, "onEvent " + a + c); + return; + } + OpTable.Op info = ops.op(op); + int pops = info.pops(), pushes = info.pushes(); + String inline = a; + if (op == StratOps.SPAWN || op == 0x94 || op == 0x95) { + String first = a.split(",")[0].trim(); + Integer n = Num.parse(first); + pops = n == null ? -1 : n + 1; + } + if (pops < 0 + || pushes < 0 + || pushes > 1 + || l.text().startsWith(".") + || op == StratOps.SKIP_A + || op == StratOps.SKIP_B + || op == StratOps.SWITCH) { + flush(); + out(off, l.text() + c); + return; + } + List args = new ArrayList<>(); + for (int k = 0; k < pops; k++) { + args.add(0, strip(pop())); + } + String call = mn + (inline.isEmpty() ? "" : "<" + inline + ">") + "(" + String.join(", ", args) + ")"; + if (pushes == 1) { + push(off, call); + } else { + int b0 = begin(off); + flush(); + out(b0, call + c); + } + } +} diff --git a/src/main/resources/com/eliasmueller/llamamod/editor/tools/help/context-help.txt b/src/main/resources/com/eliasmueller/llamamod/editor/tools/help/context-help.txt index ecfade5..524c7ca 100644 --- a/src/main/resources/com/eliasmueller/llamamod/editor/tools/help/context-help.txt +++ b/src/main/resources/com/eliasmueller/llamamod/editor/tools/help/context-help.txt @@ -179,3 +179,12 @@ dialog.Viewport navigation* = 04-navigation.md#navigation-presets dialog.Keyboard shortcuts = 19-shortcuts.md dialog.Shortcut sheet = 19-shortcuts.md dialog.Getting started = tutorial-first-level.md + +# ---- strat scripts +panel.strats.script = 22-strats.md#the-script-panel +inspector.x:Script = 22-strats.md#the-script-panel +action.strats.show = 22-strats.md#the-script-panel +action.strats.newType = 22-strats.md#your-own-object-types +dialog.Edit script constant = 22-strats.md#changing-numbers +dialog.New object type from script = 22-strats.md#your-own-object-types +dialog.Clone* = 22-strats.md#your-own-object-types diff --git a/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv b/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv new file mode 100644 index 0000000..793dce1 --- /dev/null +++ b/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv @@ -0,0 +1,435 @@ +# opcode name operands pops pushes semantics confidence reference (Croc 2 PSX jump table / Croc 1 name) +0x00 nop_0x00 - 0 0 No-op (shared empty handler 00426500); unused in levels. high stCommandError +0x01 hide imm 0 0 arg!=0 sets, arg==0 clears flag 0x1000000 in +0xE8; renderer and shadow pass skip strats with this bit, so it hides the strat. medium stHide +0x02 skipArgs count 0 0 Inline block: count word, then count push instructions (0x44 one word, 0x45/0xea two words) that are skipped without executing; data block read by 0x0c event descriptors (same use count as 0x0c). medium - +0x03 print count 0 0 Debug print with output stripped: skips count inline push instructions like 0x02 and advances debug text line y DAT_006d9db8 by 12 (reset to 0x20/0x48 each frame). medium stPrint +0x04 pushVar var 0 1 Pushes own variable n (+0xF4 array). high stLocal +0x05 pushCodeWord rel 0 1 Pushes the 32-bit word at pc+arg*4 (inline data in the code stream, pc = next instruction). high - +0x06 pushGlobal global 0 1 Pushes global n (0x6D9E8C[n]). high stGlobal +0x07 pushProp prop 0 1 Pushes own property n via FUN_00550E60(self,self,n). high stAlienVar +0x08 pushVarAddr var 0 1 Pushes address of own variable n (for 0x47/0x48/0x4d). high stLocalAddress +0x09 pushCodeAddr rel 0 1 Pushes address pc+arg*4 (address of inline code-stream data word). high - +0x0a pushGlobalAddr global 0 1 Pushes address of global n. high stGlobalAddress +0x0b pushPropAddr prop 0 1 Pushes address of own property n (FUN_005509F0(self,self,n)). high stAlienVarAddress +0x0c onEvent rel,imm 0 0 Installs event handler slot (2nd word) in table +0xF8 (16-byte slots): descriptor at word pc+1+off holds type, param, rel code address of the handler; type 0x40 = timer (param converted to frames), types 0x80/0x100 enable player-distance tracking (+0xEC bit 0x4000). high - +0x0d clearEvent imm 0 0 Clears event handler slot arg (type word := 0). high - +0x0e disableEvent imm 0 0 Disables event handler slot arg (sets bit 31 of its type word). high - +0x0f enableEvent imm 0 0 Re-enables event handler slot arg (clears bit 31 of its type word). high - +0x10 callYield rel 0 1 Pushes return address (next pc), jumps to next+arg*4 and yields (+0xE8 bit 2); unused in levels. high stJsr +0x11 call rel 0 1 Pushes return address (next pc) and jumps to next+arg*4 without yielding; the call form actually used by the compiler. high stJsrImm +0x12 jzYield rel 1 0 Pops cond; if 0 jumps to next+arg*4; always yields this frame. high stBeq +0x13 jnzYield rel 1 0 Pops cond; if !=0 jumps to next+arg*4; always yields this frame. high stBne +0x14 jz rel 1 0 Pops cond; if 0 jumps to next+arg*4. high stBeqImm +0x15 jnz rel 1 0 Pops cond; if !=0 jumps to next+arg*4; unused. high stBneImm +0x16 jmp rel 0 0 Jumps to next+arg*4. high stJumpImm +0x17 switch rel 1 0 Pops value; table at next+arg*4+4: count, then entries (case value as 0x44 short or 0x45 imm, then rel offset); on match jumps to next+offset*4, otherwise falls through. high stIndex_Jump +0x18 pushParentVar var 0 1 Pushes variable n of the linked strat at +0xC (parent/creator). high stExt_Local +0x19 pushParentVarAddr var 0 1 Pushes address of variable n of the +0xC strat. high stExt_LocalAddress +0x1a pushParentProp prop 0 1 Pushes property n of the +0xC strat. high stExt_AlienVar +0x1b pushParentPropAddr prop 0 1 Pushes address of property n of the +0xC strat; unused. high stExt_AlienVarAddress +0x1c pushPlayerProp playerProp 0 1 Pushes property n of the player strat (DAT_006D9E1C, set by 0xb5), 0 if none. high stPlayer_AlienVar +0x1d pushPlayerPropAddr playerProp 0 1 Pushes address of player property n (0 if no player); unused. high stPlayer_AlienVarAddress +0x1e pushCameraProp prop 0 1 Pushes property n of the camera strat (DAT_006D9E28, set by 0xc8), 0 if none. high stCamera_AlienVar +0x1f pushCameraPropAddr prop 0 1 Pushes address of camera property n (0 if none); unused. high stCamera_AlienVarAddress +0x20 pushTargetProp prop 0 1 Pushes property n of the camera target strat (DAT_006D9E30, set by 0xc9 from the player character strats), 0 if none. medium stTarget_AlienVar +0x21 pushTargetPropAddr prop 0 1 Pushes address of target property n (0 if none); unused. medium stTarget_AlienVarAddress +0x22 error_0x22 - 0 0 Prints "stCollide_AlienVar" not-implemented error; unused. high stCollide_AlienVar +0x23 error_0x23 - 0 0 Prints "stCollide_AlienVarAddress" not-implemented error; unused. high stCollide_AlienVarAddress +0x24 pushTarget2Prop prop 0 1 Pushes property n of the second target strat (DAT_006D9E34, set by 0xca), 0 if none; unused. medium stTarget_AlienVar +0x25 pushTarget2PropAddr prop 0 1 Pushes address of target2 property n (0 if none); unused. medium stTarget_AlienVarAddress +0x26 pushBossProp prop 0 1 Pushes property n of the strat in DAT_006D9E24 (set by 0xef), no null check; unused. low stBoss_AlienVar +0x27 pushBossPropAddr prop 0 1 Pushes address of property n of the DAT_006D9E24 strat; unused. low stBoss_AlienVarAddress +0x28 pushDialogProp prop 0 1 Pushes property n of the strat in DAT_006D9E2C (set by 0x113, used by NPC/puzzle strats), 0 if none. low stDialog_AlienVar +0x29 pushDialogPropAddr prop 0 1 Pushes address of property n of the DAT_006D9E2C strat (0 if none); unused. low stDialog_AlienVarAddress +0x2a shadow imm 0 0 arg!=0 sets, 0 clears +0xE8 bit 0x20 (shadow on; shadow pass requires it plus size from 0x59 and texture from 0x5a). medium stShadow +0x2b jmpYield rel 0 0 Jumps to next+arg*4 and yields this frame (loop back-edge / wait one frame). high - +0x2c nop_0x2c - 0 0 No-op (shared empty handler); unused. high - +0x2d nop_0x2d - 0 0 No-op (shared empty handler); unused. high - +0x2e nop_0x2e - 0 0 No-op (shared empty handler); unused. high - +0x2f flagEC10000 imm 0 0 arg!=0 sets, 0 clears +0xEC bit 0x10000 (with 0x8000 both set the strat skips player-distance updates; property 0xf2 reads it); meaning unclear. low - +0x30 trackPlayer imm 0 0 If no player yet: re-executes itself next frame (pc-=4, yield); else arg sets/clears +0xEC bit 0x4000 = update distance to player each frame (+0x148). medium - +0x31 flagEC2 imm 0 0 arg sets/clears +0xEC bit 2 (checked by collision/floor code via +0xC0, readable as property 0x1f). low - +0x32 flagE8_80000000 imm 0 0 arg sets/clears +0xE8 bit 31; unused. low - +0x33 flagEC100 imm 0 0 arg sets/clears +0xEC bit 0x100 (tested in the collision response, skips floor handling; used by drops/projectiles/rollercoaster). low - +0x34 flagEC200000 imm 0 0 arg sets/clears +0xEC bit 0x200000 (used by NPC helpers/canoe/llama). low - +0x35 nop_0x35 - 0 0 No-op (shared empty handler); unused. high - +0x36 nop_0x36 - 0 0 No-op (shared empty handler); unused. high - +0x37 popPlayerProp playerProp 1 0 Pops value and stores it into player property n (discarded if no player). high - +0x38 popCameraProp prop 1 0 Pops value into camera strat property n (discarded if none). high - +0x39 popTargetProp prop 1 0 Pops value into camera target (DAT_006D9E30) property n (discarded if none). medium - +0x3a popTarget2Prop prop 1 0 Pops value into DAT_006D9E34 strat property n (discarded if none); unused. medium - +0x3b popDialogProp prop 1 0 Pops value into DAT_006D9E2C strat property n (discarded if none); unused. low - +0x3c error_0x3c - 0 0 Prints "stCollide_AlienVar_Equals" not-implemented error; unused. high - +0x3d popBossProp prop 1 0 Pops value into DAT_006D9E24 strat property n (no null check); unused. low - +0x3e popParentVar var 1 0 Pops value into variable n of the +0xC strat. high - +0x3f popParentProp prop 1 0 Pops value into property n of the +0xC strat; unused. high - +0x40 popProp prop 1 0 Pops value into own property n (via FUN_005509F0 address). high - +0x41 popVar var 1 0 Pops value into own variable n. high - +0x42 popCodeWord rel 1 0 Pops value into the code-stream word at pc+arg*4 (self-modifying inline data, pairs with 0x05). high - +0x43 popGlobal global 1 0 Pops value into global n. high - +0x44 pushShort imm 0 1 Pushes the sign-extended 16-bit arg (raw, not shifted to 20.12). high stNumber +0x45 push imm 0 1 Pushes inline 32-bit word (handler shared with 0xd7). high stNumber +0x46 neg - 1 1 Pops a, pushes -a. high stUMinus +0x47 inc - 1 0 Pops address, adds 1.0 (0x1000) to the word there. high stIncrease +0x48 dec - 1 0 Pops address, subtracts 1.0 (0x1000) from the word there. high stDecrease +0x49 add - 2 1 Pops b, a; pushes a+b. high stAdd +0x4a sub - 2 1 Pops b, a; pushes a-b. high stSub +0x4b mul - 2 1 Pops b, a; pushes fixed-point a*b>>12. high stMul +0x4c div - 2 1 Pops b, a; pushes fixed-point (a<<8)/b<<4. high stDiv +0x4d store - 2 0 Pops value, then address; stores value at address; unused (compiler uses pop ops). high stEquals +0x4e eq - 2 1 Pops b, a; pushes a==b (0/1, raw). high stCompare +0x4f lt - 2 1 Pops b, a; pushes a>12) (b fixed shift count). high stShiftLeft +0x51 shr - 2 1 Pops b, a; pushes a >> (b>>12) arithmetic (also stashes a in 0x6DA2D8). high stShiftRight +0x52 animToFrame - 1 0 Pops frame (fixed); steps current anim frame +0x110 forward (wrapping) until it reaches frame (clamped to last frame of anim +0x14), applying each frame to the model. medium - +0x53 gt - 2 1 Pops b, a; pushes a>b. high stGreaterThan +0x54 setModel - 1 0 Pops model address; sets +0x10 and updates +0xEC bit 0x20 / +0xE8 bit 0x80 from model header fields. high stSetModel +0x55 scale - 1 0 Pops scale (fixed); sets x/y/z scale +0x60/+0x64/+0x68. high stScale +0x56 scaleX - 1 0 Pops scale (fixed) into +0x60. high stScaleX +0x57 scaleY - 1 0 Pops scale (fixed) into +0x64. high stScaleY +0x58 scaleZ - 1 0 Pops scale (fixed) into +0x68. high stScalez +0x59 shadowSize - 1 0 Pops shadow size (fixed) into +0x12C (shadow drawn only if >=0x80). high stShadowSize +0x5a shadowTexture - 1 0 Pops shadow texture index (TEXT texture) into +0x128 (short). medium stShadowType +0x5b error_0x5b - 0 0 Prints "stFlash" not-implemented error; unused. high stFlash +0x5c error_0x5c - 0 0 Prints "stTrans" not-implemented error; unused. high stTrans +0x5d moveUp - 1 0 Pops dist (fixed); moves strat by local (0,+d,0) via FUN_005501D0. high stMoveUp +0x5e moveDown - 1 0 Pops dist; moves by local (0,-d,0). high stMoveDown +0x5f moveForward - 1 0 Pops dist; moves by local (0,0,+d) along its facing. high stMoveForward +0x60 moveBackward - 1 0 Pops dist; moves by local (0,0,-d). high stMoveBackward +0x61 moveLeft - 1 0 Pops dist; moves by local (+d,0,0). high stMoveLeft +0x62 moveRight - 1 0 Pops dist; moves by local (-d,0,0). high stMoveRight +0x63 turnRight - 1 0 Pops angle; yaw +0x24 -= angle (24-bit wrap; 4096.0 = full turn). high stTurnRight +0x64 turnLeft - 1 0 Pops angle; yaw +0x24 += angle. high stTurnLeft +0x65 tiltLeft - 1 0 Pops angle; roll +0x28 -= angle. high stTiltLeft +0x66 tiltRight - 1 0 Pops angle; roll +0x28 += angle. high stTiltRight +0x67 tiltForward - 1 0 Pops angle; pitch +0x20 += angle. high stTiltForward +0x68 tiltBackward - 1 0 Pops angle; pitch +0x20 -= angle; unused. high stTiltBackward +0x69 turnToPlayerX - 1 0 Pops max step (angle); turns pitch +0x20 toward the player by at most step; unused. high stTurnToPlayerX +0x6a turnToPlayerY - 1 0 Pops max step (angle); turns yaw +0x24 toward the player by at most step. high stTurnToPlayerY +0x6b turnToPlayerXY - 1 0 Pops max step (angle); turns yaw and pitch toward the player. high stTurnToPlayerXY +0x6c turnToX - 4 0 Pops step, z, y, x; turns pitch toward point (x,y,z) by at most step. high stTurnToX +0x6d turnToY - 4 0 Pops step, z, y, x; turns yaw toward point (x,y,z) by at most step. high stTurnToY +0x6e turnToXY - 4 0 Pops step, z, y, x; turns yaw and pitch toward point (x,y,z). high stTurnToXY +0x6f wobble - 1 0 Pops and discards one value (unimplemented; shared pop handler); unused. medium stWobble +0x70 resetPos - 0 0 Restores 8 words (angles+position, +0x20..+0x3C) from the saved copy at +0xA0. high stReSetPos +0x71 savePos - 0 0 Saves 8 words (+0x20..+0x3C angles+position) to +0xA0 for 0x70. high stSetPos +0x72 nop_0x72 - 0 0 No-op (shared empty handler); unused. medium stJump +0x73 wpFirst - 0 0 Current waypoint +0x120 := first waypoint +0x118. high stWPFirst +0x74 wpLast - 0 0 Current waypoint +0x120 := last waypoint +0x11C. high stWPLast +0x75 wpNext - 0 0 Current waypoint := its next link; wraps to first. high stWPNext +0x76 wpPrev - 0 0 Current waypoint := its prev link; wraps to last. high stWPPrev +0x77 error_0x77 - 0 0 Prints "stWpdel" not-implemented error; unused. high stWPDel +0x78 error_0x78 - 0 0 Prints "stWpnew" not-implemented error; unused. high stWPNew +0x79 wpNearest - 0 0 Sets current waypoint to the one nearest the strat position; unused. high stWPNearest +0x7a wpFurthest - 0 0 Sets current waypoint to the one furthest from the strat (excluding last); unused. high stWPFurthest +0x7b wpTurnToX - 1 0 Pops max step (angle); turns pitch toward the current waypoint (pos at wp+0x18). high stWPTurnToX +0x7c wpTurnToY - 1 0 Pops max step (angle); turns yaw toward the current waypoint (if any). high stWPTurnToY +0x7d wpTurnToXY - 1 0 Pops max step (angle); turns yaw and pitch toward the current waypoint. high stWPTurnToXY +0x7e animPlay - 1 0 Pops anim address; sets +0x14, frame +0x110 := 0, sets +0xE8 bit 8 (playing) and applies frame 0 to the model. high stAnimPlay +0x7f animStop - 0 0 Clears +0xE8 bit 8 (anim playing). high stAnimStop +0x80 animClear - 0 0 Clears the current anim pointer (+0x14) and flag 0x8 in +0xE8 (no anim playing). medium stAnimClear +0x81 animSetSpeed - 0 0 Unimplemented on PC: only logs "stAnimsetspeed" via the debug print; no stack effect. high stAnimSetSpeed +0x82 collisionType - 1 0 pops type (raw int); clears bits 0x42C of +0xEC then sets 1->0x8, 2->0x400, 3->0x20, 4->none, other->0x4 (collision shape/type flags). high stCollisionType +0x83 collRadius - 1 0 pops radius; stores its low 16 bits raw into collision radius short +0xD2. high stCollRadius +0x84 collHeight - 0 0 Unimplemented: logs "stCollheight" only. high stCollHeight +0x85 collExtent - 1 0 pops extent; stores its low 16 bits raw into short +0xC4. medium stCollextent +0x86 collView - 0 0 Unimplemented: logs "stCollview" only. high stCollView +0x87 nop_0x87 - 0 0 Shared no-op handler (0x426500); unused. Slot of Croc2 stCollPoints. low stCollPoints +0x88 nop_0x88 - 0 0 Shared no-op handler (0x426500); unused. Slot of Croc2 stCollSetPoint. low stCollSetPoint +0x89 createTrigger - 0 0 Sets flag 0x40 in +0xE8 (strat becomes a trigger/touchable, used by pickups like Health, Carrot). medium stCreateTrigger +0x8a killTrigger - 0 0 Clears flags 0x40 and 0x4000000 in +0xE8 (undoes createTrigger). medium stKillTrigger +0x8b wait - 1 0 pops frames (fixed, >>12) into wait counter short +0xFE and sets flag 0x2 in +0xE8 (strat sleeps until the counter runs out). high stWait +0x8c hold - 0 0 Sets flags 0x12 (0x2 wait + 0x10 stopped) in +0xE8: suspends the strat indefinitely until released. medium stHold +0x8d release - 0 0 Unimplemented: logs "stRelease" only. high stRelease +0x8e remove - 0 0 Sets flags 0x6 in +0xE8 (yield + kill); same handler as 0xb4 end. high stRemove +0x8f nop_0x8f - 0 0 Shared no-op handler (0x426500); unused. Slot of Croc2 stMapRemove. low stMapRemove +0x90 mapAdd - 0 0 Unimplemented: logs "stMapadd" only. high stMapAdd +0x91 mapReplace - 0 0 Unimplemented: logs "stMapreplace" only. high stMapReplace +0x92 activated - 0 0 Sets bit 0x8 in byte +0x44 of the strat's map-instance record (+0xF0): marks the map object as activated (persistent state). medium stActivated +0x93 collected - 0 0 Sets bit 0x4 in byte +0x44 of the map-instance record (+0xF0): marks the map object as collected so it does not respawn. medium stCollected +0x94 spawnLegacy count,imm,imm,flags,imm ? 0 pops code address of the new strat, then argCount (first inline word) argument values (popped inside FUN_0054bfc0); spawns a child strat at this strat's position (+0x20 block); inline words 3-5 are spawn parameters/flags (word 3 tested for 0x40000/0x80000/0x8000 collision-info allocation). Unused in shipped levels. medium stSpawn +0x95 spawnFrom count,imm,imm,flags,imm,imm ? 0 Like spawn (pops code address + args) but with an extra inline block giving a local offset (x/y/z) that is rotated by the strat's matrix (+0x70..) and added to its translation (+0x94..): spawn at an offset relative to this strat. Unused. medium stSpawnFrom +0x96 link - 0 0 Unimplemented: logs "stLink" only. high stLink +0x97 unlink - 0 0 Unimplemented: logs "stUnlink" only. high stUnlink +0x98 soundShift - 2 0 pops pitch (fixed, >>12), then sound channel; changes pitch of a playing sound channel owned by this strat (FUN_00546400). medium stSoundShift +0x99 soundStop - 1 0 pops sound channel; stops that channel if owned by this strat, or (channel <0/invalid) all sounds of this strat. high stSoundStop +0x9a musicPlay - 1 0 pops music track index (fixed, >>12); if different from the current track (0x586430) starts that ASF music stream (FUN_00546b70). high stCdPlay +0x9b streamPlay - 0 0 Unimplemented: logs "stStreamPlay" only. high - +0x9c speechSetPos - 1 0 pops a value (fixed, >>12) into global 0x6DA2B8 (meaning unknown, likely speech volume/range) and moves the speech/voice stream (handle 0x6D94B4, started by '#' text) to this strat's position. low - +0x9d discard_0x9d - 1 0 pops a value (>>12) and passes it to the no-op debug print; effectively drop. Croc2 slot midiVolume/cdFade. low stMidiVolume +0x9e speechStop - 0 0 Stops the current speech/voice stream (handle 0x6D94B4) if one is playing and resets the handle to -1. high stMidiStop +0x9f streamQueue - 0 0 Unimplemented: logs "stStreamQueue" only. high stMidiQueue +0xa0 isLight - 0 0 Unimplemented: logs "stIsLight" only. high stIsLight +0xa1 lightCol - 0 0 Unimplemented: logs "stLightCol" only. high stLightCol +0xa2 lightFade - 0 0 Unimplemented: logs "stLightFade" only. high stLightFade +0xa3 lightAtten - 0 0 Unimplemented: logs "stLightAtten" only. high stLightAtten +0xa4 lightType - 0 0 Unimplemented: logs "stLightType" only. high stLightType +0xa5 collisionOn flags 0 0 ORs the inline flag word into +0xE8; if any of 0x34000 is set also sets 0x4000 in +0xEC. high stCollisionOn +0xa6 collisionOff flags 0 0 Clears the inline flag word's bits in +0xE8. high stCollisionOff +0xa7 collisionOffAll - 0 0 Clears all collision flag bits 0x7E000 in +0xE8. high stCollisionOffAll +0xa8 soundPlayEx - 3 0 pops pitch (fixed), volume (fixed), sound id; in game states 3/4 plays the sound on this strat and sets its volume and pitch (no channel returned). high stSoundPlay +0xa9 soundPlay4_0xa9 - 4 0 pops 4 values and discards them (stubbed sound-play variant). medium stSoundPlay +0xaa soundPlayAssEx - 3 1 pops pitch, volume, sound id; plays it like 0xa8 and pushes the channel (raw int), or -1 if not in game state 3/4. high stSoundPlayAss +0xab soundPlayAss4_0xab - 4 0 pops 4 values and discards them (same stub as 0xa9; pushes nothing). medium stSoundPlayAss +0xac int - 1 1 pops x; pushes x & 0xFFFFF000 (fixed-point floor/truncate to integer). high stInt +0xad sin - 1 1 pops angle (fixed, 4096 table units = full turn); pushes sin from table 0x574318 (20.12). high stSin +0xae cos - 1 1 pops angle; pushes cos (table 0x574318 offset by quarter turn 0x400). high stCos +0xaf not - 1 1 pops x; pushes 1 if x==0 else 0. high stNot +0xb0 pop - 1 0 Drops the top stack value (stack pointer +0x104 -= 4). high stPop +0xb1 stkCmp imm 0 1 Peeks (does not pop) the top stack entry, dereferences it as a pointer and pushes imm minus the pointed-to int (compare helper). low stStkCmp +0xb2 address addr 0 1 Patches itself into 0x45 (push immediate) after resolving the operand to an absolute address (negative = import table 0x6DA324, else + STPC base 0x6D9DBC) and pushes it (code/model/anim address). high stAddress +0xb3 return - 1 0 pops code address and jumps there (pc = popped). high stReturn +0xb4 end - 0 0 Sets flags 0x6 in +0xE8 (yield + kill): ends the strat. Same handler as 0x8e. high stEndStrat +0xb5 isPlayer - 0 0 Registers this strat as the player (global 0x6D9E1C = strat) and sets flag 0x800 in +0xE8. high stIsPlayer +0xb6 and - 2 1 pops b, a; pushes 1 if both are non-zero else 0 (logical and). high stAnd +0xb7 or - 2 1 pops b, a; pushes a | b (bitwise, used as logical or). high stOr +0xb8 bitwiseAnd - 2 1 pops b, a; pushes a & b. high stBitwiseAnd +0xb9 discard_0xb9 - 1 0 Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 ext-var slots). medium - +0xba discard_0xba - 1 0 Shared stub 0x554fb0: pops one value and discards it. Unused. medium - +0xbb nop_0xbb - 0 0 Shared no-op handler (0x426500); unused. low - +0xbc notEqual - 2 1 pops b, a; pushes 1 if a != b else 0. high stNotEqual +0xbd greaterEqual - 2 1 pops b, a; pushes 1 if a >= b else 0. high stGreaterEqual +0xbe lessEqual - 2 1 pops b, a; pushes 1 if a <= b else 0. high stLessEqual +0xbf rnd - 1 1 pops n; pushes random value (two rand() halves) modulo n, 0 if n==0; result is in the same units as n (fixed in, fixed out). high stRnd +0xc0 loseHeart - 0 0 In game state 3, unless player invulnerable (0x584710 bit 0): health 0x584E70 -= 1.0, or if already 0 sets 0x584638 = 2 (player dies); refreshes HUD (FUN_004268c0(1)). high stLoseHeart +0xc1 nop_0xc1 - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stResettoCheckPoint. low stResettoCheckPoint +0xc2 forceCollision - 0 0 Runs world collision for this strat (FUN_00403020 with level geometry): updates floor/ceiling/wall info in the collision block +0xC0 (ground height, surface type). medium stForceCollision +0xc3 turnFromPlayerY - 1 0 pops max turn step (angle, 0x1000000 = full turn); turns yaw +0x24 toward the direction from the player (0x6D9E1C) to this strat, i.e. away from the player, by at most step. medium stTurnFromPlayerY +0xc4 rumble - 2 0 Shared stub 0x555090: pops two values and discards them (PSX pad rumble/vibrate not implemented on PC). low stRumble +0xc5 suspendIfTooFar - 1 0 Shared stub 0x554fb0: pops one value (likely distance) and discards it. low stSuspendIfTooFar +0xc6 collisionBone - 2 0 pops value (short), then bone id (fixed, >>12); sets/adds entry {id, value} in the bone table at +0xC8 (count +0xC6). medium stCollisionBone +0xc7 useBone - 1 0 pops one value (ignored); if a model/anim is set (+0x14) rebuilds the rotation matrix and stores a transformed point (matrix * local vector + translation +0x94) into +0xD8/+0xDC/+0xE0. low stUseBone +0xc8 isCamera - 0 0 Registers this strat as the camera (global 0x6D9E28) and sets flag 0x8000000 in +0xE8. high stIsCamera +0xc9 lookAtMe - 0 0 Sets camera look-at target global 0x6D9E30 = this strat. high stLookatme +0xca lookAtMe2 - 0 0 Sets secondary look-at global 0x6D9E34 = this strat. Unused. medium stLookatme +0xcb pushCamera - 0 0 Pushes current camera (0x6D9E28) and look-at (0x6D9E30) pair onto the camera stack 0x6D9DCC (index 0x6D9E40). high stPushCamera +0xcc popCamera - 0 0 If the camera stack is not empty, restores camera and look-at from it and increments camera-change counter 0x6D9D34. high stPopCamera +0xcd nop_0xcd - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stResetCamerapos. low stResetCamerapos +0xce gainHeart - 0 0 Health 0x584E70 += 1.0 if below max 0x584E6C; refreshes HUD (FUN_004268c0(1)). high stGainHeart +0xcf nop_0xcf - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stGainHeartPot. low stGainHeartPot +0xd0 addInv imm 0 0 Adds inline item to inventory via FUN_00426860(item,1,1,0,0), which is a stub returning 0 on PC (no effect). medium stAddInv +0xd1 nop_0xd1 - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stGainCrystal. low stGainCrystal +0xd2 discard_0xd2 - 1 0 Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 slot stCutscene). low stCutscene +0xd3 inventory imm 0 1 Queries inline item via stub FUN_00426860 and pushes result<<12 (always 0 on PC). medium stInventory +0xd4 debugName imm,count 0 0 Strat name: skips the first inline word, reads the byte length of the following name string, sets the name pointer (+0x18) to that string and jumps past it. high stDebugname +0xd5 soundPlay - 1 0 pops sound id; plays it on this strat (FUN_00546170); outside game states 3/4 first sets game state 0x5846E4 = 5. high stSoundPlay +0xd6 soundPlayAss - 1 1 pops sound id; in game states 3/4 plays it and pushes the channel (raw int), else pushes -1. high stSoundPlayass +0xd7 soundAddress sound 0 1 Pushes the inline sound id (same handler as 0x45 push immediate). high stSoundAddress +0xd8 nop_0xd8 - 0 0 Shared no-op handler (0x426500); unused. Croc2 slot stOnground/stObjectFallslow. low stOnground +0xd9 collisionOffset - 3 0 pops z, y, x; stores low 16 bits into shorts +0xD0, +0xCE, +0xCC (collision centre offset). high stCollisionOffset +0xda abs - 1 1 pops x; pushes |x|. high stAbs +0xdb pickup imm 0 0 inline flag: nonzero sets bit 0x200 in +0xEC, zero clears it (strat can be picked up/carried). medium stPickup +0xdc min - 2 1 pops b, a; pushes the smaller (signed). high stMin +0xdd max - 2 1 pops b, a; pushes the larger (signed). high stMax +0xde spawnParticle - 6 0 pops param (texture or colour, type-dependent), vz, vy, vx (velocity, fixed), scale (fixed, 0x1000 = default), particle type (raw int); emits a particle at this strat's position (FUN_00428ea0). medium stSpawnparticle +0xdf sgn - 1 1 pops x; pushes 1.0 (0x1000), -1.0 or 0 by sign. high stSgn +0xe0 spawn count,count,imm,flags,imm ? 0 Spawns a child strat via FUN_0054bfc0: pops code address of the new strat (top), then pops operand1 (param count) values from the parent stack as the child's MAP parameters; operand2 = local variable count of the child, operand4 = spawn flags (ORed with own +0x138; bits 0x40000/0x80000/0x8000 select model/collision setup), operands 3/5 extra sizes/flags; child copies parent orientation/position block (+0x20) and waypoint fields; fails silently (params still popped) if strat pool full medium stSpawn +0xe1 nop_0xe1 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stDontLookatme? +0xe2 nop_0xe2 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stRunat? +0xe3 moveForwardQ - 1 0 pops distance (fixed); x(+0x30) += d*sin(yaw), z(+0x38) += d*cos(yaw), yaw = +0x24 (4096 units per turn, table 0x574318) high stMoveForwardq +0xe4 moveBackwardQ - 1 0 pops distance (fixed); moves the strat backwards along its yaw (x -= d*sin, z -= d*cos) high stMoveBackwardq +0xe5 playSoundVol - 2 0 pops volume (fixed, >>12), sound id; if game state DAT_005846e4 is 3 or 4 plays the sound for this strat (FUN_00546170) and sets the channel volume (+0x6a, FUN_00546360) high stSoundPlay +0xe6 playSoundChannel - 2 1 pops volume (fixed), sound id; if game state DAT_005846e4==3 plays the sound with that volume and pushes the channel index (raw int, not fixed), else pushes -1 high stSoundPlayass +0xe7 setWP - 0 0 saves the current waypoint: +0x124 = +0x120 high stSetWP +0xe8 resetWP - 0 0 restores the saved waypoint: +0x120 = +0x124 high stResetWP +0xe9 soundVolume - 2 0 pops volume (fixed), sound channel; sets the channel volume (+0x6a) if the channel belongs to this strat (FUN_00546360) high stSoundVolume +0xea nop_0xea - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stString? +0xeb setBossHearts - 1 0 pops count (fixed); sets boss heart max DAT_00584f0c and current DAT_00584f10 to count high stSetbossHearts +0xec loseBossHeart - 0 0 decrements boss hearts DAT_00584f10 (not below 0) and refreshes the HUD (FUN_004268c0(1)) high stLosebossHeart +0xed soundShiftRelative - 2 0 pops shift/pitch (fixed, >>12), sound channel; sets channel +0x70 and updates a playing voice (FUN_00546460) if the channel belongs to this strat medium stSoundShiftrelative +0xee smin - 2 1 pops limit (top), value; pushes value with its magnitude clamped to limit, keeping the sign (sign(v)*min(|v|,limit)) high stSmin +0xef isBoss - 0 0 registers this strat as the boss: DAT_006d9e24 = self high stIsboss +0xf0 topSay text 0 0 shows dialog text (inline text index) in the top text box: sets DAT_006d9858=1 and formats via FUN_0040d4c0 high stTopsay +0xf1 getParentPos - 0 0 copies parent's (+0xC) orientation+position block (+0x20..+0x3C, 8 words) into this strat high stGetparentpos +0xf2 setDrawMode - 1 0 pops mode (raw int): 1 sets flag 0x80000 in +0xEC, 2 sets 0x100000, other clears both (exact effect of the bits unknown); unused low - +0xf3 afterBoss - 0 0 if no boss (DAT_006d9e24) rewinds pc and yields (waits); else relinks this strat in the process list right after the boss high stAfterboss +0xf4 afterPlayer - 0 0 if no player (DAT_006d9e1c) rewinds pc and yields (waits); else relinks this strat in the process list right after the player high stAfterPlayer +0xf5 beforePlayer - 0 0 if no player rewinds pc and yields (waits); else relinks this strat in the process list right before the player high stBeforePlayer +0xf6 nop_0xf6 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stBeforeboss?/stNohang? +0xf7 pushZero - 0 1 pushes 0 (same handler as 0x1a7); unused high stZero +0xf8 nop_0xf8 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stTophead? +0xf9 popDiscard_0xf9 - 1 0 pops and discards one value (stub FUN_00554fb0, shared with 0x6f etc.); unused high stTopdialog? +0xfa bottomSay text 0 0 shows dialog text (inline text index) in the bottom text box: sets DAT_006d94e8=2 and formats via FUN_0040d4c0 high stBottomsay +0xfb nop_0xfb - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stBottomhead? +0xfc popDiscard_0xfc - 1 0 pops and discards one value (stub FUN_00554fb0); unused high stBottomdialog? +0xfd getPlayerPos - 0 0 if a player exists copies its orientation+position block (+0x20..+0x3C) into this strat high stGetPlayerpos +0xfe getWPPos - 0 0 sets rotation x/y/z (+0x20..+0x28) from the current waypoint's (+0x120) ints <<12 and position (+0x30..+0x3C) from waypoint +0x18..+0x24 high stGetWPpos +0xff getBossPos - 0 0 copies the boss's (DAT_006d9e24) orientation+position block (+0x20..+0x3C) into this strat (no null check); unused high stGetbosspos +0x100 getDoorPos - 0 0 finds the level door record (DAT_00584648+0x24) in this strat's x/z cell nearest in y; if found sets current door DAT_00584644, places the strat at the door position/yaw (FUN_0054de00) and, unless door flag 2, moves it forward by the door's distance (+0x38) medium stGetdoorpos +0x101 fadeOut - 1 0 pops speed selector (fixed, 1/2/3/other) and starts a screen fade-out (FUN_00415390 mode 1/5/0xb/3); sets DAT_00584638=6 unless it is 7 or 10 medium stFadeout +0x102 fadeIn - 1 0 pops speed selector (fixed, 1/3/other) and starts a screen fade-in (FUN_00415390 mode 2/10/4); DAT_00584638=0 medium stFadein +0x103 moveUpQ - 1 0 pops distance (fixed); y(+0x34) += d high stMoveUpq +0x104 moveDownQ - 1 0 pops distance (fixed); y(+0x34) -= d high stMoveDownq +0x105 forcePlayerDist - 0 0 recomputes the cached squared player distance +0x148 (coords >>6) and clears +0x1C; 0x200000 if no player; unused high stForcePlayerdist +0x106 nop_0x106 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stShadeType? +0x107 setAnimSpeed - 1 0 pops value and stores its low 16 bits (raw, not >>12) in halfword +0x114 (animation timing field, zeroed when the anim frame is set) low stSetanimSpeed? +0x108 nop_0x108 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stCheckleveldoor? +0x109 nop_0x109 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x10a nop_0x10a - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x10b nop_0x10b - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stGainjigsaw? +0x10c nop_0x10c - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stGaingoldengobbo? +0x10d gainSecret - 0 0 increments the collected-secrets counter DAT_00584e64 (copied to the HUD/stats at level reset); used only by strat Secret high stGainCrystal? +0x10e nop_0x10e - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x10f nop_0x10f - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); used once (LlamaChase) high stCheckPoint? +0x110 setMainCameraFlag - 0 0 sets flag 0x8000000 in own flags +0xE8 (same flag the camera-register opcode FUN_005500f0 sets, without registering DAT_006d9e28); used by CameraCollisionInFront medium stIsmainCamera? +0x111 nop_0x111 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x112 nop_0x112 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x113 isDialog - 0 0 registers this strat as the dialog partner: DAT_006d9e2c = self high stIsdialog +0x114 distance - 3 1 pops z (top), y, x; pushes the 3D distance from the strat's position (+0x30) to (x,y,z) (FUN_00404bb0, float sqrt -> int) high stDistance +0x115 showLevelStartInfo imm 0 0 inline word: low byte stored in DAT_006da294 (level start info / title card index), logs 'show level start info %d' medium - +0x116 topCloseDialog - 0 0 closes the top dialog text box: DAT_006d9858 = 0 high stTopclosedialog +0x117 bottomCloseDialog - 0 0 closes the bottom dialog text box: DAT_006d94e8 = 0 high stBottomclosedialog +0x118 nop_0x118 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stNextinventory? +0x119 nop_0x119 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stPrevinventory? +0x11a otherPiece - 0 0 finds the switchable map piece at the strat's grid cell (FUN_00553f10, table DAT_00584648+0x50) and sets it to its alternate state (*piece[5] = piece[4]), swapping its texture/collision table entries if DAT_006da330 bit 0x10000000 medium stOtherpiece +0x11b normalPiece - 0 0 same as otherPiece but restores the piece's normal state (*piece[5] = piece[3]) medium stNormalpiece +0x11c delInv imm 0 0 inline word (inventory item id) passed to FUN_00426860(item,1,-1,0,0), which is a stub returning 0 in this build: effectively no-op; unused medium stDelinv +0x11d nop_0x11d - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x11e nop_0x11e - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x11f nop_0x11f - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x120 nop_0x120 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x121 nop_0x121 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x122 nop_0x122 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x123 nop_0x123 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x124 nop_0x124 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high - +0x125 moveLeftQ - 1 0 pops distance (fixed); strafes: x += d*cos(yaw), z += d*sin(yaw-half turn) (= -d*sin) high stMoveLeftq +0x126 moveRightQ - 1 0 pops distance (fixed); strafes opposite to moveLeft (x -= d*cos(yaw), z += d*sin(yaw)) high stMoveRightq +0x127 bitwiseNot - 1 1 pops v; pushes ~v high stBitwisenot +0x128 bordersOn - 0 0 enables cinematic letterbox borders: DAT_005fd090 = 1 high stBorderson +0x129 bordersOff - 0 0 disables letterbox borders: DAT_005fd090 = 0 high stBordersoff +0x12a soundAdsr - 5 0 pops and discards 5 values (stubbed); unused high stSoundadsr +0x12b soundAdsrRelative - 3 0 pops a (top), b (fixed), sound channel; if the channel belongs to this strat sets channel +0x34 = b>>12, +0x38 = a>>12 (FUN_005463b0, envelope/range params) medium stSoundadsrrelative +0x12c nop_0x12c - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stRotatepiece? +0x12d setAmbient - 3 0 pops b, g, r (fixed, >>12, top = b) into bytes +0x135/+0x134/+0x133 and enables the own ambient light colour (+0x136 = 1) high stSet_ambient +0x12e resetAmbient - 0 0 disables the own ambient colour: +0x136 = 0; unused high stReset_ambient +0x12f invActive - 0 0 calls inventory stub FUN_00426860(0,1,0,1,0) (returns 0, no effect in this build); unused high stInvactive +0x130 invInactive - 0 0 calls inventory stub FUN_00426860(0,1,0,1,1) (no effect); unused high stInvinactive +0x131 sampleStatus - 1 1 pops sound id; pushes (fixed) the channel index on which this strat is playing that sound, or -1.0 if not playing (FUN_005461e0); unused high stSamplestatus +0x132 nop_0x132 - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stResettoCheckPointnlh? +0x133 resetDoor - 0 0 restores the stored door (DAT_006da28c -> DAT_00584644) and, if a player exists, places the player at that door (FUN_0054de00) and restores its saved orientation/position block from DAT_006d9e48 high stResetdoor +0x134 storeDoor - 0 0 stores the current door (DAT_00584644 -> DAT_006da28c) and the player's orientation/position block (+0x20..+0x3C) into DAT_006d9e48 high stStoredoor +0x135 cameraModified - 0 0 if a camera strat (DAT_006d9e28) exists copies this strat's orientation/position block into it and its position into globals DAT_006d9d20..2c; unused medium stCamera_modified +0x136 pushPlayer - 0 0 saves the current player pointer DAT_006d9e1c into DAT_006d9e20 (logs an error if no player); unused high stPushPlayer +0x137 popPlayer - 0 0 restores the player pointer (DAT_006d9e1c and DAT_006d9e30) from DAT_006d9e20 and clears it; unused high stPopPlayer +0x138 resetToStart - 0 0 restores position +0x30..+0x38 from the saved (start) position +0xB0..+0xB8; unused high stReSetPostrn +0x139 gainItem - 0 0 increments item counter DAT_005fd09c up to the maximum DAT_005fd0a4; unused high stGainitem +0x13a setItem - 1 0 pops maximum (fixed); DAT_005fd0a4 = max, counter DAT_005fd09c = 0, enables item counter display (DAT_005fd0a0 = 1); unused high stSetitem +0x13b setTimer - 1 0 pops time (fixed, >>12) into DAT_005fd0ac and enables the timer (DAT_005fd0a8 = 1); unused high stSettimer +0x13c timerOff - 0 0 disables the timer: DAT_005fd0a8 = 0; unused high stTimeroff +0x13d distanceNoY - 2 1 pops z (top), x; pushes the horizontal (x/z) distance from the strat's position to (x,z) (FUN_00404c50) high stDistancenoy +0x13e nop_0x13e - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stSwim? +0x13f nop_0x13f - 0 0 no-op handler FUN_00426500 (does nothing, no inline operands); unused high stLoseCrystal? +0x140 nop_0x140 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x141 setDebugFlag5849a0On - 0 0 Writes short 1 to global 0x5849A0; that global is never read anywhere in groove.exe (dead PSX leftover). medium - +0x142 setDebugFlag5849a0Off - 0 0 Writes short 0xFFFF to global 0x5849A0 (never read; dead leftover). medium - +0x143 clearFlag5fd0b8 - 0 0 Sets global 0x5FD0B8 = 0; global is never read elsewhere (dead leftover). medium - +0x144 setFlag5fd0b8 - 0 0 Sets global 0x5FD0B8 = 1; global is never read elsewhere (dead leftover). medium - +0x145 nop_0x145 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x146 nop_0x146 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x147 nop_0x147 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x148 gotoLevel4_1 - 0 0 Hardcoded level change: next level (0x584EB4/EB8/EBC/EC0) = 4/1/1/0, previous level (0x585008..14) = 4/1/4/0, game state 0x584638 = 7 (load level). medium - +0x149 nop_0x149 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x14a hudReset2 - 0 0 Calls HUD/panel init FUN_004268c0(2): re-initialises the on-screen counter panels (coins, lives, etc.) for the current level. low - +0x14b setGlobal5fd086 imm 0 0 Stores the low 16 bits of the inline word in global 0x5FD086 (never read elsewhere; dead leftover). medium - +0x14c removeCollPoint - 1 0 pops id (fixed, >>12); removes the collision point with that id from the strat's collision point list (+0xC8 array of 4-byte entries, count +0xC6). Counterpart of 0xc6 (add/set collision point). medium stSetCollPoint (inverse) +0x14d setFlagEC_400000 flags 0 0 Inline word: nonzero sets, zero clears bit 0x400000 of strat flags2 (+0xEC); the bit selects which height constant own property 0xF2 returns. high - +0x14e atan2 - 2 1 pops b (top), a; pushes angle atan2(a, b) as (0..0xFFF)<<12, i.e. full turn = 0x1000000 (4096.0); used with a=dx, b=dz like yaw-to-target. high - +0x14f nop_0x14f - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x150 nop_0x150 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing (used once in HelpGirlSkip). high - +0x151 nop_0x151 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x152 nop_0x152 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x153 findNearestLight - 3 1 pops z (top), y, x; searches the level light table (0x586598, 0x1C-byte entries, count 0x5865D0) for the nearest light of type 2 (point light) to (x,y,z) and pushes its pointer (0 if none). medium - +0x154 setLightIntensity - 2 0 pops intensity (fixed; top), light pointer (from 0x153); stores intensity in light+0x18, rebuilds the light (FUN_0042c4a0) and re-flags/relights the map polygons within its radius (0 = off, 0x1000 = full). medium - +0x155 nop_0x155 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x156 nop_0x156 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x157 nop_0x157 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x158 nop_0x158 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x159 nop_0x159 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x15a setPosToPlayer - 0 0 Copies the player's position x/y/z (+0x30..+0x38) and +0x3C into the strat (player strat = 0x6D9E1C). high - +0x15b setPosToPlayerXZ - 0 0 Copies the player's x and z (+0x30, +0x38) into the strat; y unchanged. high - +0x15c setShadowHeight - 1 0 pops v; stores it in strat +0x14C, used by the shadow renderer as the shadow/ground height override (-0x1000 = use default 0x586424). low - +0x15d packRgb - 3 1 pops c (top), b, a (fixed 0..255); pushes (c<<16)|(b<<8)|a as a raw packed colour int (push order r,g,b gives 0x00BBGGRR). high - +0x15e trailCreate - 2 1 pops vertB (top), vertA (fixed vertex indices of the strat's model/anim frame); allocates a free trail/ribbon slot (0x5FD180, 0x42C bytes each) owned by this strat and pushes its handle (0 if none free). medium - +0x15f trailAddPoint - 3 0 pops colourB (top), colourA, trail handle; transforms the two trail vertices of the current anim frame into world space and appends them to the trail history (20 points) with the given colours; no-op if handle 0. medium - +0x160 trailFree - 1 0 pops trail handle; releases it (clears owner +0x414) if nonzero. high - +0x161 setPauseState - 1 0 pops v (fixed); sets global pause state 0x584640 = v>>12 (0 = not paused, 1 = paused, 2 = pausing disabled). medium - +0x162 setFlagE8_1000 flags 0 0 Inline word: nonzero sets, zero clears bit 0x1000 of strat flags (+0xE8). high - +0x163 setCollOffset - 3 0 pops z (top), y, x; stores them as 16-bit shorts into the strat's collision descriptor (+0xC0) at +0x32/+0x34/+0x36 = collision sphere centre offset. medium - +0x164 angleDiff - 2 1 pops b (top), a (angles, full turn 0x1000000); pushes (a-b) wrapped to the signed half-turn range (-0x800000..0x7FF000). high - +0x165 collideAsPlayer - 0 0 Sets flag 0x800 on self (excludes self), temporarily makes self the player (0x6D9E1C) and runs the player-vs-strat collision pass FUN_004051d0, then restores the player; lets projectiles (LlamaSpit) hit strats like the player does. medium - +0x166 setGlobalTint - 3 0 pops b (top), g, r (fixed, clamped 0..255); sets the global strat tint 0x6D9E14..16 and enables it, and applies it to every strat that has no own tint yet (+0x133..+0x136). high - +0x167 screenOverlay flags 0 0 Inline word: nonzero enables the full-screen colour overlay (0x583740 = 1, colour reset to 0), zero disables it. high - +0x168 setScreenOverlayColour - 4 0 pops d (top), c, b, a (fixed 0..255, >>12); sets the full-screen overlay colour to a<<24 | d<<16 | b<<8 | c (a = alpha; d,b,c land in the R,G,B byte positions of the D3D colour). medium - +0x169 nop_0x169 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x16a turnTowardStratY - 2 0 pops maxStep (top, angle), target strat pointer; turns own yaw (+0x24) toward the target's position by at most maxStep (full turn 0x1000000). high stTurnTowardY +0x16b pushSelf - 0 1 Pushes the pointer of the executing strat (this). high - +0x16c nop_0x16c - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x16d nop_0x16d - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x16e lerp - 3 1 pops t (top), b, a; pushes b + (a-b)*t (fixed): t=0 gives b, t=1.0 gives a. high - +0x16f clamp - 3 1 pops hi (top), x, lo; pushes max(lo, min(x, hi)) (push order lo, x, hi). high - +0x170 distSqToPos - 3 1 pops z (top), y, x; pushes squared 3D distance from own position, computed on coordinates >>6 (dx>>6)^2+(dy>>6)^2+(dz>>6)^2. high - +0x171 distSqToPosXZ - 2 1 pops z (top), x; pushes squared horizontal distance from own position, (dx>>6)^2+(dz>>6)^2. high - +0x172 approach - 3 1 pops step (top), target, value; pushes value moved toward target by step without overshooting. high - +0x173 distSqXZ - 4 1 pops z2 (top), x2, z1, x1; pushes (x2-x1)^2 + (z2-z1)^2 in fixed point. high - +0x174 turnTowardAngleY - 2 0 pops maxStep (top), targetYaw; turns own yaw (+0x24) toward targetYaw by at most maxStep, wrapping (full turn 0x1000000). high stTurnTowardY +0x175 setFlagEC_80 flags 0 0 Inline word: nonzero sets, zero clears bit 0x80 of strat flags2 (+0xEC). high - +0x176 inRectXZ - 6 1 pops maxZ (top), maxX, minZ, minX, z, x; pushes integer 1 (not 0x1000) if minX<=x<=maxX and minZ<=z<=maxZ, else 0. high - +0x177 waypointStep - 1 0 pops n (fixed); moves current waypoint (+0x120) n steps forward via next links (wraps to first +0x118) or -n steps back via prev links (wraps to last +0x11C). high stNextWaypoint/stPrevWaypoint +0x178 inWaypointBoxXZ - 2 1 pops z (top), x; pushes integer 1 if (x,z) lies inside the axis-aligned XZ box spanned by the current waypoint and the next one (wrapping to first), else 0. high - +0x179 nearestWaypoint - 0 0 Sets current waypoint (+0x120) to the waypoint of the list (+0x118) nearest (XZ) to the strat. high stNearestWaypointNext +0x17a nearestWaypointToPlayer - 0 0 Sets current waypoint to the waypoint nearest (XZ) to the player. high - +0x17b nearestOtherWaypoint - 0 0 Sets current waypoint to the waypoint nearest (XZ) to the strat, excluding the current one. high - +0x17c nop_0x17c - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x17d nop_0x17d - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x17e nop_0x17e - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x17f nop_0x17f - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x180 addTint - 3 0 pops b (top), g, r (fixed deltas); adds them to the strat's tint (own +0x133..135, or the level ambient at 0x584644+0x4C if none), clamps 0..255, stores and enables own tint (+0x136). high - +0x181 setAnimFrame - 1 0 pops frame (fixed); if an anim is set (+0x14) sets current frame +0x110 (out of range/negative -> 0), clears +0x114 and sets flag 8 (anim changed). high - +0x182 nop_0x182 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x183 showSaveSlotInfo - 1 0 pops slot (fixed 1..3); draws the save-game slot info text ('Gespeichertes Spiel n', chapter, etc.) for the main menu (FUN_0040db90). medium - +0x184 pushTintR - 0 1 Pushes own tint red byte (+0x133) as fixed. high - +0x185 pushTintG - 0 1 Pushes own tint green byte (+0x134) as fixed. high - +0x186 pushTintB - 0 1 Pushes own tint blue byte (+0x135) as fixed. high - +0x187 emitterInit - 7 0 pops colour/texture (top, raw), z, y, x (vector/offset), type (fixed 1..0x2B preset, or >=0x1000 type flags), size/speed (fixed, 0x1000 default), slot (0..2, >=3 = extra slot +0x153); (re)initialises the strat's particle emitter in that slot (records at 0x5FE230, 0x34 bytes; texture/colour stored per type). medium stSpawnparticle +0x188 emitParticles - 3 0 pops yStep (top, fixed), count, slot; emits count particles from the slot's emitter at own position with random XZ velocity, each raised by yStep. medium - +0x189 randSigned - 1 1 pops n; pushes a random value in (-n, n) ((rand32 - rand32) % n, same fixed scale as n). high - +0x18a emitterSetParams - 5 0 pops e (top), d, c, b, slot; sets emitter slot params: +0x254 = b (rate/speed), +0x24A = c>>12 (life); if e==0 +0x25C = (d>>12)*32-32 else +0x246 = e, +0x23C = d. Allocates the slot if empty. low - +0x18b emitterSetColour - 5 0 pops colour (top, raw packed, see 0x15d), b, g, r, slot; sets emitter colour (+0x250) and fade/alpha (+0x240..243): if r has no fraction it is an alpha value only, else r,g,b are per-channel fade values. low - +0x18c emitParticlesStill - 3 0 pops yStep (top, raw fixed), count, slot; emits count particles from the slot's emitter at own position without velocity, stacked by yStep. medium - +0x18d stubPop2_0x18d - 2 0 Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). high - +0x18e stubPop2_0x18e - 2 0 Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). high - +0x18f clampToWaypointBox - 0 0 Clamps own x/z (+0x30/+0x38) into the XZ box spanned by the current waypoint and the next one. high - +0x190 processBeforeCamera - 0 0 If no camera strat (0x6D9E28) yet: re-executes next frame (pc-=4, yield). Else unlinks self from the strat list (0x6D9E38) and reinserts it directly before the camera so it updates before the camera. high - +0x191 processBeforeCamera2 - 0 0 Same as 0x190 (waits for camera, then moves self just before the camera in the update list); functionally identical alias. high - +0x192 endLevel - 3 0 pops sub (top), level, world (fixed); saves current level as previous, sets next level, sets 'all coins collected'/completion flags, updates furthest-progress 0x585018, state 0x584638 = 10 and starts the fade-out (world=level=sub=7 means return to the level map). medium stEndlevel +0x193 stubPop1Push0_0x193 - 1 1 Stub FUN_005550b0: pops 1 value and pushes 0. high - +0x194 stubPop1Push0_0x194 - 1 1 Stub FUN_005550b0: pops 1 value and pushes 0. high - +0x195 stubPop2_0x195 - 2 0 Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). high - +0x196 stubPop2_0x196 - 2 0 Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). high - +0x197 nop_0x197 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x198 nop_0x198 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x199 nop_0x199 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x19a nop_0x19a - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x19b nop_0x19b - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x19c nop_0x19c - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x19d sqrt - 1 1 pops v (fixed); pushes fixed sqrt(v) (fsqrt of v/4096, rescaled). high - +0x19e nop_0x19e - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x19f nop_0x19f - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x1a0 setWaypointParam1 - 1 0 pops v; sets current waypoint +0x08 = (v>>12)&0xFFF (readable as own property 9). high - +0x1a1 setWaypointParam2 - 1 0 pops v; sets current waypoint +0x0C = (v>>12)&0xFFF (readable as own property 10). high - +0x1a2 setWaypointParam3 - 1 0 pops v; sets current waypoint +0x10 = (v>>12)&0xFFF (readable as own property 11). high - +0x1a3 stubPop1_0x1a3 - 1 0 Stub FUN_00554fb0: pops and discards 1 value. high - +0x1a4 stubPop1_0x1a4 - 1 0 Stub FUN_00554fb0: pops and discards 1 value. high - +0x1a5 nop_0x1a5 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - +0x1a6 emitterKill - 1 0 pops slot (fixed); frees the strat's particle emitter in that slot (+0x150+slot = 0xFF) and resets the record. high - +0x1a7 stubPush0_0x1a7 - 0 1 Stub FUN_0054e880: pushes 0. high - +0x1a8 processFirst - 0 0 Moves self to the head of the strat update list (0x6D9E38) if not already there. high - +0x1a9 setRain - 4 0 pops colour (top, raw -> 0x6D9BE8), b (->0x578EF4), a2 (->0x6D9BF4), a1 (->0x578EF0) (all >>12); if the three counts/params are 0 rain is stopped and cleared, else rain is enabled (0x6D9BE4 = 1). low - +0x1aa stubPop1Push0_0x1aa - 1 1 Stub FUN_005550b0: pops 1 value and pushes 0. high - +0x1ab stubPop2_0x1ab - 2 0 Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). high - +0x1ac stubPop1Push0_0x1ac - 1 1 Stub FUN_005550b0: pops 1 value and pushes 0. high - +0x1ad stubPop2Push0_0x1ad - 2 1 Stub FUN_005550d0: pops 2 values and pushes 0. high - +0x1ae stubPop3_0x1ae - 3 0 Stub FUN_005550f0: pops and discards 3 values. high - +0x1af setWeatherRender - 1 0 pops mode (fixed): 0 installs the normal rain/weather update+render callbacks (0x5FF034/38), 1 installs no-op callbacks and clears 0x6D94D0 (weather off). medium - +0x1b0 emitParticlesVel - 6 0 pops vz (top), vy, vx (velocity), yStep, count, slot (last three fixed >>12); emits count particles from the slot's emitter at own position with the given velocity, stacked by yStep. medium - +0x1b1 nop_0x1b1 - 0 0 Empty handler FUN_00426500 (shared no-op); does nothing. high - diff --git a/src/main/resources/com/eliasmueller/llamamod/format/strat/props.tsv b/src/main/resources/com/eliasmueller/llamamod/format/strat/props.tsv new file mode 100644 index 0000000..95ebbb0 --- /dev/null +++ b/src/main/resources/com/eliasmueller/llamamod/format/strat/props.tsv @@ -0,0 +1,195 @@ +# id name get set semantics confidence +0 zero_0 yes no Always pushes 0 (unused id; also 0x13, 0x15, 0x47, 0x75, 0x76, 0x77, 0x98, 0x9e, 0xc1, 0xd8, 0xed, 0xee, 0x102 push 0). high +1 unk_1 yes no DAT_006d94d0 << 12 (int global, cleared in FUN_00426... init; meaning unknown). low +3 wpEndFlags yes no Current waypoint (+0x120) link bits: bit0 = no previous node (node[0]==0, first), bit1 = no next node (node[1]==0, last). Raw int. high +4 wpParam yes yes Current waypoint field +0x28 (raw 32-bit, per-node parameter). medium +5 distToWp yes no 3D distance from strat position (+0x30) to current waypoint position (wp+0x18), 20.12 (FUN_00404bb0, sqrt). high +6 wpX yes yes Current waypoint x (wp+0x18), 20.12. high +7 wpY yes yes Current waypoint y (wp+0x1C), 20.12. high +8 wpZ yes yes Current waypoint z (wp+0x20), 20.12. high +9 wpRotX yes no Current waypoint int field +0x08 << 12 (node rotation x in 4096 table units, per notes). medium +10 wpRotY yes no Current waypoint int field +0x0C << 12 (node yaw in 4096 table units; e.g. checkpoint respawn yaw). medium +11 wpRotZ yes no Current waypoint int field +0x10 << 12 (third node angle/param). low +12 playerDistToWp yes no 3D distance from the player (0x6D9E1C) to this strat's current waypoint, 20.12; 100.0 (0x64000) if no player. high +13 angleToWp yes no Yaw from the strat to its current waypoint: atan2(dx,dz) (FUN_00404d60), 24-bit angle (0x1000000 = full turn, 4096 table units <<12). high +14 animFrame yes no Current anim frame +0x110 << 12. high +15 animPlaying yes no Flag +0xE8 bit 3 (0x8, anim playing) as 0/1. high +16 floorHeight yes yes Floor height under the strat, collision record (+0xC0)+0x00, 20.12 (0xFFFF0000 = no floor). Get returns 0 if no record. high +17 floorMaterial yes no Floor surface/material id, short at (+0xC0)+0x04, << 12 (0xFFFF = none; 16 = level door trigger). 0 if no record. high +18 ceilingMaterial yes no Short at (+0xC0)+0x30 << 12: material of the surface above (set together with ceiling height +0x28 in the collision code, 0xFFFF none). medium +19 zero_19 yes no Always 0. high +20 slideAngle yes no Short at (+0xC0)+0x2C << 12: direction (atan2 of old->new pos) written when the strat is pushed back off an edge/wall; 4096 units. low +21 zero_21 yes no Always 0 (both branches push 0). high +22 collisionRadius yes yes Collision radius, short at +0xD2 (raw, not shifted). Set writes a full 32-bit word at +0xD2 (also clobbers the short at +0xD4). high +29 isActivated yes no Map-instance record (+0xF0) byte +0x44 bit 0x8 (persistent "activated" state, set by op 0x92). Returns 8 or 0. No null check. high +30 isCollected yes no Map-instance record (+0xF0) byte +0x44 bit 0x4 (persistent "collected", set by op 0x93). Returns 4 or 0. No null check. high +31 onGround yes no Flag +0xEC & 0x2 (standing on a floor; slope/platform code FUN_004054c0 requires it). Returns 2 or 0. medium +32 flagE8_400000 yes no Flag +0xE8 bit 22 (0x400000) as 0/1. low +33 flagE8_800000 yes no Flag +0xE8 bit 23 (0x800000) as 0/1. low +34 x yes yes Position x +0x30, 20.12. high +35 y yes yes Position y +0x34, 20.12. high +36 z yes yes Position z +0x38, 20.12. high +37 rotX yes yes Rotation x (pitch) +0x20; get masks to 24 bits (0x1000000 = full turn), set stores raw. high +38 rotY yes yes Rotation y (yaw) +0x24; 24-bit angle, 0x1000000 = full turn. high +39 rotZ yes yes Rotation z (roll) +0x28; 24-bit angle. high +41 angleToPlayer yes no Yaw from this strat to the player: atan2(px-x, pz-z) (FUN_00404d60(player+0x30, self+0x30)), 24-bit angle; 0 if no player. (Notes called 41 "distance to player" - code is an angle.) high +42 padHeld yes no Joypad buttons currently held, DAT_005FCF20 (bit mask: 0x10 up, 0x40 down, 0x80 left?, 0x20 right?, 0x1/0x8/0x400.. buttons; 0x10000-0x80000 = direction relative to player yaw). high +43 padReleased yes no Buttons released this frame, DAT_005FCF34 (= old & ~new). high +44 padPressed yes no Buttons newly pressed this frame, DAT_005FCF30 (= new & ~old). high +45 distToPlayer yes no 3D distance to the player, cached in +0x1C (computed lazily as sqrt of +0x148, the squared distance updated per frame), 20.12. high +47 scale yes no Scale y +0x64 (used as the uniform scale value), 20.12; see op 0x55. high +48 scaleX yes no Scale x +0x60, 20.12. high +49 scaleY yes no Scale y +0x64, 20.12. high +50 scaleZ yes no Scale z +0x68, 20.12. high +51 stratCount yes no Number of strats in use DAT_006D9DC8 << 12. medium +52 stratCapacity yes no Level header (DAT_00584648)+0x08 << 12 (max strat count of the level; compared against stratCount on spawn). medium +53 distToWpXZ yes no Horizontal (x/z) distance from strat to current waypoint, 20.12 (FUN_00404c50). high +54 boneX yes no +0xD8: x of the bone/attachment point computed by op 0xc7 (useBone), 20.12 world. medium +55 boneY yes no +0xDC bone point y. medium +56 boneZ yes no +0xE0 bone point z. medium +57 playerPtr yes no Pointer to the player strat (DAT_006D9E1C); 0 if none (usable as "player exists"). high +58 camLookAtX yes yes Camera look-at point x DAT_006D9D80, 20.12 (default 0; cut-scene cameras write it). medium +59 camLookAtY yes yes Camera look-at point y DAT_006D9D84 (default 1.0). medium +60 camLookAtZ yes yes Camera look-at point z DAT_006D9D88. medium +61 tribe yes no Current level tribe DAT_00584EB4 << 12 (level id part 1, %d_%d_%d file names). high +62 world yes no Current level world DAT_00584EB8 << 12. high +63 chapter yes no Current level chapter DAT_00584EBC << 12. high +64 map yes no Current map/sub-level DAT_00584EC0 << 12. high +65 hasCamTarget yes no -1 if a camera target strat (DAT_006D9E30, op 0xc9) is set, else 0. medium +66 hasCamTarget2 yes no -1 if the second camera target (DAT_006D9E34, op 0xca) is set, else 0. medium +67 padType yes no DAT_005FCF18 << 12: 3 = digital pad, 2 = direction/analog input active this frame. low +68 camDistToTarget yes no 3D distance camera strat (0x6D9E28) -> camera target (0x6D9E30), 20.12; 0 if no camera. high +69 camDistToTarget2 yes no 3D distance camera -> second target (0x6D9E34), 20.12; 0 if no camera. high +70 camTargetYawDiff yes no Signed yaw difference target.rotY - camera.rotY (wrapped to +-0x800000, FUN_0054dae0). No null checks. high +72 camDistToTargetXZ yes no Horizontal distance camera -> target (FUN_00404cc0), 20.12. No null check. high +73 camDistToTarget2XZ yes no Horizontal distance camera -> second target, 20.12. No null check. high +75 stickX yes no Analog/digital stick x DAT_005FCF1C (-0x2000..0x1FC0, i.e. about +-2.0 fixed). high +76 stickY yes no Stick y DAT_005FCF2C (same range). high +77 stickMagnitude yes no Stick deflection DAT_005FCEFC, raw int 0..0xB5 (thresholds: 42 deadzone, 84 direction bits, see ids 94-97/130-131). high +78 stickAngle yes no Stick direction DAT_005FCF4E (short, 4096 units) << 12. high +79 stickAngleWorld yes no (stick angle + camera yaw DAT_005FCF00) << 12, masked to 24 bits: stick direction in world yaw. high +80 stick2X yes no DAT_005FCF28, never written by the exe (second stick set, always 0). low +81 stick2Y yes no DAT_005FCF24, never written (always 0). low +82 stick2Magnitude yes no DAT_005FCEF8, never written (always 0). low +83 stick2Angle yes no Short DAT_005FCF4C << 12, never written. low +84 stick2AngleWorld yes no (camera yaw + DAT_005FCF4C) << 12 masked 24 bits. low +85 slopeForward yes no Floor normal rotated into the strat's yaw frame, forward component DAT_0057DDA8 (FUN_004054c0; 0 unless onGround), 20.12. medium +86 slopeSide yes no Floor normal, sideways component in the strat's frame DAT_0057DD90, 20.12. medium +87 slopeAngle yes no Downhill direction relative to the strat's yaw DAT_0057DD94 (0..0xFFF) << 12. medium +88 padAux yes no DAT_005FCF58 (extra input bits, cleared when >1 pads). low +90 padAuxPressed yes no DAT_005FCF60 = new & ~old of padAux. low +91 unk_5b yes no DAT_00584F10 << 12 (game state global, unknown). low +92 health yes no Player health DAT_00584E70 (fixed, starts at 5.0, HUD shows it; reset to 5.0 on respawn). Same value as id 189 (settable there). medium +93 unk_5d yes no DAT_0058464C (raw, game-state pointer/flag). low +94 stickWalkThreshold yes no Constant 84 (0x54, raw int): stick magnitude above which direction bits are generated. medium +95 stickRunThreshold yes no Constant 123 (0x7B, raw int). medium +96 stickWalkThreshold2 yes no Constant 84 (duplicate of 94). medium +97 stickRunThreshold2 yes no Constant 123 (duplicate of 95). medium +98 hasWaypoints yes no 1.0 if the EXECUTING strat (not the queried one) has a first waypoint (+0x118), else 0. For 0x1c (player prop) this still checks the caller. high +99 distToBoss yes no 3D distance from the boss strat (DAT_006D9E24, op 0xef) to this strat, 20.12; 100.0 if none. high +100 distToBossXZ yes no Horizontal distance boss -> this strat, 20.12; 100.0 if none. high +101 zoneX yes no Map zone/cell record containing the strat (FUN_0054d560, same x/z grid cell, nearest y) field +0x0C, integer part (& 0xFFFFF000); -1.0 if none. low +102 zoneY yes no Zone record field +0x10 integer part; -1.0 if none. low +103 zoneZ yes no Zone record field +0x14 integer part; -1.0 if none. low +104 distToWpSq yes no Squared 3D distance to current waypoint ((d>>6)^2 summed, i.e. 20.12 of world units squared). Same as 194. high +105 distToWpSqXZ yes no Squared horizontal distance to current waypoint. high +106 flag83730Clear yes no 1.0 if DAT_00583730 == 0 (FUN_00414fb0), else 0. low +107 distToPlayerXZ yes no Horizontal distance player -> this strat, 20.12; 100.0 if no player. high +108 saveByte5219 yes no DAT_00585219 << 12 (per-save-slot byte, reset to 0 on new game). low +110 queueAEmpty yes no 1.0 if DAT_006D9858 < 1 (message/stream queue A empty), else 0. low +111 queueBEmpty yes no 1.0 if DAT_006D94E8 < 1 (queue B empty; also blocks HUD counters), else 0. low +120 camFloorHeight yes yes Get: floor height of the map cell under the camera DAT_00586424 (-16.0 if outside the map). Set: writes the OVERRIDE DAT_0058641C (non-zero replaces the computed value). Get and set use different variables. medium +121 camX yes yes Camera position x DAT_006D9D20 (copied from the camera strat each frame), 20.12. high +122 camY yes yes Camera position y DAT_006D9D24. high +123 camZ yes yes Camera position z DAT_006D9D28. high +124 flagEC_1000000 yes no Flag +0xEC bit 24 as 0/1. low +125 flagEC_2000000 yes no Flag +0xEC bit 25 as 0/1. low +126 hitDepth yes no Short +0xD4 (raw): penetration depth of the last player-vs-strat sphere hit (FUN_004051d0), 0xFFFF none. medium +127 hitAngle yes no Short +0xD6 << 12: direction of the last sphere hit (4096 units; player gets the opposite of the strat). medium +128 unk_80 yes no DAT_00585004 << 12 (unknown game global). low +129 zoneFlags yes no Zone record (FUN_0054d560) +0x20 & 0xC00, << 12; -1.0 if none. low +130 stickDeadzone yes no Constant 42 (0x2A, raw int). medium +131 stickDeadzone2 yes no Constant 42 (duplicate). medium +132 oldX yes yes Previous-frame position x +0x50, 20.12 (collision code restores it on edge push-back). medium +133 oldY yes yes Previous position y +0x54. medium +134 oldZ yes yes Previous position z +0x58. medium +135 unk_6da2c0 yes no DAT_006DA2C0, no other reference (always 0). low +136 unk_6da2c4 yes no DAT_006DA2C4, no other reference (always 0). low +137 queuesEmpty yes no 1.0 if both queue A and queue B are empty (ids 110 and 111), else 0. low +138 alpha yes yes Transparency +0x10C (renderer: < 0x1000 = translucent, 1.0 = opaque), 20.12. medium +139 oldRotX yes yes Previous rotation x +0x40 (24-bit masked on get). medium +140 oldRotY yes yes Previous yaw +0x44 (compared with +0x24 by the collision code). medium +141 oldRotZ yes yes Previous rotation z +0x48. medium +142 optionFlag80000 yes no DAT_006DA330 & 0x80000 (global option/debug flag word). low +143 camScale yes yes DAT_006D9D30, camera value initialised to 1.0 (zoom/fov factor?). low +144 levelHeaderC yes no Level header (DAT_00584648)+0x0C ushort << 12. low +145 slopeSteepness yes no 1.0 - floor normal y (DAT_0057DDA4) << 12: 0 on flat ground (FUN_004054c0). medium +147 levelTableByte yes no Byte table 0x585024[map + (chapter + tribe*10)*4] << 12 (per-level constant). low +149 flagEC_1 yes no Flag +0xEC & 0x1. low +150 flagEC_8000000 yes no Flag +0xEC & 0x8000000. low +151 cheat5 yes no Cheat toggle bits DAT_00584710 & 0x20 (button-sequence cheats, FUN_0041a0xx). medium +153 frameCounter yes no Strat-update frame counter DAT_006D9E10 (+1.0 per frame). high +154 hitSphere yes no Byte +0x130 << 12: index of the collision sphere hit by the player (FUN_004051d0). medium +155 unk_6d9d38 yes no DAT_006D9D38, no other reference. low +156 unk_6d9cf0 yes no DAT_006D9CF0, no other reference. low +157 isFloorCollisionSolid yes no 1 (raw) if +0xEC has both 0x8000 and 0x10000 set, else 0. low +159 scratch6d9e68 yes yes DAT_006D9E68 (only accessed by strats). low +160 saveEnabled yes yes DAT_006D9E74 (1.0 enables the save-game prompt, reset to 0 on level start). low +161 hudValueA yes yes DAT_006DA300 (HUD element value, shown via >>12, reset per level). low +162 hudValueB yes yes DAT_006DA2F8 (HUD element value, reset per level). low +163 audioMode yes yes DAT_006DA2FC: 3.0/4.0/5.0 from the sound output menu (Stereo/Quatro/Surround); also written (slot+1) by save code. low +164 instanceData0 yes yes Map-instance record (+0xF0)+0x28 -> word 0. Set returns NULL (crash) unless record+0x24 != 0. Get pushes nothing if no record. low +165 instanceData1 yes yes Map-instance record (+0xF0)+0x28 -> word 1. low +166 floorCollisionOn yes no Flag +0xEC & 0x8000 (floor collision, ops 0xa5/0xa6). medium +169 lives yes yes DAT_00584E6C (fixed, starts at 5.0, saved per slot) - lives or max health. low +171 saveProgressA yes no DAT_00585008 << 12 (saved progress byte). low +172 progressWorld yes no DAT_0058500C << 12 (saved; world of the furthest/selected level, 8 = finale). low +173 progressChapter yes no DAT_00585010 << 12 (saved; chapter, 6 with world 8 = last level). low +174 saveProgressB yes no DAT_00585014 << 12 (saved progress byte). low +175 unk_6da308 yes no DAT_006DA308, no other reference. low +178 cheat4 yes no DAT_00584710 & 0x10 (cheat toggle). medium +179 hudFlags yes yes DAT_006DA2E0 HUD element enable mask (default 0x3F000; 0x2000 timer, 0x8000/0x10000/0x20000/0x40000 counters). medium +181 gameState1d74 yes yes DAT_00581D74 (game/menu state, checked == 0 for normal play). low +182 camFlag800000 yes no Camera strat +0xEC & 0x800000 (0 if no camera). low +183 slopeNormalX yes no Floor normal x DAT_0057DD9C (world, 20.12; FUN_004054c0). medium +184 slopeNormalZ yes no Floor normal z DAT_0057DDA0. medium +185 wpParam2 yes yes Current waypoint field +0x2C (raw); 0 / NULL if no waypoint. medium +186 hitRadius yes yes +0x13C: player interaction radius (player collides when squared player distance +0x148 <= r*r), 20.12. medium +187 hitDamage yes yes +0x140: damage dealt on contact (copied into the other strat's +0x144). medium +188 damageTaken yes no +0x144: damage received from the last contact (player cleared to 0 each frame). medium +189 health2 yes yes DAT_00584E70 = health (same as 92, settable). medium +190 levelRecord yes yes Per-level saved word DAT_00584F1C[chapter + world*6] (best/collected value for the current level; HUD shows it). low +191 counterC yes yes DAT_00584FFC: persistent HUD counter (icon 0x150), saved. low +192 counterA yes yes DAT_00584FF4: persistent HUD counter (icon 0x184, displayed up to 999) - probably coins. low +201 distToPlayerSq yes no +0x148: cached squared distance to the player ((d>>6)^2 sum), updated each frame when tracking (op 0x30). high +202 distToPlayerSqXZ yes no Squared horizontal distance player -> strat (FUN_00404c90); 100.0 if no player. high +203 soundVecX yes yes DAT_006DA2CC; on the next set of 203/204 the magnitude sqrt(x^2+y^2)*4 is passed to FUN_00546140 (sound/volume parameter). low +204 soundVecY yes yes DAT_006DA2D0, see 203. low +210 collisionHeight yes yes Collision record (+0xC0)+0x18: strat height/size used for ceiling tests (ceiling hit if ceiling <= y + this), 20.12. Get 0 if no record. medium +211 mapFloorHeight yes no (+0xC0)+0x1C: floor height from the landscape test (FUN_004036d0), 0xFFFF0000 none. No null check. low +212 mapFloorMaterial yes no Short (+0xC0)+0x24 << 12: material of that floor (0xFFFF none). low +213 objFloorHeight yes no (+0xC0)+0x20: floor height from the strat-object test (FUN_00403ad0, skipped with +0xE8 bit 0x400). low +214 objFloorMaterial yes no Short (+0xC0)+0x26 << 12: material of the object floor. low +215 wallMaterial yes no Short (+0xC0)+0x3A << 12 (material of the wall/side surface hit, set with object/face +0x3C/+0x3E). low +217 collisionField38 yes yes Short (+0xC0)+0x38 raw on get; set writes 32 bits at +0x38 (also clobbers +0x3A). low +218 hudDigit yes yes DAT_006DA290: HUD single-digit counter (shown as '0'+value>>12). low +226 animFrameCount yes no Anim header (+0x14)+0x08 << 12 (number of frames). No null check. high +227 angleToCamLookAt yes no Yaw from the strat to the camera look-at point (DAT_006D9D80), 24-bit angle. medium +228 skyParamA yes yes DAT_0057D830 (render global, sky/background). low +229 skyParamB yes yes DAT_0057D83C. low +230 skyParamC yes yes DAT_0057D840. low +231 skyRotation yes yes DAT_0057D834: rotation added to a background model's yaw (>>12 & 0xFFF). low +232 hudTimer yes yes DAT_006DA304: HUD timer/counter value (>>12 displayed, element bit 0x2000). low +233 animPtr yes no Current anim address +0x14 (0 = none). high +234 cheat1 yes no DAT_00584710 & 0x2 (cheat toggle). medium +236 hudRefresh yes yes DAT_006DA2DC (forces HUD counter redraw when non-zero). low +239 unk_57ddb0 yes no DAT_0057DDB0, no other reference. low +240 unk_57ddb4 yes no DAT_0057DDB4, no other reference. low +241 unk_57ddb8 yes no DAT_0057DDB8, no other reference. low +242 drawDistance yes no 1000.0 unless +0xEC bit 0x10000; else (bit 0x400000 ? DAT_0058658C : DAT_00586590) + 0.5 (view/clip distance globals). low +243 ceilingHeight yes no (+0xC0)+0x28: height of the surface above (1000.0 default when ceiling test enabled by +0xE8 0x200000). No null check. medium +244 zoneParam yes no DAT_006DA298: short +0x38 of the zone record the strat entered (op FUN_0054df30, used as conveyor/offset). low +249 wpPtr yes yes Current waypoint pointer +0x120 (set allows jumping to a stored node). high +250 counterB yes yes DAT_00584E60: HUD counter (icon 0x11C), reset when entering a different level. low +253 musicBusy yes no 1 (raw) if FUN_00546600(DAT_006D94B4,0) == 2 (stream/voice state), else 0. low +265 musicTrack yes no Current music track DAT_00586430 << 12 (level header +0x58, 0x23/0x27 for special modes). medium diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratEditsTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratEditsTest.java new file mode 100644 index 0000000..982e5ad --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratEditsTest.java @@ -0,0 +1,136 @@ +package com.eliasmueller.llamamod.editor.tools.strats; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.eliasmueller.llamamod.editor.io.Bases; +import com.eliasmueller.llamamod.editor.io.Project; +import com.eliasmueller.llamamod.editor.io.ProjectIO; +import com.eliasmueller.llamamod.editor.io.WadBuilder; +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.History; +import com.eliasmueller.llamamod.editor.tools.catalog.CrossImport; +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.MapChunk; +import com.eliasmueller.llamamod.format.Strats; +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StratCode; +import com.eliasmueller.llamamod.format.strat.StratListing; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; +import org.junit.jupiter.api.Test; + +/** Script edits in a project: constant patch, custom type, save/reload, import afterwards (needs -Dllamamod.cd). */ +class StratEditsTest { + static Bases cd() { + String cd = System.getProperty("llamamod.cd", ""); + assumeTrue(!cd.isEmpty() && Files.isDirectory(Path.of(cd, "wads")), "original CD not available"); + return Bases.fromCd(Path.of(cd)); + } + + /** The constant pushed right before Coin's turnLeft (spin speed per frame). */ + static StratListing.Line spin(Project p, String type) { + int code = p.scene().catalog().type(type).variant(0).code(); + int unit = StratEdits.analysis(p.scene()).img().unitOf(code); + List ls = StratEdits.listing(p.scene(), unit).lines; + for (int i = 0; i + 1 < ls.size(); i++) { + if ("turnLeft".equals(ls.get(i + 1).text()) && ls.get(i).constAt() >= 0) { + return ls.get(i); + } + } + throw new AssertionError("no turnLeft constant in " + type); + } + + static String nameAt(byte[] wad, int code) { + Level lv = Level.parse(wad); + byte[] st = lv.wad().require("STPC"); + return Strats.nameOr(st, code, new StpcImage(st, lv.wfpc()).codeStart, "?"); + } + + @Test + void constantCloneSaveReloadImport() throws Exception { + Bases cd = cd(); + StratEdits.init(); + CrossImport.init(); + Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "edit", "t1l1m001"), cd), cd); + StratListing.Line l = spin(p, "Coin"); + assertEquals(0x40000, l.value()); + int unit = StratEdits.analysis(p.scene()).img().unitOf(l.offset()); + StratEdits.setConstant(p, unit, l.constAt(), l.constShort(), l.value(), 0x100000); + assertEquals(0x100000, spin(p, "Coin").value()); + + StratEdits.CustomType t = StratEdits.cloneOf(p.scene(), "Coin", "TestCoin"); + assertTrue(t.source().contains("debugName \"TestCoin\""), t.source()); + String src = t.source().replace("push 22.5deg", "push 90deg"); + int code = + StratEdits.addType(p, new StratEdits.CustomType(t.name(), t.basedOn(), t.entryLabel(), src, t.proto())); + assertNotNull(p.scene().catalog().type("TestCoin")); + History h = new History(p.scene()); + h.execute(Edits.placeStrat(p.scene(), "TestCoin", 0, 30, 2, 30, 0.0)); + Strat placed = p.scene().strats().get(p.scene().strats().size() - 1); + assertEquals(code, placed.code()); + assertEquals(0x400000, spin(p, "TestCoin").value()); + + byte[] wad = WadBuilder.build(p.scene()); + assertEquals("TestCoin", nameAt(wad, code)); + Project q = ProjectIO.read(ProjectIO.write(p, cd), cd); + assertTrue(q.warnings().isEmpty(), q.warnings().toString()); + assertArrayEquals(wad, WadBuilder.build(q.scene()), "reloaded project builds the same WAD"); + + // import after the custom type: custom units move behind the imports on reload + CrossImport.importInto(q, List.of(new CrossImport.TypeRef("t1l2m001", "Spider", spiderCode(cd))), cd); + StratEdits.updateSession(q); + byte[] wad2 = WadBuilder.build(q.scene()); + Project r = ProjectIO.read(ProjectIO.write(q, cd), cd); + assertTrue(r.warnings().isEmpty(), r.warnings().toString()); + Strat last = r.scene().strats().get(r.scene().strats().size() - 1); + assertEquals("TestCoin", nameAt(WadBuilder.build(r.scene()), last.code())); + assertEquals(0x400000, spin(r, "TestCoin").value()); + assertEquals(0x100000, spin(r, "Coin").value()); + Level lv = Level.parse(WadBuilder.build(r.scene())); + byte[] st = lv.wad().require("STPC"); + StpcImage img = new StpcImage(st, lv.wfpc()); + Set roots = new TreeSet<>(); + for (MapChunk.Strat s : lv.map().strats) { + roots.add(s.code()); + } + assertTrue(StratCode.analyse(img, roots).errors.isEmpty()); + assertEquals(wad2.length, WadBuilder.build(r.scene()).length); + } + + @Test + void templateAssemblesAndVerifies() throws Exception { + Bases cd = cd(); + Project p = Project.create(cd, "edit", "t1l1m001"); + StratCatalog.Variant v = p.scene().catalog().type("Coin").variant(0); + StratEdits.Built b = StratEdits.build( + p.scene(), new StratEdits.CustomType("MyStrat", null, "MyStrat", TypeDialog.TEMPLATE, v)); + assertTrue(b.problems().isEmpty(), b.problems().toString()); + assertEquals("MyStrat", nameAt(wadWith(p, b), b.entry())); + } + + static byte[] wadWith(Project p, StratEdits.Built b) { + StratEdits.chunk(p.scene()).setRaw("data", b.stpc()); + return WadBuilder.build(p.scene()); + } + + static int spiderCode(Bases cd) throws Exception { + Level lv = Level.parse(cd.wad("t1l2m001")); + byte[] st = lv.wad().require("STPC"); + int lo = new StpcImage(st, lv.wfpc()).codeStart; + for (MapChunk.Strat s : lv.map().strats) { + if ("Spider".equals(Strats.nameOr(st, s.code(), lo, ""))) { + return s.code(); + } + } + throw new AssertionError(); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/format/strat/StratRoundTripTest.java b/src/test/java/com/eliasmueller/llamamod/format/strat/StratRoundTripTest.java new file mode 100644 index 0000000..c26d8b7 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/format/strat/StratRoundTripTest.java @@ -0,0 +1,84 @@ +package com.eliasmueller.llamamod.format.strat; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import com.eliasmueller.llamamod.format.Level; +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StratCode; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.TreeSet; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; + +/** Disassemble → assemble gives byte-identical units for every strat of every level (needs -Dllamamod.cd). */ +class StratRoundTripTest { + static List levels() throws IOException { + String cd = System.getProperty("llamamod.cd", ""); + Path dir = Path.of(cd, "wads"); + assumeTrue(!cd.isEmpty() && Files.isDirectory(dir), "original CD not available (llamamod.cd)"); + try (Stream s = Files.list(dir)) { + return s.filter(p -> + p.getFileName().toString().toLowerCase(Locale.ROOT).matches("t\\d[il]\\dm\\d{3}\\.wad")) + .sorted() + .toList(); + } + } + + record Loaded(StpcImage img, StratCode code, Set entries) {} + + /** null for WADs without a MAP (front-end test WADs). */ + static Loaded load(Path p) throws IOException { + com.eliasmueller.llamamod.format.Wad w = com.eliasmueller.llamamod.format.Wad.parse(Files.readAllBytes(p)); + if (!w.has("MAP ") || !w.has("STPC")) { + return null; + } + Level lv = Level.parse(Files.readAllBytes(p)); + StpcImage img = new StpcImage(lv.wad().require("STPC"), lv.wfpc()); + Set roots = new TreeSet<>(); + lv.map().strats.forEach(s -> roots.add(s.code())); + return new Loaded(img, StratCode.analyse(img, roots), roots); + } + + @Test + void everyUnitOfEveryLevelRoundTrips() throws IOException { + int levels = 0, units = 0, raw = 0, words = 0; + for (Path p : levels()) { + Loaded l = load(p); + if (l == null) { + continue; + } + levels++; + for (int u = 0; u < l.img.units.size(); u++) { + StratListing li = StratListing.of(l.img, l.code, u, l.entries, StratListing.Names.NONE); + String text = li.text(); + StratAssembler.Result r = StratAssembler.assemble(text, StratListing.layout(l.img, li.start)); + assertArrayEquals(li.words(), r.words(), p.getFileName() + " unit " + u); + units++; + StratPseudo ps = StratPseudo.of(li); + long inRows = ps.constants().stream().mapToLong(List::size).sum(); + long all = li.lines.stream().filter(x -> x.constAt() >= 0).count(); + assertEquals( + all, inRows, p.getFileName() + " unit " + u + ": constants reachable from the script view"); + words += r.words().length; + for (StratListing.Line x : li.lines) { + if (x.kind() == StratListing.Kind.INSTR && x.text().startsWith(".word")) { + raw++; + System.out.println("raw: " + p.getFileName() + " unit " + u + ": " + x.render()); + } + } + } + } + System.out.printf( + "round trip: %d levels, %d units, %d words, %d raw instructions%n", levels, units, words, raw); + assertTrue(levels >= 33); + assertEquals(0, raw, "instructions emitted as raw words"); + } +}