diff --git a/docs/manual/22-strats.md b/docs/manual/22-strats.md index 7232bd0..6fe027e 100644 --- a/docs/manual/22-strats.md +++ b/docs/manual/22-strats.md @@ -77,6 +77,51 @@ empty template) opens the type editor: 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. +### Editing and deleting your types + +Select an object of a custom type (or pick the type in the Script panel's *Type* list): + +* **Edit type…** (Script panel or the Inspector's *Script* section) opens the type editor with + its script. Change it, **Check**, then **Apply**. Objects of the type that are already placed + keep their place and run the new script. **Ctrl+Z** undoes the change. +* **Delete type…** removes the type. If objects in the level use it, the editor tells you how many + and deletes them together with the type (also undoable). + +### Writing a script instead of assembly + +Set **Language** in the type editor to **Script** to write the behaviour in a structured language +that looks like the Script view: + +``` +strat Pulser { + alias size = v0 + setModel(model:5) + size = 1.0 + loop { + while (size < 4.0) { // grow to four times the size + size = size + 0.05 + scale(size) + yield // wait for the next frame + } + do { // and shrink back + size = size - 0.05 + scale(size) + yield + } while (size > 1.0) + } +} +``` + +* Statements: `x = value`, `x += value`, `if (…) { } else { }`, `while (…) { }`, `loop { }`, + `do { } while (…)`, `break`, `continue`, `yield`, `end`, `call name` (for `sub name { … }`). +* Any instruction can be called by name with its values in brackets: `turnLeft(4deg)`, + `scale(size)`; values that are part of the instruction go in `<>`: `collisionOn<0x8000>()`. +* Places: `v0`… (or a name given with `alias`), globals `g12`, properties `self.y`, `player.x`… +* Numbers mean the same as in the assembly: write `10.0` for ten (a plain `10` is ten 1/4096 + steps), angles as `90deg`. +* **Check** compiles and verifies; errors name the line. Switching *Language* back to *Assembly* + shows the generated instructions. The full reference is in `notes/strat-language.md`. + 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 @@ -137,14 +182,15 @@ circle: ## Limits -* Custom types cannot be deleted or edited in place yet – clone the custom type again with the - changes and replace the placed objects. +* Custom types can't be edited or deleted right after importing object types from another level + in the same session – save and reopen the project first. * 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. +* The Script view shows `while`, `do … while`, `switch`, `break` and `continue` where it can and + `goto` where the original jumps don't form nested blocks (a few hundred places in the whole + game); 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/notes/img/mods/strats_custom_panel.jpg b/notes/img/mods/strats_custom_panel.jpg new file mode 100644 index 0000000..724b64d Binary files /dev/null and b/notes/img/mods/strats_custom_panel.jpg differ diff --git a/notes/img/mods/strats_edit_type.jpg b/notes/img/mods/strats_edit_type.jpg new file mode 100644 index 0000000..f97cf0b Binary files /dev/null and b/notes/img/mods/strats_edit_type.jpg differ diff --git a/notes/img/mods/strats_rain_storm.jpg b/notes/img/mods/strats_rain_storm.jpg new file mode 100644 index 0000000..03ae29a Binary files /dev/null and b/notes/img/mods/strats_rain_storm.jpg differ diff --git a/notes/img/mods/strats_script_game.jpg b/notes/img/mods/strats_script_game.jpg new file mode 100644 index 0000000..c55d215 Binary files /dev/null and b/notes/img/mods/strats_script_game.jpg differ diff --git a/notes/strat-language.md b/notes/strat-language.md index 1892622..51cc544 100644 --- a/notes/strat-language.md +++ b/notes/strat-language.md @@ -9,7 +9,8 @@ the STPC chunk as **code units** (one per compiled source file; notes/editor-cro | `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) | +| `StratPseudo` | read-only structured view (expressions, if/else, loops, switch, break/continue) | +| `StratCompiler` | script language → assembly (new behaviour) | | `Num` | literals: `42`, `0x2a`, `1.5` (20.12), `90deg` | **Round trip (golden test `StratRoundTripTest`)**: disassemble → assemble is byte-identical for @@ -95,11 +96,23 @@ own-unit addresses), plus the model/animation/unit offsets of the target STPC fo ## 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): +Read-only (not parsed back). Stack code folded into expressions (`self.y = self.floorHeight + 204`), +properties as `self.x`, `player.y`, `parent.v3`, `camera.…`. Control flow is rebuilt from the jumps +where the blocks nest: + +| Jumps | Shown as | +|---|---| +| forward `jz`/`jnz` over a block (+ `jmp` over a second block) | `if (…) {…} else {…}`; an `if` that is the whole else part → `} else if (…) {`; an empty then-part is negated | +| backward `jmp`/`jmpYield` | `loop {… yield}`; first statement `if (c) break` → `while (!c) {…}` | +| backward conditional jump | `do {…} while (c)` (`/*yield*/` for jzYield/jnzYield; empty body: `do {} while (c) /*yield*/`) | +| jump to the end / head of the innermost loop | `break` / `continue` (also as `if (c) break`) | +| `switch` + `jmp end` with the cases in between | `switch (v) { case 1.0: … break … }` (`default:` when the jmp goes to a case-like block) | + +Everything else stays `goto label` (mostly multi-level breaks out of nested loops and `||` chains +that jump to a common far target). Over all 1461 units: **11 019 → 400 gotos** (2026-11-07; +guarded by `StratPseudoTest`, which also checks balanced blocks and that every remaining goto has +its label). Inline operands in `<>`: `spawn<3, 6, 0, 0, 0>(10.0, 204, 450, unit:35+0x28)`. +Example (Coin): ``` strat Coin: @@ -116,6 +129,58 @@ if (v0) { loop { turnLeft(5.625deg); yield } ``` +## Script language (`StratCompiler`) – writing NEW behaviour + +A small structured language in the style of the script view, compiled to the assembly above (so +everything the assembler checks still applies). Implemented slice (2026-11-07): strats, subroutines, +aliases, assignments, expressions, if/else if/else, while, loop, do-while, break/continue, yield, +calls of any opcode by name. Tests: `StratCompilerTest`; verified in game (village 1: +`img/mods/strats_script_game.jpg`). + +``` +// bobs one cell up and down and turns +strat Bobber { + alias base = v0 // .alias base = v0 + alias phase = v1 + setModel(model:5) // any opcode: name(stack arguments) + base = self.y + 1.0 + phase = 0 + loop { + phase = phase + 4deg + self.y = base + sin(phase) + turnLeft(4deg) + yield // last statement of a loop → the loop's jmpYield back edge + } +} + +sub wobble { // called with "call wobble", ends with return + self.rotZ = sin(self.rotY) * 0.1 +} +``` + +| Construct | Compiles to | +|---|---| +| `strat Name { … }` | `.string "Name"`, label `Name:`, `debugName "Name"`, body, `end` unless the body cannot fall through | +| `sub name { … }` / `call name` / `return` | label, body, `return` / `call name` / `return` | +| `x = e`, `x += e` (`-=`, `*=`, `/=`) | e, pop op of x; `x += 1.0` / `x -= 1.0` → address + `inc`/`dec` | +| places | `vN`, alias, `gN`, `self.prop`, `player.prop`, `parent.prop`, `parent.vN`, `camera.`/`target.`/`target2.`/`boss.`/`dialog.prop` (props.tsv names or `propN`) | +| literals | as in the assembly: `10.0` = ten, `10` = 10 raw units (1/4096), `90deg`, `0x10`; 16-bit values use `pushShort`, others `push` | +| references | `model:N`, `anim:N`, `unit:N` (`address`), `sound:N` (`soundAddress`), `tex:N` (`push`) | +| operators (low → high) | `||` `|` (or) · `&&` (and) · `&` · `==` `!=` · `<` `>` `<=` `>=` · `<<` `>>` · `+` `-` · `*` `/` · unary `-` `!` | +| `op(e1, e2)` | e1, e2, `op a, b`; the argument count must match the opcode's pops; a value-returning op used as a statement is followed by `pop` | +| `if (c) {…} else if … else {…}` | `c; jz else; …; jmp end; else: …` | +| `while (c) {…}` / `loop {…}` | head label, `c; jz end`, body, `jmp`/`jmpYield head` | +| `do {…} while (c)` | body, `c; jnz head` | +| `break` / `continue` / `yield` / `end` | `jmp end` / `jmp head` (do: the condition) / `jmpYield next` / `end` | + +The generated assembly starts with the script as `;| ` comment lines, so the editor can show the +script again (*Edit type…*). Comments: `//`, `/* */`; `;` and newlines separate statements. + +**Next steps (not implemented)**: event handlers (`on touched { … }` → `.event` table + `onEvent`), +`switch`, `spawn` with typed arguments, local names for parameters from the object schema, `for`, +constant folding, typed numbers (int vs fixed) instead of the assembly's literal rules, +short-circuit `&&`/`||` (now: both sides are evaluated, like the original `and`/`or` opcodes). + ## Editing in the level editor (`editor.tools.strats`) * **Script panel** (bottom dock, View → Panels → Script, Ctrl+Shift+K): unit chooser (all code units @@ -136,6 +201,15 @@ loop { turnLeft(5.625deg); yield } Verification: the merged STPC is disassembled again (no decode errors, entry decoded, every `address` operand of the new unit hits a model, animation set or code, and the new unit's listing re-assembles to the same words). +* **Edit type… / Delete type…** (Script panel, Inspector *Script* section of a custom object): + edit opens the same dialog with the type's source (script or assembly), *Apply* rebuilds all + custom units (`TypeEdits.edit`, one undo step) and the placed objects move to the new entry. + Delete warns with the number of objects that use the type and removes them with it + (`TypeEdits.delete`: `Edits.delete` of the users + the type list change, one undo step). + Creating is undoable too (`TypeEdits.create`), except when imported types follow the custom + block (then appended as before). +* **Language** (dialog): *Assembly* or *Script* (compiled by `StratCompiler`, see above); + switching Script → Assembly shows the generated code. * **Load order** (`ProjectIO.LoadHook.order()`): constants of base units (−10) → imported types (CrossImport, 0) → constants of imported units and custom types (+10). Custom units are always last on load; if types were imported after a custom type in the session, the stored strat codes @@ -143,8 +217,11 @@ loop { turnLeft(5.625deg); yield } ## 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. +* Custom types are edited and deleted as a whole list: the custom units are cut off the code and + appended again (objects follow their type's new entry). Not possible while object types imported + after the custom ones sit behind them in the code (save and reopen the project first; on load the + custom types always come last). A custom type that addresses another custom type's unit with + `unit:N` breaks when that index changes. Constants of custom types are changed through their source. * `pushShort` holds only −8.0…7.99; bigger values need a source edit (`push`). * The script view is a reading aid (gotos where blocks do not nest; expressions assume the documented pop/push counts; ops with variable stack use are shown raw). diff --git a/notes/strat-opcodes.md b/notes/strat-opcodes.md index b2f88d8..c20ce17 100644 --- a/notes/strat-opcodes.md +++ b/notes/strat-opcodes.md @@ -10,8 +10,19 @@ properties). Language/syntax: `notes/strat-language.md`. Disassembler/assembler: 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. +* Confidence: **353 high, 79 medium, 2 low**. Of the **222 opcodes the 34 level WADs use**: 177 high, + 44 medium, 1 low; high-confidence opcodes cover **97.3 %** of the 556314 instructions in the levels. +* **Low-confidence review (2026-11-07)**: all 34 former low opcodes re-read (handler table entry checked + against the exe, Ghidra decompile of handler + callees, `objdump` of the exe where Ghidra's C was + incomplete – e.g. it drops the bone index of 0xc7 and every reader of +0x114), and cross-checked + with their uses in the WADs. Findings: 0x107 setAnimSpeed is the per-frame animation step (sub-frame + accumulator +0x116, 1.0 = one frame per game frame); 0x2f freezes a strat while it is off-camera + (with the camera pass's +0xEC 0x8000); 0x28 is read by the camera scripts to look at the strat + registered with 0x113; 0x14a is a no-op (FUN_004268c0 only acts for 0); 0x9c's value is dead; + 0x18a/0x18b set the pending particle settings consumed by the next spawnParticle; 0x1a9's top + value is the rain texture and the others are wind x, amount, wind z; all no-op/drop stubs verified. + Still low: 0x34 and 0xf2 (no reader of their flag bits found). Names unchanged (stored sources use + them); see the table for the new descriptions. * **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). @@ -100,30 +111,30 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0x26 | `pushBossProp` | prop | 0 | 1 | | high | stBoss_AlienVar | Pushes property n of the boss strat DAT_006D9E24 (set by 0xef isBoss) via the getter FUN_00550E60; no null check; unused in the levels. 552b10 | +| 0x27 | `pushBossPropAddr` | prop | 0 | 1 | | high | stBoss_AlienVarAddress | Pushes the address of property n of the boss strat DAT_006D9E24 (setter FUN_005509F0 returns the address); no null check; unused. 552b30 | +| 0x28 | `pushDialogProp` | prop | 0 | 1 | 1678 | high | stDialog_AlienVar | Pushes property n of the strat registered by 0x113 (DAT_006D9E2C: the NPC/puzzle/door that a cut-scene is about), 0 if none. WAD use: Camera and CutSceneCameraDirectional read its x/y/z/rotY to look at it (1585 of 1678 uses). 552cb0 | +| 0x29 | `pushDialogPropAddr` | prop | 0 | 1 | | high | stDialog_AlienVarAddress | Pushes the 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 | +| 0x2f | `flagEC10000` | imm | 0 | 0 | 1888 | medium | - | arg!=0 sets, 0 clears +0xEC bit 0x10000 = "freeze when off-camera": the camera pass (FUN_00436190) sets +0xEC 0x8000 when the strat is outside the camera range; with both bits set the frame loop (0x54c603) and the strat-vs-strat collision loop (0x54cc1a) skip it. Used by ~1350 strats (pickups, triggers, enemies). 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 | +| 0x31 | `flagEC2` | imm | 0 | 0 | 410 | medium | - | arg sets/clears +0xEC bit 2 = "others can stand on / touch me": in the strat collision loop (0x54cc96) a strat touching a strat with this bit gets +0xE8 0x400000 (and 0x800000 when it moves) = properties 32/33; also readable as property 0x1f. Set by Llama, Coin, Jar, EnemyCoin, RubyIdol. 54e090 | +| 0x32 | `flagE8_80000000` | imm | 0 | 0 | | medium | - | arg sets/clears +0xE8 bit 31; no reader of the bit found in the exe (scan of all +0xE8 flag tests); unused in the levels. 54dd90 | +| 0x33 | `flagEC100` | imm | 0 | 0 | 60 | medium | - | arg sets/clears +0xEC bit 0x100; the collision response (0x4044f7) skips the floor/slope handling when it is set. Used by LlamaSpit, DropObject, RollerCoaster, KuscoTurtle (objects that move by script, not by floor physics). 54dd30 | +| 0x34 | `flagEC200000` | imm | 0 | 0 | 50 | low | - | arg sets/clears +0xEC bit 0x200000; no direct test of the bit found in the exe (all +0xEC flag tests scanned). Used by Llama, RiverCroc, RiverLog, Rock, river NPCs (river/canoe level objects). 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 | +| 0x3b | `popDialogProp` | prop | 1 | 0 | | high | - | Pops a value into property n of the DAT_006D9E2C strat (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 | +| 0x3d | `popBossProp` | prop | 1 | 0 | | high | - | Pops a value into property n of the boss strat DAT_006D9E24 (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 | @@ -197,15 +208,15 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0x87 | `nop_0x87` | - | 0 | 0 | | high | stCollPoints | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stCollPoints. 426500 | +| 0x88 | `nop_0x88` | - | 0 | 0 | | high | stCollSetPoint | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. 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 | +| 0x8f | `nop_0x8f` | - | 0 | 0 | | high | stMapRemove | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. 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 | @@ -218,8 +229,8 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0x9c | `speechSetPos` | - | 1 | 0 | 31 | high | - | Pops a value into global 0x6DA2B8 (written only, never read: no effect) and moves the speech/voice channel (DAT_006D94A0, started by "#" texts) to this strat's position (+0x30..0x38), so the voice plays from the speaker. 54d530 | +| 0x9d | `discard_0x9d` | - | 1 | 0 | 28 | high | stMidiVolume | Pops a value (>>12) and passes it to the empty function 0x426500: a pure drop on PC. 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 | @@ -239,7 +250,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0xb1 | `stkCmp` | imm | 0 | 1 | | high | stStkCmp | Reads one inline word imm (pc += 4), peeks the top stack entry (no pop), dereferences it as an int pointer and pushes imm − *top. Unused in the levels. 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 | @@ -249,36 +260,36 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0xbb | `nop_0xbb` | - | 0 | 0 | | high | - | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. 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 | +| 0xc1 | `nop_0xc1` | - | 0 | 0 | | high | stResettoCheckPoint | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. 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 | +| 0xc4 | `rumble` | - | 2 | 0 | 93 | high | stRumble | Shared stub 0x555090: pops two values and discards them (PSX pad rumble is not implemented on PC). 555090 | +| 0xc5 | `suspendIfTooFar` | - | 1 | 0 | 232 | high | stSuspendIfTooFar | Shared stub 0x554fb0: pops one value (distance) and discards it – no effect on PC (op 0x2f gives the off-camera freeze instead). 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 | +| 0xc7 | `useBone` | - | 1 | 0 | 345 | high | stUseBone | Pops bone n (>>12); if the strat has an animated model (+0x14) it takes bone n of the current animation frame (+0x110; anim+0x1C frame table, 64 bytes per bone, offset +0x30..0x38 = bone position), rotates it by the strat's rotation matrix and adds the position: world point in +0xD8/+0xDC/+0xE0 = properties boneX/Y/Z. (The Ghidra C output drops the bone index; read from the assembly.) Used to spawn effects at hands/mouths. 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 | +| 0xcd | `nop_0xcd` | - | 0 | 0 | | high | stResetCamerapos | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. 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 | +| 0xcf | `nop_0xcf` | - | 0 | 0 | | high | stGainHeartPot | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. 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 | +| 0xd1 | `nop_0xd1` | - | 0 | 0 | | high | stGainCrystal | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stGainCrystal. 426500 | +| 0xd2 | `discard_0xd2` | - | 1 | 0 | | high | 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 | +| 0xd8 | `nop_0xd8` | - | 0 | 0 | | high | stOnground | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. 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 | @@ -304,7 +315,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0xf2 | `setDrawMode` | - | 1 | 0 | | low | - | Pops mode (raw int): 1 sets +0xEC 0x80000, 2 sets 0x100000, anything else clears both; no reader of these two bits found in the exe; unused in the levels. 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 | @@ -325,7 +336,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0x107 | `setAnimSpeed` | - | 1 | 0 | 645 | high | stSetanimSpeed? | Pops the animation speed (20.12, 1.0 = one animation frame per game frame) into halfword +0x114; while animating (+0xE8 bit 8) the frame loop adds it to the sub-frame counter +0x116 and advances the frame (+0x110) each time it passes 1.0 (0x54c630). WAD use: 1.0, 0.25, 1.5 and computed values. 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 | @@ -392,7 +403,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0x14a | `hudReset2` | - | 0 | 0 | | high | - | Calls FUN_004268c0(2), which returns at once for any argument other than 0 (verified in the assembly): a no-op. Unused. 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 | @@ -410,7 +421,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0x15c | `setShadowHeight` | - | 1 | 0 | 1 | medium | - | Pops v into +0x14C, the water/floor height limit used by the shadow pass (FUN_00555140: -0x1000 = use the camera-cell floor height DAT_00586424, prop camFloorHeight); the shadow is skipped when the floor under the strat lies below it. One use (SinkingStatuePad). 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 | @@ -456,8 +467,8 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0x18a | `emitterSetParams` | - | 5 | 0 | 113 | medium | - | Pops e (top), d, c, b, slot (>>12): pending particle settings of this strat (slot 0–2 → record index at strat+0x150+slot, ≥3 → +0x153, record allocated on demand in the 0x34-byte table 0x5FE23C): b = size scale (1.0 = default), c>>12 = extra lifetime in frames, e≠0: +0x246 = e (drift), +0x23C = d; e==0: +0x25C = (d>>12)*32-32 (sprite size). spawnParticle consumes and resets the +0x153 record; same as spawnParticle type 0x19. 5530b0 | +| 0x18b | `emitterSetColour` | - | 5 | 0 | 9 | medium | - | Pops colour (top, raw packed, see 0x15d), b, g, r, slot: pending particle colour for the next particle(s) of this strat (record as 0x18a; same as spawnParticle type 0x1e): +0x250 = colour; r without fraction = alpha r>>12 (7 bits), else r,g,b>>12 = per-channel fade, +0x243 = 0x80; alpha 0 = 0xFF/lifetime. 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 | @@ -487,7 +498,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 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 | +| 0x1a9 | `setRain` | - | 4 | 0 | 4 | medium | - | Weather: pops texture (top, raw tex index → 0x6D9BE8, read by the rain renderer), windZ (>>12 → 0x578EF4), c (>>12 → 0x6D9BF4, only used for the off test), windX (>>12 → 0x578EF0); each raindrop restarts with velocity (windX, -256, windZ) per frame. windX, c, windZ all 0 = rain off (drops cleared, 0x6D9BE4 = 0), else on. Rain type: setRain(v0, v1, v2, tex:768) with params 16.0, 0, 16.0. 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 | diff --git a/src/main/java/com/eliasmueller/llamamod/editor/model/StratCatalog.java b/src/main/java/com/eliasmueller/llamamod/editor/model/StratCatalog.java index 2ad14f2..6815d1e 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/model/StratCatalog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/model/StratCatalog.java @@ -74,6 +74,20 @@ public final class StratCatalog { names.putIfAbsent(code, name); } + /** Removes a type and the names of its variants' codes (custom types being rebuilt). */ + public void remove(String name) { + Type t = types.remove(name); + if (t != null) { + t.variants.forEach(v -> names.remove(v.code, name)); + } + codeOnly.remove(name); + } + + /** Forgets the name of a code offset. */ + public void removeName(int code) { + names.remove(code); + } + /** Placeable types, sorted by name. */ public List types() { return List.copyOf(types.values()); 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 index 34301f4..5aadd72 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/ScriptPanel.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/ScriptPanel.java @@ -97,6 +97,23 @@ final class ScriptPanel extends JPanel { "duplicate", "Copy this type into a custom type and edit its script", () -> TypeDialog.showClone(ctx, primaryType()))); + row2.add(Widgets.button("Edit type…", null, "Edit the script of this custom type (undoable)", () -> { + int k = customIndex(); + if (k < 0) { + status.setText("Only custom types can be edited – use Clone as new type… for original types"); + } else { + TypeDialog.showEdit(ctx, k); + } + })); + row2.add(Widgets.button( + "Delete type…", "delete", "Delete this custom type (warns about objects using it)", () -> { + int k = customIndex(); + if (k < 0) { + status.setText("Only custom types can be deleted"); + } else { + TypeDialog.delete(ctx, k); + } + })); JPanel top = new JPanel(new java.awt.GridLayout(2, 1)); top.add(row1); top.add(row2); @@ -148,6 +165,11 @@ final class ScriptPanel extends JPanel { reload(); } + private int customIndex() { + Scene s = ctx.scene(); + return s == null || listing == null ? -1 : StratEdits.customIndex(s, listing.unit); + } + private int selectedUnit() { UnitItem u = (UnitItem) units.getSelectedItem(); return u == null ? -1 : u.unit; @@ -172,7 +194,7 @@ final class ScriptPanel extends JPanel { RawChunk c = s == null ? null : StratEdits.chunk(s); if (s != shownScene || c == null || c.data() != shownData) { int keep = list.getSelectedIndex(); - int u = selectedUnit(); + int u = listing != null ? listing.unit : selectedUnit(); reloadUnits(); if (u >= 0) { selectUnit(u); @@ -501,7 +523,7 @@ final class ScriptPanel extends JPanel { return; } if (StratEdits.isCustomUnit(ctx.scene(), listing.unit)) { - status.setText("Custom types are edited through their source: clone it again with the changed script"); + status.setText("Custom types are edited through their source: Edit type…"); return; } Map changes = ConstantDialog.show(ctx, r.consts); 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 index 54c87ca..0261140 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratEdits.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratEdits.java @@ -519,9 +519,14 @@ public final class StratEdits { /** 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; + return build(a.img.data, s.settings().wfpc(), a.entries, t); + } + + /** Assembles a type's source as a new unit appended to the STPC data old and verifies it. */ + static Built build(byte[] old, int wfpc, Set entries, CustomType t) { + StpcImage img0 = new StpcImage(old, wfpc); int base = old.length + 4; - StratAssembler.Result r = StratAssembler.assemble(t.source, StratListing.layout(a.img, base)); + StratAssembler.Result r = StratAssembler.assemble(t.source, StratListing.layout(img0, base)); Integer e = r.labels().get(t.entryLabel); if (e == null) { throw new IllegalArgumentException("entry label " + t.entryLabel + " not found in the source"); @@ -530,20 +535,20 @@ public final class StratEdits { 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); + putInt(d, old.length, n); 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); + putInt(d, img0.codeStart, Bytes.i32(d, img0.codeStart) + 1); + List problems = verify(d, wfpc, entries, base, base + e, r); return new Built(d, base, base + e, r, problems); } + private static void putInt(byte[] d, int o, int v) { + d[o] = (byte) v; + d[o + 1] = (byte) (v >> 8); + d[o + 2] = (byte) (v >> 16); + d[o + 3] = (byte) (v >> 24); + } + /** * 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 @@ -637,6 +642,154 @@ public final class StratEdits { return code; } + /** Why the custom types of a scene cannot be rebuilt in place (null = they can). */ + public static String rebuildBlocker(Scene s) { + State st = state(s); + if (st.firstCustom >= 0 && st.firstCustom + st.customEntries.size() != units(s)) { + return "object types were imported after the custom types – save and reopen the project first"; + } + return null; + } + + /** STPC data of a scene without its custom units. */ + private static byte[] withoutCustom(Scene s) { + State st = state(s); + Analysis a = analysis(s); + if (st.firstCustom < 0) { + return a.img.data; + } + int cut = a.img.units.get(st.firstCustom).start() - 4; + byte[] d = java.util.Arrays.copyOf(a.img.data, cut); + putInt(d, a.img.codeStart, Bytes.i32(d, a.img.codeStart) - st.customEntries.size()); + return d; + } + + /** + * Problems of a new list of custom types for a scene (assembled one after another behind the + * base code, as on load); null when all of them assemble and verify. index = the type to report + * words for (out[0], −1 = none). + */ + public static List checkTypes(Scene s, List types, int index, int[] words) { + String blocker = rebuildBlocker(s); + if (blocker != null) { + return List.of(blocker); + } + byte[] d = withoutCustom(s); + Set entries = new TreeSet<>(analysis(s).entries); + entries.removeIf(o -> o >= d.length); + byte[] cur = d; + for (int i = 0; i < types.size(); i++) { + CustomType t = types.get(i); + Built x = build(cur, s.settings().wfpc(), entries, t); + if (!x.problems.isEmpty()) { + return x.problems.stream().map(m -> t.name + ": " + m).toList(); + } + if (i == index && words != null) { + words[0] = x.asm.words().length; + } + entries.add(x.entry); + for (int v : x.asm.strats().values()) { + entries.add(x.base + v); + } + cur = x.stpc; + } + return null; + } + + /** + * Replaces the custom types of reference and scene by types (the custom units are cut off the + * code and the new ones appended); objects of old type k move to the entry of type remap[k] + * (objects of a type mapped to −1 must have been removed). Records the list in the project. + */ + static void setTypes(Project p, List types, int[] remap) { + for (Scene s : List.of(p.reference(), p.scene())) { + List problems = checkTypes(s, types, -1, null); + if (problems != null) { + throw new IllegalStateException(String.join("; ", problems)); + } + } + for (Scene s : List.of(p.reference(), p.scene())) { + Runnable r = () -> rebuild(s, types, remap); + if (s == p.scene()) { + s.bulk(r); + } else { + r.run(); + } + } + Records rec = Records.of(p.extra()); + new Records(rec.constants, List.copyOf(types)).store(p.extra(), session(p.scene())); + } + + private static void rebuild(Scene s, List types, int[] remap) { + State st = state(s); + List oldEntries = new ArrayList<>(st.customEntries); + Analysis a = analysis(s); + int from = st.firstCustom < 0 + ? a.img.data.length + : a.img.units.get(st.firstCustom).start() - 4; + int to = a.img.data.length; + List oldUnitOf = new ArrayList<>(), oldCode = new ArrayList<>(); + for (Strat x : s.strats()) { + boolean custom = x.code() >= from && x.code() < to; + oldUnitOf.add(custom ? a.img.unitOf(x.code()) - st.firstCustom : -1); + oldCode.add(x.code()); + if (custom) { + s.set(x, "code", 0); // keep stale codes out of the analysis roots while appending + } + } + byte[] cut = withoutCustom(s); + StratCatalog cat = s.catalog(); + for (var e : List.copyOf(cat.names().entrySet())) { + if (e.getKey() >= from && e.getKey() < to) { + StratCatalog.Type t = cat.type(e.getValue()); + if (t != null && t.variants().stream().allMatch(v -> v.code() >= from && v.code() < to)) { + cat.remove(e.getValue()); + } + cat.removeName(e.getKey()); + } + } + chunk(s).setRaw("data", cut); + st.firstCustom = -1; + st.customEntries.clear(); + for (CustomType t : types) { + appendType(s, t, false); + } + List strats = List.copyOf(s.strats()); + for (int i = 0; i < strats.size(); i++) { + int k = oldUnitOf.get(i); + if (k < 0) { + continue; + } + Strat x = strats.get(i); + int nk = k < remap.length ? remap[k] : -1; + if (nk < 0 || nk >= st.customEntries.size()) { + throw new IllegalStateException(x.id() + " still uses a removed custom type"); + } + int oldEntry = oldEntries.get(k); + // objects placed at the entry follow the entry; other codes keep their offset in the unit + int c0 = oldCode.get(i); + int code = c0 == oldEntry ? st.customEntries.get(nk) : c0 - oldEntry + st.customEntries.get(nk); + s.set(x, "code", code); + } + } + + /** Objects of the scene whose code lies in custom type index k's unit. */ + public static List users(Scene s, int k) { + State st = state(s); + Analysis a = analysis(s); + List out = new ArrayList<>(); + if (st.firstCustom < 0 || k < 0 || k >= st.customEntries.size()) { + return out; + } + int unit = st.firstCustom + k; + for (Strat x : s.strats()) { + if (a.img.unitOf(x.code()) == unit) { + out.add(x); + } + } + return out; + } + /** Current position of the custom units (for remapping codes on load), or null. */ static Map session(Scene s) { State st = state(s); 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 index 9c6aba2..61ca70c 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratsEditorPlugin.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratsEditorPlugin.java @@ -123,10 +123,17 @@ public final class StratsEditorPlugin implements EditorPlugin { 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); + JPanel buttons = new JPanel(new java.awt.FlowLayout(java.awt.FlowLayout.TRAILING, 4, 0)); + int k = a == null || a.img().unitOf(s.code()) < 0 + ? -1 + : StratEdits.customIndex(s.scene(), a.img().unitOf(s.code())); + if (k >= 0) { + buttons.add(Widgets.button( + "Edit type…", null, "Edit this custom type's script", () -> TypeDialog.showEdit(ctx, k))); + } + buttons.add(Widgets.button( + "Open script", null, "Show and edit this type's strat script", () -> ctx.showPanel(PANEL))); + p.add(buttons, 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 index 0ca1616..bf35c9a 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeDialog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeDialog.java @@ -5,6 +5,7 @@ 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 com.eliasmueller.llamamod.format.strat.StratCompiler; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.FlowLayout; @@ -42,6 +43,18 @@ final class TypeDialog { end """; + static final String SCRIPT_TEMPLATE = + """ + // A custom strat in the script language (manual chapter "Strat scripts"). + strat MyStrat { + setModel(model:0) // first model of the level (pick one from another type's script) + loop { + turnLeft(4deg) // turn per frame + yield // wait one frame + } + } + """; + static void showClone(EditorContext ctx, String type) { if (ctx.document() == null) { return; @@ -57,7 +70,7 @@ final class TypeDialog { 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); + open(ctx, "Clone " + type + " as a new type", t.name(), t.source(), type, t.entryLabel(), true, -1); } static void showNew(EditorContext ctx) { @@ -67,7 +80,86 @@ final class TypeDialog { 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); + open(ctx, "New object type from script", freeName(s, "MyStrat"), TEMPLATE, base, "MyStrat", false, -1); + } + + /** Edits custom type k in place: its name, entry label and source (type fields are kept). */ + static void showEdit(EditorContext ctx, int k) { + if (ctx.document() == null) { + return; + } + String blocker = StratEdits.rebuildBlocker(ctx.scene()); + List types = + StratEdits.records(ctx.document().project()).types(); + if (blocker != null || k < 0 || k >= types.size()) { + JOptionPane.showMessageDialog( + ctx.frame(), + blocker != null ? "Cannot edit the type: " + blocker + "." : "Not a custom type.", + "Edit object type", + JOptionPane.INFORMATION_MESSAGE); + return; + } + StratEdits.CustomType t = types.get(k); + open( + ctx, + "Edit object type " + t.name(), + t.name(), + t.source(), + t.basedOn(), + t.entryLabel(), + t.basedOn() != null && !t.basedOn().isEmpty(), + k); + } + + /** Deletes custom type k after a confirmation that names the objects using it. */ + static void delete(EditorContext ctx, int k) { + if (ctx.document() == null) { + return; + } + Project p = ctx.document().project(); + Scene s = ctx.scene(); + List types = StratEdits.records(p).types(); + if (k < 0 || k >= types.size()) { + return; + } + String name = types.get(k).name(); + String blocker = StratEdits.rebuildBlocker(s); + List rest = new java.util.ArrayList<>(types); + rest.remove(k); + List problems = blocker != null ? List.of(blocker) : StratEdits.checkTypes(s, rest, -1, null); + if (problems != null) { + JOptionPane.showMessageDialog( + ctx.frame(), + "Cannot delete " + name + ": " + problems.get(0) + ".", + "Delete object type", + JOptionPane.WARNING_MESSAGE); + return; + } + int n = StratEdits.users(s, k).size(); + String msg = n == 0 + ? "Delete the custom type " + name + "? No object in the level uses it." + : n + " object" + (n == 1 ? "" : "s") + " in the level use" + (n == 1 ? "s " : " ") + name + + ".\nDelete the type and " + (n == 1 ? "this object" : "these objects") + "?"; + Object[] opts = {n == 0 ? "Delete type" : "Delete type and objects", "Cancel"}; + int r = JOptionPane.showOptionDialog( + ctx.frame(), + msg, + "Delete object type", + JOptionPane.DEFAULT_OPTION, + JOptionPane.WARNING_MESSAGE, + null, + opts, + opts[1]); + if (r != 0) { + return; + } + try { + ctx.execute(TypeEdits.delete(p, k)); + } catch (RuntimeException ex) { + ctx.error("Delete object type", ex); + return; + } + ctx.status("Object type " + name + " deleted" + (n == 0 ? "" : " with " + n + " object" + (n == 1 ? "" : "s"))); } static String freeName(Scene s, String base) { @@ -85,14 +177,25 @@ final class TypeDialog { String source, String fieldsFrom, String entry, - boolean clone) { + boolean clone, + int editIndex) { 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); + boolean editing = editIndex >= 0; + String oldName = editing ? name : null; + if (editing) { + from.setEnabled(false); + from.setToolTipText("An edited type keeps its type fields and parameters"); + } + String script = StratCompiler.embeddedSource(source); + JTextArea text = new JTextArea(script != null ? script : source, 30, 90); + JComboBox lang = new JComboBox<>(new String[] {"Assembly", "Script"}); + lang.setSelectedIndex(script != null ? 1 : 0); + lang.setToolTipText("Assembly = the exact instruction list; Script = structured language compiled to assembly"); text.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12)); text.setTabSize(8); JLabel result = new JLabel(" "); @@ -101,10 +204,12 @@ final class TypeDialog { top.add(nameField); top.add(new JLabel("Entry label")); top.add(entryField); + top.add(new JLabel("Language")); + top.add(lang); top.add(new JLabel("Type fields / parameters from")); top.add(from); JButton check = new JButton("Check"); - JButton create = new JButton("Create type"); + JButton create = new JButton(editing ? "Apply" : "Create type"); JButton cancel = new JButton("Cancel"); JPanel bottom = new JPanel(new BorderLayout()); JPanel buttons = new JPanel(new FlowLayout(FlowLayout.TRAILING)); @@ -128,6 +233,39 @@ final class TypeDialog { d.getContentPane().add(north, BorderLayout.NORTH); d.getContentPane().add(new JScrollPane(text), BorderLayout.CENTER); d.getContentPane().add(bottom, BorderLayout.SOUTH); + String[] shown = {text.getText()}; + int[] shownLang = {lang.getSelectedIndex()}; + lang.addActionListener(e -> { + int to = lang.getSelectedIndex(); + if (to == shownLang[0]) { + return; + } + String cur = text.getText(); + if (to == 0) { + try { + text.setText(StratCompiler.compile(cur)); + } catch (StratCompiler.CompileException ex) { + result.setText("⚠ " + ex.getMessage() + " – fix the script before switching to assembly"); + highlight(text, ex.line); + lang.setSelectedIndex(1); + return; + } + } else { + String embedded = StratCompiler.embeddedSource(cur); + if (embedded != null) { + text.setText(embedded); + } else if (cur.equals(TEMPLATE) || cur.equals(shown[0]) && !clone && editIndex < 0) { + text.setText(SCRIPT_TEMPLATE); + } else { + result.setText( + "⚠ Assembly cannot be turned into script; start from the script template (New object type from script…)"); + lang.setSelectedIndex(0); + return; + } + } + shownLang[0] = to; + text.setCaretPosition(0); + }); Runnable[] verify = new Runnable[1]; StratEdits.CustomType[] ok = new StratEdits.CustomType[1]; verify[0] = () -> { @@ -138,12 +276,55 @@ final class TypeDialog { result.setText("⚠ The name must be letters, digits and _ (max 40)."); return; } - if (s.catalog().has(n) || s.catalog().names().containsValue(n)) { + if (!n.equals(oldName) && (s.catalog().has(n) || s.catalog().names().containsValue(n))) { result.setText("⚠ A type named " + n + " already exists."); return; } String src = text.getText(); + if (lang.getSelectedIndex() == 1) { + try { + src = StratCompiler.compile(src); + } catch (StratCompiler.CompileException e) { + result.setText("⚠ " + e.getMessage()); + highlight(text, e.line); + return; + } + String first = StratCompiler.firstStrat(text.getText()); + if (first != null && !src.contains("\n" + entryField.getText().trim() + ":\n")) { + entryField.setText(first); + } + } StratCatalog.Type ft = s.catalog().type((String) from.getSelectedItem()); + if (editing) { + StratEdits.CustomType old = + StratEdits.records(ctx.document().project()).types().get(editIndex); + StratEdits.CustomType t = new StratEdits.CustomType( + n, old.basedOn(), entryField.getText().trim(), src, old.proto()); + List all = new java.util.ArrayList<>( + StratEdits.records(ctx.document().project()).types()); + all.set(editIndex, t); + int[] words = new int[1]; + try { + List ps = StratEdits.checkTypes(s, all, editIndex, words); + if (ps != null) { + result.setText("⚠ " + String.join("; ", ps)); + return; + } + } 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()); + return; + } catch (RuntimeException e) { + result.setText("⚠ " + e.getMessage()); + return; + } + int users = StratEdits.users(s, editIndex).size(); + result.setText("✓ " + words[0] + " words, verified. " + users + " object" + (users == 1 ? "" : "s") + + " in the level use" + (users == 1 ? "s" : "") + " this type."); + ok[0] = t; + return; + } if (ft == null) { result.setText("⚠ Choose the type whose type fields and parameters the new type uses."); return; @@ -181,12 +362,21 @@ final class TypeDialog { } Project p = ctx.document().project(); try { - StratEdits.addType(p, ok[0]); + if (editing) { + ctx.execute(TypeEdits.edit(p, editIndex, ok[0])); + } else if (StratEdits.rebuildBlocker(ctx.scene()) == null) { + ctx.execute(TypeEdits.create(p, ok[0])); + } else { + StratEdits.addType(p, ok[0]); // types imported after the custom ones: append, no undo + } } catch (RuntimeException ex) { - ctx.error("Create object type", ex); + ctx.error(editing ? "Edit object type" : "Create object type", ex); return; } - ctx.status("Object type " + ok[0].name() + " created – place it from Assets → Objects"); + ctx.status( + editing + ? "Object type " + ok[0].name() + " updated – rebuild/play to see the effect" + : "Object type " + ok[0].name() + " created – place it from Assets → Objects"); d.dispose(); }); cancel.addActionListener(e -> d.dispose()); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeEdits.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeEdits.java new file mode 100644 index 0000000..cea072a --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeEdits.java @@ -0,0 +1,81 @@ +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.Strat; +import com.eliasmueller.llamamod.editor.model.cmd.Batch; +import com.eliasmueller.llamamod.editor.model.cmd.Command; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import java.util.ArrayList; +import java.util.List; + +/** Undoable create / edit / delete of custom object types (the custom code block is rebuilt). */ +final class TypeEdits { + private TypeEdits() {} + + /** Replaces the custom type list; fwd/back map type indexes of the old list to the new one and back. */ + record SetTypes( + Project p, + String label, + List before, + List after, + int[] fwd, + int[] back) + implements Command { + @Override + public void apply(Scene s) { + StratEdits.setTypes(p, after, fwd); + } + + @Override + public void revert(Scene s) { + StratEdits.setTypes(p, before, back); + } + } + + static int[] identity(int n) { + int[] a = new int[n]; + for (int i = 0; i < n; i++) { + a[i] = i; + } + return a; + } + + static Command create(Project p, StratEdits.CustomType t) { + List before = StratEdits.records(p).types(); + List after = new ArrayList<>(before); + after.add(t); + return new SetTypes( + p, "Create object type " + t.name(), before, after, identity(before.size()), identity(before.size())); + } + + /** Replaces custom type k by t (objects of the type stay and use the new code). */ + static Command edit(Project p, int k, StratEdits.CustomType t) { + List before = StratEdits.records(p).types(); + List after = new ArrayList<>(before); + after.set(k, t); + return new SetTypes( + p, "Edit object type " + t.name(), before, after, identity(before.size()), identity(before.size())); + } + + /** Deletes custom type k together with the objects that use it. */ + static Command delete(Project p, int k) { + List before = StratEdits.records(p).types(); + List after = new ArrayList<>(before); + StratEdits.CustomType gone = after.remove(k); + int[] fwd = new int[before.size()], back = new int[after.size()]; + for (int i = 0; i < fwd.length; i++) { + fwd[i] = i < k ? i : i == k ? -1 : i - 1; + } + for (int j = 0; j < back.length; j++) { + back[j] = j < k ? j : j + 1; + } + List cmds = new ArrayList<>(); + List users = StratEdits.users(p.scene(), k); + if (!users.isEmpty()) { + cmds.add(Edits.delete(p.scene(), users)); + } + cmds.add(new SetTypes(p, "Delete object type " + gone.name(), before, after, fwd, back)); + return new Batch("Delete object type " + gone.name(), cmds); + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/StratCompiler.java b/src/main/java/com/eliasmueller/llamamod/format/strat/StratCompiler.java new file mode 100644 index 0000000..73bc975 --- /dev/null +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/StratCompiler.java @@ -0,0 +1,652 @@ +package com.eliasmueller.llamamod.format.strat; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Compiler for strat scripts, a small structured language for NEW behaviour (notes/strat-language.md, + * "Script language"): statements and expressions in the style of the script view, compiled to the + * assembly of {@link StratAssembler}. Literals mean the same as in the assembly (10.0 = ten, 10 = + * ten raw 1/4096 units, 90deg). + */ +public final class StratCompiler { + /** A compile error at a 1-based source line. */ + public static final class CompileException extends RuntimeException { + public final int line; + + CompileException(int line, String message) { + super("line " + line + ": " + message); + this.line = line; + } + } + + /** Prefix of the comment lines that carry the script source inside the generated assembly. */ + public static final String SOURCE_PREFIX = ";| "; + + private record Tok(String kind, String text, int line) {} + + private static final Map PREC = Map.ofEntries( + Map.entry("||", 1), + Map.entry("&&", 2), + Map.entry("|", 3), + Map.entry("&", 4), + Map.entry("==", 5), + Map.entry("!=", 5), + Map.entry("<", 6), + Map.entry(">", 6), + Map.entry("<=", 6), + Map.entry(">=", 6), + Map.entry("<<", 7), + Map.entry(">>", 7), + Map.entry("+", 8), + Map.entry("-", 8), + Map.entry("*", 9), + Map.entry("/", 9)); + + private static final Map BINOP = Map.ofEntries( + Map.entry("||", "or"), + Map.entry("|", "or"), + Map.entry("&&", "and"), + Map.entry("&", "bitwiseAnd"), + Map.entry("==", "eq"), + Map.entry("!=", "notEqual"), + Map.entry("<", "lt"), + Map.entry(">", "gt"), + Map.entry("<=", "lessEqual"), + Map.entry(">=", "greaterEqual"), + Map.entry("<<", "shl"), + Map.entry(">>", "shr"), + Map.entry("+", "add"), + Map.entry("-", "sub"), + Map.entry("*", "mul"), + Map.entry("/", "div")); + + /** Target prefix → push, pop, address mnemonics (variables of the parent use the *Var ops). */ + private static final Map TARGETS = Map.of( + "self", new String[] {"pushProp", "popProp", "pushPropAddr"}, + "player", new String[] {"pushPlayerProp", "popPlayerProp", null}, + "parent", new String[] {"pushParentProp", "popParentProp", "pushParentPropAddr"}, + "camera", new String[] {"pushCameraProp", "popCameraProp", null}, + "target", new String[] {"pushTargetProp", "popTargetProp", null}, + "target2", new String[] {"pushTarget2Prop", "popTarget2Prop", null}, + "boss", new String[] {"pushBossProp", "popBossProp", null}, + "dialog", new String[] {"pushDialogProp", "popDialogProp", null}); + + private static final List REFS = List.of("model", "anim", "unit", "sound", "tex", "text"); + + private final OpTable ops = OpTable.get(); + private final List toks; + private int p; + private final StringBuilder out = new StringBuilder(); + private final Map aliases = new HashMap<>(); + private int labels; + /** Enclosing loops: {break label, continue label}. */ + private final Deque loops = new ArrayDeque<>(); + + private StratCompiler(String src) { + toks = lex(src); + } + + /** Compiles a script to assembly text; the script is kept as {@link #SOURCE_PREFIX} comments. */ + public static String compile(String src) { + StratCompiler c = new StratCompiler(src); + for (String l : src.split("\n", -1)) { + c.out.append(SOURCE_PREFIX).append(l.stripTrailing()).append('\n'); + } + c.program(); + return c.out.toString(); + } + + /** The script embedded in compiled assembly, or null if the text is not compiler output. */ + public static String embeddedSource(String asm) { + if (!asm.startsWith(SOURCE_PREFIX)) { + return null; + } + StringBuilder b = new StringBuilder(); + for (String l : asm.split("\n", -1)) { + if (!l.startsWith(SOURCE_PREFIX.trim())) { + break; + } + b.append(l.length() >= SOURCE_PREFIX.length() ? l.substring(SOURCE_PREFIX.length()) : "") + .append('\n'); + } + return b.toString().stripTrailing() + "\n"; + } + + /** Entry label of the first strat of a script (null if none). */ + public static String firstStrat(String src) { + List t = lex(src); + for (int i = 0; i + 1 < t.size(); i++) { + if (t.get(i).text.equals("strat") && t.get(i + 1).kind.equals("id")) { + return t.get(i + 1).text; + } + } + return null; + } + + // ------------------------------------------------------------------ lexer + + private static List lex(String s) { + List out = new ArrayList<>(); + int line = 1; + int i = 0; + while (i < s.length()) { + char c = s.charAt(i); + if (c == '\n') { + line++; + i++; + } else if (Character.isWhitespace(c) || c == ';') { + i++; + } else if (s.startsWith("//", i)) { + while (i < s.length() && s.charAt(i) != '\n') { + i++; + } + } else if (s.startsWith("/*", i)) { + int e = s.indexOf("*/", i + 2); + e = e < 0 ? s.length() : e + 2; + for (int k = i; k < e; k++) { + line += s.charAt(k) == '\n' ? 1 : 0; + } + i = e; + } else if (Character.isDigit(c)) { + int j = i; + if (s.startsWith("0x", i) || s.startsWith("0X", i)) { + j += 2; + while (j < s.length() && Character.digit(s.charAt(j), 16) >= 0) { + j++; + } + } else { + while (j < s.length() && (Character.isDigit(s.charAt(j)) || s.charAt(j) == '.')) { + j++; + } + if (s.startsWith("deg", j)) { + j += 3; + } + } + out.add(new Tok("num", s.substring(i, j), line)); + i = j; + } else if (Character.isLetter(c) || c == '_') { + int j = i; + while (j < s.length() && (Character.isLetterOrDigit(s.charAt(j)) || s.charAt(j) == '_')) { + j++; + } + out.add(new Tok("id", s.substring(i, j), line)); + i = j; + } else if (c == '"') { + int j = i + 1; + while (j < s.length() && s.charAt(j) != '"' && s.charAt(j) != '\n') { + j += s.charAt(j) == '\\' ? 2 : 1; + } + out.add(new Tok("str", s.substring(i, Math.min(j + 1, s.length())), line)); + i = j + 1; + } else { + String two = i + 1 < s.length() ? s.substring(i, i + 2) : ""; + String op = List.of("==", "!=", "<=", ">=", "&&", "||", "<<", ">>", "+=", "-=", "*=", "/=") + .contains(two) + ? two + : String.valueOf(c); + out.add(new Tok("op", op, line)); + i += op.length(); + } + } + out.add(new Tok("eof", "", line)); + return out; + } + + // ------------------------------------------------------------------ parser helpers + + private Tok peek() { + return toks.get(p); + } + + private Tok peek(int k) { + return toks.get(Math.min(p + k, toks.size() - 1)); + } + + private Tok next() { + return toks.get(p < toks.size() - 1 ? p++ : p); + } + + private boolean at(String text) { + return peek().text.equals(text) && !peek().kind.equals("str"); + } + + private boolean at1(String text) { + return peek(1).text.equals(text) && !peek(1).kind.equals("str"); + } + + private boolean accept(String text) { + if (at(text)) { + p++; + return true; + } + return false; + } + + private Tok expect(String text) { + if (!at(text)) { + throw err("expected '" + text + "' but found " + describe(peek())); + } + return next(); + } + + private String ident() { + if (!peek().kind.equals("id")) { + throw err("expected a name but found " + describe(peek())); + } + return next().text; + } + + private static String describe(Tok t) { + return t.kind.equals("eof") ? "the end of the script" : "'" + t.text + "'"; + } + + private CompileException err(String m) { + return new CompileException(peek().line, m); + } + + private String label() { + return "c_" + (++labels); + } + + private void emit(String instr) { + out.append(" ").append(instr).append('\n'); + } + + private void place(String label) { + out.append(label).append(":\n"); + } + + // ------------------------------------------------------------------ program + + private void program() { + while (!peek().kind.equals("eof")) { + if (accept("strat")) { + String name = ident(); + out.append(" .string \"").append(name).append("\"\n"); + place(name); + emit("debugName \"" + name + "\""); + boolean ends = block(); + if (!ends) { + emit("end"); + } + } else if (accept("sub")) { + String name = ident(); + place(name); + accept("("); + accept(")"); + boolean ends = block(); + if (!ends) { + emit("return"); + } + } else if (at("alias")) { + statement(); + } else { + throw err("expected 'strat', 'sub' or 'alias' but found " + describe(peek())); + } + } + } + + /** Parses { statements }; returns whether control never falls out of its end. */ + private boolean block() { + expect("{"); + boolean ends = false; + while (!at("}")) { + if (peek().kind.equals("eof")) { + throw err("missing '}'"); + } + ends = statement(); + } + expect("}"); + return ends; + } + + /** One statement; returns whether control never continues after it (end, endless loop, return). */ + private boolean statement() { + Tok t = peek(); + switch (t.text) { + case "if" -> { + next(); + ifRest(); + return false; + } + case "while" -> { + next(); + String top = label(), done = label(); + place(top); + expect("("); + cond(done); + expect(")"); + loops.push(new String[] {done, top}); + loopBody(top); + loops.pop(); + place(done); + return false; + } + case "loop" -> { + next(); + String top = label(), done = label(); + place(top); + loops.push(new String[] {done, top}); + loopBody(top); + loops.pop(); + place(done); + return false; + } + case "do" -> { + next(); + String top = label(), check = label(), done = label(); + place(top); + loops.push(new String[] {done, check}); + block(); + loops.pop(); + expect("while"); + expect("("); + place(check); + expr(); + expect(")"); + emit("jnz " + top); + place(done); + return false; + } + case "break", "continue" -> { + next(); + if (loops.isEmpty()) { + throw new CompileException(t.line, t.text + " outside a loop"); + } + emit("jmp " + loops.peek()[t.text.equals("break") ? 0 : 1]); + return true; + } + case "yield" -> { + next(); + String l = label(); + emit("jmpYield " + l); + place(l); + return false; + } + case "return" -> { + next(); + emit("return"); + return true; + } + case "end" -> { + next(); + emit("end"); + return true; + } + case "call" -> { + next(); + String l = ident(); + if (accept("(")) { + expect(")"); + } + emit("call " + l); + return false; + } + case "alias" -> { + next(); + String n = ident(); + expect("="); + String v = ident(); + if (!v.matches("v\\d+")) { + throw new CompileException(t.line, "an alias names a variable vN"); + } + aliases.put(n, v); + out.append(" .alias ").append(n).append(" = ").append(v).append('\n'); + return false; + } + default -> { + // fall through to assignments and calls + } + } + if (!t.kind.equals("id")) { + throw err("expected a statement but found " + describe(t)); + } + if (isOp(t.text) && !isLvalueStart()) { + String mn = callExpr(); + OpTable.Op op = ops.byName(mn); + if (op.pushes() == 1) { + emit("pop"); + } + return mn.equalsIgnoreCase("end") || mn.equalsIgnoreCase("remove"); + } + assignment(); + return false; + } + + private void ifRest() { + String other = label(); + expect("("); + cond(other); + expect(")"); + block(); + if (accept("else")) { + String done = label(); + emit("jmp " + done); + place(other); + if (accept("if")) { + ifRest(); + } else { + block(); + } + place(done); + } else { + place(other); + } + } + + /** Condition: jumps to falseTarget when it is 0. */ + private void cond(String falseTarget) { + expr(); + emit("jz " + falseTarget); + } + + /** Loop body; a trailing yield becomes the yielding back edge. */ + private void loopBody(String top) { + expect("{"); + int mark = -1; + while (!at("}")) { + if (peek().kind.equals("eof")) { + throw err("missing '}'"); + } + mark = at("yield") && peek(1).text.equals("}") ? out.length() : -1; + if (mark >= 0) { + next(); + break; + } + statement(); + } + expect("}"); + emit((mark >= 0 ? "jmpYield " : "jmp ") + top); + } + + private boolean isOp(String name) { + return ops.byName(name) != null; + } + + private boolean isLvalueStart() { + String a = peek().text; + return aliases.containsKey(a) + || a.matches("[vg]\\d+") + || TARGETS.containsKey(a) && peek(1).text.equals("."); + } + + // ------------------------------------------------------------------ assignments and lvalues + + /** A writable place: push/pop/address instructions with their operand. */ + private record Place(String push, String pop, String addr) {} + + private Place place() { + Tok t = peek(); + String a = ident(); + if (aliases.containsKey(a)) { + a = aliases.get(a); + } + if (a.matches("v\\d+")) { + return new Place("pushVar " + a, "popVar " + a, "pushVarAddr " + a); + } + if (a.matches("g\\d+")) { + return new Place("pushGlobal " + a, "popGlobal " + a, "pushGlobalAddr " + a); + } + if (TARGETS.containsKey(a) && accept(".")) { + String n = ident(); + if (a.equals("parent") && n.matches("v\\d+")) { + return new Place("pushParentVar " + n, "popParentVar " + n, "pushParentVarAddr " + n); + } + if (ops.propByName(n) == null && !n.matches("prop\\d+")) { + throw new CompileException(t.line, "unknown property " + n); + } + String[] m = TARGETS.get(a); + return new Place(m[0] + " " + n, m[1] + " " + n, m[2] == null ? null : m[2] + " " + n); + } + throw new CompileException( + t.line, + "unknown name " + a + " (variables are vN or aliases, globals gN, " + + "properties self.name, player.name, …)"); + } + + private void assignment() { + Tok t = peek(); + Place pl = place(); + String op = next().text; + switch (op) { + case "=" -> { + expr(); + emit(pl.pop); + } + case "+=", "-=", "*=", "/=" -> { + boolean one = at("1.0") && (peek(1).line != peek().line || at1("}")); + if ((op.equals("+=") || op.equals("-=")) && pl.addr != null && one) { + next(); + emit(pl.addr); + emit(op.equals("+=") ? "inc" : "dec"); + } else { + emit(pl.push); + expr(); + emit(BINOP.get(op.substring(0, 1))); + emit(pl.pop); + } + } + default -> throw new CompileException(t.line, "expected '=', '+=', '-=', '*=' or '/=' after a name"); + } + } + + // ------------------------------------------------------------------ expressions + + private void expr() { + binary(1); + } + + private void binary(int min) { + unary(); + while (peek().kind.equals("op") && PREC.containsKey(peek().text) && PREC.get(peek().text) >= min) { + String op = next().text; + binary(PREC.get(op) + 1); + emit(BINOP.get(op)); + } + } + + private void unary() { + if (accept("-")) { + if (peek().kind.equals("num")) { + number("-" + next().text); + return; + } + unary(); + emit("neg"); + } else if (accept("!")) { + unary(); + emit("not"); + } else { + primary(); + } + } + + private void primary() { + Tok t = peek(); + if (t.kind.equals("num")) { + number(next().text); + } else if (accept("(")) { + expr(); + expect(")"); + } else if (t.kind.equals("id") && REFS.contains(t.text) && peek(1).text.equals(":")) { + next(); + next(); + StringBuilder ref = new StringBuilder(t.text + ":" + next().text); + if (at("+") && peek(1).kind.equals("num") && !t.text.equals("sound") && !t.text.equals("tex")) { + next(); + ref.append('+').append(next().text); + } + switch (t.text) { + case "sound" -> emit("soundAddress " + ref); + case "tex" -> emit("push " + ref); + case "text" -> throw new CompileException(t.line, "text:N is an inline operand: topSay()"); + default -> emit("address " + ref); + } + } else if (t.kind.equals("id") && isOp(t.text) && !isLvalueStart()) { + String mn = callExpr(); + if (ops.byName(mn).pushes() != 1) { + throw new CompileException(t.line, mn + " gives no value"); + } + } else if (t.kind.equals("id")) { + emit(place().push); + } else { + throw err("expected a value but found " + describe(t)); + } + } + + private void number(String text) { + Integer v = Num.parse(text); + if (v == null) { + throw err("bad number " + text); + } + emit((v >= Short.MIN_VALUE && v <= Short.MAX_VALUE ? "pushShort " : "push ") + text); + } + + /** op(args): pushes the arguments and emits the opcode; returns its mnemonic. */ + private String callExpr() { + Tok t = peek(); + String mn = ident(); + OpTable.Op op = ops.byName(mn); + mn = op.name(); + List inline = new ArrayList<>(); + if (accept("<")) { + StringBuilder cur = new StringBuilder(); + while (!at(">")) { + if (peek().kind.equals("eof")) { + throw err("missing '>'"); + } + Tok x = next(); + if (x.text.equals(",")) { + inline.add(cur.toString()); + cur.setLength(0); + } else { + cur.append(x.text); + } + } + expect(">"); + inline.add(cur.toString()); + } + int args = 0; + if (accept("(")) { + while (!at(")")) { + expr(); + args++; + if (!accept(",")) { + break; + } + } + expect(")"); + } + if (op.pops() >= 0 && args != op.pops()) { + throw new CompileException( + t.line, mn + " takes " + op.pops() + " value" + (op.pops() == 1 ? "" : "s") + ", got " + args); + } + emit(mn + (inline.isEmpty() ? "" : " " + String.join(", ", inline))); + return mn; + } +} diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java b/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java index afe2e12..f3b9f8e 100644 --- a/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java @@ -119,7 +119,19 @@ public final class StratPseudo { return b.toString(); } - private record Block(int open, int close, String kind) {} + /** + * A structured block. if: open = the conditional jump, close = its target label; loop/do: open + * = head label, close = the backward jump; switch: open = the switch, close = the end label. + */ + private record Block(int open, int close, String kind) { + boolean breakable() { + return !kind.equals("if"); + } + + boolean isLoop() { + return kind.startsWith("loop"); + } + } private String mn(StratListing.Line l) { String t = l.text(); @@ -133,27 +145,64 @@ public final class StratPseudo { return sp < 0 ? "" : t.substring(sp + 1).trim(); } + private static boolean condJump(int op) { + return op >= 0x12 && op <= 0x15; + } + + private static boolean jump(int op) { + return condJump(op) || op == 0x16 || op == 0x2b; + } + + // structure found by build(), used while emitting + private final Map ifAt = new HashMap<>(), loopEnd = new HashMap<>(), switchAt = new HashMap<>(); + private final Map> opensAt = new HashMap<>(); + private final Map closeCount = new HashMap<>(), elseAt = new HashMap<>(); + private final Map switchDepth = new HashMap<>(); + private final Map> caseAt = new HashMap<>(); + private final Set elseJumps = new HashSet<>(), silentJumps = new HashSet<>(); + private final Map jumpAs = new HashMap<>(); + private Map labelIdx; + + private Integer target(int i) { + return labelIdx.get(operand(ls.get(i))); + } + + /** Index of the first non-label line at or after i (labels at the same address share it). */ + private int anchor(int i) { + while (i < ls.size() && ls.get(i).kind() == StratListing.Kind.LABEL) { + i++; + } + return i; + } + private void build() { - Map labelIdx = new HashMap<>(); + 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); } } + Map> tables = switchTables(); // candidate blocks List cand = new ArrayList<>(); Map elseOf = new HashMap<>(); + List switches = new ArrayList<>(); + Map> switchCases = 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(); + if (op == StratOps.SWITCH) { + switchCandidate(i, tables, switches, switchCases); + continue; + } Integer t = labelIdx.get(operand(l)); if (t == null) { continue; } - if ((op == 0x14 || op == 0x15 || op == 0x12 || op == 0x13) && t > i && clean(i, t)) { + if (condJump(op) && t > i && clean(i, t)) { cand.add(new Block(i, t, "if")); int pj = prevInstr(t); if (pj > i && ls.get(pj).op() == 0x16) { @@ -164,54 +213,85 @@ public final class StratPseudo { } } else if ((op == 0x16 || op == 0x2b) && t <= i && clean(t, i)) { cand.add(new Block(t, i, op == 0x2b ? "loopYield" : "loop")); + } else if (condJump(op) && t <= i && clean(t, i)) { + cand.add(new Block(t, i, "do")); } } 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); - } + boolean withElse = b.kind.equals("if") && elseOf.containsKey(b.open); + if (withElse && !fits(b, true, accepted, elseOf, elseAccepted)) { + withElse = false; } - if (ok && accepted.stream().noneMatch(a -> a.open == b.open)) { + if (fits(b, withElse, accepted, elseOf, elseAccepted) + && accepted.stream().noneMatch(a -> a.open == b.open && a.close == b.close) + && !(b.isLoop() && accepted.stream().anyMatch(a -> a.open == b.open && a.isLoop())) + && (b.kind.equals("if") + || accepted.stream().noneMatch(a -> a.open == b.open && a.kind.equals("if")))) { accepted.add(b); - if (elseOf.containsKey(b.open) && b.kind.equals("if")) { + if (withElse) { 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); + for (Block sw : switches) { + if (!fits(sw, false, accepted, elseOf, elseAccepted)) { + continue; + } + boolean ok = true; + for (int[] c : switchCases.get(sw.open)) { + for (Block a : accepted) { + int ae = end(a, elseOf, elseAccepted); + if (a.open > sw.open && ae <= sw.close && a.open < c[0] && c[0] < ae) { + ok = false; + } } - } else { - loopAt.computeIfAbsent(b.open, k -> b); - loopEnd.put(b.close, b); - consumed.merge(ls.get(b.open).label(), 1, Integer::sum); + } + if (ok) { + accepted.add(sw); } } + Map consumed = new HashMap<>(); + for (Block b : accepted) { + switch (b.kind) { + case "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); + } + } + case "switch" -> { + switchAt.put(b.open, b); + closeCount.merge(b.close, 1, Integer::sum); + silentJumps.add(b.open + 1); + consumed.merge(ls.get(b.close).label(), 1, Integer::sum); + for (int[] c : switchCases.get(b.open)) { + String text = c[1] < 0 + ? "default:" + : "case " + tables.get(operand(ls.get(b.open))).get(c[1])[0] + ":"; + caseAt.computeIfAbsent(c[0], k -> new ArrayList<>()).add(text); + caseOwner.put(c[0], b.open); + consumed.merge(ls.get(c[0]).label(), 1, Integer::sum); + } + } + default -> { + opensAt.computeIfAbsent(b.open, k -> new ArrayList<>()).add(b); + loopEnd.put(b.close, b); + consumed.merge(ls.get(b.open).label(), 1, Integer::sum); + } + } + } + opensAt.values().forEach(l -> l.sort((a, b) -> Integer.compare(b.close, a.close))); + resolveJumps(accepted, consumed); Map refs = new HashMap<>(); for (StratListing.Line l : ls) { if (l.kind() == StratListing.Kind.INSTR || l.kind() == StratListing.Kind.DATA) { @@ -245,6 +325,13 @@ public final class StratPseudo { out(-1, "} else {"); depth++; } + if (caseAt.containsKey(i)) { + depth = switchDepth.get(caseOwner.get(i)); + for (String t : caseAt.get(i)) { + out(-1, t); + } + depth++; + } int r = refs.getOrDefault(l.label(), 0) - consumed.getOrDefault(l.label(), 0); if (src.strats.containsKey(l.offset())) { out(l.offset(), ""); @@ -254,8 +341,8 @@ public final class StratPseudo { || l.label().startsWith("ev_")) { out(l.offset(), l.label() + ":"); } - if (loopAt.containsKey(i)) { - out(-1, "loop {"); + for (Block b : opensAt.getOrDefault(i, List.of())) { + out(-1, b.kind.equals("do") ? "do {" : "loop {"); depth++; } } @@ -263,10 +350,245 @@ public final class StratPseudo { flush(); out(l.offset(), l.text()); } - case INSTR -> instr(i, l, ifAt, loopEnd, elseJumps); + case INSTR -> instr(i, l); } } flush(); + whileLoops(); + elseIfChains(); + } + + private final Map caseOwner = new HashMap<>(); + + private static int end(Block a, Map elseOf, Set elseAccepted) { + return a.kind.equals("if") && elseAccepted.contains(a.open) ? elseOf.get(a.open) : a.close; + } + + private static boolean fits( + Block b, boolean withElse, List accepted, Map elseOf, Set elseAccepted) { + int end = withElse ? elseOf.get(b.open) : b.close; + for (Block a : accepted) { + int ae = end(a, elseOf, elseAccepted); + boolean ok = nests(b.open, end, a.open, ae); + if (ok && withElse) { + 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) { + return false; + } + } + return true; + } + + /** Switch tables of the unit: table label → rows {case value text, target label}. */ + private Map> switchTables() { + Map> out = new HashMap<>(); + for (int i = 0; i + 1 < ls.size(); i++) { + if (ls.get(i).kind() == StratListing.Kind.LABEL + && ls.get(i + 1).kind() == StratListing.Kind.DATA + && ls.get(i + 1).text().startsWith(".switch ")) { + Integer n = Num.parse(ls.get(i + 1).text().substring(8).trim()); + List rows = new ArrayList<>(); + for (int k = 0; n != null && k < n && i + 2 + k < ls.size(); k++) { + String t = ls.get(i + 2 + k).text(); + if (!t.startsWith(".case ")) { + break; + } + t = t.substring(6).trim(); + if (t.startsWith("imm ")) { + t = t.substring(4); + } + int comma = t.lastIndexOf(','); + rows.add(new String[] { + t.substring(0, comma).trim(), t.substring(comma + 1).trim() + }); + } + if (n != null && rows.size() == n) { + out.put(ls.get(i).label(), rows); + } + } + } + return out; + } + + /** switch at s followed by "jmp E": cases (and default) inside (s, E] become a switch block. */ + private void switchCandidate( + int s, Map> tables, List out, Map> cases) { + List rows = tables.get(operand(ls.get(s))); + if (rows == null || s + 1 >= ls.size()) { + return; + } + StratListing.Line j = ls.get(s + 1); + if (j.kind() != StratListing.Kind.INSTR || j.op() != 0x16) { + return; + } + Integer d = target(s + 1); + if (d == null || d <= s + 1) { + return; + } + List cs = new ArrayList<>(); + int e = d; + for (int k = 0; k < rows.size(); k++) { + Integer t = labelIdx.get(rows.get(k)[1]); + if (t == null || t <= s + 1) { + return; + } + cs.add(new int[] {t, k}); + e = Math.max(e, t); + } + // the end: the default target when it lies behind every case, else the first label after + // the last case's code that a "jmp" in a case body targets + int endIdx = d; + if (d < e) { + endIdx = -1; + for (int i = s + 2; i < ls.size() && endIdx < 0; i++) { + if (ls.get(i).kind() == StratListing.Kind.INSTR && ls.get(i).op() == 0x16) { + Integer t = target(i); + if (t != null && t > e) { + endIdx = t; + } + } + } + if (endIdx < 0) { + return; + } + cs.add(new int[] {d, -1}); + } + if (!clean(s, endIdx)) { + return; + } + for (int[] c : cs) { + if (c[0] > endIdx) { + return; + } + } + int stop = endIdx; + cs.removeIf(c -> c[0] == stop); + if (cs.isEmpty()) { + return; + } + cs.sort((a, b) -> Integer.compare(a[0], b[0])); + if (anchor(cs.get(0)[0]) != anchor(s + 2)) { + return; + } + out.add(new Block(s, endIdx, "switch")); + cases.put(s, cs); + } + + /** Remaining jumps to the end (or head) of the innermost enclosing loop/switch become break/continue. */ + private void resolveJumps(List accepted, Map consumed) { + for (int i = 0; i < ls.size(); i++) { + StratListing.Line l = ls.get(i); + if (l.kind() != StratListing.Kind.INSTR + || !jump(l.op()) + || ifAt.containsKey(i) + || loopEnd.containsKey(i) + || elseJumps.contains(i) + || silentJumps.contains(i)) { + continue; + } + Integer t = target(i); + if (t == null) { + continue; + } + Block in = null; + for (Block b : accepted) { + if (b.breakable() + && b.open < i + && i < b.close + && (in == null || b.open >= in.open && b.close <= in.close)) { + in = b; + } + } + if (in == null) { + continue; + } + int endAt = in.kind.equals("switch") ? in.close : in.close + 1; + String as = null; + if (anchor(t) == anchor(endAt)) { + as = "break"; + } else if (in.isLoop() && anchor(t) == anchor(in.open)) { + as = "continue"; + } + if (as != null) { + jumpAs.put(i, as); + consumed.merge(ls.get(t).label(), 1, Integer::sum); + } + } + } + + /** "loop {" directly followed by "if (c) break" becomes "while (!c) {". */ + private void whileLoops() { + for (int i = 0; i + 1 < lines.size(); i++) { + Line a = lines.get(i), b = lines.get(i + 1); + if (a.text.equals("loop {") + && b.depth == a.depth + 1 + && b.text.startsWith("if (") + && b.text.endsWith(") break")) { + String c = b.text.substring(4, b.text.length() - 7); + String w = c.startsWith("!(") && c.endsWith(")") && balanced(c.substring(2, c.length() - 1)) + ? c.substring(2, c.length() - 1) + : c.startsWith("!") && !c.contains(" ") ? c.substring(1) : "!(" + c + ")"; + lines.set(i, new Line(b.offset, a.depth, "while (" + w + ") {")); + lines.remove(i + 1); + } + } + } + + /** "} else {" + a lone "if" up to the else's end becomes "} else if (…) {"; an empty then-block is negated. */ + private void elseIfChains() { + for (int i = 0; i + 1 < lines.size(); i++) { + Line a = lines.get(i), b = lines.get(i + 1); + if (a.text.startsWith("if (") + && a.text.endsWith(") {") + && b.text.equals("} else {") + && b.depth == a.depth) { + String c = a.text.substring(4, a.text.length() - 3); + String n = c.startsWith("!(") && c.endsWith(")") && balanced(c.substring(2, c.length() - 1)) + ? c.substring(2, c.length() - 1) + : "!(" + c + ")"; + lines.set(i, new Line(a.offset, a.depth, "if (" + n + ") {")); + lines.remove(i + 1); + } + } + for (int i = 0; i + 1 < lines.size(); i++) { + Line a = lines.get(i), b = lines.get(i + 1); + if (!a.text.equals("} else {") + || b.depth != a.depth + 1 + || !b.text.startsWith("if (") + || !b.text.endsWith(" {")) { + continue; + } + int d = a.depth; + int close = -1; + boolean ok = true; + for (int k = i + 2; k < lines.size(); k++) { + Line x = lines.get(k); + if (x.depth <= d) { + close = x.depth == d && x.text.equals("}") ? k : -1; + break; + } + if (x.depth == d + 1 && !x.text.startsWith("}")) { + ok = false; + break; + } + } + if (!ok || close < 0 || !lines.get(close - 1).text.equals("}") || lines.get(close - 1).depth != d + 1) { + continue; + } + lines.set(i, new Line(b.offset, d, "} else " + b.text)); + lines.remove(i + 1); + close--; + lines.remove(close - 1); + close--; + for (int k = i + 1; k < close; k++) { + Line x = lines.get(k); + lines.set(k, new Line(x.offset, x.depth - 1, x.text)); + } + } } private static boolean nests(int a0, int a1, int b0, int b1) { @@ -339,8 +661,7 @@ public final class StratPseudo { return d == 0; } - private void instr( - int i, StratListing.Line l, Map ifAt, Map loopEnd, Set elseJumps) { + private void instr(int i, StratListing.Line l) { int op = l.op(); String mn = mn(l); String a = operand(l); @@ -388,18 +709,29 @@ public final class StratPseudo { push(off, "!" + pop()); return; } - boolean cond = op >= 0x12 && op <= 0x15; - if (cond) { + if (condJump(op)) { String e = strip(pop()); boolean neg = op == 0x13 || op == 0x15; String yield = op <= 0x13 ? " /*yield*/" : ""; int b0 = begin(off); flush(); + Block lp = loopEnd.get(i); + String jumpIf = neg ? e : "!(" + e + ")"; if (ifAt.containsKey(i)) { out(b0, "if (" + (neg ? "!(" + e + ")" : e) + ")" + yield + " {"); depth++; + } else if (lp != null) { + depth--; + Line last = lines.isEmpty() ? null : lines.get(lines.size() - 1); + if (last != null && last.text.equals("do {") && last.depth == depth) { + lines.set(lines.size() - 1, new Line(b0, depth, "do {} while (" + jumpIf + ")" + yield)); + } else { + out(b0, "} while (" + jumpIf + ")" + yield); + } + } else if (jumpAs.containsKey(i)) { + out(b0, "if (" + jumpIf + ") " + jumpAs.get(i) + yield); } else { - out(b0, "if (" + (neg ? e : "!(" + strip(e) + ")") + ") goto " + a + yield); + out(b0, "if (" + jumpIf + ") goto " + a + yield); } return; } @@ -412,15 +744,26 @@ public final class StratPseudo { } depth--; out(-1, "}"); - } else if (elseJumps.contains(i)) { + } else if (elseJumps.contains(i) || silentJumps.contains(i)) { if (op == 0x2b) { out(off, "yield"); } + } else if (jumpAs.containsKey(i)) { + out(off, (op == 0x2b ? "yield; " : "") + jumpAs.get(i)); } else { out(off, (op == 0x2b ? "yield; " : "") + "goto " + a); } return; } + if (op == StratOps.SWITCH && switchAt.containsKey(i)) { + String v = strip(pop()); + int b0 = begin(off); + flush(); + switchDepth.put(i, depth); + out(b0, "switch (" + v + ") { // " + a); + depth++; + return; + } if (op == 0x11 || op == 0x10) { flush(); out(off, "call " + a + (op == 0x10 ? " /*yield*/" : "") + "()"); diff --git a/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv b/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv index 793dce1..e7cf0cb 100644 --- a/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv +++ b/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv @@ -37,30 +37,30 @@ 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 +0x26 pushBossProp prop 0 1 Pushes property n of the boss strat DAT_006D9E24 (set by 0xef isBoss) via the getter FUN_00550E60; no null check; unused in the levels. high stBoss_AlienVar +0x27 pushBossPropAddr prop 0 1 Pushes the address of property n of the boss strat DAT_006D9E24 (setter FUN_005509F0 returns the address); no null check; unused. high stBoss_AlienVarAddress +0x28 pushDialogProp prop 0 1 Pushes property n of the strat registered by 0x113 (DAT_006D9E2C: the NPC/puzzle/door that a cut-scene is about), 0 if none. WAD use: Camera and CutSceneCameraDirectional read its x/y/z/rotY to look at it (1585 of 1678 uses). high stDialog_AlienVar +0x29 pushDialogPropAddr prop 0 1 Pushes the address of property n of the DAT_006D9E2C strat (0 if none); unused. high 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 - +0x2f flagEC10000 imm 0 0 arg!=0 sets, 0 clears +0xEC bit 0x10000 = "freeze when off-camera": the camera pass (FUN_00436190) sets +0xEC 0x8000 when the strat is outside the camera range; with both bits set the frame loop (0x54c603) and the strat-vs-strat collision loop (0x54cc1a) skip it. Used by ~1350 strats (pickups, triggers, enemies). medium - 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 - +0x31 flagEC2 imm 0 0 arg sets/clears +0xEC bit 2 = "others can stand on / touch me": in the strat collision loop (0x54cc96) a strat touching a strat with this bit gets +0xE8 0x400000 (and 0x800000 when it moves) = properties 32/33; also readable as property 0x1f. Set by Llama, Coin, Jar, EnemyCoin, RubyIdol. medium - +0x32 flagE8_80000000 imm 0 0 arg sets/clears +0xE8 bit 31; no reader of the bit found in the exe (scan of all +0xE8 flag tests); unused in the levels. medium - +0x33 flagEC100 imm 0 0 arg sets/clears +0xEC bit 0x100; the collision response (0x4044f7) skips the floor/slope handling when it is set. Used by LlamaSpit, DropObject, RollerCoaster, KuscoTurtle (objects that move by script, not by floor physics). medium - +0x34 flagEC200000 imm 0 0 arg sets/clears +0xEC bit 0x200000; no direct test of the bit found in the exe (all +0xEC flag tests scanned). Used by Llama, RiverCroc, RiverLog, Rock, river NPCs (river/canoe level objects). 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 - +0x3b popDialogProp prop 1 0 Pops a value into property n of the DAT_006D9E2C strat (discarded if none); unused. high - 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 - +0x3d popBossProp prop 1 0 Pops a value into property n of the boss strat DAT_006D9E24 (no null check); unused. high - 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 - @@ -134,15 +134,15 @@ 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 +0x87 nop_0x87 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stCollPoints. high stCollPoints +0x88 nop_0x88 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stCollSetPoint. high 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 +0x8f nop_0x8f - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stMapRemove. high 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 @@ -155,8 +155,8 @@ 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 +0x9c speechSetPos - 1 0 Pops a value into global 0x6DA2B8 (written only, never read: no effect) and moves the speech/voice channel (DAT_006D94A0, started by "#" texts) to this strat's position (+0x30..0x38), so the voice plays from the speaker. high - +0x9d discard_0x9d - 1 0 Pops a value (>>12) and passes it to the empty function 0x426500: a pure drop on PC. Croc2 slot midiVolume/cdFade. high 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 @@ -176,7 +176,7 @@ 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 +0xb1 stkCmp imm 0 1 Reads one inline word imm (pc += 4), peeks the top stack entry (no pop), dereferences it as an int pointer and pushes imm − *top. Unused in the levels. high 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 @@ -186,36 +186,36 @@ 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 - +0xbb nop_0xbb - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. high - 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 +0xc1 nop_0xc1 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stResettoCheckPoint. high 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 +0xc4 rumble - 2 0 Shared stub 0x555090: pops two values and discards them (PSX pad rumble is not implemented on PC). high stRumble +0xc5 suspendIfTooFar - 1 0 Shared stub 0x554fb0: pops one value (distance) and discards it – no effect on PC (op 0x2f gives the off-camera freeze instead). high 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 +0xc7 useBone - 1 0 Pops bone n (>>12); if the strat has an animated model (+0x14) it takes bone n of the current animation frame (+0x110; anim+0x1C frame table, 64 bytes per bone, offset +0x30..0x38 = bone position), rotates it by the strat's rotation matrix and adds the position: world point in +0xD8/+0xDC/+0xE0 = properties boneX/Y/Z. (The Ghidra C output drops the bone index; read from the assembly.) Used to spawn effects at hands/mouths. high 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 +0xcd nop_0xcd - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stResetCamerapos. high 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 +0xcf nop_0xcf - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stGainHeartPot. high 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 +0xd1 nop_0xd1 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stGainCrystal. high stGainCrystal +0xd2 discard_0xd2 - 1 0 Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 slot stCutscene). high 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 +0xd8 nop_0xd8 - 0 0 Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stOnground/stObjectFallslow. high 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 @@ -241,7 +241,7 @@ 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 - +0xf2 setDrawMode - 1 0 Pops mode (raw int): 1 sets +0xEC 0x80000, 2 sets 0x100000, anything else clears both; no reader of these two bits found in the exe; unused in the levels. 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 @@ -262,7 +262,7 @@ 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? +0x107 setAnimSpeed - 1 0 Pops the animation speed (20.12, 1.0 = one animation frame per game frame) into halfword +0x114; while animating (+0xE8 bit 8) the frame loop adds it to the sub-frame counter +0x116 and advances the frame (+0x110) each time it passes 1.0 (0x54c630). WAD use: 1.0, 0.25, 1.5 and computed values. high 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 - @@ -329,7 +329,7 @@ 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 - +0x14a hudReset2 - 0 0 Calls FUN_004268c0(2), which returns at once for any argument other than 0 (verified in the assembly): a no-op. Unused. high - 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 - @@ -347,7 +347,7 @@ 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 - +0x15c setShadowHeight - 1 0 Pops v into +0x14C, the water/floor height limit used by the shadow pass (FUN_00555140: -0x1000 = use the camera-cell floor height DAT_00586424, prop camFloorHeight); the shadow is skipped when the floor under the strat lies below it. One use (SinkingStatuePad). medium - 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 - @@ -393,8 +393,8 @@ 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 - +0x18a emitterSetParams - 5 0 Pops e (top), d, c, b, slot (>>12): pending particle settings of this strat (slot 0–2 → record index at strat+0x150+slot, ≥3 → +0x153, record allocated on demand in the 0x34-byte table 0x5FE23C): b = size scale (1.0 = default), c>>12 = extra lifetime in frames, e≠0: +0x246 = e (drift), +0x23C = d; e==0: +0x25C = (d>>12)*32-32 (sprite size). spawnParticle consumes and resets the +0x153 record; same as spawnParticle type 0x19. medium - +0x18b emitterSetColour - 5 0 Pops colour (top, raw packed, see 0x15d), b, g, r, slot: pending particle colour for the next particle(s) of this strat (record as 0x18a; same as spawnParticle type 0x1e): +0x250 = colour; r without fraction = alpha r>>12 (7 bits), else r,g,b>>12 = per-channel fade, +0x243 = 0x80; alpha 0 = 0xFF/lifetime. medium - 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 - @@ -424,7 +424,7 @@ 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 - +0x1a9 setRain - 4 0 Weather: pops texture (top, raw tex index → 0x6D9BE8, read by the rain renderer), windZ (>>12 → 0x578EF4), c (>>12 → 0x6D9BF4, only used for the off test), windX (>>12 → 0x578EF0); each raindrop restarts with velocity (windX, -256, windZ) per frame. windX, c, windZ all 0 = rain off (drops cleared, 0x6D9BE4 = 0), else on. Rain type: setRain(v0, v1, v2, tex:768) with params 16.0, 0, 16.0. medium - 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 - 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 index 982e5ad..d9e4c8c 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratEditsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratEditsTest.java @@ -106,6 +106,86 @@ class StratEditsTest { assertEquals(wad2.length, WadBuilder.build(r.scene()).length); } + static int entry(Project p, String type) { + return p.scene().catalog().type(type).variant(0).code(); + } + + static StratEdits.CustomType withSource(StratEdits.CustomType t, String src) { + return new StratEdits.CustomType(t.name(), t.basedOn(), t.entryLabel(), src, t.proto()); + } + + /** Edit and delete custom types: codes of placed objects follow, undo/redo, save/reload. */ + @Test + void editAndDeleteCustomTypes() throws Exception { + Bases cd = cd(); + StratEdits.init(); + Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "types", "t1l1m001"), cd), cd); + History h = new History(p.scene()); + StratEdits.CustomType a = StratEdits.cloneOf(p.scene(), "Coin", "CoinA"); + h.execute(TypeEdits.create(p, a)); + StratEdits.CustomType b = StratEdits.cloneOf(p.scene(), "Coin", "CoinB"); + h.execute(TypeEdits.create(p, b)); + h.execute(Edits.placeStrat(p.scene(), "CoinA", 0, 30, 2, 30, 0.0)); + h.execute(Edits.placeStrat(p.scene(), "CoinB", 0, 31, 2, 30, 0.0)); + h.execute(Edits.placeStrat(p.scene(), "CoinB", 0, 32, 2, 30, 0.0)); + int n = p.scene().strats().size(); + List placed = List.copyOf(p.scene().strats().subList(n - 3, n)); + int b0 = entry(p, "CoinB"); + assertEquals( + List.of(1, 2), + List.of( + StratEdits.users(p.scene(), 0).size(), + StratEdits.users(p.scene(), 1).size())); + + // edit A: three more words, faster spin → B's entry moves by 12 bytes + String src = a.source() + .replace("push 5.625deg", "push 90deg") + .replace("\nCoinA:\n", "\nCoinA:\n nop_0x00 0\n nop_0x00 0\n nop_0x00 0\n"); + assertTrue(src.contains("nop_0x00"), src); + assertTrue(StratEdits.checkTypes(p.scene(), List.of(withSource(a, src), b), 0, null) == null); + h.execute(TypeEdits.edit(p, 0, withSource(a, src))); + assertEquals(b0 + 12, entry(p, "CoinB")); + assertEquals(entry(p, "CoinB"), placed.get(1).code()); + assertEquals(entry(p, "CoinB"), placed.get(2).code()); + assertEquals("CoinB", placed.get(2).type()); + assertEquals(entry(p, "CoinA"), placed.get(0).code()); + assertEquals(0x400000, spin(p, "CoinA").value()); + assertTrue(StratEdits.records(p).types().get(0).source().contains("nop_0x00")); + assertTrue(StratEdits.analysis(p.scene()).code().errors.isEmpty()); + h.undo(); + assertEquals(b0, entry(p, "CoinB")); + assertEquals(b0, placed.get(1).code()); + assertEquals(0x40000, spin(p, "CoinA").value()); + h.redo(); + assertEquals(b0 + 12, placed.get(2).code()); + + // delete A: its object goes, B moves to A's place + int a0 = entry(p, "CoinA"); + byte[] before = WadBuilder.build(p.scene()); + h.execute(TypeEdits.delete(p, 0)); + assertTrue(p.scene().catalog().type("CoinA") == null); + assertTrue(!p.scene().strats().contains(placed.get(0))); + assertEquals(1, StratEdits.records(p).types().size()); + assertEquals(entry(p, "CoinB"), placed.get(1).code()); + assertTrue(entry(p, "CoinB") < b0, "CoinB moved down to the start of the custom block"); + assertEquals("CoinB", nameAt(WadBuilder.build(p.scene()), placed.get(1).code())); + Project q = ProjectIO.read(ProjectIO.write(p, cd), cd); + assertTrue(q.warnings().isEmpty(), q.warnings().toString()); + assertArrayEquals( + WadBuilder.build(p.scene()), WadBuilder.build(q.scene()), "reloaded project builds the same WAD"); + h.undo(); + assertTrue(p.scene().strats().contains(placed.get(0))); + assertEquals(a0, placed.get(0).code()); + assertEquals("CoinA", placed.get(0).type()); + assertArrayEquals(before, WadBuilder.build(p.scene()), "undo restores the level"); + // undo the creation of both types: no custom code left + while (h.canUndo()) { + h.undo(); + } + assertTrue(StratEdits.records(p).types().isEmpty()); + assertEquals(-1, StratEdits.firstCustomUnit(p.scene())); + } + @Test void templateAssemblesAndVerifies() throws Exception { Bases cd = cd(); diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsGameLevel.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsGameLevel.java new file mode 100644 index 0000000..7fd2a5d --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsGameLevel.java @@ -0,0 +1,122 @@ +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.Strat; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.model.cmd.History; +import com.eliasmueller.llamamod.format.strat.StratCompiler; +import java.nio.file.Path; +import java.util.List; + +/** + * Builds the in-game test level for custom types and compiled scripts (not a unit test): + * {@code java -cp all.jar:build/classes/java/test ...StratsGameLevel OUT.llamalevel [rain]}. Village 1 + * with, in front of the start: BigCoin (a Coin clone, edited afterwards to scale 3), Bobber (script: + * bobs up and down), Pulser (script: grows and shrinks), and a deleted type (its object is gone). + */ +public final class StratsGameLevel { + static final String BOBBER = + """ + // bobs one cell up and down and turns + strat Bobber { + alias base = v0 + alias phase = v1 + setModel(model:5) + base = self.y + 1.0 + phase = 0 + loop { + phase = phase + 4deg + self.y = base + sin(phase) + turnLeft(4deg) + yield + } + } + """; + + static final String PULSER = + """ + // grows to 4x, shrinks back to 1x, forever + strat Pulser { + alias size = v0 + setModel(model:5) + self.y = self.y + 1.0 + size = 1.0 + loop { + while (size < 4.0) { + size = size + 0.05 + scale(size) + yield + } + do { + size = size - 0.05 + scale(size) + yield + } while (size > 1.0) + } + } + """; + + /** Rain with strong wind in x from the start (setRain: windX, unused, windZ, texture). */ + static final String STORM = + """ + strat Storm { + setRain(200.0, 0, 0, tex:768) + loop { + yield + } + } + """; + + public static void main(String[] a) throws Exception { + Bases cd = Bases.fromCd(Path.of(System.getProperty("user.home") + "/.wine-lama/iso-dk/Setup/Eng/Cd")); + StratEdits.init(); + if (a.length > 1 && a[1].equals("rain")) { + Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "edit", "t1l2m001"), cd), cd); + History h = new History(p.scene()); + StratEdits.CustomType coin = StratEdits.cloneOf(p.scene(), "Coin", "X"); + h.execute(TypeEdits.create( + p, new StratEdits.CustomType("Storm", null, "Storm", StratCompiler.compile(STORM), coin.proto()))); + Strat start = p.scene().playerStart(); + h.execute(Edits.placeStrat( + p.scene(), "Storm", 0, start.x() / 4096.0, start.y() / 4096.0, start.z() / 4096.0, 0.0)); + ProjectIO.save(p, Path.of(a[0]), cd); + System.out.println("saved " + a[0]); + return; + } + Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "edit", "t1l1m001"), cd), cd); + History h = new History(p.scene()); + StratEdits.CustomType big = StratEdits.cloneOf(p.scene(), "Coin", "BigCoin"); + h.execute(TypeEdits.create(p, big)); + StratEdits.CustomType temp = StratEdits.cloneOf(p.scene(), "Coin", "TempCoin"); + h.execute(TypeEdits.create(p, temp)); + for (String[] s : List.of(new String[] {"Bobber", BOBBER}, new String[] {"Pulser", PULSER})) { + String asm = StratCompiler.compile(s[1]); + h.execute(TypeEdits.create(p, new StratEdits.CustomType(s[0], null, s[0], asm, big.proto()))); + } + Strat start = p.scene().playerStart(); + double sx = start.x() / 4096.0, sy = start.y() / 4096.0, sz = start.z() / 4096.0; + double yaw = start.ry() * 2 * Math.PI / 4096.0; + double fx = Math.sin(yaw), fz = Math.cos(yaw), rx = Math.cos(yaw), rz = -Math.sin(yaw); + // a row between the start view and the VillagerHerder (34.5, 7.25, 66.1), 1 cell apart + String[] types = {"Coin", "BigCoin", "Bobber", "Pulser", "TempCoin"}; + for (int i = 0; i < types.length; i++) { + h.execute(Edits.placeStrat(p.scene(), types[i], 0, 31.5 + i, 8.0, 64.6, 0.0)); + } + // edit BigCoin after placing it: three times the size (the object follows the new code) + String src = + big.source().replace(" setModel\n", " setModel\n pushShort 3.0\n scale\n"); + if (src.equals(big.source())) { + throw new IllegalStateException("no setModel line in the Coin source"); + } + h.execute( + TypeEdits.edit(p, 0, new StratEdits.CustomType("BigCoin", "Coin", big.entryLabel(), src, big.proto()))); + // delete TempCoin together with its object + h.execute(TypeEdits.delete(p, 1)); + System.out.println("types: " + StratEdits.customNames(p)); + ProjectIO.save(p, Path.of(a[0]), cd); + System.out.println("saved " + a[0] + " (" + p.scene().strats().size() + " objects, start " + sx + "," + sy + "," + + sz + ")"); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsTour.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsTour.java new file mode 100644 index 0000000..86c7bb0 --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsTour.java @@ -0,0 +1,119 @@ +package com.eliasmueller.llamamod.editor.tools.strats; + +import com.eliasmueller.llamamod.editor.model.Strat; +import com.eliasmueller.llamamod.editor.model.cmd.Edits; +import com.eliasmueller.llamamod.editor.ui.EditorApp; +import com.eliasmueller.llamamod.editor.ui.EditorEnv; +import com.eliasmueller.llamamod.editor.ui.EditorWindow; +import com.eliasmueller.llamamod.format.strat.StratCompiler; +import java.awt.Rectangle; +import java.awt.Robot; +import java.awt.Window; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Callable; +import java.util.concurrent.atomic.AtomicReference; +import javax.imageio.ImageIO; +import javax.swing.JDialog; +import javax.swing.SwingUtilities; + +/** + * Screenshots of custom-type editing and the script view (not a unit test): + * {@code java -cp all.jar:build/classes/java/test -Dllamamod.data=DIR ...StratsTour OUTDIR}. + */ +public final class StratsTour { + static EditorWindow w; + static Path out; + + public static void main(String[] args) throws Exception { + out = Path.of(args[0]); + Files.createDirectories(out); + EditorEnv env = EditorEnv.resolve(); + edt(() -> { + EditorApp.applyTheme("light"); + w = EditorApp.open(env, "t1l1m001"); + w.setBounds(0, 0, 1600, 1000); + return null; + }); + sleep(4000); + for (int i = 0; i < 300 && edt(() -> w.busy() || w.scene() == null); i++) { + sleep(200); + } + sleep(2000); + edt(() -> { + var p = w.document().project(); + StratEdits.CustomType big = StratEdits.cloneOf(w.scene(), "Coin", "BigCoin"); + w.execute(TypeEdits.create(p, big)); + String asm = StratCompiler.compile(StratsGameLevel.PULSER); + w.execute(TypeEdits.create(p, new StratEdits.CustomType("Pulser", null, "Pulser", asm, big.proto()))); + Strat st = w.scene().playerStart(); + double x = st.x() / 4096.0, y = st.y() / 4096.0, z = st.z() / 4096.0; + w.execute(Edits.placeStrat(w.scene(), "Pulser", 0, x + 1, y, z + 2, 0.0)); + w.execute(Edits.placeStrat(w.scene(), "Pulser", 0, x - 1, y, z + 2, 0.0)); + w.execute(Edits.placeStrat(w.scene(), "BigCoin", 0, x, y, z + 3, 0.0)); + Strat last = w.scene().strats().get(w.scene().strats().size() - 2); + w.selection().set(last, null); + w.showPanel(StratsEditorPlugin.PANEL); + return null; + }); + sleep(2500); + shot("strats_custom_panel"); + SwingUtilities.invokeLater(() -> TypeDialog.showEdit(w, 1)); + sleep(2500); + shot("strats_edit_script"); + closeDialogs(); + SwingUtilities.invokeLater(() -> TypeDialog.delete(w, 1)); + sleep(2000); + shot("strats_delete_warning"); + closeDialogs(); + edt(() -> { + w.selection().set(w.scene().playerStart(), null); + return null; + }); + sleep(2500); + shot("strats_view_llama"); + System.out.println("tour done"); + System.exit(0); + } + + static void closeDialogs() throws Exception { + edt(() -> { + for (Window x : Window.getWindows()) { + if (x instanceof JDialog d && d.isShowing()) { + d.dispose(); + } + } + return null; + }); + sleep(800); + } + + static void shot(String name) throws Exception { + Rectangle r = new Rectangle(0, 0, 1600, 1000); + ImageIO.write( + new Robot().createScreenCapture(r), + "png", + out.resolve(name + ".png").toFile()); + System.out.println("shot " + name); + } + + static void sleep(long ms) throws InterruptedException { + Thread.sleep(ms); + } + + static T edt(Callable c) throws Exception { + AtomicReference r = new AtomicReference<>(); + AtomicReference e = new AtomicReference<>(); + SwingUtilities.invokeAndWait(() -> { + try { + r.set(c.call()); + } catch (Exception x) { + e.set(x); + } + }); + if (e.get() != null) { + throw e.get(); + } + return r.get(); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/format/strat/StratCompilerTest.java b/src/test/java/com/eliasmueller/llamamod/format/strat/StratCompilerTest.java new file mode 100644 index 0000000..425099b --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/format/strat/StratCompilerTest.java @@ -0,0 +1,86 @@ +package com.eliasmueller.llamamod.format.strat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +/** Script language → assembly → words → script view again. */ +class StratCompilerTest { + static final String SPINNER = + """ + // spins up, bobs, then waits for the player + strat Spinner { + alias count = v0 + count = 0 + while (count < 10.0) { + turnLeft(4deg) + count += 1.0 + yield + } + loop { + if (self.y > 2.0 && !self.animPlaying) { + self.y = self.y - 0.01 + } else if (self.distToPlayerSqXZ < 4.0) { + break + } else { + call wobble + } + yield + } + do { + count -= 1.0 + } while (count > 0) + } + + sub wobble { + self.rotZ = sin(self.rotY) * 0.1 + } + """; + + @Test + void compilesStructuredCode() { + String asm = StratCompiler.compile(SPINNER); + assertEquals(SPINNER.stripTrailing() + "\n", StratCompiler.embeddedSource(asm)); + assertEquals("Spinner", StratCompiler.firstStrat(SPINNER)); + StratListing li = StratPseudoTest.listing(asm, "Spinner"); + String view = StratPseudo.of(li).text(); + assertFalse(view.contains("goto"), view); + assertTrue(view.contains("strat Spinner:"), view); + assertTrue(view.contains("while (v0 < 10.0) {\n turnLeft(4deg)\n v0 += 1.0\n yield\n}"), view); + assertTrue(view.contains("if ((self.y > 2.0) && !self.animPlaying) {"), view); + assertTrue( + view.contains( + "} else if (self.distToPlayerSqXZ < 4.0) {\n break\n } else {\n call L_00b4()"), + view); + assertTrue(view.contains("do {\n v0 -= 1.0\n} while (v0 > 0)"), view); + assertTrue(view.contains("self.rotZ = sin(self.rotY) * 0.1\nreturn"), view); + } + + @Test + void numbersMatchTheAssembly() { + String asm = StratCompiler.compile("strat N { v0 = 1.5\n v1 = 100.0\n v2 = 90deg\n v3 = -3 }"); + assertTrue(asm.contains("pushShort 1.5\n"), asm); + assertTrue(asm.contains("push 100.0\n"), asm); + assertTrue(asm.contains("push 90deg\n"), asm); + assertTrue(asm.contains("pushShort -3\n"), asm); + assertTrue(asm.trim().endsWith("end"), asm); + } + + @Test + void reportsErrorsWithLines() { + StratCompiler.CompileException e = assertThrows( + StratCompiler.CompileException.class, () -> StratCompiler.compile("strat A {\n v0 = \n}")); + assertEquals(3, e.line); + e = assertThrows( + StratCompiler.CompileException.class, () -> StratCompiler.compile("strat A {\n turnLeft(1, 2)\n}")); + assertTrue(e.getMessage().contains("turnLeft takes 1 value"), e.getMessage()); + e = assertThrows(StratCompiler.CompileException.class, () -> StratCompiler.compile("strat A {\n break\n}")); + assertEquals(2, e.line); + e = assertThrows( + StratCompiler.CompileException.class, () -> StratCompiler.compile("strat A {\n self.nope = 1\n}")); + assertTrue(e.getMessage().contains("unknown property nope"), e.getMessage()); + } +} diff --git a/src/test/java/com/eliasmueller/llamamod/format/strat/StratPseudoTest.java b/src/test/java/com/eliasmueller/llamamod/format/strat/StratPseudoTest.java new file mode 100644 index 0000000..eedebdd --- /dev/null +++ b/src/test/java/com/eliasmueller/llamamod/format/strat/StratPseudoTest.java @@ -0,0 +1,175 @@ +package com.eliasmueller.llamamod.format.strat; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.eliasmueller.llamamod.format.stpcmerge.StpcImage; +import com.eliasmueller.llamamod.format.stpcmerge.StratCode; +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +/** Structuring of the script view: loops, do-while, while, break/continue, switch, else-if. */ +class StratPseudoTest { + /** A one-unit STPC (no models/animations) holding the assembled source. */ + static StratListing listing(String src, String entry) { + StratAssembler.Result r = + StratAssembler.assemble(src, new StratAssembler.Layout(16, List.of(), List.of(), List.of())); + int[] w = r.words(); + // header: 0 models, 0 animation sets, 1 unit + byte[] stpc = new byte[16 + 4 * w.length]; + put(stpc, 0, 0); + put(stpc, 4, 0); + put(stpc, 8, 1); + put(stpc, 12, w.length); + for (int i = 0; i < w.length; i++) { + put(stpc, 16 + 4 * i, w[i]); + } + StpcImage img = new StpcImage(stpc, 0); + Set roots = Set.of(16 + r.labels().get(entry)); + return StratListing.of(img, StratCode.analyse(img, roots), 0, roots, StratListing.Names.NONE); + } + + static void put(byte[] d, int o, int v) { + d[o] = (byte) v; + d[o + 1] = (byte) (v >> 8); + d[o + 2] = (byte) (v >> 16); + d[o + 3] = (byte) (v >> 24); + } + + static final String SRC = + """ + Main: + push 0 + popVar v0 + top: + pushVar v0 ; while (v0 < 10) + push 10.0 + lt + jz done + pushVar v0 + push 5.0 + eq + jz notFive + jmp done ; break + notFive: + pushVar v1 + jz skip + jmp top ; continue + skip: + pushVarAddr v0 + inc + jmpYield top + done: + again: + pushVar v2 ; do … while (v2) + push 1.0 + sub + popVar v2 + pushVar v2 + jnz again + pushVar v3 + switch tab + jmp end + one: + push 1.0 + popVar v4 + jmp end + two: + push 2.0 + popVar v4 + end: + pushVar v5 + jz e1 + push 1.0 + popVar v6 + jmp e3 + e1: + pushVar v7 + jz e2 + push 2.0 + popVar v6 + jmp e3 + e2: + push 3.0 + popVar v6 + e3: + end + tab: + .switch 2 + .case imm 1.0, one + .case imm 2.0, two + """; + + @Test + void structuresLoopsSwitchAndElseIf() { + StratListing li = listing(SRC, "Main"); + String t = StratPseudo.of(li).text(); + assertFalse(t.contains("goto"), t); + assertTrue(t.contains("while (v0 < 10.0) {"), t); + assertTrue(t.contains("if (v0 == 5.0) {\n break\n }"), t); + assertTrue(t.contains("if (v1) {\n continue\n }"), t); + assertTrue(t.contains("do {\n v2 = v2 - 1.0\n} while (v2)"), t); + assertTrue( + t.contains("switch (v3) { // sw_00d4\ncase 1.0:\n v4 = 1.0\n break\ncase 2.0:\n v4 = 2.0\n}"), + t); + assertTrue(t.contains("if (v5) {\n v6 = 1.0\n} else if (v7) {\n v6 = 2.0\n} else {\n v6 = 3.0\n}"), t); + // the assembly still round-trips + StratAssembler.Result back = StratAssembler.assemble(li.text(), StratListing.layout(li.img, li.start)); + assertEquals(List.of(toList(li.words())), List.of(toList(back.words()))); + } + + static List toList(int[] a) { + return java.util.Arrays.stream(a).boxed().toList(); + } + + /** Over all units of the CD: balanced blocks, every remaining goto has its label, few gotos left. */ + @Test + void everyUnitIsWellFormed() throws IOException { + int gotos = 0, units = 0; + Pattern g = Pattern.compile("goto (\\w+)"); + for (Path p : StratRoundTripTest.levels()) { + StratRoundTripTest.Loaded l = StratRoundTripTest.load(p); + if (l == null) { + continue; + } + for (int u = 0; u < l.img().units.size(); u++) { + StratListing li = StratListing.of(l.img(), l.code(), u, l.entries(), StratListing.Names.NONE); + StratPseudo ps = StratPseudo.of(li); + units++; + int open = 0; + Set labels = new HashSet<>(); + for (StratPseudo.Line x : ps.lines) { + String s = x.text().replaceAll("\\s*//.*$", ""); + if (s.startsWith("}")) { + open--; + } + assertTrue(open >= 0, p + " unit " + u + ": unbalanced at " + s); + if (s.endsWith("{")) { + open++; + } + if (s.matches("\\w+:")) { + labels.add(s.substring(0, s.length() - 1)); + } + } + assertEquals(0, open, p + " unit " + u + ": unclosed block"); + for (StratPseudo.Line x : ps.lines) { + Matcher m = g.matcher(x.text()); + if (m.find()) { + gotos++; + assertTrue(labels.contains(m.group(1)), p + " unit " + u + ": goto without label " + x.text()); + } + } + } + } + System.out.printf("script view: %d units, %d gotos%n", units, gotos); + assertTrue(units >= 1400); + assertTrue(gotos < 500, "gotos: " + gotos); + } +}