diff --git a/docs/manual/22-strats.md b/docs/manual/22-strats.md index 6fe027e..c225f12 100644 --- a/docs/manual/22-strats.md +++ b/docs/manual/22-strats.md @@ -36,7 +36,9 @@ one, and how many objects of it are placed. meaning, the script uses its name (`snapToFloor`). * `g12` is a global variable shared by all scripts. * Numbers are in game units: `1.0` = one cell (or "true"), angles are written like `5.625deg`. - Speeds and turn rates are **per frame** (the game runs at 30 frames per second). + Speeds and turn rates are **per frame** (the game runs at 30 frames per second). Small raw + numbers have an `r`: `204r` is 204/4096 of a cell (about 0.05). +* `break name` / `continue name` leave or repeat the outer loop labelled `name:`. * `yield` ends the object's turn for this frame; a `loop { … yield }` runs once per frame. * `onEvent` installs a handler, for example "when the player touches me" (event type 4). * `spawn<…>(…)` creates another object, `address model:5` names a 3D model of the level. @@ -55,7 +57,7 @@ can be edited show a tooltip; edited lines turn **orange**. * It applies to **every object of that type** in the level, also to objects other scripts spawn. * It is saved in the project and written into the level when you build or play. -Example: in the Coin's script, `self.y = self.floorHeight + 204` puts snapping coins just above +Example: in the Coin's script, `self.y = self.floorHeight + 204r` puts snapping coins just above the floor. Changing `204` to `1.5` makes them float one and a half cells high: ![Original and edited Coin script in the game](img/strat_coin_constant_ab.jpg) @@ -70,7 +72,8 @@ empty template) opens the type editor: 1. **Name** – the name of the new type (letters, digits, `_`). 2. **Entry label** – where the script starts (for clones: the renamed strat). 3. **Type fields / parameters from** – which existing type's settings (parameters, waypoints, - flags) new objects of this type start with. + flags) new objects of this type start with. In a script you can use that type's parameter + names (for a Coin: `snapToFloor` for parameter 0). 4. Edit the source. **Check** assembles it and verifies the result; errors name the line. 5. **Create type**. The type appears in **Assets → Objects** and can be placed like any other. @@ -83,7 +86,9 @@ Select an object of a custom type (or pick the type in the Script panel's *Type* * **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. + keep their place and run the new script. **Ctrl+Z** undoes the change. *Type fields / + parameters from* can be changed here too: new objects start with the other type's settings, + placed ones keep theirs. * **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). @@ -113,12 +118,19 @@ strat Pulser { ``` * Statements: `x = value`, `x += value`, `if (…) { } else { }`, `while (…) { }`, `loop { }`, - `do { } while (…)`, `break`, `continue`, `yield`, `end`, `call name` (for `sub name { … }`). + `do { } while (…)`, `switch (…) { case 1: … break default: … }`, `break`, `continue`, `yield`, + `end`, `call name` (for `sub name { … }`). +* `on touched { … }` runs its block when the player touches the object (the object needs + `collisionOn<0x20000>()` and `collRadius(…)`, like a coin); `on event(type, param) { … }` for + other events. The block ends like a sub (`remove` deletes the object). +* `a && b` and `a || b` in conditions stop as soon as the answer is known. * 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`. +* Numbers: `10` and `10.0` both mean ten cells, angles are written `90deg`. For a raw value (as + the Script view shows them) add `r`: `215r` = 215/4096; hex like `0x8000` is raw too. Values + inside `<…>` are raw. Scripts written before this rule start with `use rawNumbers` when you open + them, so they keep working. * **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`. @@ -138,11 +150,11 @@ in one test level below: on the left the waypoint way, on the right the script w 1. Import Bucky (**Assets → All worlds**, search "Bucky", drag it into the level). 2. Select it and open the **Script** panel. The script shows `wpFirst()`, `wpNext()` and - `wpTurnToY(v1)` / `moveForwardQ(215)`: Bucky **runs along its waypoint chain**, turning + `wpTurnToY(v1)` / `moveForwardQ(215r)`: Bucky **runs along its waypoint chain**, turning with the rate in parameter 1 and moving 215/4096 cells per frame. 3. At every node it reads `self.wpParam` (the node's field **+26**): the low part chooses run (`0`) or jump (`1.0`, `2.0`), the high part `128.0` means "run on", `64.0` "wait until the - player comes close". At the end: `if (!(!v0 && (self.wpEndFlags & 2))) goto …` – with + player comes close". At the end: `} while (!(!v0 && (self.wpEndFlags & 2r)))` – with **parameter 0** set the chain repeats instead of ending. ### Way 1: a circular waypoint chain (no script change) @@ -182,15 +194,13 @@ circle: ## Limits -* 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 `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. + `goto` where the original jumps don't form nested blocks (31 places in the whole game, all in + the llama's script); the Assembly view is always exact. * In the clone dialog, renaming the type doesn't yet rename the entry label or the name inside the script text – edit those by hand if you want them to match. diff --git a/notes/img/mods/strats2_edit_type.jpg b/notes/img/mods/strats2_edit_type.jpg new file mode 100644 index 0000000..9ee3e0f Binary files /dev/null and b/notes/img/mods/strats2_edit_type.jpg differ diff --git a/notes/img/mods/strats2_game_import.jpg b/notes/img/mods/strats2_game_import.jpg new file mode 100644 index 0000000..915d71c Binary files /dev/null and b/notes/img/mods/strats2_game_import.jpg differ diff --git a/notes/strat-language.md b/notes/strat-language.md index 51cc544..b4e9670 100644 --- a/notes/strat-language.md +++ b/notes/strat-language.md @@ -74,11 +74,12 @@ L_0218: | – globals (pushGlobal, popGlobal, …) | `g12` | | – properties (pushProp, popProp, pushPlayerProp, …) | name from props.tsv (`x`, `rotY`, `floorHeight`) or `prop17` | | – branches/calls (jz jnz jzYield jnzYield jmp jmpYield call callYield pushCodeWord pushCodeAddr popCodeWord) | label (or a number = raw word offset) | +| – renamed opcodes | former names (`flagEC10000` …, `op-aliases.tsv`) still assemble | | – `switch` | label of the `.switch` table | | – `onEvent entry, slot[, hi=N]` | label of the `.event` entry, slot number | | – `skipArgs`/`print` (0x02/0x03) | raw words: count, items… (`hi=N` first if the opcode word has high bits) | | `push v` (0x45) | number; `tex:N` marks a texture index (before shadowTexture/spawnParticle/…) | -| `address ref` (0xb2) | `model:N[+off]`, `anim:N[+off]`, `unit:N[+off]`, `@label[+off]` (own unit), or a number (negative = DEFANIM.WAD animation) | +| `address ref` (0xb2) | `model:N[+off]`, `anim:N[+off]`, `unit:N[+off]`, `unit:Name[+off]` (custom types, see below), `@label[+off]` (own unit), or a number (negative = DEFANIM.WAD animation) | | `soundAddress sound:N` (0xd7), `topSay text:N` … (0xf0/0xfa) | level sound / text index | | `debugName "Name"[, k]` (0xd4 0xea rel) | name string: the k-th (default first) occurrence of the string + NUL in the unit that starts a word or follows a NUL; or a label | | other long ops | as many numbers as inline words (`spawn 3, 6, 0, 0, 0`) | @@ -108,21 +109,31 @@ where the blocks nest: | 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)`. +| jump out of an outer loop (to its end / head) | `break name` / `continue name`, the loop shown as `name: loop {` (`while`, `do`) | +| jump to a `return` / `end` / `remove` | `return` / `end` / `remove` (the same instruction) | +| conditional jumps separated only by expression code, to a common false target / the body | one condition with `&&` / `||` (short-circuit; also what the compiler generates) | +| `if (a) { if (b) {…} }` with nothing else in the outer block | `if (a && b) {…}`; leading `if (c) break` lines of a loop → `while (!c && …)` | + +Everything else stays `goto label`. Over all 1461 units: **11 019 → 400 → 31 gotos** (2026-11-07, +second pass; 138 labeled break/continue). The 31 left are one pattern in the Llama strat of every +level: a jump from inside a then-part into the else part (`if (a) { call; if (!b) goto else; … } +else {…}`); earlier versions showed these as a nested `if` without the else, which was wrong +(`fits` now refuses an inner if that targets the outer if's else label). Guarded by +`StratPseudoTest` (balanced blocks, every goto has its label, every `break name` names a loop). +Inline operands in `<>`: `spawn<3, 6, 0, 0, 0>(10.0, 204r, 450r, unit:35+0x28)`. Raw integers +pushed on the stack are written with an `r` suffix (`204r`), as in the script language (below); +`1.0`-style and `deg` values as in the assembly. Example (Coin): ``` strat Coin: setModel(model:5) -collRadius(409) +collRadius(409r) onEvent ev_0000, 0 // event type 4 → L_0084 if (v1) { collisionOn<32768>() ... } if (v0) { if (self.floorMaterial == -1.0) { skipArgs … } else { - self.y = self.floorHeight + 204 + self.y = self.floorHeight + 204r self.rotY = rnd(4096.0) } } @@ -132,10 +143,15 @@ 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`). +everything the assembler checks still applies). Language version 2 (2026-11-07): strats, subs, +aliases, parameter names, assignments, expressions with constant folding, short-circuit `&&`/`||`, +if/else if/else, while, loop, do-while, switch, break/continue, yield, event handlers, calls of any +opcode by name. Tests: `StratCompilerTest`. In game (village 1, apptest, 2026-11-07): version 1 +scripts `img/mods/strats_script_game.jpg`; version 2 (`StratsGameLevel … v2`: a custom type, then +an imported Spider in the same session, the custom type edited afterwards, compiled Toucher/Ticker +with `on touched` / `on event(0x40, 30)`, switch, `&&`/`||`) loads and plays without errors +(`img/mods/strats2_game_import.jpg`); the handlers' visible effect was not confirmed (the test +objects were out of the camera's view under the slow harness). ``` // bobs one cell up and down and turns @@ -143,8 +159,11 @@ strat Bobber { alias base = v0 // .alias base = v0 alias phase = v1 setModel(model:5) // any opcode: name(stack arguments) - base = self.y + 1.0 + base = self.y + 1 // 1 = one cell phase = 0 + on touched { // event handler (type 4), runs as a subroutine + soundPlay(sound:6) + } loop { phase = phase + 4deg self.y = base + sin(phase) @@ -160,26 +179,44 @@ sub wobble { // called with "call wobble", ends with return | Construct | Compiles to | |---|---| -| `strat Name { … }` | `.string "Name"`, label `Name:`, `debugName "Name"`, body, `end` unless the body cannot fall through | +| `strat Name { … }` | `.string "Name"`, label `Name:`, `debugName "Name"`, body, `end` unless the body cannot fall through; event handler code behind it | | `sub name { … }` / `call name` / `return` | label, body, `return` / `call name` / `return` | -| `x = e`, `x += e` (`-=`, `*=`, `/=`) | e, pop op of x; `x += 1.0` / `x -= 1.0` → address + `inc`/`dec` | -| places | `vN`, alias, `gN`, `self.prop`, `player.prop`, `parent.prop`, `parent.vN`, `camera.`/`target.`/`target2.`/`boss.`/`dialog.prop` (props.tsv names or `propN`) | -| literals | as in the assembly: `10.0` = ten, `10` = 10 raw units (1/4096), `90deg`, `0x10`; 16-bit values use `pushShort`, others `push` | -| references | `model:N`, `anim:N`, `unit:N` (`address`), `sound:N` (`soundAddress`), `tex:N` (`push`) | -| operators (low → high) | `||` `|` (or) · `&&` (and) · `&` · `==` `!=` · `<` `>` `<=` `>=` · `<<` `>>` · `+` `-` · `*` `/` · unary `-` `!` | -| `op(e1, e2)` | e1, e2, `op a, b`; the argument count must match the opcode's pops; a value-returning op used as a statement is followed by `pop` | -| `if (c) {…} else if … else {…}` | `c; jz else; …; jmp end; else: …` | -| `while (c) {…}` / `loop {…}` | head label, `c; jz end`, body, `jmp`/`jmpYield head` | -| `do {…} while (c)` | body, `c; jnz head` | +| `x = e`, `x += e` (`-=`, `*=`, `/=`) | e, pop op of x; `x += 1` / `x -= 1` (= 1.0) → address + `inc`/`dec` | +| places | `vN`, alias, parameter name, `gN`, `self.prop`, `player.prop`, `parent.prop`, `parent.vN`, `camera.`/`target.`/`target2.`/`boss.`/`dialog.prop` (props.tsv names or `propN`) | +| numbers | plain integer = game units: `10` = 10.0 (40960 raw); `1.5`, `90deg` as in the assembly; **raw words**: `215r` or hex `0x10` | +| references | `model:N`, `anim:N`, `unit:N` / `unit:Name` (`address`), `sound:N` (`soundAddress`), `tex:N` (`push`) | +| operators (low → high) | `||` · `&&` · `|` · `&` · `==` `!=` · `<` `>` `<=` `>=` · `<<` `>>` · `+` `-` · `*` `/` · unary `-` `!` | +| constant folding | operators over constants are computed with the VM's rules (20.12 `*` = a·b>>12, `/` = (a<<8)/b<<4, shifts by b>>12, comparisons 0/1): `2 * 3 + 0.5` → `pushShort 6.5` | +| `op(e1, e2)` | e1, e2, `op a, b`; the argument count must match the opcode's pops; a value-returning op used as a statement is followed by `pop`. Inline operands `<…>` are raw words as in the assembly | +| conditions (`if`, `while`, `do … while`) | **short-circuit**: `a && b` → `a; jz F; b; jz F`, `a || b` → `a; jnz T; b; jz F; T:`; `!` swaps the jump; constant conditions need no code. In a value (`x = a && b`) both sides are evaluated (`and`/`or` opcodes) | +| `if (c) {…} else if … else {…}` | `c → else; …; jmp end; else: …` | +| `while (c) {…}` / `loop {…}` | head label, `c → end`, body, `jmp`/`jmpYield head` | +| `do {…} while (c)` | body, `c → head` | +| `switch (e) { case 1: … break … default: … }` | `e; switch tab; jmp default/end`, case bodies in order (C rules: fall through, `break` leaves the switch, `continue` the enclosing loop); table `.switch n` + `.case [imm] v, label` (short form when v fits 16 bits). Case values are constants | +| `on touched { … }` / `on event(type[, param]) [slot n] { … }` | `onEvent ev, slot` where it stands (slots 0, 1, … in order unless given); entry `.event type, param, handler`; the handler body (ends with `return`; `remove`/`end` allowed) behind the strat. type/param/slot are raw words; type 4 = touched (`collisionOn` + `collRadius` needed, like Coin), 0x40 = timer, 0x80/0x100 = player distance | | `break` / `continue` / `yield` / `end` | `jmp end` / `jmp head` (do: the condition) / `jmpYield next` / `end` | +| `use rawNumbers` | the version-1 number rule (plain integers are raw words) for the rest of the script | + +Layout: event entries and switch tables go to the **start** of the unit (like the original code): +data behind a terminating instruction would be decoded as dead code by `StratCode`'s +terminator-follower pass and break the listing. + +**Parameter names**: `StratCompiler.compile(src, names)` takes predefined names → `vN`; the editor +passes the parameter names of the object schema of the type the custom type's fields come from +(`StratEdits.paramNames`, the same names as the script view's aliases, e.g. `snapToFloor` = v0 +for Coin). The script's own `alias` wins. + +**Numbers, compatibility**: language version 1 (before 2026-11-07) read `10` as 10 raw units. The +compiler now writes the marker line `; script language 2` behind the embedded source; +`embeddedSource` of older output (no marker) puts `use rawNumbers` in front of the script, so an +old type opened with *Edit type…* compiles to the same code. Stored types are assembly anyway +(the script is a comment), so loading never recompiles. The generated assembly starts with the script as `;| ` comment lines, so the editor can show the script again (*Edit type…*). Comments: `//`, `/* */`; `;` and newlines separate statements. -**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). +**Not implemented**: `for`, `spawn` with typed arguments, typed variables (int vs fixed), a view of +event handlers as `on … { }` (the script view shows `onEvent` + the handler label). ## Editing in the level editor (`editor.tools.strats`) @@ -197,7 +234,8 @@ short-circuit `&&`/`||` (now: both sides are evaluated, like the original `and`/ source text (the cloned unit's listing with the strat renamed, or a template), entry label, type fields/parameters taken from an existing type. *Check* assembles and verifies; *Create* appends the unit at the end of the STPC code region (nothing else moves), adds the type to the - catalogue (Assets → Objects), stores `stratEdits.types` `{name, basedOn, entry, source, proto}`. + catalogue (Assets → Objects), stores `stratEdits.types` `{name, basedOn, entry, source, proto + [, fieldsFrom]}` (`fieldsFrom` = type of the fields/parameter names when it differs from basedOn). Verification: the merged STPC is disassembled again (no decode errors, entry decoded, every `address` operand of the new unit hits a model, animation set or code, and the new unit's listing re-assembles to the same words). @@ -206,8 +244,20 @@ short-circuit `&&`/`||` (now: both sides are evaluated, like the original `and`/ 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). + Creating is undoable too (`TypeEdits.create`). In edit mode *Type fields / parameters from* can + be switched to another type (new placements start with its fields; placed objects keep theirs); + `(keep current fields)` is the default. Renaming a type also renames `unit:Old` in the other + custom types. +* **References between custom types**: `unit:N` operands that point at a custom type are stored + as `unit:TypeName` (`StratEdits.symbolic` on create/edit; on load for older projects, with the + saved `session.firstUnit`); the assembler resolves names through `Layout.withUnitNames`, the + listing prints them (`Names.unitName`). Deleting a referenced type is refused ("unknown unit + name"). +* **Imports in the same session** (CrossImport `CodeHook`): the merge is computed on the code + without the custom units (`mergeBase`), and the commit cuts the custom units off, merges, and + appends them again behind the imported code (`StratEdits.aroundImport`; objects follow by type + index and offset from the entry) – the same order as on load, so editing/deleting/undoable + creating keep working without reopening. (Imports themselves still clear the undo history.) * **Language** (dialog): *Assembly* or *Script* (compiled by `StratCompiler`, see above); switching Script → Assembly shows the generated code. * **Load order** (`ProjectIO.LoadHook.order()`): constants of base units (−10) → imported types @@ -218,10 +268,9 @@ short-circuit `&&`/`||` (now: both sides are evaluated, like the original `and`/ ## Limits * Custom types are edited and deleted as a whole list: the custom units are cut off the code and - appended again (objects follow their type's new entry). Not possible while object types imported - after the custom ones sit behind them in the code (save and reopen the project first; on load the - custom types always come last). A custom type that addresses another custom type's unit with - `unit:N` breaks when that index changes. Constants of custom types are changed through their source. + appended again (objects follow their type's new entry). Projects saved by older versions with + imports behind the custom types are reordered on load as before. Constants of custom types are + changed through their source. * `pushShort` holds only −8.0…7.99; bigger values need a source edit (`push`). * The script view is a reading aid (gotos where blocks do not nest; expressions assume the documented pop/push counts; ops with variable stack use are shown raw). diff --git a/notes/strat-opcodes.md b/notes/strat-opcodes.md index c20ce17..0fd3d0e 100644 --- a/notes/strat-opcodes.md +++ b/notes/strat-opcodes.md @@ -21,8 +21,13 @@ properties). Language/syntax: `notes/strat-language.md`. Disassembler/assembler: 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. + Still low: 0x34 and 0xf2 (no reader of their flag bits found); see the table for the new descriptions. +* **Renamed opcodes (aliases)**: placeholder names whose meaning the review established got clear + names – 0x2f `flagEC10000` → `freezeOffCamera`, 0x31 `flagEC2` → `touchable`, 0x33 `flagEC100` → + `noFloorPhysics`, 0x14a `hudReset2` → `hudResetNoop`. The old names stay valid mnemonics + (resource `op-aliases.tsv`, `OpTable.alias`), so stored sources still assemble; listings and the + compiler use the new names. Names of flags without a known reader (0x32, 0x34, 0x141–0x144, + 0x14b) keep their address-style names. * **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). @@ -120,11 +125,11 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 0x2c | `nop_0x2c` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | | 0x2d | `nop_0x2d` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | | 0x2e | `nop_0x2e` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | -| 0x2f | `flagEC10000` | imm | 0 | 0 | 1888 | medium | - | arg!=0 sets, 0 clears +0xEC bit 0x10000 = "freeze when off-camera": the camera pass (FUN_00436190) sets +0xEC 0x8000 when the strat is outside the camera range; with both bits set the frame loop (0x54c603) and the strat-vs-strat collision loop (0x54cc1a) skip it. Used by ~1350 strats (pickups, triggers, enemies). 54f720 | +| 0x2f | `freezeOffCamera` (was `flagEC10000`) | imm | 0 | 0 | 1888 | medium | - | arg!=0 sets, 0 clears +0xEC bit 0x10000 = "freeze when off-camera": the camera pass (FUN_00436190) sets +0xEC 0x8000 when the strat is outside the camera range; with both bits set the frame loop (0x54c603) and the strat-vs-strat collision loop (0x54cc1a) skip it. Used by ~1350 strats (pickups, triggers, enemies). 54f720 | | 0x30 | `trackPlayer` | imm | 0 | 0 | | medium | - | If no player yet: re-executes itself next frame (pc-=4, yield); else arg sets/clears +0xEC bit 0x4000 = update distance to player each frame (+0x148). 54fa80 | -| 0x31 | `flagEC2` | imm | 0 | 0 | 410 | medium | - | arg sets/clears +0xEC bit 2 = "others can stand on / touch me": in the strat collision loop (0x54cc96) a strat touching a strat with this bit gets +0xE8 0x400000 (and 0x800000 when it moves) = properties 32/33; also readable as property 0x1f. Set by Llama, Coin, Jar, EnemyCoin, RubyIdol. 54e090 | +| 0x31 | `touchable` (was `flagEC2`) | imm | 0 | 0 | 410 | medium | - | arg sets/clears +0xEC bit 2 = "others can stand on / touch me": in the strat collision loop (0x54cc96) a strat touching a strat with this bit gets +0xE8 0x400000 (and 0x800000 when it moves) = properties 32/33; also readable as property 0x1f. Set by Llama, Coin, Jar, EnemyCoin, RubyIdol. 54e090 | | 0x32 | `flagE8_80000000` | imm | 0 | 0 | | medium | - | arg sets/clears +0xE8 bit 31; no reader of the bit found in the exe (scan of all +0xE8 flag tests); unused in the levels. 54dd90 | -| 0x33 | `flagEC100` | imm | 0 | 0 | 60 | medium | - | arg sets/clears +0xEC bit 0x100; the collision response (0x4044f7) skips the floor/slope handling when it is set. Used by LlamaSpit, DropObject, RollerCoaster, KuscoTurtle (objects that move by script, not by floor physics). 54dd30 | +| 0x33 | `noFloorPhysics` (was `flagEC100`) | imm | 0 | 0 | 60 | medium | - | arg sets/clears +0xEC bit 0x100; the collision response (0x4044f7) skips the floor/slope handling when it is set. Used by LlamaSpit, DropObject, RollerCoaster, KuscoTurtle (objects that move by script, not by floor physics). 54dd30 | | 0x34 | `flagEC200000` | imm | 0 | 0 | 50 | low | - | arg sets/clears +0xEC bit 0x200000; no direct test of the bit found in the exe (all +0xEC flag tests scanned). Used by Llama, RiverCroc, RiverLog, Rock, river NPCs (river/canoe level objects). 54dd60 | | 0x35 | `nop_0x35` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | | 0x36 | `nop_0x36` | - | 0 | 0 | | high | - | No-op (shared empty handler); unused. 426500 | @@ -403,7 +408,7 @@ ops = operands (inline words for long ops, the 16-bit argument for short ops); p | 0x147 | `nop_0x147` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | | 0x148 | `gotoLevel4_1` | - | 0 | 0 | | medium | - | Hardcoded level change: next level (0x584EB4/EB8/EBC/EC0) = 4/1/1/0, previous level (0x585008..14) = 4/1/4/0, game state 0x584638 = 7 (load level). 54e430 | | 0x149 | `nop_0x149` | - | 0 | 0 | | high | - | Empty handler FUN_00426500 (shared no-op); does nothing. 426500 | -| 0x14a | `hudReset2` | - | 0 | 0 | | high | - | Calls FUN_004268c0(2), which returns at once for any argument other than 0 (verified in the assembly): a no-op. Unused. 54f700 | +| 0x14a | `hudResetNoop` (was `hudReset2`) | - | 0 | 0 | | high | - | Calls FUN_004268c0(2), which returns at once for any argument other than 0 (verified in the assembly): a no-op. Unused. 54f700 | | 0x14b | `setGlobal5fd086` | imm | 0 | 0 | | medium | - | Stores the low 16 bits of the inline word in global 0x5FD086 (never read elsewhere; dead leftover). 54e890 | | 0x14c | `removeCollPoint` | - | 1 | 0 | 49 | medium | stSetCollPoint (inverse) | pops id (fixed, >>12); removes the collision point with that id from the strat's collision point list (+0xC8 array of 4-byte entries, count +0xC6). Counterpart of 0xc6 (add/set collision point). 54e720 | | 0x14d | `setFlagEC_400000` | flags | 0 | 0 | | high | - | Inline word: nonzero sets, zero clears bit 0x400000 of strat flags2 (+0xEC); the bit selects which height constant own property 0xF2 returns. 54f780 | diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CrossImport.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CrossImport.java index a6a3fc0..096a98d 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CrossImport.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/catalog/CrossImport.java @@ -56,6 +56,23 @@ public final class CrossImport { /** Makes sure the load hook is registered. */ public static void init() {} + /** + * Lets the custom strat types (editor.tools.strats) step aside during an import: the merge + * runs on mergeBase (the code without them, null = unchanged) and the commit inside around, + * which takes them off and appends them again behind the imported code. + */ + public interface CodeHook { + byte[] mergeBase(Scene s); + + void around(Project p, Runnable commit); + } + + private static volatile CodeHook codeHook; + + public static void setCodeHook(CodeHook h) { + codeHook = h; + } + /** One imported type: source level, type name and its code entry in the source STPC. */ public record TypeRef(String level, String type, int code) { Map json() { @@ -172,8 +189,16 @@ public final class CrossImport { int pages = ref.pages().size(), tex = ref.textures().size(), anims = ref.textureAnims().size(); - apply(o, ref, false, -1, -1, -1); - apply(o, cur, true, pages, tex, anims); + Runnable merge = () -> { + apply(o, ref, false, -1, -1, -1); + apply(o, cur, true, pages, tex, anims); + }; + CodeHook h = codeHook; + if (h != null) { + h.around(p, merge); + } else { + merge.run(); + } @SuppressWarnings("unchecked") List l = p.extra().get(KEY) instanceof List x ? (List) x : new ArrayList<>(); List nl = new ArrayList<>(l); @@ -326,12 +351,16 @@ public final class CrossImport { if (c == null) { throw new IllegalStateException("the level has no STPC chunk"); } + return analysis(s, c.data()); + } + + private static StpcMerge.Analysis analysis(Scene s, byte[] stpc) { Set roots = new TreeSet<>(); for (Strat x : s.strats()) { roots.add(x.code()); } roots.addAll(s.catalog().names().keySet()); - StpcImage img = new StpcImage(c.data(), s.settings().wfpc()); + StpcImage img = new StpcImage(stpc, s.settings().wfpc()); roots.removeIf(o -> o < img.codeStart || img.unitOf(o) < 0); return new StpcMerge.Analysis(img, roots); } @@ -361,7 +390,9 @@ public final class CrossImport { /** Merges a batch into (a copy of) the scene's level data; the scene is not changed. */ public static Outcome compute(Scene s, List batch, Bases cd) throws IOException { - StpcMerge.Analysis tgt = analysis(s); + CodeHook h = codeHook; + byte[] base = h == null ? null : h.mergeBase(s); + StpcMerge.Analysis tgt = base == null ? analysis(s) : analysis(s, base); TextMerge.Textures tex = textures(s); int p0 = tex.pages.size(), e0 = tex.entries.size(), a0 = tex.anims.size(); RawChunk sm = chunk(s, "SMPC"); diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratEdits.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratEdits.java index 0261140..f13946d 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratEdits.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/StratEdits.java @@ -8,6 +8,7 @@ import com.eliasmueller.llamamod.editor.model.Scene; import com.eliasmueller.llamamod.editor.model.SceneEvent; import com.eliasmueller.llamamod.editor.model.Strat; import com.eliasmueller.llamamod.editor.model.StratCatalog; +import com.eliasmueller.llamamod.editor.tools.catalog.CrossImport; import com.eliasmueller.llamamod.editor.tools.objects.ParamSchema; import com.eliasmueller.llamamod.editor.tools.objects.Schemas; import com.eliasmueller.llamamod.format.Bytes; @@ -64,14 +65,32 @@ public final class StratEdits { /** * A custom type: name, source text, the entry label in the source, type fields (MAP record - * prototype), and where it came from. + * prototype), where it came from (basedOn, the cloned type) and the type its fields and + * parameter names are taken from (fieldsFrom). */ public record CustomType( - String name, String basedOn, String entryLabel, String source, StratCatalog.Variant proto) { + String name, + String basedOn, + String entryLabel, + String source, + StratCatalog.Variant proto, + String fieldsFrom) { + /** A type whose fields come from the type it is based on. */ + public CustomType(String name, String basedOn, String entryLabel, String source, StratCatalog.Variant proto) { + this(name, basedOn, entryLabel, source, proto, basedOn); + } + + public CustomType withSource(String src) { + return new CustomType(name, basedOn, entryLabel, src, proto, fieldsFrom); + } + Map json() { Map m = new LinkedHashMap<>(); m.put("name", name); m.put("basedOn", basedOn == null ? "" : basedOn); + if (fieldsFrom != null && !fieldsFrom.isEmpty() && !fieldsFrom.equals(basedOn)) { + m.put("fieldsFrom", fieldsFrom); + } m.put("entry", entryLabel); m.put("source", source); Map p = new LinkedHashMap<>(); @@ -129,12 +148,14 @@ public final class StratEdits { params, List.copyOf(chain), "custom"); + String based = (String) m.get("basedOn"); return new CustomType( (String) m.get("name"), - (String) m.get("basedOn"), + based, (String) m.get("entry"), (String) m.get("source"), - v); + v, + m.get("fieldsFrom") instanceof String ff ? ff : based); } } @@ -181,6 +202,8 @@ public final class StratEdits { boolean edited; int firstCustom = -1; final List customEntries = new ArrayList<>(); + final List customNames = new ArrayList<>(); + final List customFields = new ArrayList<>(); } private static final Map STATE = Collections.synchronizedMap(new WeakHashMap<>()); @@ -217,6 +240,20 @@ public final class StratEdits { /** Makes sure the load hooks are registered. */ public static void init() {} + static { + CrossImport.setCodeHook(new CrossImport.CodeHook() { + @Override + public byte[] mergeBase(Scene s) { + return importBase(s); + } + + @Override + public void around(Project p, Runnable commit) { + aroundImport(p, commit); + } + }); + } + private static int num(Map m, String k) { return ((Number) m.get(k)).intValue(); } @@ -247,7 +284,24 @@ public final class StratEdits { return; } int startBefore = codeEnd(cur); - for (CustomType t : r.types) { + Map ses = json.get(KEY) instanceof Map m && m.get("session") instanceof Map x ? x : null; + // unit:N operands of older projects that point at custom types → unit:Name + int savedFirst = ses != null && ses.get("firstUnit") instanceof Number n ? n.intValue() : units(cur); + List names = r.types.stream().map(CustomType::name).toList(); + List stored = json.get(KEY) instanceof Map m && m.get("types") instanceof List l + ? List.copyOf(l) + : List.of(); + for (int i = 0; i < r.types.size(); i++) { + CustomType t = r.types.get(i); + String src = symbolic(t.source, savedFirst, names.subList(0, i)); + if (!src.equals(t.source)) { + t = t.withSource(src); + if (i < stored.size() && stored.get(i) instanceof Map sm) { + @SuppressWarnings("unchecked") + Map w = (Map) sm; + w.put("source", src); + } + } try { appendType(ref, t, false); appendType(cur, t, false); @@ -255,7 +309,7 @@ public final class StratEdits { warnings.add("custom strat type " + t.name + " could not be assembled: " + e.getMessage()); } } - if (json.get(KEY) instanceof Map m && m.get("session") instanceof Map ses) { + if (ses != null) { int cs = num(ses, "start"), ce = num(ses, "end"); int b = startBefore - cs; if (b != 0) { @@ -359,6 +413,10 @@ public final class StratEdits { if (n == null) { n = com.eliasmueller.llamamod.format.Strats.name(a.img.data, code, a.img.codeStart); } + int k = customIndex(s, a.img.unitOf(code)); + if (k >= 0 && state(s).customFields.get(k) != null) { + n = state(s).customFields.get(k); // parameter names of the type the fields come from + } ParamSchema.TypeSchema ts = Schemas.of(n); ParamSchema.ParamDef d = ts == null ? null : ts.param(i); return d == null ? null : ident(d.name()); @@ -368,9 +426,30 @@ public final class StratEdits { public String entry(int code) { return cat.nameOf(code); } + + @Override + public String unitName(int u) { + int k = customIndex(s, u); + return k >= 0 ? state(s).customNames.get(k) : null; + } }); } + /** Script names of a type's parameters from its object schema: name → "vN" (empty if unknown). */ + public static Map paramNames(String type) { + Map m = new LinkedHashMap<>(); + ParamSchema.TypeSchema ts = type == null ? null : Schemas.of(type); + if (ts != null) { + for (ParamSchema.ParamDef d : ts.params()) { + String n = ident(d.name()); + if (n != null && !m.containsKey(n)) { + m.put(n, "v" + d.index()); + } + } + } + return m; + } + static String ident(String label) { StringBuilder b = new StringBuilder(); boolean up = false; @@ -519,14 +598,28 @@ 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); - return build(a.img.data, s.settings().wfpc(), a.entries, t); + return build(a.img.data, s.settings().wfpc(), a.entries, t, customUnits(s)); } - /** Assembles a type's source as a new unit appended to the STPC data old and verifies it. */ - static Built build(byte[] old, int wfpc, Set entries, CustomType t) { + /** Unit index of each custom type of the scene by name (for unit:Name operands). */ + static Map customUnits(Scene s) { + State st = state(s); + Map m = new LinkedHashMap<>(); + for (int i = 0; i < st.customNames.size(); i++) { + m.put(st.customNames.get(i), st.firstCustom + i); + } + return m; + } + + /** + * Assembles a type's source as a new unit appended to the STPC data old and verifies it; + * names = unit index of the custom types before it (unit:Name). + */ + static Built build(byte[] old, int wfpc, Set entries, CustomType t, Map names) { StpcImage img0 = new StpcImage(old, wfpc); int base = old.length + 4; - StratAssembler.Result r = StratAssembler.assemble(t.source, StratListing.layout(img0, base)); + StratAssembler.Result r = StratAssembler.assemble( + t.source, StratListing.layout(img0, base).withUnitNames(names)); Integer e = r.labels().get(t.entryLabel); if (e == null) { throw new IllegalArgumentException("entry label " + t.entryLabel + " not found in the source"); @@ -628,11 +721,14 @@ public final class StratEdits { st.firstCustom = unit; } st.customEntries.add(b.entry); + st.customNames.add(t.name); + st.customFields.add(t.fieldsFrom); return b.entry; } /** Adds a custom type to an open project (reference + scene) and records it. Returns the entry code. */ public static int addType(Project p, CustomType t) { + t = t.withSource(symbolic(p.scene(), t.source)); appendType(p.reference(), t, false); int code = appendType(p.scene(), t, true); Records r = Records.of(p.extra()); @@ -642,6 +738,36 @@ public final class StratEdits { return code; } + private static final java.util.regex.Pattern UNIT_REF = java.util.regex.Pattern.compile("\\bunit:(\\d+)\\b"); + + /** Source with unit:N operands that point at custom types written as unit:TypeName (survives reordering). */ + public static String symbolic(Scene s, String src) { + State st = state(s); + return symbolic(src, st.firstCustom, st.customNames); + } + + static String symbolic(String src, int first, List names) { + if (first < 0 || names.isEmpty()) { + return src; + } + java.util.regex.Matcher m = UNIT_REF.matcher(src); + StringBuilder b = new StringBuilder(); + while (m.find()) { + int k = Integer.parseInt(m.group(1)) - first; + String rep = k >= 0 && k < names.size() ? "unit:" + names.get(k) : m.group(); + m.appendReplacement(b, java.util.regex.Matcher.quoteReplacement(rep)); + } + m.appendTail(b); + return b.toString(); + } + + /** Source with unit:Old operands renamed to unit:New. */ + static String renameUnitRefs(String src, String oldName, String newName) { + return src.replaceAll( + "\\bunit:" + java.util.regex.Pattern.quote(oldName) + "(?![\\w])", + java.util.regex.Matcher.quoteReplacement("unit:" + newName)); + } + /** 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); @@ -678,9 +804,19 @@ public final class StratEdits { Set entries = new TreeSet<>(analysis(s).entries); entries.removeIf(o -> o >= d.length); byte[] cur = d; + int unit = new StpcImage(d, s.settings().wfpc()).units.size(); + Map names = new LinkedHashMap<>(); for (int i = 0; i < types.size(); i++) { CustomType t = types.get(i); - Built x = build(cur, s.settings().wfpc(), entries, t); + Built x; + try { + x = build(cur, s.settings().wfpc(), entries, t, names); + } catch (StratAssembler.AsmException e) { + if (i == index) { + throw e; + } + return List.of(t.name + ": " + e.getMessage()); + } if (!x.problems.isEmpty()) { return x.problems.stream().map(m -> t.name + ": " + m).toList(); } @@ -691,6 +827,7 @@ public final class StratEdits { for (int v : x.asm.strats().values()) { entries.add(x.base + v); } + names.put(t.name, unit + i); cur = x.stpc; } return null; @@ -709,7 +846,7 @@ public final class StratEdits { } } for (Scene s : List.of(p.reference(), p.scene())) { - Runnable r = () -> rebuild(s, types, remap); + Runnable r = () -> append(s, types, remap, cut(s)); if (s == p.scene()) { s.bulk(r); } else { @@ -720,20 +857,24 @@ public final class StratEdits { new Records(rec.constants, List.copyOf(types)).store(p.extra(), session(p.scene())); } - private static void rebuild(Scene s, List types, int[] remap) { + /** Objects of the custom types while their code is cut off: custom type index and old code; old entries. */ + private record Detached(Map objects, List entries) {} + + /** Cuts the custom units off a scene's code and removes them from the catalog (objects keep code 0). */ + private static Detached cut(Scene s) { State st = state(s); List oldEntries = new ArrayList<>(st.customEntries); Analysis a = analysis(s); int from = st.firstCustom < 0 ? a.img.data.length : a.img.units.get(st.firstCustom).start() - 4; - int to = a.img.data.length; - List oldUnitOf = new ArrayList<>(), oldCode = new ArrayList<>(); + int to = st.firstCustom < 0 + ? a.img.data.length + : a.img.units.get(st.firstCustom + st.customEntries.size() - 1).end(); + Map objects = new java.util.IdentityHashMap<>(); 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) { + if (x.code() >= from && x.code() < to) { + objects.put(x, new int[] {a.img.unitOf(x.code()) - st.firstCustom, x.code()}); s.set(x, "code", 0); // keep stale codes out of the analysis roots while appending } } @@ -751,28 +892,61 @@ public final class StratEdits { chunk(s).setRaw("data", cut); st.firstCustom = -1; st.customEntries.clear(); + st.customNames.clear(); + st.customFields.clear(); + return new Detached(objects, oldEntries); + } + + /** Appends types to a scene whose custom units were cut off; objects of old type k follow type remap[k]. */ + private static void append(Scene s, List types, int[] remap, Detached old) { + State st = state(s); for (CustomType t : types) { appendType(s, t, false); } - List strats = List.copyOf(s.strats()); - for (int i = 0; i < strats.size(); i++) { - int k = oldUnitOf.get(i); - if (k < 0) { + for (Strat x : s.strats()) { + int[] o = old.objects.get(x); + if (o == null) { continue; } - Strat x = strats.get(i); - int nk = k < remap.length ? remap[k] : -1; + int k = o[0]; + int nk = k >= 0 && 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); + int oldEntry = old.entries.get(k); // objects placed at the entry follow the entry; other codes keep their offset in the unit - int c0 = oldCode.get(i); + int c0 = o[1]; int code = c0 == oldEntry ? st.customEntries.get(nk) : c0 - oldEntry + st.customEntries.get(nk); s.set(x, "code", code); } } + /** + * Runs an import of object types (CrossImport) with the custom units cut off first and + * appended again afterwards, so that they stay the last units (as on load) and can still be + * edited and deleted. + */ + static void aroundImport(Project p, Runnable commit) { + List types = records(p).types; + if (state(p.scene()).firstCustom < 0 || types.isEmpty() || rebuildBlocker(p.scene()) != null) { + commit.run(); + return; + } + Detached r = cut(p.reference()); + Detached[] sc = new Detached[1]; + p.scene().bulk(() -> sc[0] = cut(p.scene())); + commit.run(); + int[] id = TypeEdits.identity(types.size()); + append(p.reference(), types, id, r); + p.scene().bulk(() -> append(p.scene(), types, id, sc[0])); + updateSession(p); + } + + /** STPC data an import merges into: the code without the custom units (null = the scene's own). */ + static byte[] importBase(Scene s) { + return state(s).firstCustom < 0 || rebuildBlocker(s) != null ? null : withoutCustom(s); + } + /** Objects of the scene whose code lies in custom type index k's unit. */ public static List users(Scene s, int k) { State st = state(s); @@ -802,6 +976,7 @@ public final class StratEdits { m.put("start", (long) (a.img.units.get(st.firstCustom).start() - 4)); m.put("end", (long) a.img.units.get(Math.min(last, a.img.units.size() - 1)).end()); + m.put("firstUnit", (long) st.firstCustom); return m; } diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeDialog.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeDialog.java index bf35c9a..c880118 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeDialog.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeDialog.java @@ -83,7 +83,7 @@ final class TypeDialog { 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). */ + /** Edits custom type k in place: its name, entry label, source and (optionally) where its type fields come from. */ static void showEdit(EditorContext ctx, int k) { if (ctx.document() == null) { return; @@ -105,7 +105,7 @@ final class TypeDialog { "Edit object type " + t.name(), t.name(), t.source(), - t.basedOn(), + t.fieldsFrom(), t.entryLabel(), t.basedOn() != null && !t.basedOn().isEmpty(), k); @@ -170,6 +170,13 @@ final class TypeDialog { return n; } + static final String KEEP = "(keep current fields)"; + + /** Parameter names (from the object schema) the script may use for the chosen fields type. */ + private static java.util.Map names(String fieldsType) { + return StratEdits.paramNames(fieldsType); + } + private static void open( EditorContext ctx, String title, @@ -183,14 +190,19 @@ final class TypeDialog { JTextField nameField = new JTextField(name, 18); JTextField entryField = new JTextField(entry, 14); JComboBox from = new JComboBox<>(); - ctx.scene().catalog().types().forEach(t -> from.addItem(t.name())); - from.setSelectedItem(fieldsFrom); 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"); + from.addItem(KEEP); + from.setToolTipText("New objects of the type start with these type fields and parameters;" + + " objects already placed keep theirs"); } + ctx.scene().catalog().types().forEach(t -> { + if (!t.name().equals(oldName)) { + from.addItem(t.name()); + } + }); + from.setSelectedItem(editing ? KEEP : fieldsFrom); String script = StratCompiler.embeddedSource(source); JTextArea text = new JTextArea(script != null ? script : source, 30, 90); JComboBox lang = new JComboBox<>(new String[] {"Assembly", "Script"}); @@ -206,8 +218,9 @@ final class TypeDialog { top.add(entryField); top.add(new JLabel("Language")); top.add(lang); - top.add(new JLabel("Type fields / parameters from")); - top.add(from); + JPanel fieldsRow = new JPanel(new FlowLayout(FlowLayout.LEADING)); + fieldsRow.add(new JLabel("Type fields / parameters from")); + fieldsRow.add(from); JButton check = new JButton("Check"); JButton create = new JButton(editing ? "Apply" : "Create type"); JButton cancel = new JButton("Cancel"); @@ -228,7 +241,10 @@ final class TypeDialog { : "") + "Syntax: notes/strat-language.md (manual chapter “Strat scripts”). The new code is appended to the level and verified by disassembling it again.")); JPanel north = new JPanel(new BorderLayout()); - north.add(top, BorderLayout.NORTH); + JPanel rows = new JPanel(new java.awt.GridLayout(2, 1)); + rows.add(top); + rows.add(fieldsRow); + north.add(rows, BorderLayout.NORTH); north.add(help, BorderLayout.SOUTH); d.getContentPane().add(north, BorderLayout.NORTH); d.getContentPane().add(new JScrollPane(text), BorderLayout.CENTER); @@ -243,7 +259,8 @@ final class TypeDialog { String cur = text.getText(); if (to == 0) { try { - text.setText(StratCompiler.compile(cur)); + String ft = (String) from.getSelectedItem(); + text.setText(StratCompiler.compile(cur, names(KEEP.equals(ft) ? fieldsFrom : ft))); } catch (StratCompiler.CompileException ex) { result.setText("⚠ " + ex.getMessage() + " – fix the script before switching to assembly"); highlight(text, ex.line); @@ -281,9 +298,11 @@ final class TypeDialog { return; } String src = text.getText(); + String chosen = (String) from.getSelectedItem(); + String fieldsType = KEEP.equals(chosen) ? fieldsFrom : chosen; if (lang.getSelectedIndex() == 1) { try { - src = StratCompiler.compile(src); + src = StratCompiler.compile(src, names(fieldsType)); } catch (StratCompiler.CompileException e) { result.setText("⚠ " + e.getMessage()); highlight(text, e.line); @@ -294,12 +313,18 @@ final class TypeDialog { entryField.setText(first); } } - StratCatalog.Type ft = s.catalog().type((String) from.getSelectedItem()); + StratCatalog.Type ft = s.catalog().type(chosen); if (editing) { StratEdits.CustomType old = StratEdits.records(ctx.document().project()).types().get(editIndex); + boolean keep = KEEP.equals(chosen) || ft == null; StratEdits.CustomType t = new StratEdits.CustomType( - n, old.basedOn(), entryField.getText().trim(), src, old.proto()); + n, + old.basedOn(), + entryField.getText().trim(), + src, + keep ? old.proto() : ft.variant(0), + keep ? old.fieldsFrom() : chosen); List all = new java.util.ArrayList<>( StratEdits.records(ctx.document().project()).types()); all.set(editIndex, t); @@ -330,7 +355,7 @@ final class TypeDialog { return; } StratEdits.CustomType t = new StratEdits.CustomType( - n, clone ? fieldsFrom : null, entryField.getText().trim(), src, ft.variant(0)); + n, clone ? fieldsFrom : null, entryField.getText().trim(), src, ft.variant(0), chosen); try { StratEdits.Built b = StratEdits.build(s, t); if (!b.problems().isEmpty()) { diff --git a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeEdits.java b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeEdits.java index cea072a..24e7a6c 100644 --- a/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeEdits.java +++ b/src/main/java/com/eliasmueller/llamamod/editor/tools/strats/TypeEdits.java @@ -44,16 +44,26 @@ final class TypeEdits { static Command create(Project p, StratEdits.CustomType t) { List before = StratEdits.records(p).types(); List after = new ArrayList<>(before); - after.add(t); + after.add(t.withSource(StratEdits.symbolic(p.scene(), t.source()))); 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). */ + /** + * Replaces custom type k by t (objects of the type stay and use the new code); a new name is + * also changed in the unit:Name references of the other types. + */ static Command edit(Project p, int k, StratEdits.CustomType t) { List before = StratEdits.records(p).types(); List after = new ArrayList<>(before); - after.set(k, t); + after.set(k, t.withSource(StratEdits.symbolic(p.scene(), t.source()))); + String oldName = before.get(k).name(); + if (!oldName.equals(t.name())) { + for (int i = 0; i < after.size(); i++) { + StratEdits.CustomType x = after.get(i); + after.set(i, x.withSource(StratEdits.renameUnitRefs(x.source(), oldName, t.name()))); + } + } return new SetTypes( p, "Edit object type " + t.name(), before, after, identity(before.size()), identity(before.size())); } diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/OpTable.java b/src/main/java/com/eliasmueller/llamamod/format/strat/OpTable.java index 405685b..e97cfa9 100644 --- a/src/main/java/com/eliasmueller/llamamod/format/strat/OpTable.java +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/OpTable.java @@ -34,6 +34,9 @@ public final class OpTable { private final Op[] ops = new Op[StratOps.COUNT]; private final Map byName = new HashMap<>(); + /** Former mnemonics (lower case) that still assemble (resource op-aliases.tsv). */ + private final Map aliases = new HashMap<>(); + private final Map props = new HashMap<>(); private final Map propsByName = new HashMap<>(); private final Map globals = new HashMap<>(); @@ -64,6 +67,14 @@ public final class OpTable { byName.put(name, ops[i]); } } + for (String[] f : rows("op-aliases.tsv")) { + Op op = f.length < 2 ? null : byName.get(lc(f[1].trim())); + String old = lc(f[0].trim()); + if (op != null && !old.isEmpty() && !byName.containsKey(old)) { + byName.put(old, op); + aliases.put(old, op); + } + } named("props.tsv", props, propsByName, true); named("globals.tsv", globals, globalsByName, false); } @@ -128,7 +139,12 @@ public final class OpTable { return ops[code]; } - /** Opcode by mnemonic (case-insensitive) or "op_XX", or null. */ + /** The opcode a former mnemonic now names, or null if name is not an old alias. */ + public Op alias(String name) { + return aliases.get(lc(name)); + } + + /** Opcode by mnemonic (case-insensitive, current or former name) or "op_XX", or null. */ public Op byName(String name) { Op o = byName.get(lc(name)); if (o == null && lc(name).startsWith("op_")) { diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/StratAssembler.java b/src/main/java/com/eliasmueller/llamamod/format/strat/StratAssembler.java index 0a74d9e..dbabd6a 100644 --- a/src/main/java/com/eliasmueller/llamamod/format/strat/StratAssembler.java +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/StratAssembler.java @@ -16,9 +16,19 @@ public final class StratAssembler { * unit's first word; models/anims/units = STPC offsets of model headers, animation sets, unit * starts. */ - public record Layout(int base, List models, List anims, List units) { + public record Layout( + int base, List models, List anims, List units, Map unitNames) { + public Layout(int base, List models, List anims, List units) { + this(base, models, anims, units, Map.of()); + } + public Layout withBase(int b) { - return new Layout(b, models, anims, units); + return new Layout(b, models, anims, units, unitNames); + } + + /** The same layout where unit:Name resolves to the unit with that index (type names). */ + public Layout withUnitNames(Map names) { + return new Layout(base, models, anims, units, Map.copyOf(names)); } } @@ -662,7 +672,17 @@ public final class StratAssembler { add = d == null ? 0 : d; r = r.substring(0, plus); } - Integer idx = number(ln, r); + String key = r.trim(); + Integer idx = kind.equals("unit") && key.matches("[A-Za-z_]\\w*") + ? l.unitNames().get(key) + : null; + if (idx == null && kind.equals("unit") && key.matches("[A-Za-z_]\\w*")) { + err(ln, "unknown unit name '" + key + "' (custom object types are named, others use unit:N)"); + return null; + } + if (idx == null) { + idx = number(ln, r); + } if (idx == null) { return null; } diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/StratCompiler.java b/src/main/java/com/eliasmueller/llamamod/format/strat/StratCompiler.java index 73bc975..9aeb4a8 100644 --- a/src/main/java/com/eliasmueller/llamamod/format/strat/StratCompiler.java +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/StratCompiler.java @@ -10,8 +10,9 @@ 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). + * assembly of {@link StratAssembler}. Numbers: a plain integer is in game units ({@code 10} = 10.0), + * {@code 10r} and hex are raw words, {@code 1.5} and {@code 90deg} as in the assembly. Scripts + * compiled before this rule ({@code use rawNumbers}) read plain integers as raw words. */ public final class StratCompiler { /** A compile error at a 1-based source line. */ @@ -27,8 +28,24 @@ public final class StratCompiler { /** Prefix of the comment lines that carry the script source inside the generated assembly. */ public static final String SOURCE_PREFIX = ";| "; + /** Marker line behind the embedded source of scripts with unit numbers (language version 2). */ + static final String VERSION_MARK = "; script language 2"; + + /** Statement that keeps the old number rule (plain integers are raw words). */ + public static final String RAW_NUMBERS = "use rawNumbers"; + private record Tok(String kind, String text, int line) {} + /** + * Expression tree. num: value (raw word) and text (assembly literal); ins: one instruction that + * pushes a value; bin/neg/not: operator over kids; call: instruction text after the kids. + */ + private record Node(String kind, String text, int value, List kids, int line) { + boolean isNum() { + return kind.equals("num"); + } + } + private static final Map PREC = Map.ofEntries( Map.entry("||", 1), Map.entry("&&", 2), @@ -78,43 +95,78 @@ public final class StratCompiler { private static final List REFS = List.of("model", "anim", "unit", "sound", "tex", "text"); + /** Named event types for {@code on name { … }}. */ + private static final Map EVENTS = Map.of("touched", 4); + private final OpTable ops = OpTable.get(); private final List toks; private int p; - private final StringBuilder out = new StringBuilder(); + private StringBuilder out = new StringBuilder(); + /** Event handler code of the current strat/sub, placed behind its code. */ + private StringBuilder deferred = new StringBuilder(); + /** Event entries and switch tables, placed at the start of the unit. */ + private final StringBuilder tables = new StringBuilder(); + private final Map aliases = new HashMap<>(); private int labels; - /** Enclosing loops: {break label, continue label}. */ - private final Deque loops = new ArrayDeque<>(); + private int eventSlot; + private boolean rawNumbers; + /** Enclosing loops/switches: {break label, continue label (null = none)}. */ + private Deque loops = new ArrayDeque<>(); - private StratCompiler(String src) { + private StratCompiler(String src, Map names) { toks = lex(src); + names.forEach((n, v) -> { + if (v.matches("v\\d+") && n.matches("[A-Za-z_]\\w*")) { + aliases.put(n, v); + } + }); } /** 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(); + return compile(src, Map.of()); } - /** The script embedded in compiled assembly, or null if the text is not compiler output. */ + /** + * Compiles a script; names = predefined variable names (parameter names of the object schema → + * "vN"), overridden by the script's own aliases. + */ + public static String compile(String src, Map names) { + StratCompiler c = new StratCompiler(src, names); + StringBuilder b = new StringBuilder(); + for (String l : src.split("\n", -1)) { + b.append(SOURCE_PREFIX).append(l.stripTrailing()).append('\n'); + } + b.append(VERSION_MARK).append('\n'); + c.program(); + // tables first: data behind a terminating instruction would be tried as dead code + return b.append(c.tables).append(c.out).toString(); + } + + /** + * The script embedded in compiled assembly, or null if the text is not compiler output. Scripts + * compiled with the old number rule get a {@value #RAW_NUMBERS} line, so they mean the same. + */ public static String embeddedSource(String asm) { if (!asm.startsWith(SOURCE_PREFIX)) { return null; } StringBuilder b = new StringBuilder(); + boolean v2 = false; for (String l : asm.split("\n", -1)) { if (!l.startsWith(SOURCE_PREFIX.trim())) { + v2 = l.equals(VERSION_MARK); break; } b.append(l.length() >= SOURCE_PREFIX.length() ? l.substring(SOURCE_PREFIX.length()) : "") .append('\n'); } - return b.toString().stripTrailing() + "\n"; + String src = b.toString().stripTrailing() + "\n"; + if (!v2 && !src.contains(RAW_NUMBERS)) { + src = RAW_NUMBERS + " // written when a plain 10 meant 10 raw units (1/4096)\n" + src; + } + return src; } /** Entry label of the first strat of a script (null if none). */ @@ -165,6 +217,10 @@ public final class StratCompiler { } if (s.startsWith("deg", j)) { j += 3; + } else if (j < s.length() + && s.charAt(j) == 'r' + && (j + 1 >= s.length() || !Character.isLetterOrDigit(s.charAt(j + 1)))) { + j++; } } out.add(new Tok("num", s.substring(i, j), line)); @@ -215,10 +271,6 @@ public final class StratCompiler { 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++; @@ -270,10 +322,12 @@ public final class StratCompiler { out.append(" .string \"").append(name).append("\"\n"); place(name); emit("debugName \"" + name + "\""); + eventSlot = 0; boolean ends = block(); if (!ends) { emit("end"); } + flushDeferred(); } else if (accept("sub")) { String name = ident(); place(name); @@ -283,14 +337,20 @@ public final class StratCompiler { if (!ends) { emit("return"); } - } else if (at("alias")) { + flushDeferred(); + } else if (at("alias") || at("use")) { statement(); } else { - throw err("expected 'strat', 'sub' or 'alias' but found " + describe(peek())); + throw err("expected 'strat', 'sub', 'alias' or 'use' but found " + describe(peek())); } } } + private void flushDeferred() { + out.append(deferred); + deferred = new StringBuilder(); + } + /** Parses { statements }; returns whether control never falls out of its end. */ private boolean block() { expect("{"); @@ -319,7 +379,7 @@ public final class StratCompiler { String top = label(), done = label(); place(top); expect("("); - cond(done); + jumpIfFalse(expr(), done); expect(")"); loops.push(new String[] {done, top}); loopBody(top); @@ -347,18 +407,35 @@ public final class StratCompiler { expect("while"); expect("("); place(check); - expr(); + jumpIfTrue(expr(), top); expect(")"); - emit("jnz " + top); place(done); return false; } + case "switch" -> { + next(); + switchStatement(); + return false; + } + case "on" -> { + next(); + onEvent(t); + return false; + } case "break", "continue" -> { next(); - if (loops.isEmpty()) { - throw new CompileException(t.line, t.text + " outside a loop"); + boolean brk = t.text.equals("break"); + String target = null; + for (String[] l : loops) { + target = brk ? l[0] : l[1]; + if (target != null) { + break; + } } - emit("jmp " + loops.peek()[t.text.equals("break") ? 0 : 1]); + if (target == null) { + throw new CompileException(t.line, t.text + " outside a " + (brk ? "loop or switch" : "loop")); + } + emit("jmp " + target); return true; } case "yield" -> { @@ -399,6 +476,15 @@ public final class StratCompiler { out.append(" .alias ").append(n).append(" = ").append(v).append('\n'); return false; } + case "use" -> { + next(); + String what = ident(); + if (!what.equals("rawNumbers")) { + throw new CompileException(t.line, "unknown option " + what + " (use rawNumbers)"); + } + rawNumbers = true; + return false; + } default -> { // fall through to assignments and calls } @@ -407,12 +493,13 @@ public final class StratCompiler { throw err("expected a statement but found " + describe(t)); } if (isOp(t.text) && !isLvalueStart()) { - String mn = callExpr(); - OpTable.Op op = ops.byName(mn); + Node call = callExpr(); + value(call); + OpTable.Op op = ops.byName(call.text.split(" ")[0]); if (op.pushes() == 1) { emit("pop"); } - return mn.equalsIgnoreCase("end") || mn.equalsIgnoreCase("remove"); + return op.name().equalsIgnoreCase("end") || op.name().equalsIgnoreCase("remove"); } assignment(); return false; @@ -421,7 +508,7 @@ public final class StratCompiler { private void ifRest() { String other = label(); expect("("); - cond(other); + jumpIfFalse(expr(), other); expect(")"); block(); if (accept("else")) { @@ -439,29 +526,157 @@ public final class StratCompiler { } } - /** 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; + boolean yieldEdge = false; while (!at("}")) { if (peek().kind.equals("eof")) { throw err("missing '}'"); } - mark = at("yield") && peek(1).text.equals("}") ? out.length() : -1; - if (mark >= 0) { + if (at("yield") && peek(1).text.equals("}")) { next(); + yieldEdge = true; break; } statement(); } expect("}"); - emit((mark >= 0 ? "jmpYield " : "jmp ") + top); + emit((yieldEdge ? "jmpYield " : "jmp ") + top); + } + + /** + * switch (e) { case c: … default: … }: C rules (cases fall through, break leaves the switch). + * Compiles to the VM's switch table (placed behind the strat) and a jump to default or the end. + */ + private void switchStatement() { + Tok t = peek(); + expect("("); + Node v = expr(); + expect(")"); + expect("{"); + String table = "sw_" + (++labels), done = label(); + value(v); + emit("switch " + table); + int jmpAt = out.length(); + emit("jmp " + done); + List cases = new ArrayList<>(); + String dflt = null; + String cont = loops.isEmpty() ? null : loops.peek()[1]; + loops.push(new String[] {done, cont}); + while (!at("}")) { + if (peek().kind.equals("eof")) { + throw err("missing '}'"); + } + if (accept("case")) { + Tok ct = peek(); + Node c = expr(); + if (!c.isNum()) { + throw new CompileException(ct.line, "a case value must be a constant"); + } + expect(":"); + String l = label(); + for (String[] x : cases) { + if (Integer.parseInt(x[0]) == c.value) { + throw new CompileException(ct.line, "duplicate case " + c.text); + } + } + cases.add(new String[] {Integer.toString(c.value), l}); + place(l); + } else if (accept("default")) { + expect(":"); + if (dflt != null) { + throw new CompileException(t.line, "two default labels"); + } + dflt = label(); + place(dflt); + } else { + statement(); + } + } + expect("}"); + loops.pop(); + place(done); + if (dflt != null) { + out.replace(jmpAt, jmpAt + (" jmp " + done + "\n").length(), " jmp " + dflt + "\n"); + } + if (cases.isEmpty()) { + throw new CompileException(t.line, "a switch needs at least one case"); + } + tables.append(table).append(":\n"); + tables.append(" .switch ").append(cases.size()).append('\n'); + for (String[] c : cases) { + int x = Integer.parseInt(c[0]); + boolean small = x >= Short.MIN_VALUE && x <= Short.MAX_VALUE; + tables.append(" .case ") + .append(small ? "" : "imm ") + .append(Num.literal(x)) + .append(", ") + .append(c[1]) + .append('\n'); + } + } + + /** + * on touched { … } / on event(type[, param]) [slot n] { … }: installs an event handler here + * (onEvent); the handler body is placed behind the strat and ends with return. + */ + private void onEvent(Tok t) { + String type, param = "0"; + if (accept("event")) { + expect("("); + type = rawWord(); + if (accept(",")) { + param = rawWord(); + } + expect(")"); + } else { + String n = ident(); + Integer k = EVENTS.get(n); + if (k == null) { + throw new CompileException(t.line, "unknown event " + n + " (touched, or event(type, param))"); + } + type = Integer.toString(k); + } + int slot = eventSlot; + if (accept("slot")) { + slot = Num.parse(rawWord()); + } + eventSlot = Math.max(eventSlot, slot + 1); + String entry = "ev_" + (++labels), handler = "on_" + labels; + emit("onEvent " + entry + ", " + slot); + // the handler: compiled into its own buffer with its own loop context + StringBuilder saved = out; + Deque savedLoops = loops; + out = new StringBuilder(); + loops = new ArrayDeque<>(); + place(handler); + if (!block()) { + emit("return"); + } + StringBuilder body = out; + out = saved; + loops = savedLoops; + tables.append(entry).append(":\n"); + tables.append(" .event ") + .append(type) + .append(", ") + .append(param) + .append(", ") + .append(handler) + .append('\n'); + deferred.append(body); + } + + /** A raw word operand (event type/param, slot): number as in the assembly. */ + private String rawWord() { + boolean neg = accept("-"); + Tok t = next(); + String s = (neg ? "-" : "") + (t.text.endsWith("r") ? t.text.substring(0, t.text.length() - 1) : t.text); + if (!t.kind.equals("num") || Num.parse(s) == null) { + throw new CompileException(t.line, "expected a number but found " + describe(t)); + } + return s; } private boolean isOp(String name) { @@ -505,7 +720,7 @@ public final class StratCompiler { } throw new CompileException( t.line, - "unknown name " + a + " (variables are vN or aliases, globals gN, " + "unknown name " + a + " (variables are vN, aliases or parameter names, globals gN, " + "properties self.name, player.name, …)"); } @@ -515,18 +730,17 @@ public final class StratCompiler { String op = next().text; switch (op) { case "=" -> { - expr(); + value(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(); + Node e = expr(); + if ((op.equals("+=") || op.equals("-=")) && pl.addr != null && e.isNum() && e.value == Num.ONE) { emit(pl.addr); emit(op.equals("+=") ? "inc" : "dec"); } else { emit(pl.push); - expr(); + value(e); emit(BINOP.get(op.substring(0, 1))); emit(pl.pop); } @@ -535,45 +749,44 @@ public final class StratCompiler { } } - // ------------------------------------------------------------------ expressions + // ------------------------------------------------------------------ expressions (parse) - private void expr() { - binary(1); + private Node expr() { + return binary(1); } - private void binary(int min) { - unary(); + private Node binary(int min) { + Node l = 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)); + Tok t = next(); + Node r = binary(PREC.get(t.text) + 1); + l = fold(new Node("bin", t.text, 0, List.of(l, r), t.line)); } + return l; } - private void unary() { + private Node unary() { + Tok t = peek(); if (accept("-")) { - if (peek().kind.equals("num")) { - number("-" + next().text); - return; - } - unary(); - emit("neg"); - } else if (accept("!")) { - unary(); - emit("not"); - } else { - primary(); + return fold(new Node("neg", "-", 0, List.of(unary()), t.line)); } + if (accept("!")) { + return fold(new Node("not", "!", 0, List.of(unary()), t.line)); + } + return primary(); } - private void primary() { + private Node primary() { Tok t = peek(); if (t.kind.equals("num")) { - number(next().text); - } else if (accept("(")) { - expr(); + return number(next()); + } + if (accept("(")) { + Node e = expr(); expect(")"); - } else if (t.kind.equals("id") && REFS.contains(t.text) && peek(1).text.equals(":")) { + return e; + } + if (t.kind.equals("id") && REFS.contains(t.text) && peek(1).text.equals(":")) { next(); next(); StringBuilder ref = new StringBuilder(t.text + ":" + next().text); @@ -581,34 +794,46 @@ public final class StratCompiler { next(); ref.append('+').append(next().text); } - switch (t.text) { - case "sound" -> emit("soundAddress " + ref); - case "tex" -> emit("push " + ref); + return switch (t.text) { + case "sound" -> new Node("ins", "soundAddress " + ref, 0, List.of(), t.line); + case "tex" -> new Node("ins", "push " + ref, 0, List.of(), t.line); case "text" -> throw new CompileException(t.line, "text:N is an inline operand: topSay()"); - default -> emit("address " + ref); - } - } else if (t.kind.equals("id") && isOp(t.text) && !isLvalueStart()) { - String mn = callExpr(); - if (ops.byName(mn).pushes() != 1) { - throw new CompileException(t.line, mn + " gives no value"); - } - } else if (t.kind.equals("id")) { - emit(place().push); - } else { - throw err("expected a value but found " + describe(t)); + default -> new Node("ins", "address " + ref, 0, List.of(), t.line); + }; } + if (t.kind.equals("id") && isOp(t.text) && !isLvalueStart()) { + Node c = callExpr(); + if (ops.byName(c.text.split(" ")[0]).pushes() != 1) { + throw new CompileException(t.line, c.text.split(" ")[0] + " gives no value"); + } + return c; + } + if (t.kind.equals("id")) { + return new Node("ins", place().push, 0, List.of(), t.line); + } + throw err("expected a value but found " + describe(t)); } - private void number(String text) { + /** A number token: plain integers are game units (×4096) unless rawNumbers; "r" = raw. */ + private Node number(Tok t) { + String s = t.text; + String text; + if (s.endsWith("r")) { + text = s.substring(0, s.length() - 1); + } else if (rawNumbers || s.startsWith("0x") || s.startsWith("0X") || s.contains(".") || s.endsWith("deg")) { + text = s; + } else { + text = s + ".0"; + } Integer v = Num.parse(text); if (v == null) { - throw err("bad number " + text); + throw new CompileException(t.line, "bad number " + s); } - emit((v >= Short.MIN_VALUE && v <= Short.MAX_VALUE ? "pushShort " : "push ") + text); + return new Node("num", text, v, List.of(), t.line); } - /** op(args): pushes the arguments and emits the opcode; returns its mnemonic. */ - private String callExpr() { + /** op(args): a call node; the argument count must match the opcode's pops. */ + private Node callExpr() { Tok t = peek(); String mn = ident(); OpTable.Op op = ops.byName(mn); @@ -625,28 +850,150 @@ public final class StratCompiler { inline.add(cur.toString()); cur.setLength(0); } else { - cur.append(x.text); + cur.append( + x.kind.equals("num") && x.text.endsWith("r") + ? x.text.substring(0, x.text.length() - 1) + : x.text); } } expect(">"); inline.add(cur.toString()); } - int args = 0; + List args = new ArrayList<>(); if (accept("(")) { while (!at(")")) { - expr(); - args++; + args.add(expr()); if (!accept(",")) { break; } } expect(")"); } - if (op.pops() >= 0 && args != op.pops()) { + if (op.pops() >= 0 && args.size() != op.pops()) { throw new CompileException( - t.line, mn + " takes " + op.pops() + " value" + (op.pops() == 1 ? "" : "s") + ", got " + args); + t.line, + mn + " takes " + op.pops() + " value" + (op.pops() == 1 ? "" : "s") + ", got " + args.size()); + } + return new Node("call", mn + (inline.isEmpty() ? "" : " " + String.join(", ", inline)), 0, args, t.line); + } + + // ------------------------------------------------------------------ constant folding + + /** Folds operators over constants with the VM's integer semantics (20.12 mul/div). */ + private static Node fold(Node n) { + if (!n.kids.stream().allMatch(Node::isNum)) { + return n; + } + int a = n.kids.get(0).value; + Integer v = + switch (n.kind) { + case "neg" -> -a; + case "not" -> a == 0 ? 1 : 0; + default -> { + int b = n.kids.get(1).value; + yield switch (n.text) { + case "+" -> a + b; + case "-" -> a - b; + case "*" -> (int) (((long) a * b) >> 12); + case "/" -> b == 0 ? null : ((a << 8) / b) << 4; + case "&" -> a & b; + case "|", "||" -> a | b; + case "&&" -> a != 0 && b != 0 ? 1 : 0; + case "==" -> a == b ? 1 : 0; + case "!=" -> a != b ? 1 : 0; + case "<" -> a < b ? 1 : 0; + case ">" -> a > b ? 1 : 0; + case "<=" -> a <= b ? 1 : 0; + case ">=" -> a >= b ? 1 : 0; + case "<<" -> a << (b >> 12); + case ">>" -> a >> (b >> 12); + default -> null; + }; + } + }; + if (v == null) { + return n; + } + String text = + n.kind.equals("neg") && !n.kids.get(0).text.startsWith("-") ? "-" + n.kids.get(0).text : Num.literal(v); + return new Node("num", text, v, List.of(), n.line); + } + + // ------------------------------------------------------------------ code generation + + /** Code that pushes the value of n (&& and || as the and/or opcodes: both sides evaluated). */ + private void value(Node n) { + switch (n.kind) { + case "num" -> emit( + (n.value >= Short.MIN_VALUE && n.value <= Short.MAX_VALUE ? "pushShort " : "push ") + n.text); + case "ins" -> emit(n.text); + case "neg" -> { + value(n.kids.get(0)); + emit("neg"); + } + case "not" -> { + value(n.kids.get(0)); + emit("not"); + } + case "bin" -> { + value(n.kids.get(0)); + value(n.kids.get(1)); + emit(BINOP.get(n.text)); + } + case "call" -> { + n.kids.forEach(this::value); + emit(n.text); + } + default -> throw new IllegalStateException(n.kind); + } + } + + /** Jumps to target when n is false (0), falls through otherwise; && and || short-circuit. */ + private void jumpIfFalse(Node n, String target) { + if (n.isNum()) { + if (n.value == 0) { + emit("jmp " + target); + } + } else if (n.kind.equals("not") && isLogic(n.kids.get(0))) { + jumpIfTrue(n.kids.get(0), target); + } else if (n.kind.equals("bin") && n.text.equals("&&")) { + jumpIfFalse(n.kids.get(0), target); + jumpIfFalse(n.kids.get(1), target); + } else if (n.kind.equals("bin") && n.text.equals("||")) { + String ok = label(); + jumpIfTrue(n.kids.get(0), ok); + jumpIfFalse(n.kids.get(1), target); + place(ok); + } else { + value(n); + emit("jz " + target); + } + } + + /** && / || / ! (whose negation is folded into the jumps); a plain !x stays "not" + jump. */ + private static boolean isLogic(Node n) { + return n.kind.equals("not") || n.kind.equals("bin") && (n.text.equals("&&") || n.text.equals("||")); + } + + /** Jumps to target when n is true (non-zero), falls through otherwise. */ + private void jumpIfTrue(Node n, String target) { + if (n.isNum()) { + if (n.value != 0) { + emit("jmp " + target); + } + } else if (n.kind.equals("not") && isLogic(n.kids.get(0))) { + jumpIfFalse(n.kids.get(0), target); + } else if (n.kind.equals("bin") && n.text.equals("||")) { + jumpIfTrue(n.kids.get(0), target); + jumpIfTrue(n.kids.get(1), target); + } else if (n.kind.equals("bin") && n.text.equals("&&")) { + String no = label(); + jumpIfFalse(n.kids.get(0), no); + jumpIfTrue(n.kids.get(1), target); + place(no); + } else { + value(n); + emit("jnz " + target); } - emit(mn + (inline.isEmpty() ? "" : " " + String.join(", ", inline))); - return mn; } } diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/StratListing.java b/src/main/java/com/eliasmueller/llamamod/format/strat/StratListing.java index 5a31055..0fa5f05 100644 --- a/src/main/java/com/eliasmueller/llamamod/format/strat/StratListing.java +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/StratListing.java @@ -86,6 +86,11 @@ public final class StratListing { return null; } + /** Symbolic name of another unit for "unit:Name" operands (null = by index). */ + default String unitName(int unit) { + return null; + } + Names NONE = new Names() {}; } @@ -323,6 +328,10 @@ public final class StratListing { /** Reference text for an absolute STPC offset (operand of pushAddr). */ String addrRef(int v) { + return addrRef(v, Names.NONE); + } + + String addrRef(int v, Names names) { if (in(v) && labels.containsKey(v)) { return "@" + labels.get(v); } @@ -336,7 +345,9 @@ public final class StratListing { } int u = img.unitOf(v); if (u >= 0 && u != unit) { - return "unit:" + u + plus(v - img.units.get(u).start()); + String n = names.unitName(u); + return "unit:" + (n != null ? n : Integer.toString(u)) + + plus(v - img.units.get(u).start()); } return Num.integer(v); } @@ -673,7 +684,7 @@ public final class StratListing { } case StratOps.PUSH_ADDR -> { int v = w(o + 4); - args.add(addrRef(v)); + args.add(addrRef(v, names)); comment = addrComment(v, names); } case StratOps.PUSH_SOUND -> { diff --git a/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java b/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java index f3b9f8e..977cef7 100644 --- a/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java +++ b/src/main/java/com/eliasmueller/llamamod/format/strat/StratPseudo.java @@ -183,6 +183,7 @@ public final class StratPseudo { } } Map> tables = switchTables(); + findChains(); // candidate blocks List cand = new ArrayList<>(); Map elseOf = new HashMap<>(); @@ -199,7 +200,7 @@ public final class StratPseudo { continue; } Integer t = labelIdx.get(operand(l)); - if (t == null) { + if (t == null || chainMember.contains(i)) { continue; } if (condJump(op) && t > i && clean(i, t)) { @@ -292,6 +293,9 @@ public final class StratPseudo { } opensAt.values().forEach(l -> l.sort((a, b) -> Integer.compare(b.close, a.close))); resolveJumps(accepted, consumed); + for (int m : chainMember) { + consumed.merge(ls.get(target(m)).label(), 1, Integer::sum); + } Map refs = new HashMap<>(); for (StratListing.Line l : ls) { if (l.kind() == StratListing.Kind.INSTR || l.kind() == StratListing.Kind.DATA) { @@ -342,7 +346,8 @@ public final class StratPseudo { out(l.offset(), l.label() + ":"); } for (Block b : opensAt.getOrDefault(i, List.of())) { - out(-1, b.kind.equals("do") ? "do {" : "loop {"); + String name = loopNames.get(b.open); + out(-1, (name == null ? "" : name + ": ") + (b.kind.equals("do") ? "do {" : "loop {")); depth++; } } @@ -356,6 +361,7 @@ public final class StratPseudo { flush(); whileLoops(); elseIfChains(); + andChains(); } private final Map caseOwner = new HashMap<>(); @@ -375,6 +381,8 @@ public final class StratPseudo { } 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); + // a jump from the then-part to the else label enters the else part: not a nested if + ok &= !(b.kind.equals("if") && b.close == a.close && b.open > a.open); } if (!ok) { return false; @@ -442,19 +450,22 @@ public final class StratPseudo { // 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; - } + // a default part behind the cases (compiled switches): a case body jumps over it + int last = d < e ? e : d; + int found = -1; + for (int i = s + 2; i < ls.size() && i < (d < e ? ls.size() : d) && found < 0; i++) { + if (ls.get(i).kind() == StratListing.Kind.INSTR && ls.get(i).op() == 0x16) { + Integer t = target(i); + if (t != null && t > last) { + found = t; } } - if (endIdx < 0) { - return; - } + } + if (d < e && found < 0) { + return; + } + if (found >= 0) { + endIdx = found; cs.add(new int[] {d, -1}); } if (!clean(s, endIdx)) { @@ -487,7 +498,8 @@ public final class StratPseudo { || ifAt.containsKey(i) || loopEnd.containsKey(i) || elseJumps.contains(i) - || silentJumps.contains(i)) { + || silentJumps.contains(i) + || chainMember.contains(i)) { continue; } Integer t = target(i); @@ -503,15 +515,20 @@ public final class StratPseudo { 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 (in != null) { + int endAt = in.kind.equals("switch") ? in.close : in.close + 1; + if (anchor(t) == anchor(endAt)) { + as = "break"; + } else if (in.isLoop() && anchor(t) == anchor(in.open)) { + as = "continue"; + } + } + if (as == null) { + as = terminal(anchor(t)); + } + if (as == null) { + as = labeledJump(accepted, in, i, t); } if (as != null) { jumpAs.put(i, as); @@ -520,24 +537,306 @@ public final class StratPseudo { } } + // ---------------------------------------------------------- short-circuit condition chains + + /** Conditional jumps folded into the condition of a later one (chain members). */ + private final Set chainMember = new HashSet<>(); + + /** Last jump of a chain → its members in order and the body condition. */ + private record Chain(List members, Cond body) {} + + private final Map chains = new HashMap<>(); + + private final List pendingChain = new ArrayList<>(); + private int chainOff = -1; + + /** Boolean formula over the jumps of a chain: T, F, L (leaf k: jump k is taken; pos=false negated), AND, OR. */ + private record Cond(String kind, int k, boolean pos, Cond a, Cond b) { + static final Cond TRUE = new Cond("T", 0, true, null, null), FALSE = new Cond("F", 0, true, null, null); + + static Cond leaf(int k) { + return new Cond("L", k, true, null, null); + } + + Cond not() { + return new Cond("L", k, !pos, null, null); + } + + static Cond and(Cond x, Cond y) { + return y == TRUE ? x : x == TRUE ? y : new Cond("AND", 0, true, x, y); + } + + static Cond or(Cond x, Cond y) { + return y == FALSE ? x : x == FALSE ? y : new Cond("OR", 0, true, x, y); + } + + /** "if j then a else b" as and/or of j, a and b, or null if it does not simplify. */ + static Cond ite(Cond j, Cond a, Cond b) { + if (a == TRUE) { + return or(j, b); + } + if (a == FALSE) { + return and(j.not(), b); + } + if (b == TRUE) { + return or(j.not(), a); + } + if (b == FALSE) { + return and(j, a); + } + if (b.kind.equals("OR") && b.b.equals(a)) { + return or(and(j.not(), b.a), a); + } + if (b.kind.equals("AND") && b.b.equals(a)) { + return and(or(j, b.a), a); + } + return null; + } + } + + /** + * Finds chains of conditional jumps that compiled || / && conditions leave ("a; jnz T; b; jz F; + * T:"): separated only by expression code, each going to the false target F of the last one, + * to the code right behind it, or to the start of a later member's expression; at least one + * member (an else part would otherwise be hidden behind nested ifs that all jump to it). + */ + private void findChains() { + for (int n = ls.size() - 1; n >= 0; n--) { + if (ls.get(n).kind() != StratListing.Kind.INSTR + || !condJump(ls.get(n).op()) + || chainMember.contains(n)) { + continue; + } + Integer f = target(n); + if (f == null || f <= n) { + continue; + } + int body = anchor(n + 1); + // members backwards: segment starts (anchors) → formula from there + List members = new ArrayList<>(); + Map from = new HashMap<>(); + Cond last = Cond.leaf(n).not(); + int seg = segmentStart(n); + from.put(seg, last); + Cond best = null; + List bestMembers = null; + Cond cur = last; + while (seg > 0) { + int j = seg - 1; + while (j >= 0 && ls.get(j).kind() == StratListing.Kind.LABEL) { + j--; + } + if (j < 0 + || ls.get(j).kind() != StratListing.Kind.INSTR + || !condJump(ls.get(j).op()) + || chainMember.contains(j)) { + break; + } + Integer t = target(j); + if (t == null || t <= j || !labelsOnlyFrom(j, seg)) { + break; + } + int at = anchor(t); + Cond whenJumps = at == body ? Cond.TRUE : at == anchor(f) ? Cond.FALSE : from.get(at); + if (whenJumps == null) { + break; + } + cur = Cond.ite(Cond.leaf(j), whenJumps, cur); + if (cur == null) { + break; + } + members.add(0, j); + seg = segmentStart(j); + if (seg < 0) { + break; + } + from.put(seg, cur); + best = cur; + bestMembers = List.copyOf(members); + } + if (best == null) { + continue; + } + chainMember.addAll(bestMembers); + chains.put(n, new Chain(bestMembers, best)); + } + } + + /** Labels between line indexes j and seg are only targets of conditional jumps at or before j. */ + private boolean labelsOnlyFrom(int j, int seg) { + for (int k = j + 1; k < seg; k++) { + String lb = ls.get(k).label(); + for (int x = 0; x < ls.size(); x++) { + StratListing.Line l = ls.get(x); + boolean uses = l.kind() != StratListing.Kind.LABEL + && List.of(l.text().split("[ ,@+]+")).contains(lb); + if (uses && (x > j || l.kind() != StratListing.Kind.INSTR || !condJump(l.op()))) { + return false; + } + } + } + return true; + } + + /** + * Start (line index) of the expression code that the conditional jump at j pops: the lines + * before it must be pure expression code pushing exactly one value; −1 if not. + */ + private int segmentStart(int j) { + int need = 1; + for (int k = j - 1; k >= 0; k--) { + StratListing.Line l = ls.get(k); + if (l.kind() != StratListing.Kind.INSTR) { + return -1; + } + int d = stackEffect(l); + if (d == Integer.MIN_VALUE) { + return -1; + } + need -= d; + if (need == 0) { + return k; + } + if (need < 0) { + return -1; + } + } + return -1; + } + + /** Net pushes of a pure expression instruction (MIN_VALUE if it is not one). */ + private int stackEffect(StratListing.Line l) { + int op = l.op(); + if (PUSH.containsKey(op) + || ADDR.containsKey(op) + || op == StratOps.PUSH_SHORT + || op == StratOps.PUSH_IMM + || op == StratOps.PUSH_ADDR + || op == StratOps.PUSH_SOUND) { + return 1; + } + if (BINARY.containsKey(mn(l))) { + return -1; + } + if (op == 0x46 || op == 0xaf) { + return 0; + } + OpTable.Op info = ops.op(op); + if (info.pushes() == 1 && info.pops() >= 0 && op >= StratOps.PUSH_IMM && op != StratOps.SPAWN) { + return 1 - info.pops(); + } + return Integer.MIN_VALUE; + } + + /** Text of a chain's body condition; leaves: members' expressions (pendingChain), last = e. */ + private String chainCondition(int n, Chain c, String e) { + Map text = new HashMap<>(); + for (int k = 0; k < c.members.size(); k++) { + text.put(c.members.get(k), k < pendingChain.size() ? pendingChain.get(k) : "pop()"); + } + text.put(n, e); + pendingChain.clear(); + return render(c.body, text); + } + + private String render(Cond c, Map text) { + return switch (c.kind) { + case "L" -> { + int op = ls.get(c.k).op(); + boolean jnz = op == 0x13 || op == 0x15; + String e = text.get(c.k); + yield jnz == c.pos ? e : not(e); + } + case "AND" -> andOperand(render(c.a, text)) + " && " + andOperand(render(c.b, text)); + case "OR" -> orOperand(render(c.a, text)) + " || " + orOperand(render(c.b, text)); + default -> c.kind.equals("T") ? "1r" : "0"; + }; + } + + /** Parenthesizes an || operand with a top-level && (for reading). */ + private static String orOperand(String c) { + return topLevel(c).contains("&&") ? "(" + c + ")" : c; + } + + private static String not(String e) { + if (e.startsWith("!(") && e.endsWith(")") && balanced(e.substring(2, e.length() - 1))) { + return e.substring(2, e.length() - 1); + } + return e.matches("[\\w.]+") ? "!" + e : "!(" + e + ")"; + } + + /** Loops that a labeled break/continue names: open line index → name. */ + private final Map loopNames = new HashMap<>(); + + /** "break name"/"continue name" for a jump from line i to t out of an outer loop (null if none). */ + private String labeledJump(List accepted, Block inner, int i, int t) { + Block best = null; + String as = null; + for (Block b : accepted) { + if (b == inner || !(b.isLoop() || b.kind.equals("do")) || b.open >= i || i >= b.close) { + continue; + } + String k = anchor(t) == anchor(b.close + 1) + ? "break" + : b.isLoop() && anchor(t) == anchor(b.open) ? "continue" : null; + if (k != null && (best == null || b.open > best.open)) { + best = b; + as = k; + } + } + if (best == null || inner == null) { + return null; + } + String name = ls.get(best.open).label(); + loopNames.put(best.open, name); + return as + " " + name; + } + + /** + * "return"/"end"/"remove" when the instruction at line index i ends the strat or subroutine, + * so that a jump to it is the same as executing it (null otherwise). + */ + private String terminal(int i) { + if (i >= ls.size() || ls.get(i).kind() != StratListing.Kind.INSTR) { + return null; + } + int op = ls.get(i).op(); + return op == StratOps.JUMP_POP ? "return" : op == StratOps.END || op == 0x8e ? mn(ls.get(i)) : null; + } + /** "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 {") + if (a.text.endsWith("loop {") + && (a.text.equals("loop {") || a.text.matches("\\w+: 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 + ") {")); + String w = breakCondition(b.text); lines.remove(i + 1); + // further "if (c) break" lines at the top: while (w && !c) + while (i + 1 < lines.size() + && lines.get(i + 1).depth == a.depth + 1 + && lines.get(i + 1).text.startsWith("if (") + && lines.get(i + 1).text.endsWith(") break")) { + w = andOperand(w) + " && " + andOperand(breakCondition(lines.get(i + 1).text)); + lines.remove(i + 1); + } + String name = a.text.equals("loop {") ? "" : a.text.substring(0, a.text.length() - 6); + lines.set(i, new Line(b.offset, a.depth, name + "while (" + w + ") {")); } } } + /** The loop condition that "if (c) break" leaves: !c. */ + private static String breakCondition(String line) { + String c = line.substring(4, line.length() - 7); + return 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 + ")"; + } + /** "} 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++) { @@ -591,6 +890,59 @@ public final class StratPseudo { } } + /** "if (a) {" whose whole body is "if (b) {…}" becomes "if (a && b) {…}". */ + private void andChains() { + for (int i = 0; i + 1 < lines.size(); ) { + Line a = lines.get(i), b = lines.get(i + 1); + int close = -1; + if (isPlainIf(a.text) && isPlainIf(b.text) && b.depth == a.depth + 1) { + for (int k = i + 2; k < lines.size(); k++) { + Line x = lines.get(k); + if (x.depth <= b.depth) { + close = x.depth == b.depth && x.text.equals("}") ? k : -1; + break; + } + } + } + if (close < 0 + || close + 1 >= lines.size() + || !lines.get(close + 1).text.equals("}") + || lines.get(close + 1).depth != a.depth) { + i++; + continue; + } + String c = andOperand(a.text.substring(4, a.text.length() - 3)) + " && " + + andOperand(b.text.substring(4, b.text.length() - 3)); + lines.set(i, new Line(a.offset, a.depth, "if (" + c + ") {")); + lines.remove(close + 1); + lines.remove(i + 1); + 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)); + } + } + } + + /** c with everything inside brackets blanked out. */ + private static String topLevel(String c) { + StringBuilder top = new StringBuilder(); + int d = 0; + for (char ch : c.toCharArray()) { + d += ch == '(' ? 1 : ch == ')' ? -1 : 0; + top.append(d == 0 ? ch : ' '); + } + return top.toString(); + } + + private static boolean isPlainIf(String t) { + return t.startsWith("if (") && t.endsWith(") {") && balanced(t.substring(4, t.length() - 3)); + } + + private static String andOperand(String c) { + String t = topLevel(c); + return t.contains("||") || t.contains(" | ") || t.contains(" & ") ? "(" + c + ")" : c; + } + private static boolean nests(int a0, int a1, int b0, int b1) { return a1 <= b0 || b1 <= a0 || (a0 >= b0 && a1 <= b1) || (b0 >= a0 && b1 <= a1); } @@ -686,7 +1038,9 @@ public final class StratPseudo { || op == StratOps.PUSH_IMM || op == StratOps.PUSH_ADDR || op == StratOps.PUSH_SOUND) { - push(off, a + (l.comment() != null && l.comment().startsWith("strat") ? " /*" + l.comment() + "*/" : "")); + // the script language reads a plain integer as units: raw words get the "r" suffix + String v = a.matches("-?[1-9]\\d*") ? a + "r" : a; + push(off, v + (l.comment() != null && l.comment().startsWith("strat") ? " /*" + l.comment() + "*/" : "")); return; } if (op == 0x47 || op == 0x48) { @@ -709,11 +1063,23 @@ public final class StratPseudo { push(off, "!" + pop()); return; } + if (condJump(op) && chainMember.contains(i)) { + if (pendingChain.isEmpty()) { + chainOff = begin(off); + } + pendingChain.add(strip(pop())); + return; + } if (condJump(op)) { String e = strip(pop()); boolean neg = op == 0x13 || op == 0x15; - String yield = op <= 0x13 ? " /*yield*/" : ""; int b0 = begin(off); + if (chains.containsKey(i)) { + e = chainCondition(i, chains.get(i), e); + neg = false; + b0 = chainOff; + } + String yield = op <= 0x13 ? " /*yield*/" : ""; flush(); Block lp = loopEnd.get(i); String jumpIf = neg ? e : "!(" + e + ")"; @@ -723,8 +1089,9 @@ public final class StratPseudo { } 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)); + if (last != null && last.text.endsWith("do {") && last.depth == depth) { + String name = last.text.substring(0, last.text.length() - 4); + lines.set(lines.size() - 1, new Line(b0, depth, name + "do {} while (" + jumpIf + ")" + yield)); } else { out(b0, "} while (" + jumpIf + ")" + yield); } diff --git a/src/main/resources/com/eliasmueller/llamamod/format/strat/op-aliases.tsv b/src/main/resources/com/eliasmueller/llamamod/format/strat/op-aliases.tsv new file mode 100644 index 0000000..8ce71d1 --- /dev/null +++ b/src/main/resources/com/eliasmueller/llamamod/format/strat/op-aliases.tsv @@ -0,0 +1,5 @@ +# old mnemonic current mnemonic (old names still assemble; stored sources keep working) +flagEC10000 freezeOffCamera +flagEC2 touchable +flagEC100 noFloorPhysics +hudReset2 hudResetNoop diff --git a/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv b/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv index e7cf0cb..2fc898f 100644 --- a/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv +++ b/src/main/resources/com/eliasmueller/llamamod/format/strat/ops.tsv @@ -46,11 +46,11 @@ 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 = "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 - +0x2f freezeOffCamera 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 = "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 - +0x31 touchable 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 - +0x33 noFloorPhysics 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 - @@ -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 FUN_004268c0(2), which returns at once for any argument other than 0 (verified in the assembly): a no-op. Unused. high - +0x14a hudResetNoop - 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 - diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratEditsTest.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratEditsTest.java index d9e4c8c..01bd8ad 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratEditsTest.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratEditsTest.java @@ -213,4 +213,142 @@ class StratEditsTest { } throw new AssertionError(); } + + /** Import in the same session behind custom types: they stay last, can be edited/deleted, creating is undoable. */ + @Test + void customTypesAfterImportInTheSameSession() throws Exception { + Bases cd = cd(); + StratEdits.init(); + CrossImport.init(); + Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "imp", "t1l1m001"), cd), cd); + History h = new History(p.scene()); + h.execute(TypeEdits.create(p, StratEdits.cloneOf(p.scene(), "Coin", "CoinA"))); + h.execute(Edits.placeStrat(p.scene(), "CoinA", 0, 30, 2, 30, 0.0)); + Strat a = p.scene().strats().get(p.scene().strats().size() - 1); + CrossImport.importInto(p, List.of(new CrossImport.TypeRef("t1l2m001", "Spider", spiderCode(cd))), cd); + h.clear(); + int units = StratEdits.analysis(p.scene()).img().units.size(); + assertEquals(units - 1, StratEdits.firstCustomUnit(p.scene()), "the custom unit is the last one again"); + assertEquals(null, StratEdits.rebuildBlocker(p.scene())); + assertEquals(entry(p, "CoinA"), a.code()); + assertEquals("CoinA", nameAt(WadBuilder.build(p.scene()), a.code())); + assertNotNull(p.scene().catalog().type("Spider")); + assertTrue(StratEdits.analysis(p.scene()).code().errors.isEmpty()); + assertArrayEquals( + StratEdits.chunk(p.reference()).data(), + StratEdits.chunk(p.scene()).data(), + "reference = scene"); + + // create (undoable), edit, delete + h.execute(TypeEdits.create(p, StratEdits.cloneOf(p.scene(), "Coin", "CoinB"))); + assertEquals(2, StratEdits.records(p).types().size()); + h.undo(); + assertEquals(1, StratEdits.records(p).types().size()); + assertTrue(p.scene().catalog().type("CoinB") == null); + h.redo(); + StratEdits.CustomType ta = StratEdits.records(p).types().get(0); + h.execute(TypeEdits.edit(p, 0, withSource(ta, ta.source().replace("push 5.625deg", "push 90deg")))); + assertEquals(0x400000, spin(p, "CoinA").value()); + assertEquals(entry(p, "CoinA"), a.code()); + byte[] wad = WadBuilder.build(p.scene()); + Project q = ProjectIO.read(ProjectIO.write(p, cd), cd); + assertTrue(q.warnings().isEmpty(), q.warnings().toString()); + assertArrayEquals(wad, WadBuilder.build(q.scene()), "reloaded project builds the same WAD"); + h.execute(TypeEdits.delete(p, 0)); + assertTrue(!p.scene().strats().contains(a)); + h.undo(); + assertEquals(entry(p, "CoinA"), a.code()); + } + + /** unit:N operands that point at custom types are stored by name and follow the type when indexes shift. */ + @Test + void referencesBetweenCustomTypesAreSymbolic() throws Exception { + Bases cd = cd(); + StratEdits.init(); + Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "refs", "t1l1m001"), cd), cd); + History h = new History(p.scene()); + h.execute(TypeEdits.create(p, StratEdits.cloneOf(p.scene(), "Coin", "First"))); + h.execute(TypeEdits.create(p, StratEdits.cloneOf(p.scene(), "Coin", "Target"))); + int target = StratEdits.firstCustomUnit(p.scene()) + 1; + int off = entry(p, "Target") + - StratEdits.analysis(p.scene()).img().units.get(target).start(); + String src = + """ + .unit RefB + .string "RefB" + RefB: + debugName "RefB" + address unit:%d+%d + pop + spin: + jmpYield spin + end + """ + .formatted(target, off); + StratCatalog.Variant v = p.scene().catalog().type("Coin").variant(0); + h.execute(TypeEdits.create(p, new StratEdits.CustomType("RefB", null, "RefB", src, v, "Coin"))); + assertTrue(StratEdits.records(p).types().get(2).source().contains("address unit:Target+" + off)); + int tu = StratEdits.analysis(p.scene()).img().unitOf(entry(p, "Target")); + assertEquals(StratEdits.analysis(p.scene()).img().units.get(tu).start() + off, addressIn(p, "RefB")); + // delete the first type: Target moves one unit down, RefB still points at it + h.execute(TypeEdits.delete(p, 0)); + tu = StratEdits.analysis(p.scene()).img().unitOf(entry(p, "Target")); + assertEquals(StratEdits.analysis(p.scene()).img().units.get(tu).start() + off, addressIn(p, "RefB")); + int ru = StratEdits.analysis(p.scene()).img().unitOf(entry(p, "RefB")); + assertTrue(StratEdits.listing(p.scene(), ru).text().contains("unit:Target+0x" + Integer.toHexString(off))); + // renaming the target renames the reference; deleting it is refused + StratEdits.CustomType t = StratEdits.records(p).types().get(0); + h.execute(TypeEdits.edit( + p, 0, new StratEdits.CustomType("Goal", t.basedOn(), t.entryLabel(), t.source(), t.proto()))); + assertTrue(StratEdits.records(p).types().get(1).source().contains("unit:Goal+" + off)); + List rest = List.of(StratEdits.records(p).types().get(1)); + List problems = StratEdits.checkTypes(p.scene(), rest, -1, null); + assertNotNull(problems); + assertTrue(problems.get(0).contains("unknown unit name"), problems.toString()); + 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())); + } + + /** Value of the first address operand in a type's unit. */ + static int addressIn(Project p, String type) { + StratEdits.Analysis a = StratEdits.analysis(p.scene()); + int u = a.img().unitOf(entry(p, type)); + for (StratCode.Site s : a.code().addrSites) { + if (a.img().unitOf(s.offset()) == u) { + return s.value(); + } + } + throw new AssertionError("no address operand in " + type); + } + + /** Edit can switch the type the fields come from; it is saved. */ + @Test + void editChangesTheFieldsType() throws Exception { + Bases cd = cd(); + StratEdits.init(); + Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "fields", "t1l1m001"), cd), cd); + History h = new History(p.scene()); + StratEdits.CustomType t = StratEdits.cloneOf(p.scene(), "Coin", "CoinF"); + h.execute(TypeEdits.create(p, t)); + String other = p.scene().catalog().types().stream() + .map(StratCatalog.Type::name) + .filter(n -> !n.equals("Coin") + && !n.equals("CoinF") + && p.scene().catalog().type(n).variant(0).nVars() + != p.scene().catalog().type("Coin").variant(0).nVars()) + .findFirst() + .orElseThrow(); + StratCatalog.Variant ov = p.scene().catalog().type(other).variant(0); + h.execute(TypeEdits.edit( + p, 0, new StratEdits.CustomType("CoinF", "Coin", t.entryLabel(), t.source(), ov, other))); + assertEquals(ov.nVars(), p.scene().catalog().type("CoinF").variant(0).nVars()); + Project q = ProjectIO.read(ProjectIO.write(p, cd), cd); + assertEquals(other, StratEdits.records(q).types().get(0).fieldsFrom()); + assertEquals("Coin", StratEdits.records(q).types().get(0).basedOn()); + h.undo(); + assertEquals( + p.scene().catalog().type("Coin").variant(0).nVars(), + p.scene().catalog().type("CoinF").variant(0).nVars()); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsGameLevel.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsGameLevel.java index 7fd2a5d..4cedbb4 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsGameLevel.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsGameLevel.java @@ -69,9 +69,64 @@ public final class StratsGameLevel { } """; + /** Script language 2: switch over a state that an event handler advances, unit numbers, && / ||. */ + static final String TOUCHER = + """ + // grows each time the player touches it: 1x, 2x, 3x, then spins fast + strat Toucher { + alias hits = v5 + alias cooldown = v6 + setModel(model:5) + collisionOn<0x20000>() + collRadius(0.6) + self.y = self.y + 1 + hits = 0 + cooldown = 0 + on touched { + if (cooldown == 0 && hits < 3) { + hits += 1 + cooldown = 20 + soundPlay(sound:6) + } + } + loop { + if (cooldown > 0 || hits > 9) { + cooldown -= 1 + } + switch (hits) { + case 0: + scale(1) + turnLeft(2deg) + break + case 1: + scale(2) + turnLeft(4deg) + break + case 2: + scale(3) + turnLeft(8deg) + break + default: + scale(3) + turnLeft(45deg) + } + yield + } + } + """; + + /** The same with a timer event (type 0x40) instead of touched: grows without the player. */ + static final String TICKER = TOUCHER.replace("strat Toucher", "strat Ticker") + .replace("on touched", "on event(0x40, 30)") + .replace(" collisionOn<0x20000>()\n collRadius(0.6)\n", ""); + 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("v2")) { + v2(cd, Path.of(a[0])); + return; + } 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()); @@ -119,4 +174,42 @@ public final class StratsGameLevel { System.out.println("saved " + a[0] + " (" + p.scene().strats().size() + " objects, start " + sx + "," + sy + "," + sz + ")"); } + + /** + * Village 1: a custom type, then an import (Spider) in the same session, then the custom type + * edited (3x scale) and a compiled script type created (Toucher, 2.5 cells ahead of the start). + */ + static void v2(Bases cd, Path out) throws Exception { + com.eliasmueller.llamamod.editor.tools.catalog.CrossImport.init(); + Project p = ProjectIO.read(ProjectIO.write(Project.create(cd, "strats2", "t1l1m001"), cd), cd); + History h = new History(p.scene()); + StratEdits.CustomType big = StratEdits.cloneOf(p.scene(), "Coin", "BigCoin"); + h.execute(TypeEdits.create(p, big)); + com.eliasmueller.llamamod.editor.tools.catalog.CrossImport.importInto( + p, + List.of(new com.eliasmueller.llamamod.editor.tools.catalog.CrossImport.TypeRef( + "t1l2m001", "Spider", StratEditsTest.spiderCode(cd))), + cd); + h.clear(); + String src = + big.source().replace(" setModel\n", " setModel\n pushShort 3.0\n scale\n"); + h.execute(TypeEdits.edit(p, 0, big.withSource(src))); + String asm = StratCompiler.compile(TOUCHER, StratEdits.paramNames("Coin")); + h.execute(TypeEdits.create(p, new StratEdits.CustomType("Toucher", null, "Toucher", asm, big.proto(), "Coin"))); + Strat start = p.scene().playerStart(); + double sx = start.x() / 4096.0, sy = start.y() / 4096.0, sz = start.z() / 4096.0; + String tick = StratCompiler.compile(TICKER, StratEdits.paramNames("Coin")); + h.execute(TypeEdits.create(p, new StratEdits.CustomType("Ticker", null, "Ticker", tick, big.proto(), "Coin"))); + // the start view looks along +z (the row of the first in-game test is in front of it) + h.execute(Edits.placeStrat(p.scene(), "Toucher", 0, sx, sy, sz + 2.5, 0.0)); + h.execute(Edits.placeStrat(p.scene(), "Ticker", 0, 30.0, 8.0, 64.6, 0.0)); + String[] types = {"Coin", "BigCoin", "Spider"}; + for (int i = 0; i < types.length; i++) { + h.execute(Edits.placeStrat(p.scene(), types[i], 0, 31.5 + 1.5 * i, 8.0, 64.6, 0.0)); + } + System.out.println( + "types: " + StratEdits.customNames(p) + ", start " + sx + "," + sy + "," + sz + " yaw " + start.ry()); + ProjectIO.save(p, out, cd); + System.out.println("saved " + out); + } } diff --git a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsTour.java b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsTour.java index 86c7bb0..53c3b0e 100644 --- a/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsTour.java +++ b/src/test/java/com/eliasmueller/llamamod/editor/tools/strats/StratsTour.java @@ -44,12 +44,13 @@ public final class StratsTour { 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()))); + String asm = StratCompiler.compile(StratsGameLevel.TOUCHER, StratEdits.paramNames("Coin")); + w.execute(TypeEdits.create( + p, new StratEdits.CustomType("Toucher", null, "Toucher", asm, big.proto(), "Coin"))); 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(), "Toucher", 0, x + 1, y, z + 2, 0.0)); + w.execute(Edits.placeStrat(w.scene(), "Toucher", 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); diff --git a/src/test/java/com/eliasmueller/llamamod/format/strat/StratCompilerTest.java b/src/test/java/com/eliasmueller/llamamod/format/strat/StratCompilerTest.java index 425099b..c64313b 100644 --- a/src/test/java/com/eliasmueller/llamamod/format/strat/StratCompilerTest.java +++ b/src/test/java/com/eliasmueller/llamamod/format/strat/StratCompilerTest.java @@ -50,7 +50,7 @@ class StratCompilerTest { 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("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()"), @@ -60,13 +60,176 @@ class StratCompilerTest { } @Test - void numbersMatchTheAssembly() { - String asm = StratCompiler.compile("strat N { v0 = 1.5\n v1 = 100.0\n v2 = 90deg\n v3 = -3 }"); + void plainIntegersAreUnits() { + String asm = StratCompiler.compile( + "strat N { v0 = 1.5\n v1 = 100\n v2 = 90deg\n v3 = -3\n v4 = 215r\n v5 = 0x10\n v6 = -7r }"); 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.contains("pushShort -3.0\n"), asm); + assertTrue(asm.contains("pushShort 215\n"), asm); + assertTrue(asm.contains("pushShort 0x10\n"), asm); + assertTrue(asm.contains("pushShort -7\n"), asm); assertTrue(asm.trim().endsWith("end"), asm); + // the old rule: plain integers are raw words + String old = StratCompiler.compile("use rawNumbers\nstrat N { v3 = -3\n v4 = 10 }"); + assertTrue(old.contains("pushShort -3\n") && old.contains("pushShort 10\n"), old); + } + + @Test + void scriptsCompiledWithTheOldRuleKeepTheirMeaning() { + // generated before language version 2: no marker line behind the embedded source + String asm = ";| strat N {\n;| v0 = 10\n;| }\n .string \"N\"\nN:\n end\n"; + String src = StratCompiler.embeddedSource(asm); + assertTrue(src.startsWith(StratCompiler.RAW_NUMBERS), src); + assertTrue(StratCompiler.compile(src).contains("pushShort 10\n")); + // new output: the source comes back unchanged + String v2 = StratCompiler.compile("strat N {\n v0 = 10\n}"); + assertEquals("strat N {\n v0 = 10\n}\n", StratCompiler.embeddedSource(v2)); + } + + static String view(String script, String entry) { + return StratPseudo.of(StratPseudoTest.listing(StratCompiler.compile(script), entry)) + .text(); + } + + @Test + void foldsConstants() { + String asm = StratCompiler.compile("strat N { v0 = 2 * 3 + 0.5\n v1 = -(1 << 4)\n v2 = 90deg / 2 }"); + assertTrue(asm.contains("pushShort 6.5\n"), asm); + assertTrue(asm.contains("push -16.0\n"), asm); + assertTrue(asm.contains("push 512.0\n"), asm); // 45deg + assertFalse(asm.contains("mul") || asm.contains("add\n") || asm.contains("shl"), asm); + } + + @Test + void shortCircuitConditions() { + String script = + """ + strat C { + if (v0 > 2 && v1 || !v2) { + turnLeft(4deg) + } + while (v3 < 3 && v4) { + v3 += 1 + } + } + """; + String asm = StratCompiler.compile(script); + assertFalse(asm.contains(" and\n") || asm.contains(" or\n"), asm); + String v = view(script, "C"); + assertFalse(v.contains("goto"), v); + assertTrue(v.contains("if ((v0 > 2.0 && v1) || !v2) {\n turnLeft(4deg)\n}"), v); + assertTrue(v.contains("while (v3 < 3.0 && v4) {\n v3 += 1.0\n}"), v); + // value context: both sides evaluated (the and/or opcodes) + assertTrue(StratCompiler.compile("strat D { v0 = v1 && v2 }").contains(" and\n")); + } + + @Test + void switchWithFallThroughAndDefault() { + String script = + """ + strat S { + loop { + switch (v0) { + case 1: + v1 = 2 + break + case 2: + case 3: + v1 = 100000 + default: + v1 = 0 + } + yield + } + } + """; + String asm = StratCompiler.compile(script); + assertTrue(asm.contains(".switch 3\n .case 1.0, "), asm); + String v = view(script, "S"); + assertFalse(v.contains("goto"), v); + assertTrue( + v.contains("case 1.0:\n v1 = 2.0\n break\n case 2.0:\n case 3.0:\n" + + " v1 = 100000.0\n default:\n v1 = 0\n }"), + v); + CompileExceptionCheck.fails("strat S { switch (v0) { case v1: end } }", "constant"); + CompileExceptionCheck.fails("strat S { switch (v0) { case 1: case 1: end } }", "duplicate"); + } + + @Test + void eventHandlers() { + String script = + """ + strat E { + on touched { + soundPlay(sound:6) + remove + } + on event(0x40, 30) slot 3 { + v0 += 1 + } + loop { yield } + } + """; + String asm = StratCompiler.compile(script); + assertTrue(asm.contains("onEvent ev_1, 0\n"), asm); + assertTrue(asm.contains("onEvent ev_2, 3\n"), asm); + assertTrue(asm.indexOf(".event 4, 0, on_1") < asm.indexOf("\nE:\n"), "entries before the code: " + asm); + String v = view(script, "E"); + assertTrue(v.contains("onEvent ev_0000, 0 // event type 4 → "), v); + assertTrue(v.contains("soundPlay(sound:6)\nremove\n"), v); + assertTrue(v.contains("v0 += 1.0\nreturn\n"), v); + assertFalse(v.contains("goto"), v); + } + + @Test + void parameterNamesFromTheSchema() { + String asm = StratCompiler.compile( + "strat P { if (snapToFloor) { self.y = self.floorHeight } }", java.util.Map.of("snapToFloor", "v0")); + assertTrue(asm.contains("pushVar v0\n"), asm); + // the script's own alias wins + asm = StratCompiler.compile( + "strat P { alias snapToFloor = v4\n v0 = snapToFloor }", java.util.Map.of("snapToFloor", "v0")); + assertTrue(asm.contains("pushVar v4\n"), asm); + } + + /** Old and new opcode names assemble to the same words (op-aliases.tsv). */ + @Test + void renamedOpcodesKeepTheirOldNames() { + StratAssembler.Layout l = + new StratAssembler.Layout(16, java.util.List.of(), java.util.List.of(), java.util.List.of()); + int[] a = StratAssembler.assemble("x:\n flagEC10000 1\n flagEC2 1\n flagEC100 0\n hudReset2\n end", l) + .words(); + int[] b = StratAssembler.assemble( + "x:\n freezeOffCamera 1\n touchable 1\n noFloorPhysics 0\n hudResetNoop\n end", l) + .words(); + org.junit.jupiter.api.Assertions.assertArrayEquals(a, b); + assertEquals("freezeOffCamera", OpTable.get().byName("flagEC10000").name()); + assertEquals("freezeOffCamera", OpTable.get().alias("FLAGEC10000").name()); + assertTrue(StratCompiler.compile("strat A { flagEC10000<1>() }").contains("freezeOffCamera 1")); + } + + /** unit:Name resolves through the layout's unit names. */ + @Test + void unitNames() { + StratAssembler.Layout l = new StratAssembler.Layout( + 100, java.util.List.of(), java.util.List.of(), java.util.List.of(16, 40)) + .withUnitNames(java.util.Map.of("Other", 1)); + int[] w = + StratAssembler.assemble("x:\n address unit:Other+0x8\n end", l).words(); + assertEquals(48, w[1]); + StratAssembler.AsmException e = assertThrows( + StratAssembler.AsmException.class, () -> StratAssembler.assemble("x:\n address unit:Nope\n end", l)); + assertTrue(e.getMessage().contains("unknown unit name"), e.getMessage()); + } + + static final class CompileExceptionCheck { + static void fails(String src, String part) { + StratCompiler.CompileException e = + assertThrows(StratCompiler.CompileException.class, () -> StratCompiler.compile(src)); + assertTrue(e.getMessage().contains(part), e.getMessage()); + } } @Test diff --git a/src/test/java/com/eliasmueller/llamamod/format/strat/StratPseudoTest.java b/src/test/java/com/eliasmueller/llamamod/format/strat/StratPseudoTest.java index eedebdd..85a9cd0 100644 --- a/src/test/java/com/eliasmueller/llamamod/format/strat/StratPseudoTest.java +++ b/src/test/java/com/eliasmueller/llamamod/format/strat/StratPseudoTest.java @@ -125,6 +125,50 @@ class StratPseudoTest { assertEquals(List.of(toList(li.words())), List.of(toList(back.words()))); } + /** Jumps out of nested loops and to a return: labeled break/continue and return instead of goto. */ + @Test + void multiLevelBreaksAndJumpsToReturn() { + String src = + """ + Main: + outer: + pushVar v0 + jz skip + inner: + pushVar v1 + jz next + jmp out ; break out of both loops + next: + pushVar v5 + jz c2 + jmp ret ; leaves both loops to the return + c2: + pushVar v2 + jz cont + jmp outer ; continue the outer loop + cont: + jmpYield inner + skip: + jmpYield outer + out: + pushVar v3 + jz fin + jmp ret + fin: + push 1.0 + popVar v4 + ret: + return + """; + String t = StratPseudo.of(listing(src, "Main")).text(); + assertFalse(t.contains("goto"), t); + assertTrue(t.contains("entry_0000: loop {"), t); + assertTrue(t.contains("break entry_0000"), t); + assertTrue(t.contains("continue entry_0000"), t); + assertTrue(t.contains("if (v5) {\n return\n }"), t); + assertTrue(t.contains("if (!(v3)) {\n v4 = 1.0\n}\nreturn"), t); + } + static List toList(int[] a) { return java.util.Arrays.stream(a).boxed().toList(); } @@ -132,7 +176,7 @@ class StratPseudoTest { /** 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; + int gotos = 0, units = 0, labeled = 0; Pattern g = Pattern.compile("goto (\\w+)"); for (Path p : StratRoundTripTest.levels()) { StratRoundTripTest.Loaded l = StratRoundTripTest.load(p); @@ -144,7 +188,7 @@ class StratPseudoTest { StratPseudo ps = StratPseudo.of(li); units++; int open = 0; - Set labels = new HashSet<>(); + Set labels = new HashSet<>(), loopNames = new HashSet<>(); for (StratPseudo.Line x : ps.lines) { String s = x.text().replaceAll("\\s*//.*$", ""); if (s.startsWith("}")) { @@ -157,6 +201,10 @@ class StratPseudoTest { if (s.matches("\\w+:")) { labels.add(s.substring(0, s.length() - 1)); } + Matcher ln = Pattern.compile("^(\\w+): (loop|do|while)").matcher(s.trim()); + if (ln.find()) { + loopNames.add(ln.group(1)); + } } assertEquals(0, open, p + " unit " + u + ": unclosed block"); for (StratPseudo.Line x : ps.lines) { @@ -165,11 +213,16 @@ class StratPseudoTest { gotos++; assertTrue(labels.contains(m.group(1)), p + " unit " + u + ": goto without label " + x.text()); } + Matcher b = Pattern.compile("(break|continue) (\\w+)").matcher(x.text()); + if (b.find()) { + labeled++; + assertTrue(loopNames.contains(b.group(2)), p + " unit " + u + ": no loop named in " + x.text()); + } } } } - System.out.printf("script view: %d units, %d gotos%n", units, gotos); + System.out.printf("script view: %d units, %d gotos, %d labeled break/continue%n", units, gotos, labeled); assertTrue(units >= 1400); - assertTrue(gotos < 500, "gotos: " + gotos); + assertTrue(gotos <= 40, "gotos: " + gotos); } }