strats: custom object types can be edited and deleted with undo (code rebuilt, objects follow their type), low-confidence opcodes re-checked (353 high, 2 low left), script view with do-while, while, break, continue, switch and else-if (gotos 11019 -> 400), first script compiler (structured script -> assembly, script stored with the type)

This commit is contained in:
MineTec committed 2026-11-07 12:17:32 +01:00
1 parent b35792a4ed
commit 8b78958306
21 files changed
+2332 -154

No files matched your search

+50 -4
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

+85 -8
View File
@@ -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<inline operands>(stack arguments)
base = self.y + 1.0
phase = 0
loop {
phase = phase + 4deg
self.y = base + sin(phase)
turnLeft(4deg)
yield // last statement of a loop → the loop's jmpYield back edge
}
}
sub wobble { // called with "call wobble", ends with return
self.rotZ = sin(self.rotY) * 0.1
}
```
| Construct | Compiles to |
|---|---|
| `strat Name { … }` | `.string "Name"`, label `Name:`, `debugName "Name"`, body, `end` unless the body cannot fall through |
| `sub name { … }` / `call name` / `return` | label, body, `return` / `call name` / `return` |
| `x = e`, `x += e` (`-=`, `*=`, `/=`) | e, pop op of x; `x += 1.0` / `x -= 1.0` → address + `inc`/`dec` |
| places | `vN`, alias, `gN`, `self.prop`, `player.prop`, `parent.prop`, `parent.vN`, `camera.`/`target.`/`target2.`/`boss.`/`dialog.prop` (props.tsv names or `propN`) |
| literals | as in the assembly: `10.0` = ten, `10` = 10 raw units (1/4096), `90deg`, `0x10`; 16-bit values use `pushShort`, others `push` |
| references | `model:N`, `anim:N`, `unit:N` (`address`), `sound:N` (`soundAddress`), `tex:N` (`push`) |
| operators (low → high) | `||` `|` (or) · `&&` (and) · `&` · `==` `!=` · `<` `>` `<=` `>=` · `<<` `>>` · `+` `-` · `*` `/` · unary `-` `!` |
| `op<a, b>(e1, e2)` | e1, e2, `op a, b`; the argument count must match the opcode's pops; a value-returning op used as a statement is followed by `pop` |
| `if (c) {…} else if … else {…}` | `c; jz else; …; jmp end; else: …` |
| `while (c) {…}` / `loop {…}` | head label, `c; jz end`, body, `jmp`/`jmpYield head` |
| `do {…} while (c)` | body, `c; jnz head` |
| `break` / `continue` / `yield` / `end` | `jmp end` / `jmp head` (do: the condition) / `jmpYield next` / `end` |
The generated assembly starts with the script as `;| ` comment lines, so the editor can show the
script again (*Edit type…*). Comments: `//`, `/* */`; `;` and newlines separate statements.
**Next steps (not implemented)**: event handlers (`on touched { … }` → `.event` table + `onEvent`),
`switch`, `spawn` with typed arguments, local names for parameters from the object schema, `for`,
constant folding, typed numbers (int vs fixed) instead of the assembly's literal rules,
short-circuit `&&`/`||` (now: both sides are evaluated, like the original `and`/`or` opcodes).
## Editing in the level editor (`editor.tools.strats`)
* **Script panel** (bottom dock, View → Panels → Script, Ctrl+Shift+K): unit chooser (all code units
@@ -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).
+47 -36
View File
@@ -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. <sub>54d220</sub> |
| 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. <sub>552bd0</sub> |
| 0x25 | `pushTarget2PropAddr` | prop | 0 | 1 | | medium | stTarget_AlienVarAddress | Pushes address of target2 property n (0 if none); unused. <sub>552c00</sub> |
| 0x26 | `pushBossProp` | prop | 0 | 1 | | low | stBoss_AlienVar | Pushes property n of the strat in DAT_006D9E24 (set by 0xef), no null check; unused. <sub>552b10</sub> |
| 0x27 | `pushBossPropAddr` | prop | 0 | 1 | | low | stBoss_AlienVarAddress | Pushes address of property n of the DAT_006D9E24 strat; unused. <sub>552b30</sub> |
| 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. <sub>552cb0</sub> |
| 0x29 | `pushDialogPropAddr` | prop | 0 | 1 | | low | stDialog_AlienVarAddress | Pushes address of property n of the DAT_006D9E2C strat (0 if none); unused. <sub>552ce0</sub> |
| 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. <sub>552b10</sub> |
| 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. <sub>552b30</sub> |
| 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). <sub>552cb0</sub> |
| 0x29 | `pushDialogPropAddr` | prop | 0 | 1 | | high | stDialog_AlienVarAddress | Pushes the address of property n of the DAT_006D9E2C strat (0 if none); unused. <sub>552ce0</sub> |
| 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). <sub>54e8e0</sub> |
| 0x2b | `jmpYield` | rel | 0 | 0 | 2958 | high | - | Jumps to next+arg*4 and yields this frame (loop back-edge / wait one frame). <sub>553da0</sub> |
| 0x2c | `nop_0x2c` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. <sub>426500</sub> |
| 0x2d | `nop_0x2d` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. <sub>426500</sub> |
| 0x2e | `nop_0x2e` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. <sub>426500</sub> |
| 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. <sub>54f720</sub> |
| 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). <sub>54f720</sub> |
| 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). <sub>54fa80</sub> |
| 0x31 | `flagEC2` | imm | 0 | 0 | 410 | low | - | arg sets/clears +0xEC bit 2 (checked by collision/floor code via +0xC0, readable as property 0x1f). <sub>54e090</sub> |
| 0x32 | `flagE8_80000000` | imm | 0 | 0 | | low | - | arg sets/clears +0xE8 bit 31; unused. <sub>54dd90</sub> |
| 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). <sub>54dd30</sub> |
| 0x34 | `flagEC200000` | imm | 0 | 0 | 50 | low | - | arg sets/clears +0xEC bit 0x200000 (used by NPC helpers/canoe/llama). <sub>54dd60</sub> |
| 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. <sub>54e090</sub> |
| 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. <sub>54dd90</sub> |
| 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). <sub>54dd30</sub> |
| 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). <sub>54dd60</sub> |
| 0x35 | `nop_0x35` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. <sub>426500</sub> |
| 0x36 | `nop_0x36` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. <sub>426500</sub> |
| 0x37 | `popPlayerProp` | playerProp | 1 | 0 | 1131 | high | - | Pops value and stores it into player property n (discarded if no player). <sub>553920</sub> |
| 0x38 | `popCameraProp` | prop | 1 | 0 | 564 | high | - | Pops value into camera strat property n (discarded if none). <sub>553950</sub> |
| 0x39 | `popTargetProp` | prop | 1 | 0 | 186 | medium | - | Pops value into camera target (DAT_006D9E30) property n (discarded if none). <sub>553980</sub> |
| 0x3a | `popTarget2Prop` | prop | 1 | 0 | | medium | - | Pops value into DAT_006D9E34 strat property n (discarded if none); unused. <sub>5539b0</sub> |
| 0x3b | `popDialogProp` | prop | 1 | 0 | | low | - | Pops value into DAT_006D9E2C strat property n (discarded if none); unused. <sub>5539e0</sub> |
| 0x3b | `popDialogProp` | prop | 1 | 0 | | high | - | Pops a value into property n of the DAT_006D9E2C strat (discarded if none); unused. <sub>5539e0</sub> |
| 0x3c | `error_0x3c` | - | 0 | 0 | | high | - | Prints "stCollide_AlienVar_Equals" not-implemented error; unused. <sub>54d240</sub> |
| 0x3d | `popBossProp` | prop | 1 | 0 | | low | - | Pops value into DAT_006D9E24 strat property n (no null check); unused. <sub>553a10</sub> |
| 0x3d | `popBossProp` | prop | 1 | 0 | | high | - | Pops a value into property n of the boss strat DAT_006D9E24 (no null check); unused. <sub>553a10</sub> |
| 0x3e | `popParentVar` | var | 1 | 0 | 204 | high | - | Pops value into variable n of the +0xC strat. <sub>553a40</sub> |
| 0x3f | `popParentProp` | prop | 1 | 0 | | high | - | Pops value into property n of the +0xC strat; unused. <sub>553a60</sub> |
| 0x40 | `popProp` | prop | 1 | 0 | 15730 | high | - | Pops value into own property n (via FUN_005509F0 address). <sub>553a90</sub> |
@@ -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. <sub>54d300</sub> |
| 0x85 | `collExtent` | - | 1 | 0 | | medium | stCollextent | pops extent; stores its low 16 bits raw into short +0xC4. <sub>54e930</sub> |
| 0x86 | `collView` | - | 0 | 0 | | high | stCollView | Unimplemented: logs "stCollview" only. <sub>54d320</sub> |
| 0x87 | `nop_0x87` | - | 0 | 0 | | low | stCollPoints | Shared no-op handler (0x426500); unused. Slot of Croc2 stCollPoints. <sub>426500</sub> |
| 0x88 | `nop_0x88` | - | 0 | 0 | | low | stCollSetPoint | Shared no-op handler (0x426500); unused. Slot of Croc2 stCollSetPoint. <sub>426500</sub> |
| 0x87 | `nop_0x87` | - | 0 | 0 | | high | stCollPoints | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stCollPoints. <sub>426500</sub> |
| 0x88 | `nop_0x88` | - | 0 | 0 | | high | stCollSetPoint | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stCollSetPoint. <sub>426500</sub> |
| 0x89 | `createTrigger` | - | 0 | 0 | 45 | medium | stCreateTrigger | Sets flag 0x40 in +0xE8 (strat becomes a trigger/touchable, used by pickups like Health, Carrot). <sub>54e4c0</sub> |
| 0x8a | `killTrigger` | - | 0 | 0 | | medium | stKillTrigger | Clears flags 0x40 and 0x4000000 in +0xE8 (undoes createTrigger). <sub>54e4b0</sub> |
| 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). <sub>54fb40</sub> |
| 0x8c | `hold` | - | 0 | 0 | 110 | medium | stHold | Sets flags 0x12 (0x2 wait + 0x10 stopped) in +0xE8: suspends the strat indefinitely until released. <sub>54fce0</sub> |
| 0x8d | `release` | - | 0 | 0 | | high | stRelease | Unimplemented: logs "stRelease" only. <sub>54d340</sub> |
| 0x8e | `remove` | - | 0 | 0 | 985 | high | stRemove | Sets flags 0x6 in +0xE8 (yield + kill); same handler as 0xb4 end. <sub>5535e0</sub> |
| 0x8f | `nop_0x8f` | - | 0 | 0 | | low | stMapRemove | Shared no-op handler (0x426500); unused. Slot of Croc2 stMapRemove. <sub>426500</sub> |
| 0x8f | `nop_0x8f` | - | 0 | 0 | | high | stMapRemove | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Slot of Croc2 stMapRemove. <sub>426500</sub> |
| 0x90 | `mapAdd` | - | 0 | 0 | | high | stMapAdd | Unimplemented: logs "stMapadd" only. <sub>54d360</sub> |
| 0x91 | `mapReplace` | - | 0 | 0 | | high | stMapReplace | Unimplemented: logs "stMapreplace" only. <sub>54d380</sub> |
| 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). <sub>54db40</sub> |
@@ -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. <sub>54e1c0</sub> |
| 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). <sub>54d4c0</sub> |
| 0x9b | `streamPlay` | - | 0 | 0 | | high | - | Unimplemented: logs "stStreamPlay" only. <sub>54d3e0</sub> |
| 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. <sub>54d530</sub> |
| 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. <sub>54d4f0</sub> |
| 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. <sub>54d530</sub> |
| 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. <sub>54d4f0</sub> |
| 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. <sub>54d510</sub> |
| 0x9f | `streamQueue` | - | 0 | 0 | | high | stMidiQueue | Unimplemented: logs "stStreamQueue" only. <sub>54d400</sub> |
| 0xa0 | `isLight` | - | 0 | 0 | | high | stIsLight | Unimplemented: logs "stIsLight" only. <sub>54d420</sub> |
@@ -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). <sub>54e810</sub> |
| 0xaf | `not` | - | 1 | 1 | 4952 | high | stNot | pops x; pushes 1 if x==0 else 0. <sub>54fc20</sub> |
| 0xb0 | `pop` | - | 1 | 0 | | high | stPop | Drops the top stack value (stack pointer +0x104 -= 4). <sub>553dd0</sub> |
| 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). <sub>553de0</sub> |
| 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. <sub>553de0</sub> |
| 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). <sub>553630</sub> |
| 0xb3 | `return` | - | 1 | 0 | 4974 | high | stReturn | pops code address and jumps there (pc = popped). <sub>5537d0</sub> |
| 0xb4 | `end` | - | 0 | 0 | 2006 | high | stEndStrat | Sets flags 0x6 in +0xE8 (yield + kill): ends the strat. Same handler as 0x8e. <sub>5535e0</sub> |
@@ -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. <sub>552e30</sub> |
| 0xb9 | `discard_0xb9` | - | 1 | 0 | | medium | - | Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 ext-var slots). <sub>554fb0</sub> |
| 0xba | `discard_0xba` | - | 1 | 0 | | medium | - | Shared stub 0x554fb0: pops one value and discards it. Unused. <sub>554fb0</sub> |
| 0xbb | `nop_0xbb` | - | 0 | 0 | | low | - | Shared no-op handler (0x426500); unused. <sub>426500</sub> |
| 0xbb | `nop_0xbb` | - | 0 | 0 | | high | - | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. <sub>426500</sub> |
| 0xbc | `notEqual` | - | 2 | 1 | 2576 | high | stNotEqual | pops b, a; pushes 1 if a != b else 0. <sub>54fbd0</sub> |
| 0xbd | `greaterEqual` | - | 2 | 1 | 1169 | high | stGreaterEqual | pops b, a; pushes 1 if a >= b else 0. <sub>553b80</sub> |
| 0xbe | `lessEqual` | - | 2 | 1 | 2129 | high | stLessEqual | pops b, a; pushes 1 if a <= b else 0. <sub>553b50</sub> |
| 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). <sub>54f960</sub> |
| 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)). <sub>54f5c0</sub> |
| 0xc1 | `nop_0xc1` | - | 0 | 0 | | low | stResettoCheckPoint | Shared no-op handler (0x426500); unused. Croc2 slot stResettoCheckPoint. <sub>426500</sub> |
| 0xc1 | `nop_0xc1` | - | 0 | 0 | | high | stResettoCheckPoint | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stResettoCheckPoint. <sub>426500</sub> |
| 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). <sub>54e9c0</sub> |
| 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. <sub>54f1c0</sub> |
| 0xc4 | `rumble` | - | 2 | 0 | 93 | low | stRumble | Shared stub 0x555090: pops two values and discards them (PSX pad rumble/vibrate not implemented on PC). <sub>555090</sub> |
| 0xc5 | `suspendIfTooFar` | - | 1 | 0 | 232 | low | stSuspendIfTooFar | Shared stub 0x554fb0: pops one value (likely distance) and discards it. <sub>554fb0</sub> |
| 0xc4 | `rumble` | - | 2 | 0 | 93 | high | stRumble | Shared stub 0x555090: pops two values and discards them (PSX pad rumble is not implemented on PC). <sub>555090</sub> |
| 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). <sub>554fb0</sub> |
| 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). <sub>54e6c0</sub> |
| 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. <sub>54e4d0</sub> |
| 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. <sub>54e4d0</sub> |
| 0xc8 | `isCamera` | - | 0 | 0 | 34 | high | stIsCamera | Registers this strat as the camera (global 0x6D9E28) and sets flag 0x8000000 in +0xE8. <sub>5500f0</sub> |
| 0xc9 | `lookAtMe` | - | 0 | 0 | 86 | high | stLookatme | Sets camera look-at target global 0x6D9E30 = this strat. <sub>5500e0</sub> |
| 0xca | `lookAtMe2` | - | 0 | 0 | | medium | stLookatme | Sets secondary look-at global 0x6D9E34 = this strat. Unused. <sub>5500d0</sub> |
| 0xcb | `pushCamera` | - | 0 | 0 | | high | stPushCamera | Pushes current camera (0x6D9E28) and look-at (0x6D9E30) pair onto the camera stack 0x6D9DCC (index 0x6D9E40). <sub>54dbb0</sub> |
| 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. <sub>54db00</sub> |
| 0xcd | `nop_0xcd` | - | 0 | 0 | | low | stResetCamerapos | Shared no-op handler (0x426500); unused. Croc2 slot stResetCamerapos. <sub>426500</sub> |
| 0xcd | `nop_0xcd` | - | 0 | 0 | | high | stResetCamerapos | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stResetCamerapos. <sub>426500</sub> |
| 0xce | `gainHeart` | - | 0 | 0 | 94 | high | stGainHeart | Health 0x584E70 += 1.0 if below max 0x584E6C; refreshes HUD (FUN_004268c0(1)). <sub>54f650</sub> |
| 0xcf | `nop_0xcf` | - | 0 | 0 | | low | stGainHeartPot | Shared no-op handler (0x426500); unused. Croc2 slot stGainHeartPot. <sub>426500</sub> |
| 0xcf | `nop_0xcf` | - | 0 | 0 | | high | stGainHeartPot | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stGainHeartPot. <sub>426500</sub> |
| 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). <sub>54da00</sub> |
| 0xd1 | `nop_0xd1` | - | 0 | 0 | | low | stGainCrystal | Shared no-op handler (0x426500); unused. Croc2 slot stGainCrystal. <sub>426500</sub> |
| 0xd2 | `discard_0xd2` | - | 1 | 0 | | low | stCutscene | Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 slot stCutscene). <sub>554fb0</sub> |
| 0xd1 | `nop_0xd1` | - | 0 | 0 | | high | stGainCrystal | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stGainCrystal. <sub>426500</sub> |
| 0xd2 | `discard_0xd2` | - | 1 | 0 | | high | stCutscene | Shared stub 0x554fb0: pops one value and discards it. Unused (Croc2 slot stCutscene). <sub>554fb0</sub> |
| 0xd3 | `inventory` | imm | 0 | 1 | | medium | stInventory | Queries inline item via stub FUN_00426860 and pushes result<<12 (always 0 on PC). <sub>54d9d0</sub> |
| 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. <sub>553ef0</sub> |
| 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. <sub>54e230</sub> |
| 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. <sub>54e1e0</sub> |
| 0xd7 | `soundAddress` | sound | 0 | 1 | 2996 | high | stSoundAddress | Pushes the inline sound id (same handler as 0x45 push immediate). <sub>5535f0</sub> |
| 0xd8 | `nop_0xd8` | - | 0 | 0 | | low | stOnground | Shared no-op handler (0x426500); unused. Croc2 slot stOnground/stObjectFallslow. <sub>426500</sub> |
| 0xd8 | `nop_0xd8` | - | 0 | 0 | | high | stOnground | Shared empty handler 0x426500 (verified in the handler table); unused in the levels. Croc2 slot stOnground/stObjectFallslow. <sub>426500</sub> |
| 0xd9 | `collisionOffset` | - | 3 | 0 | 403 | high | stCollisionOffset | pops z, y, x; stores low 16 bits into shorts +0xD0, +0xCE, +0xCC (collision centre offset). <sub>54e050</sub> |
| 0xda | `abs` | - | 1 | 1 | 1847 | high | stAbs | pops x; pushes \|x\|. <sub>5508d0</sub> |
| 0xdb | `pickup` | imm | 0 | 0 | | medium | stPickup | inline flag: nonzero sets bit 0x200 in +0xEC, zero clears it (strat can be picked up/carried). <sub>54dd00</sub> |
@@ -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 <sub>550130</sub> |
| 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 <sub>40d480</sub> |
| 0xf1 | `getParentPos` | - | 0 | 0 | 48 | high | stGetparentpos | copies parent's (+0xC) orientation+position block (+0x20..+0x3C, 8 words) into this strat <sub>54e030</sub> |
| 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 <sub>54fe40</sub> |
| 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. <sub>54fe40</sub> |
| 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 <sub>553290</sub> |
| 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 <sub>553380</sub> |
| 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 <sub>553300</sub> |
@@ -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 <sub>550940</sub> |
| 0x105 | `forcePlayerDist` | - | 0 | 0 | | high | stForcePlayerdist | recomputes the cached squared player distance +0x148 (coords >>6) and clears +0x1C; 0x200000 if no player; unused <sub>54e950</sub> |
| 0x106 | `nop_0x106` | - | 0 | 0 | | high | stShadeType? | no-op handler FUN_00426500 (does nothing, no inline operands); unused <sub>426500</sub> |
| 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) <sub>54d880</sub> |
| 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. <sub>54d880</sub> |
| 0x108 | `nop_0x108` | - | 0 | 0 | | high | stCheckleveldoor? | no-op handler FUN_00426500 (does nothing, no inline operands); unused <sub>426500</sub> |
| 0x109 | `nop_0x109` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused <sub>426500</sub> |
| 0x10a | `nop_0x10a` | - | 0 | 0 | | high | - | no-op handler FUN_00426500 (does nothing, no inline operands); unused <sub>426500</sub> |
@@ -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. <sub>426500</sub> |
| 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). <sub>54e430</sub> |
| 0x149 | `nop_0x149` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. <sub>426500</sub> |
| 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. <sub>54f700</sub> |
| 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. <sub>54f700</sub> |
| 0x14b | `setGlobal5fd086` | imm | 0 | 0 | | medium | - | Stores the low 16 bits of the inline word in global 0x5FD086 (never read elsewhere; dead leftover). <sub>54e890</sub> |
| 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). <sub>54e720</sub> |
| 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. <sub>54f780</sub> |
@@ -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. <sub>426500</sub> |
| 0x15a | `setPosToPlayer` | - | 0 | 0 | 27 | high | - | Copies the player's position x/y/z (+0x30..+0x38) and +0x3C into the strat (player strat = 0x6D9E1C). <sub>554480</sub> |
| 0x15b | `setPosToPlayerXZ` | - | 0 | 0 | | high | - | Copies the player's x and z (+0x30, +0x38) into the strat; y unchanged. <sub>5544b0</sub> |
| 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). <sub>5544d0</sub> |
| 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). <sub>5544d0</sub> |
| 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). <sub>5544f0</sub> |
| 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). <sub>554530</sub> |
| 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. <sub>5545c0</sub> |
@@ -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). <sub>552f40</sub> |
| 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. <sub>552fb0</sub> |
| 0x189 | `randSigned` | - | 1 | 1 | 922 | high | - | pops n; pushes a random value in (-n, n) ((rand32 - rand32) % n, same fixed scale as n). <sub>54f9a0</sub> |
| 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. <sub>5530b0</sub> |
| 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. <sub>553100</sub> |
| 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. <sub>5530b0</sub> |
| 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. <sub>553100</sub> |
| 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. <sub>552ff0</sub> |
| 0x18d | `stubPop2_0x18d` | - | 2 | 0 | | high | - | Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). <sub>555090</sub> |
| 0x18e | `stubPop2_0x18e` | - | 2 | 0 | | high | - | Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). <sub>555090</sub> |
@@ -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. <sub>553150</sub> |
| 0x1a7 | `stubPush0_0x1a7` | - | 0 | 1 | | high | - | Stub FUN_0054e880: pushes 0. <sub>54e880</sub> |
| 0x1a8 | `processFirst` | - | 0 | 0 | 1 | high | - | Moves self to the head of the strat update list (0x6D9E38) if not already there. <sub>554e20</sub> |
| 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). <sub>554fc0</sub> |
| 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. <sub>554fc0</sub> |
| 0x1aa | `stubPop1Push0_0x1aa` | - | 1 | 1 | | high | - | Stub FUN_005550b0: pops 1 value and pushes 0. <sub>5550b0</sub> |
| 0x1ab | `stubPop2_0x1ab` | - | 2 | 0 | | high | - | Stub FUN_00555090: pops and discards 2 values (feature removed in PC port). <sub>555090</sub> |
| 0x1ac | `stubPop1Push0_0x1ac` | - | 1 | 1 | | high | - | Stub FUN_005550b0: pops 1 value and pushes 0. <sub>5550b0</sub> |
@@ -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<Type> types() {
return List.copyOf(types.values());
@@ -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<StratListing.Line, Integer> changes = ConstantDialog.show(ctx, r.consts);
@@ -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<Integer> 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<String> problems = verify(d, s.settings().wfpc(), a.entries, base, base + e, r);
putInt(d, img0.codeStart, Bytes.i32(d, img0.codeStart) + 1);
List<String> 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<String> checkTypes(Scene s, List<CustomType> types, int index, int[] words) {
String blocker = rebuildBlocker(s);
if (blocker != null) {
return List.of(blocker);
}
byte[] d = withoutCustom(s);
Set<Integer> 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<CustomType> types, int[] remap) {
for (Scene s : List.of(p.reference(), p.scene())) {
List<String> 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<CustomType> types, int[] remap) {
State st = state(s);
List<Integer> 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<Integer> 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<Strat> 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<Strat> users(Scene s, int k) {
State st = state(s);
Analysis a = analysis(s);
List<Strat> 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<String, Object> session(Scene s) {
State st = state(s);
@@ -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;
}
}
@@ -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<StratEdits.CustomType> 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<StratEdits.CustomType> types = StratEdits.records(p).types();
if (k < 0 || k >= types.size()) {
return;
}
String name = types.get(k).name();
String blocker = StratEdits.rebuildBlocker(s);
List<StratEdits.CustomType> rest = new java.util.ArrayList<>(types);
rest.remove(k);
List<String> 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<String> 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<String> 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<StratEdits.CustomType> all = new java.util.ArrayList<>(
StratEdits.records(ctx.document().project()).types());
all.set(editIndex, t);
int[] words = new int[1];
try {
List<String> ps = StratEdits.checkTypes(s, all, editIndex, words);
if (ps != null) {
result.setText("⚠ " + String.join("; ", ps));
return;
}
} catch (StratAssembler.AsmException e) {
List<StratAssembler.Problem> 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());
@@ -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<StratEdits.CustomType> before,
List<StratEdits.CustomType> 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<StratEdits.CustomType> before = StratEdits.records(p).types();
List<StratEdits.CustomType> 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<StratEdits.CustomType> before = StratEdits.records(p).types();
List<StratEdits.CustomType> 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<StratEdits.CustomType> before = StratEdits.records(p).types();
List<StratEdits.CustomType> 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<Command> cmds = new ArrayList<>();
List<Strat> 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);
}
}
@@ -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<String, Integer> 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<String, String> 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<String, String[]> 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<String> REFS = List.of("model", "anim", "unit", "sound", "tex", "text");
private final OpTable ops = OpTable.get();
private final List<Tok> toks;
private int p;
private final StringBuilder out = new StringBuilder();
private final Map<String, String> aliases = new HashMap<>();
private int labels;
/** Enclosing loops: {break label, continue label}. */
private final Deque<String[]> 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<Tok> 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<Tok> lex(String s) {
List<Tok> 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<text:N>()");
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<inline, …>(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<String> 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;
}
}
@@ -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<Integer, Block> ifAt = new HashMap<>(), loopEnd = new HashMap<>(), switchAt = new HashMap<>();
private final Map<Integer, List<Block>> opensAt = new HashMap<>();
private final Map<Integer, Integer> closeCount = new HashMap<>(), elseAt = new HashMap<>();
private final Map<Integer, Integer> switchDepth = new HashMap<>();
private final Map<Integer, List<String>> caseAt = new HashMap<>();
private final Set<Integer> elseJumps = new HashSet<>(), silentJumps = new HashSet<>();
private final Map<Integer, String> jumpAs = new HashMap<>();
private Map<String, Integer> 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<String, Integer> 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<String, List<String[]>> tables = switchTables();
// candidate blocks
List<Block> cand = new ArrayList<>();
Map<Integer, Integer> elseOf = new HashMap<>();
List<Block> switches = new ArrayList<>();
Map<Integer, List<int[]>> 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<Block> accepted = new ArrayList<>();
Set<Integer> 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<Integer, Block> ifAt = new HashMap<>(), loopAt = new HashMap<>(), loopEnd = new HashMap<>();
Map<Integer, Integer> closeCount = new HashMap<>(), elseAt = new HashMap<>();
Set<Integer> elseJumps = new HashSet<>();
Map<String, Integer> 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<String, Integer> 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<String, Integer> 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<Integer, Integer> caseOwner = new HashMap<>();
private static int end(Block a, Map<Integer, Integer> elseOf, Set<Integer> 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<Block> accepted, Map<Integer, Integer> elseOf, Set<Integer> 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<String, List<String[]>> switchTables() {
Map<String, List<String[]>> 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<String[]> 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<String, List<String[]>> tables, List<Block> out, Map<Integer, List<int[]>> cases) {
List<String[]> 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<int[]> 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<Block> accepted, Map<String, Integer> 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<Integer, Block> ifAt, Map<Integer, Block> loopEnd, Set<Integer> 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*/" : "") + "()");
@@ -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 -
Can't render this file because it contains an unexpected character in line 36 and column 30.
@@ -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<Strat> 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();
@@ -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 + ")");
}
}
@@ -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> T edt(Callable<T> c) throws Exception {
AtomicReference<T> r = new AtomicReference<>();
AtomicReference<Exception> 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();
}
}
@@ -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());
}
}
@@ -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<Integer> 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<Integer> 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<String> 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);
}
}