diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09e90c..ef008c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,5 @@ jobs: # `dist/testing.js` and its declarations are what another repository's tests import at # a tag to run triggers, so they are held to the same rule. with: - artifacts: dist/compiler.js dist/testing.js dist/testing.d.ts + artifacts: dist/compiler.js dist/testing.js dist/testing.d.ts dist/gametest.js secrets: inherit diff --git a/.gitignore b/.gitignore index c6245a8..371d800 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,9 @@ node_modules/ *.tsbuildinfo # Blizzard's maps, copied in for the round-trip test; never committed. fixtures/ +# Videos a probe imports (probes/bad-apple.ts): not ours, made on the spot with scripts/import-video.mts. +probes/*.video +# The halftone probe's pictures and walks: made on the spot by probes/lib/halftone-gen.mts. +probes/*.image +# The sound demo's imported sound: made on the spot by probes/lib/sound-demo-gen.mts. +probes/*.sound diff --git a/README.md b/README.md index 9c22151..ce60bcb 100644 --- a/README.md +++ b/README.md @@ -10,14 +10,18 @@ players, arrays, classes and the standard library are all there. Those are ordin triggers and play on every version of the game. Code inside `program(() => { … })` is the other half: it runs *in the game*, with -variables, arrays, texts, functions, classes, loops and `sleep`. A program is built into the map by -[eudplib](https://github.com/scm-js/plugin-eudplib), the compiler behind euddraft, when -you save, and a map with a program in it needs **StarCraft: Remastered**. A script that -only calls `trigger()` never involves eudplib at all. +variables, arrays, texts, functions, classes, loops, `sleep` and `await`. TrigScript builds +the programs into the map when you save, as the game's own triggers, in under a second and +with nothing else to install. A map with a program in it needs **StarCraft: Remastered**. +A script with something TrigScript does not take yet is not built, and Problems says why on +the line (see [Use](#use)). The guide's page [TrigScript and +eudplib](https://docs.scmjs.dev/trigscript/trigscript-and-eudplib/) sets TrigScript beside +[eudplib](https://github.com/armoha/eudplib), the compiler behind euddraft: what you write, +the build, speed and size in the game, checking, and libraries. The editor is Monaco with a `.d.ts` generated from the open map, so `locations.`, -`switches.`, `units.` and `players.` complete to what your map actually has, and a -location passed where a unit belongs is a type error before you build. +`switches.`, `units.` and `players.` complete to what your map has, and a location passed +where a unit belongs is a type error before you build. ## Install @@ -35,12 +39,12 @@ scmJS's own [user guide](https://docs.scmjs.dev/guide/trigscript/); the referenc is the full one. Monaco, the TypeScript compiler and the standard library's declarations are not loaded -with the plugin's own files: the first time the editor opens they are fetched from -jsDelivr — Monaco and the library from this repository's own build of them (`dist/`), -TypeScript from the npm package's `lib/typescript.js` — so that first open needs a +with the plugin's own files. The first time the editor opens, they are fetched from +jsDelivr: Monaco and the library from this repository's own build of them (`dist/`), and +TypeScript from the npm package's `lib/typescript.js`. So the first open needs a connection. The copy compiled into the editor also fetches the release's own `dist/compiler.js` for its compile worker. The browser keeps them all in its cache -afterwards, which is also what the desktop build relies on when it is offline. +afterwards, and the desktop build relies on that cache when it is offline. ## Use @@ -50,119 +54,131 @@ is, with the same keys where it has one: | Where | What is there | | --- | --- | | **Activity bar**, at the far left | The Explorer and **Testing**; the icon of the view that is showing hides the sidebar. Testing carries the count of the tests that fail. | -| **Explorer**, at the left (Ctrl+B) | The script's files as a tree — `main.ts` is where the script starts; *New file* and *New folder* are on the section and on a folder's row, a row's pencil and bin rename and remove it, and its right-click menu has all of them — and under them the **programs** with the variables each keeps in the game. A click goes to the line. See [Folders](#folders). | +| **Explorer**, at the left (Ctrl+B) | The script's files as a tree, and under them the **programs** with the variables each keeps in the game. `main.ts` is where the script starts. *New file* and *New folder* are on the section and on a folder's row; a row's pencil and bin rename and remove it; the right-click menu has all of them. A click goes to the line. See [Folders](#folders). | | **Tabs**, over the editor | One per open file; a file with problems is red, with their count, and two files of one name say their folder. Go to Definition on a name another file exports opens that file. | | **Run controls**, right of the tabs | Play (F5), Simulate (Ctrl+F5), Apply (Ctrl+Shift+B), Pick from map, the switch between the window and the panel beside the map, and **…** for the rest. | -| **Panel**, under the editor (Ctrl+J) | **Problems** (Ctrl+Shift+M), **Output** (Ctrl+Shift+U) — what Apply, Play and the builds of the programs reported, with the build log — **Simulate**, **Test Results**, and **Settings** (Ctrl+,): what the map's author chose about how its programs are built, kept in the map beside the script so that it builds the same on any computer. It takes its room from the bottom, and only when asked for or when something failed. | -| **Status bar** | The problem count, the tests (`12 passed`, or `1 failed` in red; a click opens Testing), whether the script's triggers are in the map (a click applies it), the last build of the programs, the eudplib plugin's state, the cursor. | +| **Panel**, under the editor (Ctrl+J) | **Problems** (Ctrl+Shift+M); **Output** (Ctrl+Shift+U), what Apply, Play and the builds reported, with the build log; **Simulate**; **Test Results**; and **Settings** (Ctrl+,), what the map's author chose about how its programs are built, kept in the map beside the script so that it builds the same on any computer. The panel opens when asked for or when something failed. | +| **Status bar** | The problem count; the tests (`12 passed`, or `1 failed` in red; a click opens Testing); whether the script's triggers are in the map (a click applies it); the last build of the programs (**built by TrigScript**); the cursor. | | **Command palette** (F1 or Ctrl+Shift+P) | Every command above under *TrigScript:*, beside Monaco's own. | -Something that needs an answer — the triggers were edited outside the script, the map -renamed a location — is a notification in the corner, and an item of the status bar brings -it back after it was dismissed. Nothing that appears moves the text. +Something that needs an answer, such as triggers edited outside the script or a location +the map renamed, is a notification in the corner. An item of the status bar brings it back +after it was dismissed. Nothing that appears moves the text. Edits are saved into the map as you type (the files are members of the archive, like a sound), and Ctrl+S saves the map from inside the editor too. There is no build step to remember. **Saving the map applies the script**, and so do -Tools ▸ Test Map and anything that exports the map: the script runs, its `trigger()` -records are written into the trigger list as one contiguous block (replacing the previous -block, or appended the first time), and its programs are built into the file being -written. The map you keep editing never holds eudplib's output; the editor stores the map -as you see it inside the saved file and gives that back when you open it. If the script -has an error, the map is still saved, with the triggers from the last script that -compiled, and a notice names the file and line. - -**Apply** does the first half on demand, which is how to see the generated triggers in -the Trigger Editor without saving. **Play** applies, builds the map exactly as Save would -and hands it to Test Map: on the desktop the game starts with it, in a browser it goes -into the test folder you picked once. (It was called Test until 3.10, when the script's -own [tests](#tests) took the word; F5 and what it does are as they were.) +Tools ▸ Test Map and anything that exports the map: + +1. The script runs. +2. Its `trigger()` records go into the trigger list as one block. The block replaces the + previous one, or is appended the first time. +3. Its programs are built into the file being written. + +The map you keep editing never holds the build's output: the editor stores the map as you +see it inside the saved file and gives that back when you open it. If the script has an +error, the map is still saved, with the triggers from the last script that compiled, and a +notice names the file and line. + +**Apply** does the first two steps on demand, so you can see the generated triggers in the +Trigger Editor without saving. **Play** applies, builds the map as Save would and hands it +to Test Map: on the desktop the game starts with it, and in a browser it goes into the test +folder you picked once. (Play was called Test until 3.10, when the script's own +[tests](#tests) took the word. F5 does what it did.) The Trigger Editor shows the script's triggers with a `script` badge and will not edit them; *Open TrigScript* there jumps to the file and line. The Text Trigger Editor fences -them in comments. Hand-made triggers around the block are left alone, and inserting one -before the block just moves it, since the block is found by content rather than +them in comments. Hand-made triggers around the block are left alone. A hand-made trigger +inserted before the block just moves it, since the block is found by its content, not its position. Editing a generated trigger from outside makes the block *stale*. The script remembers -every trigger it made on its own, so the editor can say how many are still the script's -and how many were changed, and the next Apply replaces the unchanged ones with the new -block and keeps the edited ones as hand-made triggers right after it — a wave system -edited in one trigger does not come back twice. *Append instead* on the notification leaves them -all in place and adds a fresh block after them, which is also what happens when the block -was moved or removed rather than edited. While a block is stale, saving does not apply -the script; the notice on Save says so. **Import the Map's Triggers** (under **…**) goes the other way, -rewriting the hand-made triggers as script in their existing order around the block, so -the whole list becomes script-generated. - -**Simulate** runs the script for 480 frames — twenty seconds of the game at Fastest — in -a built-in interpreter and lists, in the panel's Simulate view, every action that ran, -with its frame and source line, plus each program variable's final value. First in the -list, marked as errors, is what a program did that is always a mistake and that the game -passes over in silence — a read or a store past an array's end, a push the heap had no room -for — and a recursion that ran out of stack, which stops the program here as it does in the -game — one row a line however often a loop came past it. The `trigger()` records run in a trigger -interpreter (death counters, switches, preserve, list order, the wait slots) and the -programs in a program interpreter that computes every number the way the game will, the -two sharing one world, so a program's `setDeaths` is seen by a trigger and the other way -round. Both keep the game's time: the game goes through the trigger list in passes, the -first on frame 2, and a script with a program is built to have one every frame, with the -programs inside them. A script of `trigger()`s alone keeps the game's own schedule — a pass -every 31 frames (2, 33, 64, …), and on the frame after a Wait runs out — and a Wait holds -its trigger for its time at Fastest, so the frames Simulate lists are the frames the game -would act on. Reads -find what the simulation holds — death counters, the resources the programs themselves -set, the clock, the units — and 0 for what it does not (scores, the countdown); a printed -text shows with its numbers filled in and "Player 1" for a name. - -**The simulated world has units.** It starts from the ones placed on the map, where they -were placed and with the hit points their type and their own settings give, inside the -map's locations as they are drawn. From there `createUnit` makes units at the location's -centre with the hit points and shields their type has — what a program wrote into -`stats()` before counts — and `createUnitWithProperties` applies the map's slot; -`giveUnits`, `moveUnit` (to the centre of where it goes), `killUnitAt`, `removeUnitAt` -with their counts, `modifyHitPoints` and its kin, `moveLocation` and `setInvincibility` do -what they say; `bring`, `command`, `commandTheMost` and the rest count what is there, and -a unit that is killed is a death of its type for its owner. A unit takes the lowest free -place of the game's unit table, and a place that is used again is not the place it was: -a `Map` or a variable that kept the dead unit does not find the new one, as in -the game. **The players are the map's**: a program owned by a force or by All Players -runs once a frame for each of its human and computer players, each with their own -variables, a force in an action is each of its players and in a condition all of them -together, and every line of the list says whose it is — what several players did alike in -a frame is one line for all of them (`P1–P8 · …`). - -What stays out, because it cannot be known without the game: nothing walks, nothing -fights, nothing is built over time, nothing is in the way of anything (a unit that is made -always finds room), energy starts at 0 unless a properties slot says otherwise, and no -unit dies but by the script — or, in a test, by the test. The order of the unit table is -the simulator's own rule, not the game's. The same world is what the script's -[tests](#tests) run in. +every trigger it made, so the editor can say how many are still the script's and how many +were changed. While a block is stale, saving does not apply the script, and the notice on +Save says so. The notification offers two ways out: + +- **Apply** replaces the unchanged triggers with the new block and keeps the edited ones as + hand-made triggers right after it, so a wave system edited in one trigger does not come + back twice. +- **Append instead** leaves them all in place and adds a fresh block after them. This is + also what happens when the block was moved or removed rather than edited. + +**Import the Map's Triggers** (under **…**) goes the other way: it rewrites the hand-made +triggers as script, in their order around the block, so the whole list is the script's. + +**Simulate** runs the script for 480 frames, twenty seconds of the game at Fastest, in a +built-in interpreter. The panel's Simulate view lists every action that ran, with its +frame and source line, and each program variable's final value. First in the list, marked +as errors, are the mistakes the game passes over in silence: a read or a store past an +array's end, a push the heap had no room for, a recursion that ran out of stack (which +stops the program, here as in the game). Each is one row a line, however often a loop came +past it. + +The `trigger()` records run in a trigger interpreter (death counters, switches, preserve, +list order, the wait slots), and the programs in a program interpreter that computes every +number the way the game will. The two share one world, so a program's `setDeaths` is seen +by a trigger and the other way round. Both keep the game's time. The game goes through the +trigger list in passes, the first on frame 2. A script with a program is built to have a +pass every frame, with the programs inside it. A script of `trigger()`s alone keeps the +game's own schedule: a pass every 31 frames (2, 33, 64, …), and on the frame after a Wait +runs out. A Wait holds its trigger for its time at Fastest. So the frames Simulate lists +are the frames the game would act on. Reads find what the simulation holds (death +counters, the resources the programs set, the clock, the units) and 0 for what it does not +(scores, the countdown). A printed text shows with its numbers filled in and "Player 1" +for a name. + +**The simulated world has units.** It starts from the units placed on the map, where they +were placed, with the hit points their type and their own settings give, inside the map's +locations as they are drawn. From there: + +- `createUnit` makes units at the location's centre, with the hit points and shields their + type has (a program's earlier write to `stats()` counts), and + `createUnitWithProperties` applies the map's properties slot. +- `giveUnits`, `moveUnit` (to the centre of where it goes), `killUnitAt` and + `removeUnitAt` with their counts, `modifyHitPoints` and its kin, `moveLocation` and + `setInvincibility` do what they say. +- `bring`, `command`, `commandTheMost` and the rest count what is there, and a unit that + is killed is a death of its type for its owner. +- A unit takes the lowest free place of the unit table. A place used again is a new unit: + a `Map` or a variable that kept the dead unit does not find the new one, as in + the game. + +**The players are the map's.** A program owned by a force or by All Players runs once a +frame for each of its human and computer players, each with their own variables. A force +in an action is each of its players, and in a condition all of them together. Every line +of the list says whose it is; what several players did alike in a frame is one line +(`P1–P8 · …`). + +What stays out, because it cannot be known without the game: nothing walks, fights or is +built over time; nothing is in anything's way (a unit that is made always finds room); +energy starts at 0 unless a properties slot says otherwise; and no unit dies except by the +script or, in a test, by the test. The order of the unit table is the simulator's own +rule, not the game's. The script's [tests](#tests) run in this same world. ### Folders A file's name may have folders in it, and the Explorer shows them as a tree: folders -first, `main.ts` first of the files. No folder means anything to TrigScript — `tests/` is -a habit, not a rule. The archive the files are kept in has no empty folder, so a folder is -there while a file is in it: *New Folder…* asks for the folder's name and goes straight -on to its first file's. - -Renaming a file or a folder (the pencil; a folder before the name moves a file) and -dragging a row onto a folder, or onto the empty space under the tree for the top, **take -the imports along**: every `import … from "./path"` that named a file that moved, and the -moved files' own, are rewritten so the script compiles after as it did before, written -the way they were (with `.js`, as a folder with an `index.ts`, or bare). It is one change: -the notification that says what moved has an **Undo**. `main.ts` stays where it is. -Removing a folder asks once and says how many files go. +first, `main.ts` first of the files. No folder means anything to TrigScript; `tests/` is a +habit, not a rule. The archive the files are kept in has no empty folders, so a folder +exists while a file is in it: *New Folder…* asks for the folder's name and goes straight on +to its first file's. + +Renaming a file or a folder (the pencil; a folder before the name moves a file), or +dragging a row onto a folder (or onto the empty space under the tree, for the top), +**takes the imports along**. Every `import … from "./path"` that named a moved file, and +the moved files' own imports, are rewritten so the script compiles as it did before, in +the form they were written (with `.js`, as a folder with an `index.ts`, or bare). The move +is one change, and the notification that says what moved has an **Undo**. `main.ts` stays +where it is. Removing a folder asks once and says how many files go. ### Tests A script can test itself. A test is ordinary TypeScript that runs in the editor's -simulator after every change that compiles — never in the game, and it costs the map -nothing. The names are [Vitest](https://vitest.dev)'s, imported from `"trigscript"` +simulator after every change that compiles. It never runs in the game and adds nothing to +the map. The names are [Vitest](https://vitest.dev)'s, imported from `"trigscript"` (`test`, `it`, `describe`, `beforeEach`, `afterEach`, `test.only`, `test.skip`, -`test.each`, `expect`); they are not globals, so a script's own `test` is its own. +`test.each`, `expect`). They are not globals, so a script's own `test` is its own. ```ts import { test, expect, program, createUnit, killUnitAt, countUnits, print, sleep, frames, P1, P2, units, locations } from "trigscript"; @@ -192,23 +208,23 @@ test("the second wave is bigger", (sim) => { ``` **Where tests are.** In a file whose name ends in `.test.ts`, in any folder, or beside -what they test in any other file. The entry file runs first, so the programs are there; -then each test file runs, linked as any file is, so it can import the script's own +what they test in any other file. The entry file runs first, so the programs are there. +Then each test file runs, linked as any file is, so it can import the script's own functions and test them as plain TypeScript. A test file is never part of the build: importing one from a file that is not a test file is an error, and so is a `program()` or a `trigger()` inside one. Test files are kept in the map with the rest of the source and go when the source is left out of a copy. -**What a test gets.** `sim`, a world of its own for each test: [the simulated -world](#use) — the map's placed units, locations and players, the script's programs at -frame 0 and its `trigger()`s beside them — with `random()` seeded the same every time +**What a test gets.** `sim`, a world of its own for each test. It is [the simulated +world](#use): the map's placed units, locations and players, the script's programs at +frame 0 and its `trigger()`s beside them. `random()` is seeded the same every time (`sim.seed(n)` for another run). A test is not `async`: nothing in `sim` waits. **Time is the game's.** The game goes through the trigger list in passes, the first on -frame 2. A script with a program is built to have a pass every frame, and its programs run -inside them, so a program first runs on frame 2 and then every frame. A script of +frame 2. A script with a program is built to have a pass every frame, with its programs +inside it, so a program first runs on frame 2 and then every frame. A script of `trigger()`s alone keeps the game's own schedule: a pass every 31 frames (2, 33, 64, …), -and the frame after a Wait runs out. A Wait holds its trigger for its time, in +and on the frame after a Wait runs out. A Wait holds its trigger for its time, in milliseconds at Fastest, and a player has one wait slot that their triggers share. | `sim.` | | @@ -217,19 +233,19 @@ milliseconds at Fastest, and a player has one wait slot that their triggers shar | `frames(n)`, `seconds(n)`, `until(() => …, most?)`, `frame` | Time. `until` fails the test when `most` frames pass first (2400 unless said), so no test hangs. | | `press(key)`, `click(button)`, `type(line)`, `moveMouse(x, y)` | What a player does, found by the next frame; a player may be named last. | | `count(player, type, at?)`, `units(filter?)`, `resources(player)`, `deaths(player, type)`, `kills(player, type)`, `switch(n)`, `location(n)` | The world read back. | -| `program(name?, player?)` | A program's variables by their names in the source: numbers, booleans, texts, arrays, a record as an object, a unit. The program by the `name` its options give it — `program(() => { … }, { name: "waves" })`, which the Explorer shows too — or by its place in the script from 0; of a per-player program, `player`'s. | +| `program(name?, player?)` | A program's variables by their names in the source: numbers, booleans, texts, arrays, a record as an object, a unit. The program is found by the `name` its options give it (`program(() => { … }, { name: "waves" })`, which the Explorer shows too), or by its place in the script from 0; of a per-player program, `player`'s. | | `printed(player?)`, `events`, `faults` | What was shown, everything that happened by frame, and what went wrong. | `expect` has `toBe`, `toEqual`, `not`, `toBeTruthy` / `toBeFalsy`, `toBeNull`, `toBeDefined` / `toBeUndefined`, the four comparisons, `toContain`, `toHaveLength`, `toMatch`, `toThrow`, and two of its own: `expect(sim).toHavePrinted(text | RegExp, { to? })` and `expect(sim).toHaveFaulted(/depth/)`. **A fault the test did not ask for fails -it**: an index outside its array, the stack's depth, the heap full — what the game passes +it**: an index outside its array, the stack's depth, a full heap, which the game passes over in silence. So does anything the test throws, and a frame past 100 000 statements. -**A test file, and the script's own functions as plain TypeScript.** What a test file -imports is the same module the map is built from, so a helper is tested without a world -and a program with one: +**A test file, and the script's own functions as plain TypeScript.** A test file imports +the same module the map is built from, so a helper is tested without a world and a program +with one: ```ts // main.ts @@ -269,7 +285,7 @@ describe("the bounty", () => { }); ``` -**Keys and chat.** What a player does is said before the frame that finds it: +**Keys and chat.** A test says what a player does before the frame that finds it: ```ts import { test, expect, program, keyPressed, chatted, setResources, print, sleep, frames, CurrentPlayer } from "trigscript"; @@ -277,7 +293,7 @@ import { test, expect, program, keyPressed, chatted, setResources, print, sleep, program(() => { let shop = false; while (true) { - if (keyPressed(CurrentPlayer, "F2")) { shop = !shop; print(shop ? "Shop open" : "Shop closed"); } + if (keyPressed(CurrentPlayer, "F7")) { shop = !shop; print(shop ? "Shop open" : "Shop closed"); } const give = chatted(CurrentPlayer, "-give {n}"); if (give && shop) setResources(CurrentPlayer, "add", give.n, "ore"); sleep(frames(1)); @@ -287,7 +303,7 @@ program(() => { test("-give pays only while the shop is open", (sim) => { sim.type("-give 100").frames(2); expect(sim.resources(0 as Player).ore).toBe(0); - sim.press("F2").frames(1); + sim.press("F7").frames(1); expect(sim.program("shop").shop).toBe(true); sim.type("-give 100").frames(2); expect(sim.resources(0 as Player).ore).toBe(100); @@ -295,8 +311,8 @@ test("-give pays only while the shop is open", (sim) => { }); ``` -**Hooks, cases, and a fault that is asked for.** `beforeEach` runs on the test's own -world, `test.each` makes a test a row, and a fault the test expects is said so: +**Hooks, cases, and an expected fault.** `beforeEach` runs on the test's own world, +`test.each` makes a test of each row, and a test that expects a fault says so: ```ts import { test, describe, beforeEach, expect, program, createUnit, sleep, frames, P1, units, locations, type Sim } from "trigscript"; @@ -327,49 +343,51 @@ describe("the barracks", () => { }); ``` -Without that last `toHaveFaulted` the test fails by itself, at the program's line: a read -past an array's end is 0 in the game and nobody is told, which is what makes it worth a -test's while. +Without that last `toHaveFaulted` the test fails by itself, at the program's line. A read +past an array's end is 0 in the game and nobody is told, which is why it is worth a test. Without the map's player settings (a test of the compiler, a map with no players set) the world has one player; `test(name, { as: P2 }, (sim) => { … })` says which. -**In the workspace**, as VS Code does it: +**In the workspace**, as in VS Code: - A mark in the margin beside every `test(` and `describe(`: not run, passed, failed, skipped. A click runs that one. -- A failure is said where it is — `expected 8, got 6` at the end of the failing - `expect`'s line, the two values in full on hover — and a test that threw says what at - the line that threw. -- The **Testing** view in the activity bar: folder, file, `describe`, test, each with its - mark; run all, a file, a `describe` or one; run the failed ones; show only the failing. +- A failure is shown where it happened: `expected 8, got 6` at the end of the failing + `expect`'s line, with the two values in full on hover. A test that threw says what at the + line that threw. +- The **Testing** view in the activity bar: folder, file, `describe` and test, each with + its mark. It runs all of them, a file, a `describe` or one test, or the failed ones, and + can show only the failing ones. - **Test Results** in the panel: the chosen test's message, what it printed, and what - happened by frame, each a link to its line. -- Tests run again after every compile that goes through, a run dropped by the next - keystroke. If all of them take more than two seconds, only the open file's run by + happened frame by frame, each a link to its line. +- Tests run again after every compile that goes through; the next keystroke drops a run + in progress. If they all take more than two seconds, only the open file's tests run by themselves and the rest wait for *Run All*. -- A failing test is a warning in Problems at its line, and a build's log says so; an +- A failing test is a warning in Problems at its line, and a build's log says so. An `only` left in is a warning too. **Settings ▸ Tests ▸ A failing test refuses the build** is kept in the map: with it on, Save, Test Map and an export say which test fails and write the map without applying the script again. - Palette: *Run All Tests*, *Run Test at Cursor*, *Run Failed Tests*. *New File…* in a folder called `tests` proposes `.test.ts` with one test written in it. -A script with programs shows, at the right of the status bar, whether the eudplib plugin -is running and whether its runtime is on this machine yet. The runtime — Pyodide, a -Python for the browser, and eudplib — is downloaded once, about 15 MB, after asking, the -first time a map with a program is saved; the desktop app and the container image carry -it. Every build after that runs on this machine and nothing about the map leaves it. The -status bar says how the last build went, Output keeps its log, and a failure that names a -line puts a marker on it. A save whose build fails still writes the map and its script, -with no programs in it — the status bar reads **Saved without its programs** until a save -that builds — and a build that went well reads **edited since** once the script changes, -because the programs in the saved file are then the older ones. - -The files and a build manifest live in the map archive itself, under `trigscript\` +The right of the status bar says the programs are **built by TrigScript**: the plugin lays +them out into the map itself, in under a second, with nothing to download. A script with +something TrigScript does not take yet is not built; Problems says what, as a warning on the +line, and the save's notice says so again, with a button to save without the programs. A map +that also holds Magenta's triggers is not built either, until Magenta builds on TrigScript's +layout. Every build runs on this machine and nothing about the map leaves it. + +The status bar says how the last build went, Output keeps its log, and a failure that +names a line puts a marker on it. A save whose build fails still writes the map and its +script, with no programs in it, and the status bar reads **Saved without its programs** +until a save that builds. After a good build it reads **edited since** once the script +changes, because the programs in the saved file are then the older ones. + +The files and a build manifest live in the map archive under `trigscript\` (`trigscript\main.ts`, `trigscript\build.json`, …) next to `staredit\scenario.chk`, so -they travel with the `.scx`. The editor's Save dialog lists them under the archive's -other files, with a tick each, so a copy for release can leave the source out. +they travel with the `.scx`. The editor's Save dialog lists them with the archive's other +files, each with a tick, so a copy for release can leave the source out. ## The language @@ -381,21 +399,28 @@ is the comparison in one table. Programs are most of this reference: | Section | What is in it | | --- | --- | -| [Variables and records](#variables-and-records) | numbers, booleans, texts, units; a record is a variable a field | -| [Arrays](#arrays) | fixed and growing arrays, arrays of records, of units, of texts and of arrays; `map`, `filter`, `sort` and the rest; patterns and spread; copies | -| [Tables, a `Map` and a `Set`](#tables-a-map-and-a-set) | lists the script has, tables keyed by an id of the game, a `Map` and a `Set` over any number or over units | +| [Variables and records](#variables-and-records) | numbers, booleans, texts, units, players; a record is a variable a field; `undefined` | +| [Arrays](#arrays) | fixed and growing arrays, arrays of records, of units, of texts and of arrays; `map`, `filter`, `sort`, `splice` and the rest; patterns and spread; copies | +| [Tables, a `Map` and a `Set`](#tables-a-map-and-a-set) | lists the script has, tables keyed by an id of the game, a `Map` and a `Set` over any number, over units or over a few texts | | [Numbers and arithmetic](#numbers-and-arithmetic) | `number`, `u32`, `u16`, `u8`; the operators and where they differ from JavaScript | -| [Control flow and loops](#control-flow-and-loops) | loops run within the frame; the loop that never sleeps; unrolling | -| [Functions](#functions) | inlined or called, functions that call themselves, `game()` functions in other files | -| [Classes](#classes) | instances, methods, `extends`, arrays of instances, and what is settled when the script is built | +| [Numbers with a fraction](#numbers-with-a-fraction) | `fixed`, `Math.sqrt`, `Math.sin` and the rest | +| [Control flow and loops](#control-flow-and-loops) | loops run within the frame; the loop that never sleeps; unrolling; labels | +| [Functions](#functions) | inlined or called, what a function returns, functions that call themselves, `game()` functions in other files | +| [Functions as values](#functions-as-values) | functions kept in variables, arrays and fields; closures | +| [Classes](#classes) | instances, methods, `extends`, instances kept by reference, classes in any file | +| [Errors](#errors) | `try`, `catch`, `finally`, `throw` and classes of error | | [What is worked out when the script is applied](#what-is-worked-out-when-the-script-is-applied) | the line between the script and the program | | [Reading the game](#reading-the-game) | every condition's quantity as a value; player facts | | [Units](#units) | loops over units, picks, a unit's fields and what it can be told | -| [The game's tables](#the-games-tables) | `stats()`: costs, weapons, upgrades, colours | -| [Keys, the mouse and chat](#keys-the-mouse-and-chat) | `keyPressed`, `clicked`, `mouse`, `underMouse`, `chatted` | -| [Text](#text) | templates with values, `print`, `string` variables and their methods | -| [Time and edges](#time-and-edges) | `sleep`, `rose`, `once` | +| [Events](#events) | `on.created`, `on.died`, `on.killed`, `on.morphed`, `on.order` | +| [The game's tables and memory](#the-games-tables-and-memory) | `stats()`: costs, weapons, upgrades, colours; `memory.*`; EUD | +| [Keys, the mouse and chat](#keys-the-mouse-and-chat) | `keyPressed`, `keyHeld`, `clicked`, `mouse`, `underMouse`, `chatted`, `on.key`, `on.chat`; `local` and `sync()`; `select()` | +| [Text](#text) | templates with values, `print`, `string` variables and their methods, lines of JSX | +| [Sounds](#sounds) | `playWav`, sounds made when the script is built | +| [Waiting and tasks](#waiting-and-tasks) | `sleep`, `await`, `until`, `start()`, `Promise.all` and `race`; generators; `rose`, `once` | | [Owners and per-player programs](#owners-and-per-player-programs) | one program for every player, `shared()` | +| [Files the script imports](#files-the-script-imports) | videos, images, sounds and MIDI notes | +| [The standard library](#the-standard-library) | `trigscript/math`, `random`, `timers`, `signals`, `ui`, `cutscene`, `fog`, `screen`, `music` | | [What a program does not have](#what-a-program-does-not-have) | the parts of TypeScript a program refuses, and what to write instead | After those, *Coming from 3.9* and the sections under it say what each version changed. @@ -403,35 +428,35 @@ After those, *Coming from 3.9* and the sections under it say what each version c ### TrigScript beside TypeScript Outside `program()` there is nothing to compare: the script *is* TypeScript, checked by the -TypeScript compiler and run as JavaScript when it is applied, so every feature of the -language and its standard library is there. The table is about the inside of a program, -where what is written has to become something the game can do. **Same** means it is -written and behaves as in TypeScript; what differs is said, and what is missing is refused -with a message, never passed over in silence. +TypeScript compiler and run as JavaScript when it is applied, so all of the language and +its standard library is there. The table is about the inside of a program, where what is +written has to become something the game can do. **Same** means it is written and behaves +as in TypeScript. What differs is said. What is missing is refused with a message, never +passed over in silence. | TypeScript | In a program | What is different | | --- | --- | --- | -| Types: annotations, `interface`, `type`, unions of literals, tuples, generic functions, `as`, `!`, `satisfies` | Same | Erased, as in TypeScript. They also decide things: `u8` / `u16` / `u32` are widths, `Map` and `Map` are kept differently. No type parameters on a class. | +| Types: annotations, `interface`, `type`, unions of literals, tuples, generics, `as`, `!`, `satisfies` | Same | Erased, as in TypeScript. They also decide things: `u8` / `u16` / `u32` are widths, `fixed` is a number with a fraction, and `Map` and `Map` are kept differently. | | `let`, `const`, `var` | Same | A variable needs a first value (`let n = 0`). A `const` whose value the script already knows is worked out when the script is applied and costs the map nothing. | -| `number` | Different | A whole number of 32 bits, signed, that wraps at its ends as `x \| 0` does. No fractions, no `NaN`, no `Infinity`, no `bigint`. `/` is whole division towards zero and dividing by 0 gives 0. `u8`, `u16` (stop at their ends) and `u32` are added. | +| `number` | Different | A whole number of 32 bits, signed, that wraps at its ends as `x \| 0` does. It holds a fraction once one is stored into it, as a 16.16 fixed-point number. No `NaN`, no `Infinity`, no `bigint`. `/` of whole numbers is whole division towards zero, and dividing by 0 gives 0. `u8`, `u16` (stop at their ends) and `u32` are added. | | `boolean` | Same | Assigned with `=` only: no `\|\|=`, `&&=`. | -| `string` | Mostly | A value with no length to declare. `length`, `s[i]` and `slice` count characters (code points), where JavaScript counts UTF-16 units: they differ only past U+FFFF. A made text holds 1 023 bytes. The methods are a subset: no `split`, `replace`, `trim`, `toUpperCase`, `parseInt`, regular expressions. | -| `null`, `undefined` | Units only | `Unit \| null` is real (`first(…)`, `if (u)`, `u?.kill()`). A number, a boolean or a text is never either: where JavaScript would give `undefined` — `pop()`, `find()`, `get()`, `at()` — say what it is then (`xs.pop() ?? 0`). No optional fields or parameters (`y?: number`); a parameter's default (`y = 1`) is there. | -| Operators: `+ - * / %`, comparisons, `&&` `\|\|` `!`, `c ? a : b`, `& \| ^ << >> >>>`, `??`, `?.`, `++` `--`, the compound assignments, the comma | Same | `==` and `===` are one thing, since nothing is coerced. A shift by 32 or more leaves nothing, where JavaScript shifts by the remainder. Missing: `**`, `typeof`, `in`, `delete`. | -| `if`, `while`, `do`, `for`, `for…of`, `switch`, `break`, `continue` | Same | A loop runs all its rounds within one frame of the game, so one that never ends must `sleep()` on every path; `while (true)` left only by a `break` needs the sleep too. A `for` over bounds the script knows is unrolled. No `for…in`, no labels. | -| Functions: declarations, parameters by value, defaults, rest parameters, recursion, generics | Same | The compiler inlines a function or calls it; the meaning is the same. One that calls itself cannot `sleep()` and has a depth limit. A function declared inside another does not see the outer one's locals (it sees the program's). It returns a number, a boolean, a text, a unit or an instance — not a record or an array it made. | -| Arrow functions, closures, functions as values | Callbacks only | An arrow is written where a method takes it (`xs.map((x) => x + bonus)`), and sees every variable in reach. It cannot be kept: not in a variable, an array, a field or a return value. | -| Object literals | Records | A variable a field: nested, passed by reference, spread, taken apart by patterns. The shape is fixed: no `p[key]` with a key that varies, no methods or getters on a literal (a class has them), no `Object.keys`. | -| Destructuring and spread | Same | `...rest` takes the tail of an array of numbers or booleans. `f(...xs)` wants a length the script knows, as TypeScript itself does. | -| Arrays | Mostly | Fixed or growing, of numbers, booleans, texts, units, records, instances and arrays. There: `push`, `pop`, `length`, `fill`, `includes`, `indexOf`, `forEach`, `map`, `filter`, `reduce`, `some`, `every`, `find`…, `sort`, `reverse`, `slice`, `concat`, `toSorted`, `toReversed`, `join` of texts, `Array.from`. Missing: `shift`, `unshift`, `splice`, `at`, `lastIndexOf`, `flat`; `map` into texts or records. `sort` wants its function. An index past the end reads 0 and stores nothing. `const b = a` is not a second name for an array; a row of rows that grow is a *place* in the outer array, and `filter` of rows copies them. | -| Classes: fields, constructor, parameter properties, methods, `get` / `set`, `static`, `private` / `#x`, `readonly`, `extends`, `super`, `abstract`, `implements`, `instanceof` | Same | Declared inside the program. The class of every instance is settled when the script is applied, so `instanceof` is answered then, an array of instances holds one class, and a function gives back an instance only when every `return` gives the same one. No type parameters, static blocks, decorators or class expressions. | -| `Map`, `Set` | Mostly | Keys are numbers, units, or ids of the game; values are numbers or booleans. Order is JavaScript's — the order the keys went in — except for a table keyed by ids of the game, which goes by id. No chained `set()`. | -| `enum` | Outside | Declared above the program, its members are numbers a program can use. Not declared inside one. | -| `try` / `catch` / `throw` | Missing | The game has no exceptions. What is always a mistake — an index past the end, a full heap, a stack overflow — is said in the game in red, or listed by Simulate. | -| `async` / `await`, promises, generators, timers | Missing | `sleep()` is how a program waits: it gives the frame back and carries on later, and may stand anywhere but in a function that calls itself or a loop over units. Something on its own clock is another `program()`. | -| Modules | Same | `import` between the script's files and from `"trigscript"`; nothing from npm. A function another file's program calls is made with `game()`. | -| The standard library | A little | `Math.min`, `Math.max`, `Math.abs` (and the rounding ones, which change nothing), `String(n)`, `n.toString()`, `Array.from`, `new Array(n).fill(v)`. For `console.log` there is `print()`, for `Math.random()` there is `random(n)`. No `JSON`, `Date`, `RegExp`, `Object.*`, and no `Math.sqrt` or `**` on a variable. | -| — | Added | What TypeScript has no word for: `sleep`, `rose` and `once`, reads of the game (`minerals(p)`), `Unit` objects and loops over units, `stats()`, `keyPressed` / `clicked` / `mouse` / `chatted`, `shared()`, per-player programs. | +| `string` | Mostly | A value with no length to declare. `length`, `s[i]` and `slice` count characters (code points), where JavaScript counts UTF-16 units; they differ only past U+FFFF. A made text holds 1 023 bytes. `toUpperCase` and `toLowerCase` change A to Z only. `replace` and `split` take a text, not a regular expression. | +| `null`, `undefined` | Mostly | `Unit \| null` and an instance that may be none (`first(…)`, `if (u)`, `u?.kill()`). A number or a boolean may be `undefined` where TypeScript says so (`xs.find(…)`, an optional parameter), and `??`, `!== undefined` and `!` read it. | +| Operators: `+ - * / %`, `**`, comparisons, `&&` `\|\|` `!`, `c ? a : b`, `& \| ^ ~ << >> >>>`, `??`, `?.`, `++` `--`, the compound assignments, the comma | Same | `==` and `===` are one thing, since nothing is coerced. A shift by 32 or more leaves nothing, where JavaScript shifts by the remainder. `**` wants a power known when the script is built. `typeof x === "…"` and `"key" in x` work where the answer is known when the script is built, not as tests in the game. Missing: `delete`. | +| `if`, `while`, `do`, `for`, `for…of`, `switch`, `break`, `continue`, labels | Same | A loop runs all its rounds within one frame of the game, so one that never ends must `sleep()` on every path; `while (true)` left only by a `break` needs the sleep too. A `for` over bounds the script knows is unrolled. No `for…in`. | +| Functions: declarations, parameters by value, defaults, optional and rest parameters, recursion, generics | Same | The compiler inlines a function or calls it; the meaning is the same. One that calls itself cannot `sleep()` and has a depth limit. It returns a number, a boolean, a text, a unit, a record, a tuple, an array or an instance. | +| Arrow functions, closures, functions as values | Mostly | A function may be kept in a variable, an array, a field or a `Map`, returned, and called through what holds it; a closure keeps what it captured. An `async` function or a generator is not kept as a value: call it where it is written, or `start()` it. | +| Object literals | Records | A variable a field: nested, passed by reference, spread, taken apart by patterns. The shape is fixed: no methods or getters on a literal (a class has them). | +| Destructuring and spread | Same | `...rest` takes the rest of an array or of a record. `f(...xs)` wants a length the script knows, as TypeScript itself does. | +| Arrays | Mostly | Fixed or growing, of numbers, booleans, texts, units, records, instances, functions and arrays. There: `push`, `pop`, `shift`, `unshift`, `splice`, `at`, `length`, `fill`, `includes`, `indexOf`, `lastIndexOf`, `forEach`, `map`, `filter`, `reduce`, `some`, `every`, `find`…, `sort`, `reverse`, `slice`, `concat`, `toSorted`, `toReversed`, `join`, `Array.from`. Missing: `flat`; `map` into texts or records. `sort` wants its function. An index past the end reads 0 and stores nothing. `const b = a` copies the array, where JavaScript gives it a second name; a row of rows that grow is a *place* in the outer array, and `filter` of rows copies them. | +| Classes: fields, constructor, parameter properties, methods, `get` / `set`, `static`, `private` / `protected` / `#x`, `readonly`, `extends`, `super`, `abstract`, `implements`, `instanceof`, type parameters | Same | Declared in the program or at the top of any file. An array or a variable that holds several classes, or none, keeps its instances by reference, in a table of 1 023 rows; every other instance is its fields' variables. A function gives back an instance only when every `return` gives the same one. No static blocks. | +| `Map`, `Set` | Mostly | Keys are numbers, units, ids of the game, or texts of a union the script knows (`"red" \| "blue"`). Values are numbers, booleans or instances. Order is JavaScript's, the order the keys went in, except for a table keyed by ids of the game, which goes by id. No chained `set()`. | +| `enum`, `const enum`, `namespace` | Same | Their members are numbers and constants known when the script is built. | +| `try` / `catch` / `finally` / `throw` | Mostly | `throw` takes an `Error`, a `RangeError`, a `TypeError` or an instance of a class that extends one. An error nothing catches is said in the game in red, and the program stops. A caught error is not kept in a variable, an array or a field. | +| `async` / `await`, promises, generators | Mostly | `await` of `sleep()`, `until()`, an async call, a task, `Promise.all`, `race` and `any`; `.then`; `function*`, `yield*`, `for await`. A race's losers stop where they are, where JavaScript lets them run on. No `setTimeout` or `setInterval`: `trigscript/timers` has `after` and `every`. | +| Modules | Same | `import` between the script's files, from `"trigscript"` and from the standard library (`"trigscript/math"`, …), and of videos, images, sounds and MIDI notes the Explorer imported; nothing from npm. | +| The standard library | Some | `Math.min`, `max`, `abs`, `sqrt`, `hypot`, `sin`, `cos`, `tan`, `atan`, `atan2`, `floor`, `ceil`, `round`, `trunc`, `sign`, `log2`, `pow` by a known power; `String(n)`, `n.toString()`, `parseInt`, `isNaN`, `Array.from`, `new Array(n).fill(v)`. For `console.log` there is `print()`, for `Math.random()` there is `random(n)`. Inside a program `Math.random()`, `Date.now()` and `new Date()` are refused, since they would be worked out once when the map is built; game time is `elapsed()` or `memory.game.frame`. No `JSON`, `Date` or `RegExp`. | +| — | Added | What TypeScript has no word for: `sleep`, `rose` and `once`; reads of the game (`minerals(p)`); `Unit` objects and loops over units; events (`on.died`, …); `stats()` and `memory`; `keyPressed`, `keyHeld`, `clicked`, `mouse`, `chatted`, `local` and `sync()`; `shared()` and per-player programs. | The sections below say each of these in full, and [What a program does not have](#what-a-program-does-not-have) lists what to write instead of what is missing. @@ -458,65 +483,69 @@ for (const p of [P1, P2, P3]) { } ``` -`trigger(players, conditions, actions, options?)` records one trigger. `players` is a -player or an array of them; `conditions` and `actions` are arrays of what the condition -and action functions return — nested arrays are flattened and `false` / `null` / -`undefined` entries are skipped, so a helper can return a list and a condition can be -`enabled && bring(…)`. The options are the execution flags by name: `{ preserve: true }` -is the same as a `preserve()` action; `disabled`, `ignoreGameEnd` and the rest are there -too. - -Every condition and action is a function named after StarEdit's, in camel case: -`bring`, `deaths`, `switchIs` (the one whose real name is a reserved word), -`commandTheMostAt`, `createUnit`, `displayText`, `setDeaths`, `runAiScript`, `wait`, -`victory`. Arguments come in the order the classic editor shows them, and the enumerated -ones take short words — `">="`, `"<="`, `"=="`; `"set"`, `"add"`, `"subtract"`; `"set"`, -`"clear"`, `"toggle"`, `"randomize"` — with StarEdit's own labels (`"At least"`) accepted -as well. `displayText(text)` always displays; `displayText(text, false)` does not. A -count is a number or `"All"`. `not(condition)` is the opposite of a condition where one -condition can say it: `not(bring(…, ">=", 1))` is "at most 0", a switch test flips, -`always()` becomes `never()`; "not exactly 3" has no single condition and throws. - -A condition is a *value* here — the game tests it later — so `if (bring(…))` outside a +`trigger(players, conditions, actions, options?)` records one trigger: + +- `players` is a player or an array of them. +- `conditions` and `actions` are arrays of what the condition and action functions + return. Nested arrays are flattened, and `false`, `null` and `undefined` entries are + skipped, so a helper can return a list and a condition can be `enabled && bring(…)`. +- `options` are the execution flags by name. `{ preserve: true }` is the same as a + `preserve()` action; `disabled`, `ignoreGameEnd` and the rest are there too. + `disabled(condition)` or `disabled(action)` keeps one entry in the trigger but switched + off, as StarEdit can. + +Every condition and action is a function named after StarEdit's, in camel case: `bring`, +`deaths`, `switchIs` (the one whose real name is a reserved word), `commandTheMostAt`, +`createUnit`, `displayText`, `setDeaths`, `runAiScript`, `wait`, `comment`, `victory`. +Arguments come in the order the classic editor shows them. The enumerated ones take short +words (`">="`, `"<="`, `"=="`; `"set"`, `"add"`, `"subtract"`; `"set"`, `"clear"`, +`"toggle"`, `"randomize"`), and StarEdit's own labels (`"At least"`) work as well. +`displayText(text)` always displays; `displayText(text, false)` follows the player's +subtitle setting. A count is a number or `"All"`. `not(condition)` is the opposite of a +condition where one condition can say it: `not(bring(…, ">=", 1))` is "at most 0", a +switch test flips, and `always()` becomes `never()`. "Not exactly 3" has no single +condition and throws. + +A condition is a *value* here, which the game tests later. So `if (bring(…))` outside a program tests whether the object exists, and `bring(…) && deaths(…)` is just -`deaths(…)`. TypeScript allows both; the compiler reports them as errors and says -where the test belongs: in a trigger's conditions list, or in an `if` inside `program()`. +`deaths(…)`. TypeScript allows both; the compiler reports them as errors and says where +the test belongs: in a trigger's conditions, or in an `if` inside `program()`. -Because the script runs, anything TypeScript can do at build time is fair game: a class -per base, a table of waves, `Array.from`, template strings, `Math`, a function that -returns the ten triggers a shop needs. Files import each other with `import { x } from -"./name"`; nothing else can be imported. The library is `import { … } from -"trigscript"`, `import * as ts from "trigscript"` for `ts.trigger(…)`, or simply the -globals. What the script records is what the map gets, and the order of `trigger()` -calls is the order of the triggers. +Because the script runs, anything TypeScript can do at build time works: a class per +base, a table of waves, `Array.from`, template strings, `Math`, a function that returns +the ten triggers a shop needs. Files import each other with `import { x } from "./name"`. +The library is `import { … } from "trigscript"`, `import * as ts from "trigscript"` for +`ts.trigger(…)`, or the globals; the [standard library](#the-standard-library) is +`"trigscript/"`. Nothing else can be imported. What the script records is what the +map gets, and the order of the `trigger()` calls is the order of the triggers. Names come from the map. `identifier()` derives an identifier from each display name -(`Terran Marine` becomes `units.TerranMarine`), and the display name itself still works -as an index (`units["Terran Marine"]`), as do custom names the map sets, force names -under `players`, and switch names under `switches`. `P1` … `P12`, `CurrentPlayer` and -`AllPlayers` are constants; the rest of the player groups are under `players`. Raw -numbers are accepted wherever a name is, which is how you reach EUD players and -out-of-range unit ids. Types the tables do not know can be written as `condition(type, …)` -or `action(type, …)`, and `memory(address, comparison, value)` / `setMemory(address, -modifier, value)` are the standard `deaths`-at-`EPD(address)` forms. +(`Terran Marine` becomes `units.TerranMarine`), and the display name itself works as an +index (`units["Terran Marine"]`), as do custom names the map sets, force names under +`players`, and switch names under `switches`. `P1` … `P12`, `CurrentPlayer` and +`AllPlayers` are constants; the other player groups are under `players`. A plain number +works wherever a name does, which is how you reach EUD players and out-of-range unit ids. +Types the tables do not know can be written as `condition(type, …)` or +`action(type, …)`. `memory(address, comparison, value)` and `setMemory(address, modifier, +value)` are the Deaths condition and the Set Deaths action aimed at an address (EUD). ### Beside the map -The editor opens two ways: Triggers ▸ TrigScript… is a full-screen window, and -*Beside the map* (an icon right of the tabs, or Triggers ▸ TrigScript beside the map) is a -panel over the map that blocks nothing — drag it by its title, resize it by its corner, -and keep placing units while the code sits next to them. Beside the map: +The editor opens two ways. Triggers ▸ TrigScript… is a full-screen window. *Beside the +map* (an icon right of the tabs, or Triggers ▸ TrigScript beside the map) is a panel over +the map that blocks nothing: drag it by its title, resize it by its corner, and keep +placing units while the code sits next to them. Beside the map: -- **Ctrl+click** on `locations.Beacon` scrolls the map to the location and flashes it; - hovering the name says where it is and how big. +- **Ctrl+click** on `locations.Beacon` scrolls the map to the location and flashes it. + Hovering the name says where it is and how big. - **Pick from map** (the target icon, or the editor's right-click menu): click a location - or a unit on the map, and its name (`locations.Beacon`, `units.TerranMarine`) lands at - the cursor. From the window, it first moves the editor beside the map. -- When the map renames a location or a switch the script mentions — its custom name, - for a switch — a notification offers to **update the references** in every file. The + or a unit on the map, and its name (`locations.Beacon`, `units.TerranMarine`) is typed + at the cursor. From the window, it first moves the editor beside the map. +- When the map renames a location or a switch the script mentions (for a switch, its + custom name), a notification offers to **update the references** in every file. The references are the compiler's, resolved like the code: `locations["Beacon"]`, an alias - from `import { locations as L }` and `ts.locations.Beacon` follow the rename; a - comment, a string, or a parameter that happens to be called `locations` is left alone. + from `import { locations as L }` and `ts.locations.Beacon` follow the rename. A comment, + a string, or a parameter that happens to be called `locations` is left alone. ### Programs @@ -546,47 +575,75 @@ program(() => { ``` Everything inside the arrow runs in the game. **A program is a coroutine the game -resumes every frame.** Its body runs from where it left off until it reaches a `sleep()` -or its end, all within that frame; the next frame it goes on from there. A body that ends -stops for good. `while (true) { …; sleep(frames(1)); }` is therefore a game loop running -once per frame, and `sleep(seconds(2))` at its end makes it one every two seconds. +resumes every frame.** Its body runs from where it left off until it reaches a `sleep()`, +an `await` or its end, all within that frame; the next frame it goes on from there. A body +that ends stops for good, and a `return` at the top of the body ends it there +(`if (!hero) return;`). So `while (true) { …; sleep(frames(1)); }` is a game loop that +runs once a frame, and `sleep(seconds(2))` at its end makes it run every two seconds. A +trigger's `preserve()` does nothing in a program, and the editor says so. `disabled(…)` is +refused: a program leaves out an action it does not want. #### Variables and records -**Variables** hold numbers, booleans, texts (*A text is a value*, below) and units of the -game (*Units on the map are objects*, below). A `let p = { lives: 3, gold: 0, alive: true }` -is a **record**: a variable per field (`p.lives -= 1`, `if (p.alive)`), nested ones -included, declared types (`let p: { n: u8 } = { n: 0 }`, an `interface`, a `type`) -honoured, and a record passed to a function reaches it by reference. They live in the game's memory while the map is -played and cost the map nothing: no death counters, no switches, no triggers in the list. +**Variables** hold numbers, booleans, texts (*A text is a value*, below), units of the +game (*Units on the map are objects*, below) and players. A `let p = { lives: 3, gold: 0, +alive: true }` is a **record**: a variable per field (`p.lives -= 1`, `if (p.alive)`), +nested records included. Declared types (`let p: { n: u8 } = { n: 0 }`, an `interface`, a +`type`) are honoured, a record passed to a function reaches it by reference, and +`p = { lives: 3, gold: 0, alive: true }` sets every field at once. Variables live in the +game's memory while the map is played and take nothing from the map: no death counters, +no switches, no triggers in the list. + +A number or a boolean may be `undefined` where TypeScript says it may: what `find` gives +when nothing fits, an optional parameter left out, `let n: number | undefined`. `??`, +`!== undefined` and `!` read it. A player in a variable can be changed and compared; the +player of an action is still one the script knows (see [What is worked out when the script +is applied](#what-is-worked-out-when-the-script-is-applied)). -#### Arrays +```ts +program(() => { + const hp = [30, 80, 55]; + const strong = hp.find((h) => h > 50); // a number, or undefined + if (strong !== undefined) print(`${strong}`); // 80 + let leader: Player = P1; + if (hp[0] < 50) leader = P2; + if (leader == P2) print("Player 2 leads"); // Player 2 leads +}); +``` -**Arrays** hold numbers, booleans, texts, units, records, instances of a class, or other -arrays; each kind has its paragraph below. `let hp = [10, 20, 30]`, `let xs = [a, a * 2, 0]`, -`let lives: u8[] = new Array(12).fill(3)`; an index is a constant or a variable -(`hp[i] += 7`, `hp[i + 1]++`), `.length`, `for (const x of xs)` with `break` and `continue` -(`x` is a copy of the cell, as in TypeScript), `fill(v)`, `includes(v)` and `indexOf(v)` -(−1 when there is none) all run within the frame. A `const` array the body stores into is an -array of the program like a `let` one. Cells keep their type: a `u8[]` stops at both ends, a -`number[]` goes below zero. An array handed to a function is the same array. In a program of -every player each player has one; `shared([0, 0, 0])` is one for everybody. +#### Arrays -A constant index past the end is a compile error. A variable one reads 0 and stores nothing -— in the game, without a word; Simulate says where it happened, since it is always a mistake. +**Arrays** hold numbers, booleans, texts, units, records, instances of a class, functions, +or other arrays; each kind has its paragraph below. `let hp = [10, 20, 30]`, +`let xs = [a, a * 2, 0]`, `let lives: u8[] = new Array(12).fill(3)`. An index is a +constant or a variable (`hp[i] += 7`, `hp[i + 1]++`). `.length`, `for (const x of xs)` with +`break` and `continue` (`x` is a copy of the cell, as in TypeScript), `fill(v, start?, +end?)`, `includes(v)`, `indexOf(v)` and `lastIndexOf(v)` (−1 when there is none), +`at(i)` (from the end when `i` is below zero) and `join(", ")` all run within the frame. A +`const` array the body stores into is an array of the program like a `let` one. Cells keep +their type: a `u8[]` stops at both ends, a `number[]` goes below zero. An array handed to a +function is the same array. `const b = a` is a copy, where JavaScript would give the same +array a second name. In a program of every player each player has one; `shared([0, 0, 0])` +is one for everybody. + +A constant index past the end is a compile error. A variable index past the end reads 0 +and stores nothing, in the game without a word. Simulate says where it happened, since it +is always a mistake. **An array that something pushes to grows.** `const queue: number[] = []; queue.push(x)`, `queue.pop()` (`?? 0` or `!`, since TypeScript has a pop of an empty array undefined: here it -is 0), `queue.length`, `queue.length = 0`, `queue[queue.length] = x` — also when the push is in -a function the array was handed to. There is no size to declare. The cells come from one pool -the map's programs share, 16 384 of them unless **Settings** says otherwise: a block that is -full is exchanged for one twice the size, and a block given back serves the next array that -wants that size. An array declared again — in a loop, in a function called again — first gives -back the block it held, so a scratch array made every round holds one block at a time. A single -array can reach between a quarter and a half of the pool (the block after 4 096 cells is -8 192). When the pool has no block left, nothing more is pushed and the game says so once, in -red; Simulate counts blocks as the game does, so it runs out at the same push. `let xs = []` -wants its type said (`let xs: number[] = []`). +is 0), `queue.length`, `queue.length = 0`, `queue[queue.length] = x`, also when the push is +in a function the array was handed to. `shift()`, `unshift(v)` and `splice(i, n, …)` take +values off the front, put them there, and cut or fill the middle. There is no size to +declare. The cells come from one pool the map's programs share, 16 384 of them unless +**Settings** says otherwise: a block that is full is exchanged for one twice the size, and +a block given back serves the next array that wants that size. An array declared again (in +a loop, or in a function called again) first gives back the block it held, so a scratch +array made every round holds one block at a time. A single array can reach between a +quarter and a half of the pool (the block after 4 096 cells is 8 192). When the pool has no +block left, nothing more is pushed and the game says so once, in red. Simulate counts +blocks as the game does, so it runs out at the same push. `let xs = []` wants its type +said (`let xs: number[] = []`). ```ts program(() => { @@ -603,37 +660,46 @@ program(() => { }); ``` +```ts +program(() => { + const queue = [5, 6, 7]; + queue.unshift(4); + const head = queue.shift() ?? 0; + queue.splice(1, 1, 60, 61); + print(`${head} ${queue.join(",")} ${queue.at(-1) ?? 0} ${queue.lastIndexOf(61)}`); // 4 5,60,61,7 7 2 +}); +``` + **An array of records** is an array a field, all growing together: `let waves = [{ count: 4, delay: 2 }]`, `waves[i].count += 1`, `waves.push({ count: 6, delay: 1 })`, `waves.pop()`, -`waves[i] = { … }`. A record taken out of one — `const w = waves[i]`, the variable of -`for (const w of waves)`, a record handed to a function — is the array's own, as an object of -a TypeScript array is a reference: `w.delay = 9` writes the array, and `w` stays the record -it was when `i` moves on. A field is a number or a boolean (with its declared width), a -unit, a text, an array of numbers, booleans or units, or a record of the same: `let squads = -[{ id: 1, name: "red", leader: first({ owner: P1 }), seen: [] as number[], at: { x: 0, y: 0 -} }]`, `squads[i].seen.push(4)`, `squads[i].name += "!"`. An array in a row is always one -that grows, and the row owns it, as it owns a text that was made: `pop()`, `length =`, -`squads[i] = { … }` and declaring the array again give the blocks back, and `filter` gives -its rows arrays and texts of their own. The line's hint says how many cells a row comes to. +`waves[i] = { … }`. A record taken out of one (`const w = waves[i]`, the variable of +`for (const w of waves)`, a record handed to a function) is the array's own, as an object +in a TypeScript array is a reference: `w.delay = 9` writes the array, and `w` stays the +record it was when `i` moves on. A field is a number or a boolean (with its declared +width), a unit, a text, an array of numbers, booleans or units, or a record of the same: +`let squads = [{ id: 1, name: "red", leader: first({ owner: P1 }), seen: [] as number[], +at: { x: 0, y: 0 } }]`, `squads[i].seen.push(4)`, `squads[i].name += "!"`. An array in a row +is always one that grows, and the row owns it, as it owns a text that was made. `pop()`, +`length =`, `squads[i] = { … }` and declaring the array again give the blocks back, and +`filter` gives its rows arrays and texts of their own. The line's hint says how many cells a +row comes to. **An array of units** keeps the units themselves: `const squad: Unit[] = []`, `squad.push(u)`, `squad[i].hp = 10`, `squad.pop()?.kill()`, `for (const u of squad)`, `squad.length = 0`. It may be kept across a `sleep()`, which a loop over the game's units may -not: a unit of it that has died since reads 0, takes no order, and is false in an `if`, as any -kept unit is. +not. A unit of it that has died since reads 0, takes no order, and is false in an `if`, as +any kept unit is. **The methods that take a function** work on all three kinds of array, and the function is written as it is in TypeScript: `hp.forEach((h, i) => { total += h * i; })`, -`hp.some((h) => h > 20)`, `every`, `findIndex` and `findLastIndex` (−1 when nothing is found), -`hp.reduce((sum, h) => sum + h, 0)`, `hp.map((h) => h * 2)`, `hp.filter((h) => h > 7)`, -`hp.sort((a, b) => a - b)` and `hp.reverse()`, and chains of them — -`cells.filter((c) => c < 20).map((c) => c + 1).reduce((s, c) => s + c, 0)`. The function is -handed the item, its place and the array, as JavaScript hands them. It is copied into the -loop the method becomes, so a variable it uses from outside is simply that variable, it costs -nothing to write one, and the map holds no function. What follows from that is the one thing -that does not work: a function cannot be *kept* — in a variable, in an array, as something a -function returns. Write it where it is used, or give the name of one declared with `function` -in the program (`hp.forEach(report)`). +`hp.some((h) => h > 20)`, `every`, `findIndex` and `findLastIndex` (−1 when nothing is +found), `hp.reduce((sum, h) => sum + h, 0)`, `hp.map((h) => h * 2)`, +`hp.filter((h) => h > 7)`, `hp.sort((a, b) => a - b)` and `hp.reverse()`, and chains of +them: `cells.filter((c) => c < 20).map((c) => c + 1).reduce((s, c) => s + c, 0)`. The +function is handed the item, its place and the array, as JavaScript hands them. An arrow +written there is copied into the loop the method becomes, so a variable it uses from +outside is that variable, and it costs nothing to write one. A function kept in a variable +works too (`hp.forEach(report)`); see [Functions as values](#functions-as-values). ```ts program(() => { @@ -649,29 +715,29 @@ program(() => { - `map` makes an array of numbers or of booleans: of the same fixed length when what it runs over is fixed, one that grows when that grows. `filter` always makes one that grows, of - whatever it ran over — rows of an array of records stay whole, units stay units. Both are - made again each time the line runs and give their block back first, as any declaration does. - A chain makes the arrays in the middle as written. Neither can stand in the condition of a - loop, which is worked out again every turn: make the array before the loop, or inside it. -- `find` and `findLast` give a unit or none from an array of units. Of numbers they would give - `undefined` when nothing is found, which does not exist when the map is played, so say what - it is then — `hp.find((h) => h > 20) ?? -1` — or take the place with `findIndex`; of records, - take the place and read `waves[i]`. + whatever it ran over: rows of an array of records stay whole, units stay units. Both are + made again each time the line runs and give their block back first, as any declaration + does. A chain makes the arrays in the middle as written. Neither can stand in the + condition of a loop, which is worked out again every turn: make the array before the + loop, or inside it. +- `find` and `findLast` give a unit or none from an array of units, and a number or + `undefined` from an array of numbers: `hp.find((h) => h > 20) ?? -1`. Of records, take + the place with `findIndex` and read `waves[i]`. - `reduce` wants what it starts from; without it JavaScript throws on an empty array. - `sort` wants its function: without one JavaScript sorts numbers as text, 10 before 9. It sorts where the array stands, within the frame, keeping equals in the order they were in, - and gives the same array back. A list nearly in order costs a pass; one in no order its - length squared — dozens of items are nothing, hundreds every frame will be felt, and the end - of the line says *sorts in the frame*. Records are sorted row by row + and gives the same array back. A list nearly in order costs a pass; one in no order costs + its length squared. Dozens of items are nothing; hundreds every frame will be felt, and + the end of the line says *sorts in the frame*. Records are sorted row by row (`waves.sort((a, b) => a.delay - b.delay)`), units too (`squad.sort((a, b) => a.hp - b.hp)`). - No `sleep()` inside such a function: the method is one loop within the frame. A `for…of` over the same array can sleep between its turns. The units of the game take them as well: `unitsOf(P1).forEach((u) => u.heal(10))`, `some`, -`every`, `reduce`, `map`, `find` (a unit or null) and `filter`, which is how to keep them — -`const weak = unitsOf(P1, { type: units.TerranMarine }).filter((u) => u.hp < 20)` is an array -of units, which can be sorted and kept across a `sleep()`. They come in no order, so there is -no place and no `sort` until they are in an array. +`every`, `reduce`, `map`, `find` (a unit or null) and `filter`, which is how to keep them: +`const weak = unitsOf(P1, { type: units.TerranMarine }).filter((u) => u.hp < 20)` is an +array of units, which can be sorted and kept across a `sleep()`. A loop over the game's +units has no places, so there is no `sort` until they are in an array. ```ts program(() => { @@ -684,7 +750,7 @@ program(() => { A list the script made takes them too, the loop written out turn by turn as `for…of` over one is: `waves.forEach((w) => createUnit(P2, w.unit, w.count, at))` is one Create Unit a wave, and `prices.map((p) => p + bonus)` with `bonus` a variable is an array of the program. -With nothing of the program in it — `[1, 2, 3].map((x) => x * 2)` — it is still the script's +With nothing of the program in it (`[1, 2, 3].map((x) => x * 2)`), it is still the script's own arithmetic, done when the script is built. `new Array(12).fill(0)` is an array of numbers; for booleans, `new Array(12).fill(false)`. @@ -692,19 +758,19 @@ own arithmetic, done when the script is built. **Patterns and spread** are TypeScript's. `const { x, y } = mouse(P1)`, `const { count, delay: wait } = waves[i]`, `const [first, , third = 0, ...tail] = xs`, nested as deep as they are written, in a declaration, in the variable of a `for…of` -(`for (const { count, delay } of waves)`), and in a parameter — of a function +(`for (const { count, delay } of waves)`), and in a parameter of a function (`function len({ x, y }: Point)`) or of one given to a method (`waves.forEach(({ count }) => …)`). A name in a pattern is a variable of its own holding a copy, as a number is copied in JavaScript: changing `x` does not change `p.x`. A record or -an array *inside* what is taken apart stays itself, as an object does — `const [a, b] = waves` -are two rows of `waves`. A default is for what is not there — a field the record has not -got, a place past a fixed array's end; a name with neither is an error, since it would be +an array *inside* what is taken apart stays itself, as an object does: `const [a, b] = waves` +are two rows of `waves`. A default is for what is not there (a field the record has not +got, a place past a fixed array's end); a name with neither is an error, since it would be undefined. `...tail` is an array of its own, which grows if what it was taken from does. Assigned, a pattern is a swap: `[a, b] = [b, a]`, `[hp[i], hp[j]] = [hp[j], hp[i]]`, -`({ x: q.x, y: q.y } = p)` — every value is read before any is stored. `[...xs, v, ...ys]` +`({ x: q.x, y: q.y } = p)`; every value is read before any is stored. `[...xs, v, ...ys]` copies the cells into a new array (fixed when all of them are, else one that grows), and -`{ ...p, y: 9 }` the fields into a new record or a row — `waves.push({ ...w, count: 9 })`. +`{ ...p, y: 9 }` the fields into a new record or a row: `waves.push({ ...w, count: 9 })`. `function sum(...ns: number[])` takes the rest of a call's arguments as an array made at that call; such a function is copied into each call rather than called, since every call has its own number of them. An array is handed to a function as itself (`total(xs)`), not @@ -728,7 +794,7 @@ program(() => { `new Array(8).fill(0).map(() => new Array(8).fill(0))`, `Array.from({ length: h }, () => …)`, three deep if you like: `grid[y][x]`, `grid[y][x] += 1`, `grid.length`, `grid[y].length`, `for (const row of grid)`, `grid.forEach((row, y) => …)`, `some`, `every`, `findIndex`, -`reduce`, `map`. A row is an array like any other — `const row = grid[y]` is the grid's own +`reduce`, `map`. A row is an array like any other: `const row = grid[y]` is the grid's own cells, as it is in TypeScript, and takes `fill`, `sort`, `includes`, a function's parameter, a pattern (`const [top, bottom] = grid`); `grid[y] = [a, b, c]` sets one. So does an array in a record: `let p = { hp: 5, path: [0, 0, 0], seen: [] as number[] }`, `p.path[i]`, @@ -737,22 +803,22 @@ in a record: `let p = { hp: 5, path: [0, 0, 0], seen: [] as number[] }`, `p.path How it is kept is the compiler's choice, and the end of the line says which: - **Every row the same length, and no row growing**, is *one flat array*, read at - `y * width + x` — no dearer than an array. A row past its own end reads 0 and stores + `y * width + x`, no dearer than an array. A row past its own end reads 0 and stores nothing, so it never reaches into the next row. The outer array may still grow by whole rows: `const path: number[][] = []; path.push([x, y]); path.pop(); path.length = 0`. - **Rows of different lengths, or a row something pushes to** (`buckets[i].push(v)`), is - *rows that grow*: each row a block of the heap of its own, found through the outer array — + *rows that grow*: each row a block of the heap of its own, found through the outer array, a few more triggers a read than a flat one. The outer array grows too (`buckets.push([1, 2], [])`, `buckets.pop()`, `buckets.length = 3`), a row can be given - anew (`buckets[i] = [a, b]`, or an array, whose cells are copied), and a row that goes — - popped, cut off, the whole declared again in a loop — gives its block back first, so + anew (`buckets[i] = [a, b]`, or an array, whose cells are copied), and a row that goes + (popped, cut off, the whole declared again in a loop) gives its block back first, so nothing leaks. Two deep; not three. One thing is not JavaScript's: a row of rows that grow kept in a `const` is that *place* in the outer array, not the array that was there. `const last = buckets[2]; buckets.pop(); buckets.push([7])` leaves `last` reading `[7]`, where JavaScript would still have the row -that was popped. `sort`, `reverse` and `filter` work on an array of arrays, of either kind — -`points.sort((a, b) => a[0] - b[0])`, `buckets.filter((r) => r.length > 2)` — and `filter` +that was popped. `sort`, `reverse` and `filter` work on an array of arrays, of either kind +(`points.sort((a, b) => a[0] - b[0])`, `buckets.filter((r) => r.length > 2)`), and `filter` gives rows of their own, as it does of records: what is changed through the rows it made is not changed in the rows it took them from, which is the one way it is not JavaScript's. @@ -760,9 +826,9 @@ not changed in the rows it took them from, which is the one way it is not JavaSc `xs.toReversed()` and `Array.from(xs)` give a new array that grows, of numbers, booleans or units, from an array of the program or a list the script has; `[...m.keys()]`, `Array.from(m.values())` and `[...set]` give a `Map`'s or a `Set`'s as one. `f(...xs)` spreads -an array into a call's arguments when the script knows how many there are — a list it has, or +an array into a call's arguments when the script knows how many there are: a list it has, or an array TypeScript knows as a tuple (`let box: [number, number, number] = [1, 1, 1]`), which -is what TypeScript itself asks of a spread argument; an array that grows is handed over as +is what TypeScript itself asks of a spread argument. An array that grows is handed over as itself. **An array of texts** a program fills is `const names: string[] = []`: `push`, `pop()` (its @@ -786,12 +852,12 @@ program(() => { **A list the script made is a table a program can look things up in.** `const price = [50, 100, 150]` outside the program, `price[level]` inside it, is in the map once, however often -it is read, and cannot be written. A list of records is a table a field — the wave table: +it is read, and cannot be written. A list of records is a table a field, such as the wave table: `const waves = [{ unit: units.ZergZergling, count: 4 }, …]` above the program, `createUnit(P2, waves[wave].unit, waves[wave].count, at)` inside it. -**Tables keyed by an id of the game** — a unit type, a player, a location, a switch, a weapon, -an upgrade, a technology — are arrays with a cell for every id, so a key of the game is one +**Tables keyed by an id of the game** (a unit type, a player, a location, a switch, a weapon, +an upgrade, a technology) are arrays with a cell for every id, so a key of the game is one read: ```ts @@ -812,14 +878,16 @@ program(() => { }); ``` -A `Record` is read and written by its keys and reads 0 where nothing was stored; a `Map` has -`get` (`?? d` for a key never set), `set`, `has`, `delete`, `clear` and `size`; a `Set` has -`add`, `has`, `delete`, `clear` and `size`. `for (const [key, value] of lost)`, `for (const key -of seen)`, `lost.keys()` and `lost.values()` go through the keys that are there, in the order -of the ids — every id is looked at, so the editor marks the line with how many. One made inside the program is the program's, to -be written while the map is played; one made outside it is the script's, and a program only -looks things up in it. `CurrentPlayer` is not a key — in a program of every player a plain -variable is already one per player. +A `Record` is read and written by its keys and reads 0 where nothing was stored. A `Map` +has `get` (`?? d` for a key never set), `set`, `has`, `delete`, `clear` and `size`; a `Set` +has `add`, `has`, `delete`, `clear` and `size`. `for (const [key, value] of lost)`, +`for (const key of seen)`, `lost.keys()` and `lost.values()` go through the keys that are +there, in the order of the ids. Every id is looked at, so the editor marks the line with +how many. One made inside the program is the program's, to be written while the map is +played; one made outside it is the script's, and a program only looks things up in it. +`CurrentPlayer` is not a key: in a program of every player a plain variable is already one +per player. A `Map` keyed by a few texts the script knows, `new Map<"red" | "blue", +number>()`, is kept the same way. **A `Map` and a `Set` over any number** are for keys that are no id of the game: a place packed into one number, a unit's id of your own, a score. `new Map()`, @@ -845,13 +913,14 @@ order the keys went in, as JavaScript does**: a key set again stays where it was deleted and set again goes to the end, what the loop's body adds is reached and what it deletes is not. The tests run the same lines in JavaScript and compare. -What it costs is that a key is *looked for* — a few steps to find, set or delete one, where a -table keyed by ids of the game is one read, so where the keys are ids of the game, say so -(`Map`) — and that it lives in the memory the programs' arrays share: it +What it costs: a key is *looked for*, a few steps to find, set or delete one, where a table +keyed by ids of the game is one read. So where the keys are ids of the game, say so +(`Map`). It also lives in the memory the programs' arrays share: it starts at eight slots and doubles as it fills, and deleted entries go when it is next made -again. The line's hint says which kind a `Map` became. Values are numbers or booleans; for -anything more keep the place of a row of an array of records. Left out: a text for a key, -and `m.set(…).set(…)` in a chain. +again. The line's hint says which kind a `Map` became. Values are numbers, booleans or +instances of a class (kept by reference; see [Classes](#classes)). For a text or a record, +keep the place of a row of an array of records. Left out: a text for a key, other than +the few texts of a union, and `m.set(…).set(…)` in a chain. **A `Map` or a `Set` keyed by units** is the same table: `const cooldown = new Map()`, `cooldown.set(u, 72)`, `(cooldown.get(u) ?? 0)`, `new Set()` for the units @@ -863,12 +932,14 @@ loop (`for (const [u, n] of cooldown)`) it reads as no unit, which is where to d #### Numbers and arithmetic -**Numbers** are whole, and a `number` is what it is in TypeScript as far as 32 bits go: -signed, from −2 147 483 648 to 2 147 483 647. `a - b` is below zero when `b` is larger, -`while (i >= 0) { …; i--; }` ends, `-x`, `Math.abs`, `Math.min`, `Math.max` and -`clamp(x, lo, hi)` mean what they say, and a number below zero is printed with its minus -sign. The one difference from TypeScript is at the ends: a `number` wraps there, as -`x | 0` does — 2 147 483 647 + 1 is −2 147 483 648 — where TypeScript's would go on. +**Numbers** are whole unless a fraction is stored into them ([Numbers with a +fraction](#numbers-with-a-fraction), below). A whole `number` is what it is in TypeScript +as far as 32 bits go: signed, from −2 147 483 648 to 2 147 483 647. `a - b` is below zero +when `b` is larger, `while (i >= 0) { …; i--; }` ends, `-x`, `Math.abs`, `Math.min`, +`Math.max` and `clamp(x, lo, hi)` mean what they say, and a number below zero is printed +with its minus sign. The one difference from TypeScript is at the ends: a `number` wraps +there, as `x | 0` does (2 147 483 647 + 1 is −2 147 483 648), where TypeScript's would go +on. Beside it there are three types for a number that is never below zero: @@ -881,8 +952,8 @@ Beside it there are three types for a number that is never below zero: `let lives: u8 = 3` stops at its ends *after* the whole sum, never between its parts. A `u32` is for bit masks, hashes and a count past two thousand million; everything the game -is asked — `minerals()`, `deaths()`, `u.hp` — is a `number`. A constant its variable -cannot hold is a compile error (`let mask = 0xffffffff` wants `let mask: u32`). +is asked (`minerals()`, `deaths()`, `u.hp`) is a `number`. A constant its variable cannot +hold is a compile error (`let mask = 0xffffffff` wants `let mask: u32`). **A `number` and a `u32` do not mix in arithmetic** without saying which is meant: `h + n` is an error that names the two ways out, `u32(n)` and `i32(h)`, which read the same 32 @@ -891,49 +962,100 @@ whole-number constant that fits is either, so `h * 31 + 7` needs nothing. A *com between the two is no error and is exact: a number below zero is smaller than any `u32`. Storing one into a variable of the other keeps the bits. -**Where the game takes nothing below zero** — a `u8` or `u16`, a unit's hit points, an -action's amount or count, a cell of `stats()`, `random(n)`'s bound, a place on the map — a -number below zero goes in as 0: `u.hp -= 1000` kills, `createUnit(p, unit, n, at)` with +**Where the game takes nothing below zero** (a `u8` or `u16`, a unit's hit points, an +action's amount or count, a cell of `stats()`, `random(n)`'s bound, a place on the map), a +number below zero goes in as 0: `u.hp -= 1000` kills, and `createUnit(p, unit, n, at)` with `n` at −3 makes none. **Arithmetic**: `+ − * / %` between variables and constants, `*=` `/=` `%=`, `++` `--`, -`Math.min`, `Math.max`, `Math.abs`, `clamp(x, lo, hi)`, and the bitwise `& | ^ << >> >>>` +`Math.min`, `Math.max`, `Math.abs`, `clamp(x, lo, hi)`, and the bitwise `& | ^ ~ << >> >>>` (with `&=` and the rest) over the 32 bits. `>>` keeps the sign of a `number` and `>>>` never does, as in JavaScript; a shift by 32 or more leaves nothing but that sign, where -JavaScript would shift by the remainder. `/` is whole division towards zero — `-7 / 2` is -−3, as `Math.trunc(-7 / 2)` — and `%` takes the sign of what is divided, `-7 % 2` is −1, -as in JavaScript (there are no fractions in the game; `Math.floor`, `Math.trunc`, -`Math.round` and `Math.ceil` around a division are accepted and change nothing). `*` -wraps, and dividing by a variable that is 0 in the game gives 0, as `(a / 0) | 0` does. -Dividing by a constant 0 is a compile error. +JavaScript would shift by the remainder. `/` of two whole numbers is whole division towards +zero (`-7 / 2` is −3, as `Math.trunc(-7 / 2)`), and `%` takes the sign of what is divided +(`-7 % 2` is −1), as in JavaScript. `Math.floor`, `Math.trunc`, `Math.round` and +`Math.ceil` around a whole division change nothing. `*` wraps, and dividing by a variable +that is 0 in the game gives 0, as `(a / 0) | 0` does. Dividing by a constant 0 is a compile +error. `a ** 2` and `Math.pow(a, 3)` take a power known when the script is built; +`Math.sign(n)` and `Math.log2(n)` (rounded down) work on whole numbers. + +#### Numbers with a fraction + +**A number holds a fraction once one is stored into it**: `let speed = 1.5`, `x += 0.25`, +`const d = Math.cos(a)`. It is then a `fixed` number, 16.16 bits: about four decimal places, +from about −32 768 to 32 767. `fixed(n)` makes a whole number one, and a parameter or a +variable typed `fixed` is one from the start. `u8`, `u16` and `u32` stay whole; a fraction +stored into one is an error. + +- Whole numbers still divide as whole numbers: `7 / 2` is 3, and `7 / fixed(2)` is 3.5. +- Where only a whole number goes (an index, a count, a place, a shift, a unit's field), a + fixed number is an error that names `Math.floor`, `Math.round` and `Math.trunc`. `x | 0` + is `Math.trunc(x)`. +- `Math.sqrt`, `Math.hypot`, `Math.sin`, `Math.cos`, `Math.tan`, `Math.atan` and + `Math.atan2` give fixed numbers. Angles are radians, as in JavaScript. `sin`, `cos` and + `atan2` read tables the build adds to the map, within 1/65 536 of JavaScript's. +- A fixed number in a text is printed as a decimal of up to four places, without trailing + zeros. + +```ts +program(() => { + let speed = 1.5; // a fraction stored into it: a fixed number + speed += 0.25; + const across = Math.hypot(30, 40); + const half = 7 / fixed(2); // 7 / 2 alone is 3, as whole numbers divide + print(`${speed} ${across} ${half} ${Math.floor(speed * 10)}`); // 1.75 50 3.5 17 +}); +``` + +`trigscript/math` adds `lerp`, easing curves, `distance`, `angleTo` and a `Vec` of two +fixed numbers ([The standard library](#the-standard-library)). #### Control flow and loops -`if`/`else`, `while`, `do`, `for`, `switch`, `break`, `continue` and `c ? a : b` all -work. `switch (x)` over a variable tests its cases in order and falls through without -`break` as TypeScript does; the case values are known when the script is applied. `&&`, -`||` and `!` short-circuit as in TypeScript: `n >= 1 && once(…)` consumes the edge only -once `n` is 1. Conditions of the game go where a boolean goes — `if (bring(P1, -units.AnyUnit, locations.Beacon, ">=", 1) && !alarm)` — `random()` is a coin toss, and -`random(n)` a whole number from 0 to n − 1 (`n` may be a variable; 0 gives 0). The game's -own randomness seeds them, so two games differ. +`if`/`else`, `while`, `do`, `for`, `switch`, `break`, `continue`, labels and `c ? a : b` +all work. `switch (x)` over a number or a text tests its cases in order and falls through +without `break` as TypeScript does; the case values are known when the script is applied. +`&&`, `||` and `!` short-circuit as in TypeScript: `n >= 1 && once(…)` consumes the edge +only once `n` is 1. Conditions of the game go where a boolean goes: +`if (bring(P1, units.AnyUnit, locations.Beacon, ">=", 1) && !alarm)`. `random()` is a coin +toss, and `random(n)` a whole number from 0 to n − 1 (`n` may be a variable; 0 gives 0). +The game's own randomness seeds them, so two games differ. **Loops run to completion within the frame.** `while (i < 10) { …; i++; }` does all ten -rounds at once, which is what the source says. That has one consequence to keep in mind: -a loop that never ends and never sleeps would never give the frame back, and the game -would freeze. The compiler refuses one — a loop with no `sleep()` on some path around it, -whose condition never mentions a variable the body changes (or, for a condition or a read -of the game, whose body takes no action that could change it) — and says where to put a -`sleep(frames(1))`. The simulator has a guard of its own for what the check cannot see. - -A `for` whose start, bound and step are known when the script is applied — -`for (let i = 0; i < 3; i++)`, counting down, stepping by two — is **unrolled**: the body -is compiled once per value with `i` a value and not a variable, so +rounds at once, as the source says. The consequence: a loop that never ends and never +sleeps would never give the frame back, and the game would freeze. The compiler refuses +such a loop and says where to put a `sleep(frames(1))`. It looks for a loop with no +`sleep()` on some path around it whose condition never mentions a variable the body +changes (or, for a condition or a read of the game, whose body takes no action that could +change it). The simulator has a guard of its own for what the check cannot see. + +A `for` whose start, bound and step are known when the script is applied +(`for (let i = 0; i < 3; i++)`, counting down, stepping by two) is **unrolled**: the body +is compiled once per value, with `i` a value and not a variable, so `createUnit(P2, unit, i + 1, at)` is an ordinary action, and `break` and `continue` work. The editor says so at the end of the line: *unrolled ×3*. A `for` over a variable bound, or one that assigns its variable in the body, is a loop in the game. An unroll of more than 256 iterations is an error that says how to write it as a loop instead. +A label on a loop lets `break` and `continue` reach past the loop they are in: + +```ts +program(() => { + const hp = [40, 0, 25, 0, 60, 10]; + let row = 0; + let found = -1; + rows: while (row < 2) { + let col = 0; + while (col < 3) { + if (hp[row * 3 + col] > 50) { found = row * 3 + col; break rows; } + col += 1; + } + row += 1; + } + print(`${found}`); // 4 +}); +``` + **Lists are unrolled.** `for (const w of waves)` over a list known when the script is applied compiles the body once per element, with `w` bound to that element, so a wave table is ordinary data: @@ -952,59 +1074,81 @@ program(() => { #### Functions **Functions.** Arguments pass by value, as in TypeScript: `function bump(x: number) { x++; }` -leaves the caller's variable alone. A function may **return a number, a boolean, a text -or a unit** — `function canAfford(price: number) { return gold >= price; }`, -`x = twice(y) + 1`, ``function label(n: number) { return `Wave ${n}`; }`` — and an instance -of a class under the rule in *Classes*, below. It does not return a record or an array it -made: hand it the one to fill in, which reaches it as itself. Parameters take defaults -(`function f(x: number, step = 1)`), and locals start afresh at every call. - -A function used once is **inlined**: its body is written where the call stands, a -parameter that was given a value known when the script is built is that value, and one -given a variable the function never assigns reads that variable directly. A function used -a second time is **called** instead when it can be: one copy of its body in the built -map, its parameters variables that every call sets. The source is the same either way and -means the same; what calling buys is the size of the map — ten calls of a function are -one body, not ten. The end of the function's line says which it got: *called ×3*, or -*inlined ×3* with the reason when you hover it. Three things keep a function inlined: - -- it **sleeps** — the program wakes up inside it, which only an inlined body can do; -- a parameter reaches something only a value known when the script is built can fill — +leaves the caller's variable alone. A function may return a number, a boolean, a text, a +unit, a record, a tuple or an array: `function canAfford(price: number) { return gold >= +price; }`, ``function label(n: number) { return `Wave ${n}`; }``, `return { big, half }`, +`return [lo, hi] as const`. It may return an instance of a class under the rule in +*Classes*, below. Parameters take defaults (`function f(x: number, step = 1)`) and may be +optional (`extra?: number`, `undefined` when the call leaves it out). Locals start afresh +at every call. A function declared inside another sees the outer one's parameters and +locals. + +```ts +function split(n: number) { + return { big: n > 4, half: n >> 1 }; +} +function around(n: number) { + return [n - 1, n + 1] as const; +} +function advance(n: number, extra?: number) { + return extra !== undefined ? n + extra : n + 1; +} + +program(() => { + let n = 7; + const { big, half } = split(n); + const [lo, hi] = around(n); + print(`${big} ${half} ${lo}-${hi} ${advance(n)} ${advance(n, 10)}`); // true 3 6-8 8 17 +}); +``` + +A function used once is **inlined**: its body is written where the call stands. A +parameter given a value known when the script is built is that value, and one given a +variable the function never assigns reads that variable directly. A function used a second +time is **called** instead when it can be: one copy of its body in the built map, its +parameters variables that every call sets. The source means the same either way; calling +keeps the map smaller, since ten calls of a function are one body, not ten. The end of the +function's line says which it got: *called ×3*, or *inlined ×3* with the reason when you +hover it. Three things keep a function inlined: + +- It **sleeps**: the program wakes up inside it, which only an inlined body can do. +- A parameter reaches something only a value known when the script is built can fill. `function pay(p: Player, n: number) { setResources(p, "add", n, "ore"); }` needs its player when the map is built, so `pay(P2, 4)` stays inlined (the amount could be a - variable; the player cannot) — or an argument is a list only the script has (a text - written in the script is no such thing: the parameter is a text variable, set at each - call); -- it uses `rose()` or `once()`, which remember what they saw at each place they are + variable; the player cannot). The same goes for an argument that is a list only the + script has. A text written in the script is not such a list: the parameter is a text + variable, set at each call. +- It uses `rose()` or `once()`, which remember what they saw at each place they are written. -An array reaches a function as itself — what the function stores, the caller sees — and +An array reaches a function as itself (what the function stores, the caller sees), and which array is settled when the script is built. So a function that takes an array is one copy *for each array it is passed*: `total(hp)` and `total(shields)` are two copies, five calls of `total(hp)` one. The hint counts them: *called ×5, 2 copies*. **A function may call itself**, directly or through another: `fib(n - 1) + fib(n - 2)`, a flood fill over an array, two functions that call each other. Such a function is always a -called one — from the call inside itself on, whether or not anything else calls it twice — -and its line says *calls itself*. It means what it means in TypeScript: each run has its own -parameters and locals, an array declared in it is that run's own, and `c ? f(x) : 0` or +called one, from the call inside itself on, whether or not anything else calls it twice, +and its line says *calls itself*. It means what it means in TypeScript: each run has its +own parameters and locals, an array declared in it is that run's own, and `c ? f(x) : 0` or `ok && f(x)` makes the call only when TypeScript would. Three things show that it is a map and not a JavaScript engine: -- **No `sleep()` in it.** A function that sleeps is inlined, and one that calls itself - cannot be; the error says so. -- **A depth limit** — 1 024 calls deep unless the panel's **Settings** says otherwise (16 to - 65 536, kept in the map). A call past it stops the program for good, and the game says - where: *stack overflow in fill, line 12*. **Simulate** stops at the same call and lists it - with the other faults. A function with no way out at all — it calls itself on every path — - is an error when the script is built. +- **No `sleep()` or `await` in it.** A function that waits is inlined, and one that calls + itself cannot be; the error says so. +- **A depth limit**: 1 024 calls deep unless the panel's **Settings** says otherwise (16 to + 65 536, kept in the map). A call past it is a `RangeError` that a `catch` can take, as in + JavaScript, when the program throws or catches anything. Otherwise it stops the program + for good, and the game says where: *stack overflow in fill, line 12*. **Simulate** stops + at the same call and lists it with the other faults. A function with no way out at all + (it calls itself on every path) is an error when the script is built. - **It costs what it keeps.** A function's variables are single cells of the map, so around - each call that may come back the function's variables are put on a stack and taken back - after. That is a few dozen triggers a variable a call: nothing for a flood fill or a walk - a thousand deep, a visible pause for tens of thousands of calls within one frame. The - fewer variables the function has, the less a call costs. The stack is only in the built - map when some function calls itself — Settings says how large it comes to for the script - as it stands — and a function that does not call itself costs what it always did. + each call that may come back they are put on a stack and taken back after. That is a few + dozen triggers a variable a call: nothing for a flood fill or a walk a thousand deep, a + visible pause for tens of thousands of calls within one frame. The fewer variables the + function has, the less a call costs. The stack is only in the built map when some + function calls itself (Settings says how large it comes to for the script as it stands), + and a function that does not call itself costs what it always did. A flood fill over a grid, which is both of the last two sections at work: @@ -1029,7 +1173,9 @@ program(() => { A loop over units (`for (const u of unitsOf(…))`) cannot hold such a call: collect what it finds into an array, and recurse from a loop over that. -**Functions the game runs can live in any file**: `game()` marks them. +**Functions the game runs can live in any file.** A function declared at the top of any of +the script's files runs in the game when a program calls it with values of the program. +`game()` marks one that only the game runs: ```ts // shop.ts @@ -1044,18 +1190,52 @@ program(() => { }); ``` -A `game()` function follows the program's rules — inlined where it is used once, called -where it is used more, as above — and what it does is attributed to its own file and line. It sees its parameters and what any -file sees when the script is applied, not the calling program's variables. Calling one -outside a program is an error: it runs in the game, not when the script is applied. +A `game()` function follows the program's rules (inlined where it is used once, called +where it is used more, as above), and what it does is attributed to its own file and line. +It sees its parameters and what any file sees when the script is applied, not the calling +program's variables. Calling one outside a program is an error: it runs in the game, not +when the script is applied. + +#### Functions as values + +**A function can be kept**: in a variable, an array, a record's or an instance's field, or a +`Map`, and returned by another function. A call through what holds it calls whichever +function is there when the line runs. A closure keeps what it captured, as in JavaScript: +`counter()` below gives a function with its own count. A function given where it is used +(an arrow handed to a method or a parameter) costs nothing to write: it is copied in where +it is called. A kept one is a number in the map, and a call through it chooses among the +functions that could be there, one test each. + +```ts +function heal(hp: number) { return hp + 10; } +function hurt(hp: number) { return hp - 10; } +function counter() { + let n = 0; + return () => { n += 1; return n; }; +} + +program(() => { + const effects = [heal, hurt, (hp: number) => hp * 2]; + const next = counter(); + let hp = 40; + let i = 0; + while (i < effects.length) { hp = effects[i](hp); i = next(); } + print(`${hp}`); // 80 +}); +``` + +An `async` function or a generator is not kept as a value: call it where it is written, or +`start()` it ([Waiting and tasks](#waiting-and-tasks)). `(() => { … })()` runs where it is +written. #### Classes -**Classes** are TypeScript's, declared inside the program like its functions. An instance -is a record — a variable a field — and a method is a function that is handed the instance, -so everything above about functions holds for methods: one used once is inlined, one used -more is called (a copy an instance, as a function that takes an array is a copy an array), -one may call itself, and one that sleeps is inlined. +**Classes** are TypeScript's, declared inside the program or at the top of any of the +script's files. A class declared outside a program is still an ordinary class when the +script is applied, and a program's own wherever a program makes an instance of it. A +method is a function that is handed the instance, so everything above about functions +holds for methods: one used once is inlined, one used more is called, one may call itself, +and one that sleeps is inlined. ```ts program(() => { @@ -1081,38 +1261,104 @@ program(() => { ``` Fields with their first values, a constructor, `constructor(public x: number)`, methods, -`get` and `set`, `static` fields and methods, `readonly`, `private` and `#x`, `extends` -with `super(…)` and `super.method()`, `abstract`. A field holds what a record's does: a -number, a boolean, a unit, a text, an array (`members: Unit[] = []`), a record, another -instance (`pos = new Vec(0, 0)`). `this.members = []` starts the array over. - -What makes it work is that **the class of every instance is known when the script is -built** — nothing of a class is left when the map is played. So an overridden method is -found by what the instance is, even through a parameter typed as the class it extends; -`x instanceof Boss` is answered by the compiler, and the branch that is false is not built; -and the same is where the limits are: - -- **An array of instances holds one class**, since a row has the fields of one. `const xs: - Wave[] = []` that only ever gets `new Boss(…)` is an array of `Boss`; one that gets both - is an error that says to keep an array for each. -- **`new` where the instance will live**: `const w = new Wave(…)`, a field's first value, - `waves.push(new Wave(…))`, `waves[i] = new Wave(…)`, `[new Wave(…), …]`, an argument - (`run(new Wave(…))`). A row *is* the instance, so `waves.push(w)` of one kept in a variable - is refused rather than quietly copied. `const same = w` is another name for the same one. +`get` and `set`, `static` fields and methods, `readonly`, `private`, `protected` and `#x`, +`extends` with `super(…)` and `super.method()`, `abstract`, `implements`, type parameters. +A field holds what a record's does: a number, a boolean, a unit, a text, an array +(`members: Unit[] = []`), a record, another instance (`pos = new Vec(0, 0)`). +`this.members = []` starts the array over. + +**An instance is kept one of two ways**, and the compiler chooses: + +- **As its fields' variables**, when the build knows which instance each name holds. Then + nothing of the class is left when the map is played: an overridden method is found by + what the instance is, `x instanceof Boss` is answered by the compiler, and the branch + that is false is not built. This costs nothing beyond the fields. +- **By reference**, when the program needs it: an array or a variable that may hold + several classes, or none (`let target: Enemy | null`); a variable given another instance; + an instance kept in a variable and pushed to an array; a `Map` or a `Set` of instances. + Those instances are rows of one table of 1 023 rows. A method called through a reference + asks the row's class when classes give it different bodies, and `instanceof` is the same + question. A field read through none is 0, and a write through none does nothing (where + JavaScript would throw). A row nothing holds any more is given back at the next wait; + rows that hold each other in a ring are never given back. + +```ts +class Enemy { + constructor(public hp: number) {} + worth() { return 1; } +} +class Boss extends Enemy { + worth() { return 10; } +} + +program(() => { + const foes: Enemy[] = [new Enemy(5), new Boss(50), new Enemy(5)]; + let target: Enemy | null = null; + let score = 0; + for (const f of foes) { + score += f.worth(); + if (!target || f.hp > target.hp) target = f; + } + print(`${score} ${target?.hp ?? 0}`); // 12 50 +}); +``` + +The limits: + - **A function may give an instance when every `return` gives the same one**: `return this` (so `v.add(w).scale(2)` chains, the calls running in the order written), `return new Wave(n, 10)` (a function that makes one: `const w = make(3)`), or one it was - handed. `pick(a, b)` that returns one or the other is refused: which instance it is would - only be known while the map is played. For that, keep the instances in an array and - return the place. -- **A variable that is a row may be given another row of the same array**: `let cur = - waves[0]; … cur = waves[i]`. Underneath, which row it is is a number. A variable holding an - instance of its own (`let w = new Wave(…)`) is not given another. -- **A class is declared in the program** (or in a `game()` function) that uses it. One - declared outside is the script's: fine for working things out when the script is - applied, not something a program can write to. -- No type parameters, no static blocks, no decorators; a class written as a value - (`const C = class { … }`) is refused. + handed. `pick(a, b)` that returns one or the other is refused. For that, keep the + instances in an array and return the place. +- **No static blocks**: give the static fields their values where they are declared. + +#### Errors + +**`try`, `catch`, `finally` and `throw`** work as in TypeScript. `throw` takes a +`new Error(…)`, a `RangeError`, a `TypeError`, an instance of a class that extends one of +them, or what a `catch` took (`throw e`). Anything else is refused at the `throw`. A class +of error has a `message`, a `name` and fields of its own (numbers, booleans, units, texts), +and `e instanceof OutOfGold` tells the classes apart. `e.stack` is the error's class, +message and the line it was thrown at. + +```ts +class OutOfGold extends Error { + constructor(public need: number) { super(`need ${need} more`); } +} + +program(() => { + let gold = 30; + function buy(cost: number) { + if (cost > gold) throw new OutOfGold(cost - gold); + gold -= cost; + } + try { + buy(20); + buy(50); + } catch (e) { + if (e instanceof OutOfGold) print(`${e.message} (${e.need})`); // need 40 more (40) + else throw e; + } finally { + print(`${gold} left`); // 10 left + } +}); +``` + +- An error goes up through functions, including one that calls itself, to the nearest + `catch`. `finally` runs however the `try` is left: a `return`, a `break`, or an error on + its way out. +- An error nothing catches is said in the game in red, with its line ("TrigScript: + uncaught OutOfGold: need 20 more, line 12."), and Simulate lists it at the `throw`. The + program or task stops there. A handler of an [event](#events) gives up that one turn and + goes on listening. +- A `try` may span `sleep` and `await`. An error in one branch of `Promise.all` or + `Promise.race` leaves the statement, and the other branches stop where they are. +- A stack that runs out is a `RangeError`, "Maximum call stack size exceeded", which a + `catch` takes. +- A caught error is not kept in a variable, an array or a field, and `new Error(m, { cause + })` is refused: put what you need in a field of your own class. +- An index past an array's end and a full heap stay what they were: the game passes over + them, and Simulate and the tests say where. #### What is worked out when the script is applied @@ -1122,29 +1368,31 @@ one, which TypeScript keeps you from reassigning. A constant is computed when th compiler reaches its declaration, or earlier if something needs it, and one inside a branch that is never compiled is never computed at all. -**Everything else the body reads from outside is computed when the script is applied.** -A constant, a helper, a condition, an action: each is evaluated once, when the compiler -reaches it, and the compiler sees its value where the expression stood. The editor -underlines those parts with dots, so the boundary is visible as you type, and hovering a -variable says what it is. `if (false) …` and `while (false) …` are pruned, and nothing -inside them is evaluated. A helper that throws is reported at the expression that called -it, with a note that it ran when the script was applied. That is what makes `burst(4)` -above work — the helper is ordinary TypeScript, it returns two actions, and the program -runs them. It is also the one rule to keep in mind: a program variable cannot reach a -condition, an action or a helper, because those are computed before the game starts. A -parameter of a function that was given a value does reach them — such a function is -inlined, the parameter being that value — so +**What the body reads from outside, with nothing of the program in it, is computed when +the script is applied.** A constant, a helper called with known values, a condition, an +action: each is evaluated once, when the compiler reaches it, and the compiler sees its +value where the expression stood. The editor underlines those parts with dots, so the +boundary is visible as you type, and hovering a variable says what it is. `if (false) …` +and `while (false) …` are pruned, and nothing inside them is evaluated. A helper that +throws is reported at the expression that called it, with a note that it ran when the +script was applied. That is what makes `burst(4)` above work: the helper is ordinary +TypeScript, it returns two actions, and the program runs them. A helper called with a +variable of the program runs in the game instead ([Functions](#functions)), and then it +returns a value, not actions. + +**The one rule to keep in mind**: the player and the location of an action, and the amount +a condition compares with, are known when the script is applied. A program variable +cannot reach them. A parameter of a function that was given a known value does reach them +(such a function is inlined, the parameter being that value), so `function spawn(p: Player, n: number) { createUnit(p, units.Zergling, n, spawnAt); }` works with `spawn(P2, 4)`. -**An action can take a variable amount**, which is the exception to that rule. -`setResources(P1, "add", n, "ore")`, `setResources(P2, "set", wave * 10 + 5, "gas")`, -`setDeaths(…, "add", n)`, `setScore(…)`, `setCountdownTimer("set", n + 1)` take a -variable where the amount goes, one action each. `createUnit`, `killUnitAt`, -`removeUnitAt` and `giveUnits` take a variable *count*: that many units, 0 being none. -A location, a unit type or a player is known when the script is applied, and a -*condition's* amount is too — to compare against a variable, read the value and compare -it yourself, as below. +**These parts of an action can follow a variable**: the amount of `setResources`, +`setDeaths`, `setScore` and `setCountdownTimer` (`setResources(P2, "set", wave * 10 + 5, +"gas")`); the count of `createUnit`, `killUnitAt`, `removeUnitAt` and `giveUnits` (that +many units, 0 being none); an action's unit type; and the text of `displayText`, `print`, +the objectives, a leaderboard's label, a transmission and a unit type's name. To compare a +*condition's* amount with a variable, read the value and compare it yourself, as below. #### Reading the game @@ -1163,20 +1411,20 @@ let here = bring(P2, units.ZergZergling, locations.Pen); That works for `deaths`, `kill`, `bring`, `command`, `accumulate`, `score`, `opponents`, `countdownTimer` and `elapsedTime`, and there are plainer names for the common ones: `minerals(p)`, `gas(p)`, `resources(p, "oreAndGas")`, `countUnits(p, unit, location?)`, -`kills(p, unit)`, `countdown()`, `elapsed()` (both in the game's own seconds, sixteen -frames each: at Fastest they run about one and a half times as fast as `sleep(seconds())`, -so `elapsed()` reads 21 after fourteen seconds of sleeping). A read means exactly what the condition -means — a force's minerals are the force's sum, `units.Men` counts what Bring counts — -because where the game keeps no table of the value, the program asks the condition -itself, a bit at a time. What to read (the player, the unit, the location) is known when -the script is applied; `CurrentPlayer` is the player the program is running as. A read is -taken when the line runs, each time it runs: `let ore = minerals(P1)` keeps the number, -`minerals(P1)` written twice reads twice. +`kills(p, unit)`, `countdown()` and `elapsed()`. The last two count the game's own seconds, +sixteen frames each, so at Fastest they run about one and a half times as fast as +`sleep(seconds())`: `elapsed()` reads 21 after fourteen seconds of sleeping. A read means +what the condition means (a force's minerals are the force's sum, `units.Men` counts what +Bring counts), because where the game keeps no table of the value, the program asks the +condition itself, a bit at a time. What to read (the player, the unit, the location) is +known when the script is applied; `CurrentPlayer` is the player the program is running as. +A read is taken when the line runs, each time it runs: `let ore = minerals(P1)` keeps the +number, and `minerals(P1)` written twice reads twice. **Player facts** are reads too: `race(p)` against `races.Zerg` / `.Terran` / `.Protoss`, `slot(p)` against `slots.Human` / `.Computer` / `.Empty` / `.Rescuable` / `.Neutral`, `isHuman(p)`, `hasLeft(p)` (P1 … P8: a human or computer slot of the map whose player -is gone — which a slot nobody took also is; a computer never leaves), and `supply(p, "used" | +is gone, which a slot nobody took also is; a computer never leaves), and `supply(p, "used" | "max" | "provided", race?)` as the top bar shows it, of the race the player plays unless one is given. @@ -1192,47 +1440,105 @@ const target = nearest(units.TerranMarine, locations.Beacon, { owner: P1 }); if (target) target.order("move", locations.Exit); ``` -`unitsAt(location, filter?)`, `unitsOf(player, filter?)` and `allUnits(filter?)` are what a -`for…of` runs over; `first(filter?)`, `nearest(type, location, filter?)` and -`randomUnit(filter?)` pick one unit or `null`, and TypeScript wants the `if (target)` before -the unit is used (or `target?.kill()`). A filter is `{ type, owner, at }`, every part known -when you build; `units.Men`, `units.Buildings` and `units.Factories` work as a type, and -`CurrentPlayer` as an owner. Units come in the order of the game's unit table; a dying unit -is passed over, so `first()` after a `kill()` is the next one. +- `unitsAt(location, filter?)`, `unitsOf(player, filter?)` and `allUnits(filter?)` are what + a `for…of` runs over. +- `first(filter?)`, `nearest(type, location, filter?)` and `randomUnit(filter?)` pick one + unit, or `null`. `first` and `randomUnit` take a set too: + `first(unitsAt(locations.Base, { owner: P1 }))`. TypeScript wants the `if (target)` + before the unit is used (or `target?.kill()`, `target?.hp ?? 0`). +- A filter is `{ type, owner, at }`, every part known when you build. `units.Men`, + `units.Buildings` and `units.Factories` work as a type, and `CurrentPlayer` as an owner. +- Units come oldest first: the game's unit table in the order the game hands its places + out (slot 0, then 1699 downwards). The walk stops once it has met every unit in use, so + a loop costs what the map holds. A unit made in the place of one that died, once the + game has freed it, comes where the dead one did. A dying unit is passed over, so + `first()` after a `kill()` is the next one. A unit has `hp`, `shields` and `energy` in whole points, `maxHp` / `maxShields` of its type, `owner`, `type`, `x`, `y`, `kills`, `orderId`, `cooldown`, `resources` (a mineral -field's), the timers `stim` `ensnare` `plague` `lockdown` `maelstrom` `irradiate` `stasis` -in the game's own ticks, and the booleans `invincible`, `hallucinated`, `cloaked`, -`burrowed`, `underAttack`. Hit points, shields, energy, kills, the cooldown, the resources, -the timers and `invincible` can be written (`u.hp = 0` kills); the rest the game keeps for -itself — a position write ends the game with "EUD not supported", a cloak write showed -nothing — and the declarations have them `readonly`, so the editor says so as you type. -It can be told `order("move" | "patrol" | "attack", location)` (the game's own Order, -reaching this unit alone), `give(player)`, `kill()`, `remove()`, `damage(n)` and -`heal(n)` — or `{ percent: 50 }` of the type's maximum — and `locate(location)`, which -centres a location on it so that `createUnit` and the rest can happen where the unit is. -Functions take units and return them (`function weakest(): Unit | null`), and `a == b` -says whether two are one unit. +field's), `buildTime` (frames left of its making; a building finished early keeps the hit +points it had reached, and a Terran one under a third burns, so heal it after), `rank`, `matrix` (frames left of a +defensive matrix), `speed` (how fast it is moving now, in 256ths of a pixel a frame), the +timers `stim` `ensnare` `plague` `lockdown` `maelstrom` `irradiate` `stasis` in the game's +own ticks, and the booleans `invincible`, `hallucinated`, `speedUpgrade`, `cloaked`, +`burrowed`, `underAttack` (the game raised its under-attack alert for it within the last +second or so, which it does for a hit out of the player's view, not one on screen), +`hasTarget` (its order is aimed at a unit) and `inTransport`. +Hit points, shields, energy, kills, the cooldown (the ground weapon's, the air weapon's and +the spells' at once), the resources, the build time, the rank, the matrix (a write gives +250 points of matrix for that many frames; 0 takes it away), the timers, `invincible`, +`hallucinated` and `speedUpgrade` can be written (`u.hp = 0` kills). The rest the game +keeps for itself (a position write ends the game with "EUD not supported", a cloak write +showed nothing), and the declarations have them `readonly`, so the editor says so as you +type. A unit can be told: + +- `order("move" | "patrol" | "attack", location)`, the game's own Order, reaching this + unit alone; +- `give(player)`, `kill()`, `remove()`; +- `damage(n)` and `heal(n)`, or `{ percent: 50 }` of the type's maximum; +- `locate(location)`, which centres a location on it so that `createUnit` and the rest can + happen where the unit is. + +`select(player, unit)` or `select(player, [a, b])` makes what a player has selected that +unit or those units, as if they had clicked them. Functions take units and return them +(`function weakest(): Unit | null`), and `a == b` says whether two are one unit. A variable can keep a unit across a `sleep()`. The game gives a dead unit's place to the next unit made, so every use checks that the unit is still the one that was kept: once it -is gone its numbers read 0, its booleans false, and writing to it or telling it something -does nothing; `if (u)` asks. The unit of a loop's turn needs no check, and a loop over -units runs within one frame — a `sleep()` inside one is an error; to act on one unit at a -time, find it again after each sleep: +is gone, its numbers read 0, its booleans false, and writing to it or telling it something +does nothing. `if (u)` asks. The unit of a loop's turn needs no check. A loop over units +runs within one frame, so a `sleep()` or an `await` inside one is an error. To act on one +unit at a time, find it again after each sleep: ```ts let u = first({ owner: P2, at: locations.Pen }); while (u) { u.kill(); sleep(seconds(1)); u = first({ owner: P2, at: locations.Pen }); } ``` -Each loop and each pick looks through the game's 1700 unit slots when its line runs -(`randomUnit` twice), and in a per-player program once for each player. Once or a few times -a second is nothing; the editor notes it at the end of the line (`scans units`) so that a -scan inside a loop that runs every frame is a choice and not an accident. +Each loop and each pick walks the unit table when its line runs (`randomUnit` twice), and +in a per-player program once for each player. Once or a few times a second is nothing. +The editor notes it at the end of the line (`scans units`), so that a scan inside a loop +that runs every frame is a choice and not an accident. + +#### Events + +**`on.*` runs a function each time something happens to a unit**, from the frame it is +written on. `const t = on.died(…)` gives back a task ([Waiting and +tasks](#waiting-and-tasks)), and `t.cancel()` stops listening. An `async` function is +started as a task each time, so it may wait. + +- `on.created(fn)`: every unit that comes onto the map, handed over on the next frame. +- `on.died(fn)`: every unit that dies or is removed, handed over as it was. +- `on.killed((u, by) => …)`: every unit killed in a fight, with the player whose attack + killed it. +- `on.morphed((u, was) => …)`: a unit that turns into another type and stays the same unit, + with the type it was. +- `on.order((u, order, was) => …)`: a unit that takes up a new order, with the one it had. + This compares every unit's order with the last frame's, so it costs a walk of the units. + +Each takes a unit type or a filter first: `on.created(units.ZergZergling, …)`, +`on.died({ owner: P8 }, …)`, `on.killed({ owner: P8, by: P1 }, …)`, +`on.order(units.TerranMarine, orders.Move, …)`. A listener written at the top of a file, +outside any program, listens from the start of the game. `on.key` and `on.chat` are the same +for the players' keys and typed lines ([Keys, the mouse and chat](#keys-the-mouse-and-chat)). + +```ts +program(async () => { + let lost = 0; + on.died({ owner: P1 }, (u) => { + lost += 1; + if (u.type == units.TerranMarine) print(`${lost} lost`); + }); + on.killed((u, by) => { if (by == P1) setResources(P1, "add", 5, "ore"); }); + on.created(units.ZergZergling, (u) => { u.hp = u.maxHp / 2; }); + on.died({ owner: P8 }, async (u) => { + await sleep(seconds(2)); + createUnit(P8, u.type, 1, locations.Spawn); + }); +}); +``` -#### The game's tables +#### The game's tables and memory **`stats()` is the game's own tables.** What a unit type costs, what a weapon does, a player's upgrades, as properties a program reads, assigns and `+=`s: @@ -1257,18 +1563,34 @@ the game's clock), `supplyUsed`, `supplyProvided`, `sight`, `groundWeapon`, `air `robotic`; a weapon `damage`, `bonus`, `cooldown`, `factor`, `range`, `minRange`; an upgrade `minerals`, `gas`, `time` and (read only) `maxLevel`; a technology `minerals`, `gas`, `time`, `energy`; a player `color`, `upgrades[…]` and `researched[…]`. Only what was played -and seen working in StarCraft: Remastered is there — the hover on each says what it -reaches: most unit-type fields apply to units made after the write, a weapon's to every -unit using it at once. `speed` and `name` can be set but not read. A write lasts for the -game. `stats()` wants to see what it is given — `stats(units.TerranMarine)`, `stats(p)` -with `p: Player` — because a table's index is a plain number when the script runs and -only its type says which table. +and seen working in StarCraft: Remastered is there, and the hover on each says what it +reaches. Most unit-type fields apply to units made after the write, and a weapon's to +every unit using it at once. `speed` and `name` can be set but not read. A write lasts for +the game. `stats()` wants to see what it is given (`stats(units.TerranMarine)`, `stats(p)` +with `p: Player`), because a table's index is a plain number when the script runs and only +its type says which table. + +**`memory` is the game's other cells** that have no word of their own, each with a hover +that says what Remastered does with it: `memory.game.frame` (frames since the game began), +`memory.map.width` and `memory.map.height` (in tiles), and `memory.vision.countdown` +(set to 1, the next frame puts every tile back in fog, and each unit reveals what it sees +again). A field the game keeps for itself is `readonly`. + +```ts +program(() => { + if (memory.game.frame % 24 == 0) print(`${memory.map.width} by ${memory.map.height}`); + memory.vision.countdown = 1; +}); +``` + +`memory(address, comparison, value)` and `setMemory(address, modifier, value)` reach any +cell of the game's memory by its address (EUD), in a trigger or a program. #### Keys, the mouse and chat -**What the players do is `keyPressed`, `clicked`, `mouse` and `chatted`.** A key, a -click and a typed line are true on the one frame they arrive, so a program looks for them -in a loop that runs every frame: +**What the players do is `keyPressed`, `keyHeld`, `clicked`, `mouse` and `chatted`.** A +press, a click and a typed line are true on the one frame they arrive, so a program looks +for them in a loop that runs every frame, or listens with `on.key` and `on.chat`: ```ts program(() => { @@ -1290,44 +1612,77 @@ program(() => { }, { owner: AllPlayers }); ``` -`keyPressed(player, key)` is true when the press arrives — once per press, not while the -key is held (the game reports no held key), and not while the player is typing a message. -Keys are the letters and digits, `"F1"` … `"F12"` without `"F6"` (the game keeps it to -itself and reports no press of it — played and seen — so it is an error to ask), `"Space"`, `"Enter"`, `"Escape"`, -`"Tab"`, `"Shift"`, `"Ctrl"`, `"Alt"`, the arrows (`"Left"` …), `"Backspace"`, `"Delete"`, -`"Insert"`, `"Home"`, `"End"`, `"PageUp"`, `"PageDown"` and `"Numpad0"` … `"Numpad9"`; the -game's own use of a key still happens. `clicked(player, "left" | "right" | "middle")` is -the same for a mouse button. `mouse(player)` is where the player's mouse is on the map, -`x` and `y` in pixels (32 to a tile); `const at = mouse(p)` keeps the place, and -`centerLocation(location, x, y)` moves a location there — its size kept — so that -`createUnit` and the rest can happen under the cursor. `underMouse(player, filter?)` is the -unit nearest the mouse and no farther than `within` pixels from it (48 unless the filter -says), or `null`. +- `keyPressed(player, key)` is true once per press, on the frame the press arrives, and + not while the player is typing a message. `keyHeld(player, key)` is true on every frame + the key is down. +- Keys are the letters and digits, `"F1"`, `"F7"` … `"F12"`, `"Space"`, `"Enter"`, + `"Escape"`, `"Tab"`, `"Shift"`, `"Ctrl"`, `"Alt"`, the arrows (`"Left"` …), + `"Backspace"`, `"Delete"`, `"Insert"`, `"Home"`, `"End"`, `"PageUp"`, `"PageDown"` and + `"Numpad0"` … `"Numpad9"`. The game keeps F2 to F6 to itself and reports no press of + them (played and seen; F2 to F4 move the camera), so asking for one is an error. The + game's own use of a key still happens. +- `clicked(player, "left" | "right" | "middle")` is the same as `keyPressed` for a mouse + button. +- `mouse(player)` is where the player's mouse is on the map, `x` and `y` in pixels (32 to a + tile). `const at = mouse(p)` keeps the place, and `centerLocation(location, x, y)` moves a + location there, its size kept (or `centerLocation(location, x, y, width, height)`), so + that `createUnit` and the rest can happen under the cursor. +- `bounds(location)` is where a location is now: `left`, `top`, `right`, `bottom` in + pixels, read in the game (so a location `centerLocation` or Move Location moved is read + where it went) and written one edge at a time — `const b = bounds(locations.Pen); + b.left += 32; b.right += 32;` slides it a tile to the right, its size kept. +- `underMouse(player, filter?)` is the unit nearest the mouse and no farther than `within` + pixels from it (48 unless the filter says), or `null`. +- `on.key("F7", (p) => …)` runs a function each time a player presses the key + (`{ from: P1 }` for one player's), and `on.chat("-give {n}", (p, { n }) => …)` each time + a player sends a line that fits the pattern. `chatted(player, pattern)` is `null`, or what the pattern read out of the line the player sent. The pattern's own text is matched exactly and the whole line has to fit. `{n}` reads -a whole number (up to 1 048 575); `{what:unit}` a unit type by its name — `Terran Marine` -or `TerranMarine`, a custom name too — which is the rest of the line, so it comes last; -`{kind:ore|gas}` one of the listed words, giving its place in the list (0, 1, …). Names -and words match whatever the capitals; up to three values a pattern. The values are typed -from the pattern itself — `m.n` is a number, `m.what` a `UnitType`, `m.amount` an error as -you type — and they are the program's numbers: an action takes a unit type and a count -from them at once. A pattern starts with a word of its own (`-spawn`), so ordinary talk is -never taken for it; a line that fits no pattern is just chat. A game played in single -player has no chat: test typed lines in a multiplayer game, which one person can host -alone. - -The player is one of `P1` … `P8` or `CurrentPlayer`; in a per-player program each player's -own keys, mouse and lines. All of it reaches every player's computer in step, a few frames -after it happens — that is the trip between computers, and the same in a game alone. It -comes through two plugins the eudplib library carries (MSQC and chatEvent), set up by the -compiler from what the programs ask for. They take a little from the map, and only when a -program reads input: one free location among the map's first 63 (and eight more in a row -when the mouse is read — the script is told when there is no room), the Valkyrie, which -the map must not use (its type carries the input), and Player 12, who holds those units. -No death counter, switch or string is used. Outside a program these functions are an -error, where `if (keyPressed(…))` would otherwise quietly be true. Simulate presses no -keys: there they read as nothing (tests that press them come with `test()`). +a whole number (up to 1 048 575). `{what:unit}` reads a unit type by its name +(`Terran Marine` or `TerranMarine`, a custom name too); it takes the rest of the line, so +it comes last. `{kind:ore|gas}` reads one of the listed words, giving its place in the list +(0, 1, …). Names and words match whatever the capitals, and a pattern reads up to three +values. The values are typed from the pattern itself (`m.n` is a number, `m.what` a +`UnitType`, `m.amount` an error as you type), and they are the program's numbers: an action +takes a unit type and a count from them at once. Start a pattern with a word of its own +(`-spawn`), so ordinary talk is never taken for it; a line that fits no pattern is just +chat. A game played in single player has no chat: test typed lines in a multiplayer game, +which one person can host alone. + +The player is one of `P1` … `P8` or `CurrentPlayer`; in a per-player program, each +player's own keys, mouse and lines. All of it reaches every player's computer in step, a +few frames after it happens. That is the trip between computers, and it is the same in a +game alone. The map carries it, and only when a program reads input does it take something +from the map: one unit-properties slot, one unit type the map does not use (the Valkyrie, +unless the map uses it), the location the build already keeps for orders, and hidden units +of Player 12 at the map's top left corner, six for each human player and six more when the +mouse is read. No death counter, switch or string is used. Outside a +program these functions are an error, where `if (keyPressed(…))` would otherwise quietly be +true. Simulate presses no keys, so there they read as nothing; a [test](#tests) can press +them. + +**What only one computer knows** is `local`: `local.player` (the player at this computer), +`local.camera.x` and `.y` (where its screen is on the map), `local.mouse.x` and `.y` (the +mouse on the screen), `local.selected` and `local.selectionCount`. A local value may choose +what is shown on this computer. It may not change the game, since every computer would +change it differently and the game would split; the compiler refuses that. `await +sync(player, value)` sends that player's value to every computer and gives it back a few +frames later, the same everywhere. `select(P1, local.selected!)` makes this computer's +selection the player's selection on every computer. + +```ts +program(async () => { + let x = 0; + while (true) { + if (keyHeld(P1, "D")) x += 4; + if (local.player == P1) print(`screen at ${local.camera.x}`); + const cam = await sync(P1, local.camera.x); + if (cam > x) x = cam; + await nextFrame(); + } +}); +``` #### Text @@ -1340,18 +1695,18 @@ displayText(`${color(P2)}${name(P2)}\x01 has ${minerals(P2)} ore — wave ${wave print(`Wave ${wave}`, { to: AllPlayers, position: "center" }); ``` -`name(p)` is the player's name and `color(p)` the colour code of their colour, both -filled in by the game; the ordinary colour codes work as ever. `displayText` shows it to -the current player, as it always has; `print(text, { to, position })` is the way to -show it to someone else — a player, `AllPlayers`, a force — or, with `position: -"center"`, on the line in the middle of the screen where the game's own messages ("Not -enough minerals") appear. A boolean has no text of its own. A text with nothing of the -program in it stays the plain Display Text action it was. None of a program's texts enter -the map's string table: they go into the built map's. +`name(p)` is the player's name and `color(p)` the colour code of their colour, both filled +in by the game; the ordinary colour codes work as ever. `displayText` shows the text to the +current player, as it always has. `print(text, { to, position })` shows it to someone else +(a player, `AllPlayers`, a force), or, with `position: "center"`, on the line in the middle +of the screen where the game's own messages ("Not enough minerals") appear. A boolean in a +text is `true` or `false`, and a number with a fraction is a decimal of up to four places. +A text with nothing of the program in it stays the plain Display Text action it was. None +of a program's texts enter the map's string table: they go into the built map's. **A text is a value.** `string` is a type of a program as `number` is: a variable holds one, a function takes and returns one, a record has one for a field, and there is nothing -to declare beside it — no length, no capacity. +to declare beside it: no length, no capacity. ```ts let wave = 3, left = 12, shown = ""; @@ -1365,11 +1720,11 @@ setMissionObjectives(`${title} - ${line}`); The compiler keeps a text one of two ways, a variable at a time, and the end of the declaration's line says which: -- *A text of the map* — a variable that only ever receives texts written in the script: - `"Boss"`, `boss ? "Boss" : "Wave"`, `titles[level]` out of a list the script has. It +- *A text of the map*: a variable that only ever receives texts written in the script + (`"Boss"`, `boss ? "Boss" : "Wave"`, `titles[level]` out of a list the script has). It holds the text's number in the built map's string table. Assigning it and comparing it cost what a number's do, and the text of **any** action takes it. -- *A text that is made* — a template with the program's values in it, `a + b`, `s += "!"`, +- *A text that is made*: a template with the program's values in it, `a + b`, `s += "!"`, `String(n)`, a method's result. Its characters are in a block of the memory the programs' arrays share (the script's Settings set its size), and the variable owns the block: assigning copies the characters, and what the variable held before goes back. A @@ -1378,21 +1733,33 @@ declaration's line says which: in red and Simulate says on the line. What a text can do: `+`, `+=`, templates, `==` `!=` `<` `<=` `>` `>=`, `if (s)` (it is -not empty), `switch (s)` over texts written in the script, `length`, `s[i]`, `at()`, -`charAt()`, `slice()`, `substring()`, `indexOf()`, `includes()`, `startsWith()`, -`endsWith()`, `padStart()`, `padEnd()`, `repeat()`, `concat()`, `codePointAt()`, -`String(n)`, `n.toString()`, and `for (const ch of s)`. Anything else is refused by name; -a text the script has, with nothing of the program in it, takes every method JavaScript -has, since the script simply runs. +not empty), `switch (s)`, `length`, `s[i]`, `at()`, `charAt()`, `charCodeAt()`, +`codePointAt()`, `slice()`, `substring()`, `indexOf()`, `includes()`, `startsWith()`, +`endsWith()`, `padStart()`, `padEnd()`, `repeat()`, `concat()`, `trim()`, `trimStart()`, +`trimEnd()`, `toUpperCase()` and `toLowerCase()` (A to Z only), `replace()` and +`replaceAll()` of a text, `split()` at a text into an array of texts, `parseInt()` and +`isNaN()`, `String(n)`, `n.toString()`, and `for (const ch of s)`. Anything else is refused +by name. A text the script has, with nothing of the program in it, takes every method +JavaScript has, since the script simply runs. + +```ts +program(() => { + let wave = 3; + const line = ` Wave ${wave}: zerglings, hydras `; + const parts = line.trim().split(": "); + print(parts[1].toUpperCase().replaceAll(", ", " + ")); // ZERGLINGS + HYDRAS + print(`${parseInt("12 ore") + wave} ${line.includes("hydra")} ${"A".charCodeAt(0)}`); // 15 true 65 +}); +``` **A character is a character, not a byte.** The game keeps a text as UTF-8, where a -Korean syllable is three bytes; a program counts what JavaScript counts, so +Korean syllable is three bytes. A program counts what JavaScript counts, so `"저글링".length` is 3 and `"저글링"[1]` is `글`. The one difference is a character past -U+FFFF (an emoji, a rare ideograph): JavaScript counts two and a program one — and the -game draws a dark square in its place, which a hint on the line says. Since a character has no fixed -size in the game's memory, `s[i]` and `slice()` walk the text from its start; over a long -text, `for (const ch of s)` walks once. That loop runs within the frame, so `sleep()` -inside it is refused; a loop over the places can sleep between turns, which is how a text +U+FFFF (an emoji, a rare ideograph): JavaScript counts two and a program one, and the game +draws a dark square in its place, which a hint on the line says. Since a character has no +fixed size in the game's memory, `s[i]` and `slice()` walk the text from its start; over a +long text, `for (const ch of s)` walks once. That loop runs within the frame, so `sleep()` +inside it is refused. A loop over the places can sleep between turns, which is how a text is typed out a character at a time: ```ts @@ -1407,35 +1774,140 @@ proportional and a colour code has no width: it does not line columns up. **A made text outside the chat area.** The objectives, a leaderboard's label, a transmission's line and a unit type's name (`stats(units.TerranMarine).name = …`) take a -made text. The game looks those texts up by number, so the build keeps a string of its -own for each kind — a player has one objectives text, one leaderboard and one transmission -at a time — and the text is written over it just before the action runs, up to 255 bytes. -The game reads such a string again whenever it draws, so the write happens only on the +made text. The game looks those texts up by number, so the build keeps a string of its own +for each kind (a player has one objectives text, one leaderboard and one transmission at a +time), and the text is written over it just before the action runs, up to 255 bytes. The +game reads such a string again whenever it draws, so the write happens only on the computer of the player the action is for: in a program of every player, each sees their own. What is on the screen is the text as it was when the action ran; changing the variable afterwards changes nothing until the action runs again. Any other action's text (`setNextScenario`, a sound's path) is a text of the map or an error that says so. -A function takes and returns a text like any other value — it is called from its second -use on, its parameter a variable set to a copy at each call — and one that calls itself may -hold texts: around each call that may come back a text's three cells go on the stack with -the rest, so each run has its own. The one thing such a function cannot do is go through a -text with `for…of` around the call; the error says to walk it by place. What is left out -for now: `parseInt`, regular expressions and the words a player typed in chat. +A function takes and returns a text like any other value: it is called from its second use +on, its parameter a variable set to a copy at each call. One that calls itself may hold +texts: around each call that may come back, a text's three cells go on the stack with the +rest, so each run has its own. The one thing such a function cannot do is go through a +text with `for…of` around the call; the error says to walk it by place. Left out for now: +regular expressions, and the words a player typed in chat as a text. + +**Lines of JSX.** In a `.tsx` file, `…` is a line of text that may hold the +program's values, `` a bar of squares, and +`` a square of one of the game's text colours. `print` takes one, and +`ui.lines(player, () => …)` keeps the lines in a player's text area and draws them again +when the values in them change. `trigscript/ui` has panels, meters, menus and dialogs built +from them ([The standard library](#the-standard-library)). + +```tsx +program(async () => { + let hp = 0; + ui.lines(P1, () => ( + + Base {hp} + + )); + while (hp < 100) { + hp += 25; + await sleep(seconds(1)); + } + print(Wave {hp / 25}); +}); +``` + +#### Sounds -#### Time and edges +`playWav(sound, 0)` plays a sound: one of the game's own files by its path, one of the +map's own sounds by its name (`sounds.Name`, from the Sound Editor's list), or a clip the +script makes when it is built. `tone()`, `noise()`, `pluck()`, `fm()` and `silence()` make +clips; `.fade()`, `.filter()`, `.delay()`, `.reverb()` and `.limit()` change them; +`mixdown()` lays clips over one another into one, and `together()` turns down clips the +game will play at once. The build puts each clip the script plays into the map once, and +a `trigger()` and a transmission take one too. Which clip plays may follow a variable of +the program. In the game, 32 sounds play at once and copies add up with no limiter, so keep +a clip's peak under 1 where copies overlap. + +```ts +const beep = tone("A5", 0.1, { wave: "square", volume: 0.3 }); +const zap = tone("A3", 0.4, { wave: "saw" }).filter("lowpass", [[0, 300], [0.4, 3000]]).reverb({ decay: 0.3 }); + +program(() => { + playWav(beep, 0); + if (random()) playWav(zap, 0); + playWav("sound\\glue\\mousedown2.wav", 0); +}); +``` + +Music played note by note is `trigscript/music` ([The standard library](#the-standard-library)). + +#### Waiting and tasks **Time is `sleep`.** `sleep(seconds(15))` gives the frame back and resumes that much later; other programs and the map's triggers go on meanwhile. `frames(n)` is the game's -own clock, `seconds()` is twenty-four frames at Fastest, `minutes()` sixty of those. +own clock, `seconds()` is twenty-four frames at Fastest, and `minutes()` sixty of those. (`cycles(n)`, from before 3.0, is the same as `frames(n)`.) `wait()` inside a program is allowed but is a different thing: the game's own Wait holds the rest of its trigger for the time, and a player has one wait slot, so any other Wait of that player stops until it -is free. Use it for a short pause (a text, then a sound) and `sleep` to pass time. Something that runs on its own clock is another program: one -program per concurrent activity. +is free. Use it for a short pause (a text, then a sound), and `sleep` to pass time. + +**`async` and `await`** are TypeScript's. In a program written `program(async () => …)`, +`await sleep(…)` is the same pause, and `await nextFrame()` waits one frame. An `async` +function may wait anywhere in its body, and `await f()` goes on when it returns, with its +result. `await until(() => n >= 3)` asks once at once and then once a frame, and goes on +in the frame the answer is true; `until(bring(…))` takes a condition of the game as it is. + +**A task runs beside the program.** `start(f())` starts an async call and goes on at once; +the program can `await` the task later, ask `t.running`, or stop it with `t.cancel()`. A +task may outlive the program that started it, and may start tasks of its own. +`start(wave(n), { max: 4 })` lets at most four of that start's tasks run at once. +`Promise.all([…])` waits for every branch, `Promise.race([…])` for the first, and +`Promise.any` is a race. **A race's losers stop where they are**, where JavaScript lets +them run on. `.then((v) => …)` hands on what a promise gives: +`sleep(frames(3)).then(() => 0)` in a race is a time limit with a value. + +```ts +async function wave(n: number) { + createUnit(P2, units.ZergZergling, n * 4, locations.Spawn); + await sleep(frames(n * 5)); + return n; +} + +program(async () => { + let ticks = 0; + const clock = start(async () => { + while (true) { await sleep(frames(1)); ticks += 1; } + }); + const first = await Promise.race([wave(1), wave(3)]); + print(`wave ${first} ended first`); // wave 1 ended first + await until(() => ticks >= 10); + clock.cancel(); + print(`${ticks} ticks, running: ${clock.running}`); // 10 ticks, running: false +}); +``` + +A task that ends on an error throws it where it is awaited. One that fails while nothing +awaits it is said in red, and the program goes on. Something that runs on its own clock is +a task, or another program. A `sleep` or an `await` cannot stand in a loop over units, in a +`for…of` over a text, or in a function that calls itself. + +**Generators** are TypeScript's: a `function*` walked by `for…of` puts the loop's body where +each `yield` is, `yield*` hands on another generator, `break` leaves both, and a class can +be walked with `*[Symbol.iterator]()`. An `async function*` is walked by `for await` and +may wait between its values. + +```ts +function* lanes(n: number) { + let i = 0; + while (i < n) { yield i * 32; i += 1; } +} + +program(() => { + let total = 0; + for (const x of lanes(4)) total += x; + print(`${total}`); // 192 +}); +``` **Edges.** `if (rose(bring(P1, units.AnyUnit, locations.Beacon, ">=", 1)))` is true on -the frame the condition becomes true, and not again until it has been false in between; +the frame the condition becomes true, and not again until it has been false in between. `once(…)` is true the first time only. #### Owners and per-player programs @@ -1443,11 +1915,11 @@ the frame the condition becomes true, and not again until it has been false in b **A program runs for its `owner`** (default P1), as a trigger would: one player is one thread running as that player, only while that player is in the game, and `CurrentPlayer` means that player. `AllPlayers`, a force (`players.Force1`) or a list of -players (`[P1, P2, P3]`) makes a **per-player program**: it runs once each frame for -every human or computer player among them who is in the game, `CurrentPlayer` is that -player, and every variable is per player, each with their own copy. `let total = -shared(0)` is one value they all share. That is how lives, scores and cooldowns per -player are written once: +players (`[P1, P2, P3]`) makes a **per-player program**. It runs once each frame for every +human or computer player among them who is in the game, `CurrentPlayer` is that player, +and every variable, array and task is per player, each with their own copy. +`let total = shared(0)` is one value they all share. That is how lives, scores and +cooldowns per player are written once: ```ts program(() => { @@ -1465,57 +1937,148 @@ program(() => { The simulator runs such a program as one player at a time. A script may have several programs, each a thread of its own with its own variables. A program's text -(`displayText("…")`) is put into the built map's string table by eudplib, so it never -takes a string of the map you edit; a `trigger()`'s text is interned into the map when -the script is applied, as it always was. +(`displayText("…")`) is put into the built map's string table by the build, so it never +takes a string of the map you edit. A `trigger()`'s text goes into the map's string table +when the script is applied, as it always did. + +#### Files the script imports + +**Import Video…** in the Explorer turns a file into one the script can import, kept in the +map with the script: + +- a video into a `.video` file (`import film from "./film.video"`): its frames, a tile a + pixel, ink or not, for `fog.play` or `fog.light`; +- a picture into an `.image` file: its pixels at the size chosen, which `scale`, `crop`, + `threshold`, `tones` and `halftone` turn into rows for `fog.picture`; +- a sound file into a `.sound` file: a clip for `playWav` and the music library; +- a MIDI file into a `.notes` file: each track's notes, which `music.midi` plays. + +All of it is worked out when the script is built. What reaches the game is what the +script makes of it. `cover(picture, shapes)`, also worked out then, gives the brushes of +your own shapes that draw a picture's ink, for a renderer of your own; `fog.picture` is +the same with the game's sight ranges. + +#### The standard library + +**Modules shipped with TrigScript**, imported as `"trigscript/"`. Each is TypeScript +written for programs, and the hover in the editor documents every name. + +| Module | What it is for | +| --- | --- | +| `trigscript/math` | `lerp`, `remap`, easing curves, `distance`, `angleTo`, `approach`, `wrap`, `PI`, `TAU`, and `Vec`, a point of two fixed numbers | +| `trigscript/random` | `chance(25)`, `range(1, 3)`, `dice(3, 6)`, `pick(list)`, `weighted([70, 25, 5])`, `shuffle(xs)`, and `Random`, the same numbers every game for one seed | +| `trigscript/timers` | `every`, `after`, `Cooldown`, `Countdown`, `Stopwatch`, `now()`, and `over` and `spread` for a loop spread over frames | +| `trigscript/signals` | `Signal`, events of your own: `on`, `once`, `off`, `emit` | +| `trigscript/ui` | for lines of JSX: `Panel`, `Meter`, `MenuList` and `choose` (a menu chosen with W, S and Space), `DialogBox`, `say` and `typewriter`, `log`, `clock`, `thousands` | +| `trigscript/cutscene` | `Camera` (`look`, `pan`, `follow`, `shake`), `walk`, `walkAll`, `charge`, `line` (a portrait that talks), `play` (a scene the player may skip) | +| `trigscript/fog` | the fog of war as a picture: `reveal`, `light`, `rect`, `columns`, `picture`, `play` for a video | +| `trigscript/screen` | `Screen`, a grid of pixels shown on the map as units, `sprite`, and `ScreenView` for the text lines | +| `trigscript/music` | songs played note by note: `synth`, `sampler`, `kit`, `track`, `song`, `arrange`, `midi`, `play`, `loop` | + +```ts +import { chance, range } from "trigscript/random"; +import { every, after, Cooldown, SECOND } from "trigscript/timers"; + +program(async () => { + every(10 * SECOND, (n) => createUnit(P2, units.ZergZergling, range(2, 4), locations.Spawn)); + after(3 * SECOND, () => print("Go!")); + const shot = new Cooldown(12); + while (true) { + if (keyPressed(P1, "Space") && shot.use() && chance(25)) print("a lucky shot"); + await nextFrame(); + } +}); +``` + +`fog` adds a program of its own, which wipes the fog it lit every frame. `fog`, `screen` +and `cutscene` each take one of the map's first 63 locations that the map does not use, +with `reserveLocation(why)`. A script of your own can call it too: the location is chosen +when the script is built, and the reason is what the map's requirements show. #### What a program does not have Outside `program()` the script is all of TypeScript, since it simply runs. Inside one, what is written has to become something the game can do, and these cannot. Each is refused with -a message that says so, none is passed over in silence: +a message that says so; none is passed over in silence: | Not there | Write instead | | --- | --- | -| Fractions: every number is whole | work in a smaller unit (pixels, frames, hundredths) | -| `**`, `Math.sqrt`, `Math.sign` and the rest of `Math` on a variable — `min`, `max` and `abs` are there, and the rounding ones around a division | a loop, or a list the script worked out (`const roots = Array.from({ length: 100 }, (_, n) => Math.floor(Math.sqrt(n)))`, then `roots[n]`) | -| A function kept as a value: `const f = (x: number) => x + k`, a function in an array, one returned | `function f(x: number) { … }` in the program; an arrow is fine where a method takes one | -| A function that returns a record or an array it made | hand it the record or the array to fill in; an instance of a class is the exception (*Classes*) | -| `try`, `catch`, `throw` — the game has no exceptions | return a number that says what happened | -| `async`, generators, `for…in`, a labelled `break`, `typeof` | — | -| An `enum` declared inside the program | declare it above the program: its members are numbers the script has, and a variable holds one | -| On an array: `shift`, `unshift`, `splice`, `at`, `lastIndexOf`, `join` of numbers | `pop` and `push`, a place kept in a variable, `xs[xs.length - 1]`, a template in a loop | -| `map` into texts or records | `push` in a `for…of` | -| On a made text: `split`, `trim`, `replace`, `toUpperCase`, `lastIndexOf`, `parseInt`, regular expressions | keep the number beside the text and make the text from it | -| A `Map` whose values are texts or records, or whose keys are texts | the place of a row of an array of records as the value | +| `**` or `Math.pow` by a power that changes while the game runs | multiply in a loop | +| A fraction in a `u8`, `u16` or `u32`, or a fixed number where only a whole one goes (an index, a count) | `Math.floor`, `Math.round` or `Math.trunc` around it | +| `\|\|=` and `&&=` | `flag = flag \|\| other` | +| A variable with no first value (`let n;`) | `let n = 0` | +| `for…in` | `for…of`, or a loop over a list the script knows | +| `delete` | a field that says the value is gone (`hp = 0`), or a `Map` | +| `typeof x` or `"key" in x` as a test while the game runs | a variable of one type, or a `switch` on a number you keep beside it | +| A function that calls itself and waits | a loop that waits: `while (n > 0) { …; await sleep(…); n--; }` | +| An `async` function or a generator kept as a value | call it where it is written, or `start()` it | +| A function that may return one instance or another it did not make | keep the instances in an array and return the place | +| `throw` of something that is not an error; a caught error kept in a variable | `throw new Error(…)` or a class that extends it; copy what you need out of it in the `catch` | +| What `splice` takes out, as a value | read it first: `xs.slice(start, end)` | +| `flat`; `map` into texts or records | `push` in a `for…of` | +| On a made text: regular expressions, and `replace` with a function | `replace` / `replaceAll` / `split` with a text | +| A `Map` whose values are texts or records, or whose keys are texts other than a union the script knows | the place of a row of an array of records as the value | +| Methods or getters on an object literal | a class | +| A static block in a class | the static field's value where it is declared | +| A `for` written out more than 256 times | a `while` loop | +| `Math.random()`, `Date`, `JSON`, `setTimeout`, `bigint` | `random(n)`; `elapsed()` or `memory.game.frame`; `trigscript/timers` | +| A local value (`local.*`) that changes the game | `await sync(player, value)` first | | Writing a unit's position, cloak or tint | the game refuses these itself (*Units*) | - -Still to come, in this order: a debugger that steps a script in the simulator, started -from a test if you like, and a gallery of examples with their tests. The plan is -`docs/eud-plan.md`, and the IR the compiler hands eudplib is `docs/ir.md`. +| Playing on the classic game (1.16.1) | `trigger()` only | + +### Coming from 3.12 + +A 3.12 script builds into the same map, and its programs now build *without eudplib*: +TrigScript lays them out itself when you save, in under a second, with nothing to install +and no Build step. The eudplib plugin is no longer required, and a map that used it keeps +working. What 4.0 changed around that: + +- **What the build refuses, it says.** A script with something TrigScript does not take is + not built; Problems marks the line, and Save offers to save without it. Nothing is built + through another compiler behind your back. +- **Tests run in the game as well.** With the lifted game installed, *Run all tests in the + game* judges each test twice, and a test's frames carry a cost in milliseconds of + Remastered's time. Every address a build touches is checked against what Remastered + allows, line by line, before a map is written. +- **The language grew**: [functions as values](#functions-as-values) and closures, [`try` + / `catch` / `throw`](#errors), `async` functions, tasks and `Promise.*` ([Waiting and + tasks](#waiting-and-tasks)), [`on.*` events](#events), [numbers with a + fraction](#numbers-with-a-fraction), [the game's tables and `memory`](#the-games-tables-and-memory), + [`local` reads and `sync()`](#keys-the-mouse-and-chat), programs of a force or a list + of players ([Owners](#owners-and-per-player-programs)), [sounds](#sounds), pictures and + videos the script imports, and a [standard library](#the-standard-library) of nine + modules. +- **Input is the build's own.** Keys, clicks, the mouse and typed lines reach every + player's computer in step without MSQC or a chat plugin: `keyPressed`, `keyHeld`, + `clicked`, `mouse`, `chatted`, `on.key`, `on.chat`. +- **Units** gained `buildTime`, `rank`, `matrix`, `speed`, `speedUpgrade`, `hasTarget`, + `inTransport` and a writable `hallucinated`, and `bounds(location)` reads a location + where it is now. `underAttack` is the game's own alert: a hit out of the player's view. +- **Other plugins build with TrigScript** through the `trigscript.build` service ([For + other plugins](#for-other-plugins)); Magenta 0.12 is the first. ### Coming from 3.9 Nothing of the language has changed; a 3.9 script builds into the same map. What is new is around it. The Explorer shows [folders](#folders), and moving a file takes its imports along. The simulator's world [has units and players](#use): `createUnit` makes them, the -unit conditions count them, and a program of a force runs for each of its players — so +unit conditions count them, and a program of a force runs for each of its players. So Simulate of a script that makes units says more than it did, and a line of its list that said a unit condition was false may now say otherwise. The script can carry its own [tests](#tests). **Test (F5) is now Play (F5)**, since "test" came to mean those. A -program can be given a name — `program(() => { … }, { name: "waves" })` — which the -Explorer shows and a test asks for. +program can be given a name, which the Explorer shows and a test asks for: +`program(() => { … }, { name: "waves" })`. ### Coming from 3.8 Nothing a 3.8 script does has changed, with one exception that was a mistake before: a -`forEach` over a list of the script whose function only made actions — -`waves.forEach((w) => createUnit(P2, w.unit, w.count, at))` — used to build into nothing, +`forEach` over a list of the script whose function only made actions, such as +`waves.forEach((w) => createUnit(P2, w.unit, w.count, at))`, used to build into nothing, without a word. It now does what it says. And a loop that empties an array by the value of -its `pop()` — `while (queue.length > 0) { const i = queue.pop()!; … }` — was refused as one -whose condition never changes; it is the loop it looks like. What is new is the array methods that take a -function (*The methods that take a function*, above), on arrays, arrays of records, arrays -of units, the units of the game and the script's own lists. `findLast` and `findLastIndex` +its `pop()`, such as `while (queue.length > 0) { const i = queue.pop()!; … }`, was refused +as one whose condition never changes; it is the loop it looks like. What is new is the +array methods that take a function (*The methods that take a function*, above), on arrays, +arrays of records, arrays of units, the units of the game and the script's own lists. `findLast` and `findLastIndex` brought the script's standard library to ES2023. `new Array(12)` is an array of numbers where TypeScript's own declaration says `any[]`, so what is read out of one, or made from it by `map` or `reduce`, has a type; an array of booleans made that way says so: @@ -1529,8 +2092,8 @@ transmission and a unit type's name take a text the program made, where 3.8 refu anything but a text written in the script. Classes (*Classes*, above) are new: a `class` inside a program was refused in 3.8. With -them an array of records learnt to hold more than numbers and booleans — a unit, a text, an -array that grows, a record inside the record — whether its rows are instances or records +them an array of records learnt to hold more than numbers and booleans: a unit, a text, an +array that grows, or a record inside the record, whether its rows are instances or records written out. And `p.trail = []` on an array that grows, a record's or an instance's, starts it over where 3.8 said an array is assigned cell by cell. @@ -1546,7 +2109,7 @@ a script that already worked: a function handed a text written in the script function handed a number is. What it shows is the same. Patterns and spread (*Patterns and spread*, above) are new too, and mended something: -`const { n, d } = waves[0]` over a list of the script — nothing of the program in it — was +`const { n, d } = waves[0]` over a list of the script, with nothing of the program in it, was refused with "n is not defined". A constant taken out of a pattern is now the script's constant like any other. @@ -1555,7 +2118,7 @@ constant like any other. Nothing a 3.7 script does has changed. What is new is that a function may call itself (*Functions*, above), and with it a second number in the panel's **Settings**: how many calls deep such a function may go. Two smaller things came with it. A parameter given a -plain value can now be assigned inside the function — `function count(n: number) { n--; … }` +plain value can now be assigned inside the function: `function count(n: number) { n--; … }` with `count(3)` used to be refused. And `const hit = found ? 1 : 0` is a number: TypeScript calls it `0 | 1`, which the compiler did not take for one. @@ -1566,8 +2129,8 @@ used more than once is now one body that is called, where it used to be a copy o at every call (*Functions*, above), so a script with helpers builds into a smaller map. Two things show in the editor: the hint at the end of a function's line, and the Variables list, where a called function's parameter and its locals are one variable each instead of -one for every call. An array written empty — `let found: number[] = []` — is now always one -that grows, also when the only pushes are inside a function it is handed to. And the +one for every call. An array written empty, such as `let found: number[] = []`, is now +always one that grows, also when the only pushes are inside a function it is handed to. And the **Simulate** view lists what the interpreter has long recorded and never shown: a read or a store past an array's end, a push the heap had no room for, each with its line. @@ -1586,29 +2149,29 @@ A `number` is signed. Until 3.5 a program's numbers were never below zero: `3 - `3 - 10` is −7, stored, compared and printed as that. What changes for a script written before: -- A value that relied on stopping at 0 — `lives -= 1` meant to rest at 0 — goes below it. +- A value that relied on stopping at 0 (`lives -= 1` meant to rest at 0) goes below it. Declare it a `u8` or `u16`, which still stop (`let lives: u8 = 3`), or write `Math.max(lives - 1, 0)`. - A constant above 2 147 483 647 in a plain variable is an error: declare the variable a `u32`, which is what `number` used to be in all but name. - `/` rounds towards zero and `%` takes the dividend's sign. For numbers that are not below - zero — every script until now — nothing changes. + zero, which is every script until now, nothing changes. - `>>>` is its own operator; `>>` of a number below zero keeps its sign. ### Coming from 3.3 `sleep(frames(1))` goes on in the next frame, as it was always described. Until 3.4 the -built map waited one frame longer than asked after every `sleep` — a loop sleeping a frame -ran every other frame, `sleep(seconds(1))` took 25 — while Simulate did not; now both do -what the words say. A script that counted on the longer wait runs a little faster. +built map waited one frame longer than asked after every `sleep`, while Simulate did not: +a loop sleeping a frame ran every other frame, and `sleep(seconds(1))` took 25 frames. Now +both do what the words say. A script that counted on the longer wait runs a little faster. A unit type can be a variable of the program in an action (`createUnit(P1, m.what, m.n, at)`), along with the count or the amount; before, one argument at a time could be. ### Coming from 3.2 -One name moved. `Unit` is a unit on the map now, and a unit *type* — an entry of `units.`, -what a condition or an action names — is a `UnitType`. A script that annotated a +One name moved. `Unit` is a unit on the map now, and a unit *type* (an entry of `units.`, +what a condition or an action names) is a `UnitType`. A script that annotated a parameter with the old name (`(u: Unit) => createUnit(P1, u, 1, at)`) says `u: UnitType`; the type error names both. Nothing else about existing scripts changes. @@ -1643,23 +2206,53 @@ Version 2.6.1 stays installable for a map that must run on 1.16.1 with programs: The plugin registers commands, so another plugin gets the script without the editor through `api.commands.run` (check `api.commands.has` first, and listen to the -`"commands"` event if you need to know when it arrives — plugins activate in no fixed +`"commands"` event if you need to know when it arrives, since plugins activate in no fixed order). A `source` argument is either the text of `main.ts` (the map's other files stay as they are) or an object of every file by path. | Command | | | --- | --- | -| `trigscript.state()` | `{ files, source, manifest, block, stale, edited, unbuilt, programs }`: the map's script files, `main.ts` on its own, where the script's block sits in the trigger list (null when the records were edited by hand — `stale`, with `edited` counting how many are still the script's and how many changed, when that can be told), whether the files differ from what was last applied, and how many programs the script had then (any means the saved map is built by eudplib and needs StarCraft: Remastered). Null with no map. | -| `trigscript.declarations({ compact? })` | The generated `.d.ts` the script type-checks against — the whole vocabulary for this map. `compact` is the shorter variant meant for a language model. Empty with no map. | -| `trigscript.compile(source)` | A promise of a `CompileResult`: `ok`, `diagnostics` (`file`, 1-based `line` / `column`, `message`, `source: "typescript"`, `"compiler"` or `"script"` for an error the script threw), the `trigger()` records, `sources` (per record, the file and line it came from), the `programs` and their `variables`, `ir` (the programs as IR, what eudplib builds), `hints` per line, and `refs` — every `locations.X` / `switches.X` the files mention, resolved by the checker, present even when the script does not type-check. A newer compile supersedes an unfinished one, which rejects with `CompileSuperseded`. | +| `trigscript.state()` | `{ files, source, manifest, block, stale, edited, unbuilt, programs }`: the map's script files, `main.ts` on its own, where the script's block sits in the trigger list (null when the records were edited by hand: `stale`, with `edited` counting how many are still the script's and how many changed, when that can be told), whether the files differ from what was last applied, and how many programs the script had then (any means the saved map has programs built into it and needs StarCraft: Remastered). Null with no map. | +| `trigscript.declarations({ compact? })` | The generated `.d.ts` the script type-checks against: the whole vocabulary for this map. `compact` is the shorter variant meant for a language model. Empty with no map. | +| `trigscript.compile(source)` | A promise of a `CompileResult`: `ok`, `diagnostics` (`file`, 1-based `line` / `column`, `message`, `source: "typescript"`, `"compiler"` or `"script"` for an error the script threw), the `trigger()` records, `sources` (per record, the file and line it came from), the `programs` and their `variables`, `ir` (the programs as IR, what the build lays out), `hints` per line, and `refs`: every `locations.X` / `switches.X` the files mention, resolved by the checker, present even when the script does not type-check. A newer compile supersedes an unfinished one, which rejects with `CompileSuperseded`. | | `trigscript.build(source, { takeOver?, replaceStale? })` | Apply: compile and, when clean, write the `trigger()` records as the block and store the files with the map (the programs are built into the file when the map is saved): a promise of `{ compiled, block, refused?, replaced? }`. The build lands only on the map it was compiled for: `block` is null and `refused` says why when there were `"errors"`, the map `"closed"` or another one `"switched"` to the front while the script ran, or the map's names `"changed"` under it (that one is compiled again, twice at most, before it is reported). Files edited in the archive while the script ran are kept, and the state then reads as unbuilt. When the old block was edited by hand, the new one is appended, or with `replaceStale` put in the old one's place with its unchanged records removed and the edited ones kept after it (`replaced` counts both). `takeOver` replaces the whole trigger list with the script's. A settings-style transaction: not undoable, marks the map modified. | -| `trigscript.print(triggers, { imports?, header? })` | Records as `trigger()` calls in the script language — what Import map triggers writes. `imports` starts the text with an import of the names it uses. | +| `trigscript.print(triggers, { imports?, header? })` | Records as `trigger()` calls in the script language: what Import map triggers writes. `imports` starts the text with an import of the names it uses. | | `trigscript.simulate(triggers, cycles, { player? })` | The interpreter: `{ cycles, events, switches }`. | | `trigscript.triggerAt(file, line)` | Which trigger (index in the map's list) a 1-based line of a file generated; null when none did or the block is stale. | | `trigscript.open({ file?, line? })` | Open the editor, on a file and line. | The scmjs.dev plugin's *Write Triggers…* is built on these. +### Building with TrigScript + +A plugin whose rows need code in the map writes that code as TrigScript — `program(() => { +… })` bodies in the language this guide describes — and hands it to TrigScript's build instead +of building anything itself. The service is `trigscript.build` (`contract.d.ts` in this +repository is its shape; take it with `import type`): + +```typescript +import type { TrigScriptBuildService } from "./vendor/trigscript"; +api.services.watch("trigscript.build", (build) => { + mine?.dispose(); + mine = build?.contribute({ + id: "magenta", label: "Magenta", + applies: () => rowsNeedingCode() > 0, + collect: async () => ({ files: { "magenta/main.ts": renderRows() }, entries: ["magenta/main.ts"] }), + }) ?? null; +}); +``` + +On Save, Test Map and an export, TrigScript compiles what every contribution brings together +with the map's own script — the contributed files run as entries after it, so their programs +come after the script's — and lays every program out into the saved map in one build. The +files go under a folder named after the plugin, so they cannot shadow the map's own, and they +never enter the archive: the map the user edits is untouched. A contribution holds programs +only; a `trigger()` in it fails the build, since nothing would write it into the trigger list. +`check(files, entries)` compiles the same source against the open map without building, for +the plugin's own preflight: the problems at their file and line, and how many programs. +`onBuild` says how each build went, with `from` naming the contribution a failure was in. +Magenta's build rows reach the map this way. + ## Development ```sh @@ -1673,33 +2266,33 @@ The layout: | | | | --- | --- | | `plugin.ts` | Activation: the menu item, the claim on the generated block (`api.triggers.claim`), the commands. | -| `service.ts` | What the plugin does to the map: names off `api.settings` / `api.names` / `api.query`, the members through `api.document.extras`, an apply as one `document.update`, and its part in saving (`attach`): `buildSteps.before` applies a script that is newer than its block, and a contribution to the eudplib plugin's build hands over the IR, `python/trigscript.py` and eudTurbo. A compile is `prepare`d into an artifact stamped with the map's id and a hash of its names, and `install` refuses an artifact whose map is not the one in front — the dialog compiles once and installs that, never a second run. | +| `service.ts` | What the plugin does to the map: names off `api.settings` / `api.names` / `api.query`, the members through `api.document.extras`, an apply as one `document.update`, and its part in saving (`attach`): `buildSteps.before` applies a script that is newer than its block; the plugin's own build step (`buildOwn`, `compiler/backend/build.ts` in the compile worker) lays the programs out into the scenario the editor hands it, and fails with the reason, worded for the notice, when the build does not take the script or Magenta's triggers are in the map. A compile is `prepare`d into an artifact stamped with the map's id and a hash of its names, and `install` refuses an artifact whose map is not the one in front: the dialog compiles once and installs that, never a second run. | | `editor.ts` | The dialog: the file list and Monaco, plain DOM in the editor's own classes. | | `tree.ts` | The files as folders, and a move that takes the imports with it. Pure over strings. | | `testState.ts` | What the workspace shows of a compile's test report: results kept between runs, marks, the failures said at their lines, the warnings, the Testing view's tree. | -| `monaco.ts` | Monaco from `dist/` on jsDelivr's GitHub mirror (the tag `DIST_TAG` names; the workers start as blob module workers), one model per file under `file:///` so imports resolve, the theme. The plugin storage key `monacoDist` overrides where the files are fetched from — set `scmjs.plugin.trigscript.monacoDist` in the browser to `"http://localhost:3000/dist"` while developing. | +| `monaco.ts` | Monaco from `dist/` on jsDelivr's GitHub mirror (the tag `DIST_TAG` names; the workers start as blob module workers), one model per file under `file:///` so imports resolve, the theme. The plugin storage key `monacoDist` overrides where the files are fetched from: set `scmjs.plugin.trigscript.monacoDist` in the browser to `"http://localhost:3000/dist"` while developing. | | `ko.ts`, `i18n.ts` | The Korean for everything the workspace shows, English as the key, registered with `api.i18n` at activation; the open workspace is built again when the editor's language changes. Menu labels and command titles are English marked with `msg()`, and the editor translates them where it draws them. The language, the compiler's diagnostics and what a command returns stay English. `tests/ko.test.ts` keeps the catalogue and the source in step. | -| `bundle/`, `dist/` | `npm run bundle` builds Monaco with esbuild — the editor core, its features and the TypeScript language alone, styles injected by the module and the codicon font inlined, plus the two workers — and writes `lib.d.ts`, the standard library the compile worker checks scripts against (`bundle/lib.mjs`), into `dist/`, which is committed. A CDN's on-the-fly bundler turns Monaco's lazy language chunks into standalone bundles carrying a second editor core, which is why the plugin carries its own. After a Monaco bump: rebuild, commit, tag `monaco--`, move `DIST_TAG`. | +| `bundle/`, `dist/` | `npm run bundle` builds Monaco with esbuild (the editor core, its features and the TypeScript language alone, styles injected by the module and the codicon font inlined, plus the two workers) and writes `lib.d.ts`, the standard library the compile worker checks scripts against (`bundle/lib.mjs`), into `dist/`, which is committed. A CDN's on-the-fly bundler turns Monaco's lazy language chunks into standalone bundles carrying a second editor core, which is why the plugin carries its own. After a Monaco bump: rebuild, commit, tag `monaco--`, move `DIST_TAG`. | | `compile.ts` | Compiling in a worker: a blob worker `importScripts` TypeScript from the CDN, fetches `lib.d.ts` once, and imports this plugin's own compiler module by the `blob:` URL the editor's loader gave it; a request the script does not answer in fifteen seconds (an endless loop outside `program()`) terminates the worker. A worker that cannot import the blob is replaced by one that imports the release's `dist/compiler.js`; when that fails too the compile rejects with `CompilerUnavailable` and a later one starts over. The script is never run on the main thread, where an endless loop could not be stopped. | | `script.ts` | The files, the block and its manifest: hashing (the block, and every record on its own), finding the block by content, staleness and what a stale block can still be taken apart into, planning a build. Pure over a trigger list and a map of the members. | -| `compiler/` | The language. `names.ts` and `declarations.ts` generate the `.d.ts`; `runtime.ts` is the library the script calls; `compiler.ts` checks the files as one `ts.createProgram`, collects the map references, emits them through `hoist.ts`'s transformer, links and runs them (`link.ts`), and turns each `program()` — and the `game()` functions it calls — through `structured.ts` (with `scope.ts`, what a name is bound to, and `tables.ts`, the game's tables) into the IR (`ir.ts`, `docs/ir.md`); `input.ts` sets up what the programs read of keys, mouse and chat, `numbers.ts` writes into every operation whether it reads its 32 bits as a `number` or a `u32`, `recursion.ts` finds the functions that call themselves and what each call has to keep, and `eud.ts` checks the result (a loop that never sleeps, a constant divisor, a constant that does not fit its width) and serialises it with every text written out; `python/trigscript.py` is the other half, the euddraft plugin that lowers the IR to eudplib, embedded by `npm run embed`; `simulate.ts` is the trigger-cycle interpreter and `simulateIr.ts` the program interpreter, which computes every number the way the Python does, the two over the one world of `world.ts` (the unit table, the players and forces, what is done to units and what the unit conditions count); `testing.ts` is the script's own `test()`s — the registry the script fills as it runs, `sim` and `expect`, and the runner, whose report is plain data because it crosses from the compile worker; `lower.ts` is what the raw level still needs (condition negation, hyper triggers); `print.ts` is the inverse for records; `api.ts` and `record.ts` are the shared vocabulary. Nothing in here touches the DOM or the editor. | -| `python/trigscript.py` | The Remastered lowering: the euddraft plugin that turns the IR into eudplib code, handed to the eudplib plugin with every build. `npm run embed` writes it into `compiler/generated/trigscriptPy.ts`; `tests/python.test.ts` fails when the two drift. `scripts/build-fixture.mts` builds a script into a playable map under Node through a plugin-eudplib checkout; `probes/` holds a probe script for each part of the language (`arrays.ts`, `recursion.ts`, `strings.ts`, `classes.ts`, `map.ts`, …): every line prints what it expects, a test plays it in the simulator against those lines, and the built map — in the ignored `fixtures/` folder, since it sits on a Blizzard map — is played in the game before the part ships, which is the only place the Python really runs. | +| `compiler/` | The language. `names.ts` and `declarations.ts` generate the `.d.ts`; `runtime.ts` is the library the script calls; `compiler.ts` checks the files as one `ts.createProgram`, collects the map references, emits them through `hoist.ts`'s transformer, links and runs them (`link.ts`), and turns each `program()`, and the `game()` functions it calls, through `structured.ts` (with `scope.ts`, what a name is bound to, and `tables.ts`, the game's tables) into the IR (`ir.ts`, `docs/ir.md`); `input.ts` sets up what the programs read of keys, mouse and chat, `numbers.ts` writes into every operation whether it reads its 32 bits as a `number` or a `u32`, `recursion.ts` finds the functions that call themselves and what each call has to keep, and `check.ts` checks the result (a loop that never sleeps, a constant divisor, a constant that does not fit its width); `backend/` is the build of a map of our own: `emit.ts` lowers a program's whole body to a graph of the game's triggers, `own.ts` every program of a script (and says what it does not take), `layout.ts` lays the graph out as the game's trigger nodes at the addresses Remastered keeps the strings at, `build.ts` does it to a scenario's bytes (`chk.ts` reads and writes the sections it touches, `fixes.ts` does what eudplib does to a map before writing it, `maps.ts` says where each trigger and each variable is for the tools that read a built map in the lifted game); `simulate.ts` is the trigger-cycle interpreter and `simulateIr.ts` the program interpreter, which computes every number the way the Python does, the two over the one world of `world.ts` (the unit table, the players and forces, what is done to units and what the unit conditions count); `testing.ts` is the script's own `test()`s: the registry the script fills as it runs, `sim` and `expect`, and the runner, whose report is plain data because it crosses from the compile worker; `lower.ts` is what the raw level still needs (condition negation, hyper triggers); `print.ts` is the inverse for records; `api.ts` and `record.ts` are the shared vocabulary. Nothing in here touches the DOM or the editor. | | `vendor/` | The tables the compiler reads, copied from the editor: the trigger record layout and its codec, the condition and action definitions, the unit names, the flag names, the text format's printer and parser. The editor is the source of truth; copy them again when it changes. | -| `dist/plugin.js`, `dist/compiler.js` | The bundle the editor loads, and the compiler alone (`compiler/entry.ts`) for the compile worker of a copy compiled into the editor, which has no `blob:` module to hand it; `npm run build` writes both — commit both before tagging (CI commits and checks `plugin.js` on its own). | +| `dist/plugin.js`, `dist/compiler.js` | The bundle the editor loads, and the compiler alone (`compiler/entry.ts`) for the compile worker of a copy compiled into the editor, which has no `blob:` module to hand it; `npm run build` writes both. Commit both before tagging (CI commits and checks `plugin.js` on its own). | | `dist/testing.js`, `dist/testing.d.ts` | Not loaded by the plugin. The trigger text parser (`vendor/text.ts`), the trigger interpreter and its world, and the record constants, bundled from `compiler/testingEntry.ts` so that another repository's tests can run trigger text instead of reading it, and compile a script (`compileScript`, `defaultScriptNames`, `defaultLib`) to check that the TrigScript it shows a model still compiles: a devDependency on the tarball of one of this repository's tags (`https://github.com/scm-js/plugin-trigscript/archive/refs/tags/v3.10.4.tar.gz`), then `import { parseTriggers, Simulation } from "scmjs-plugin-trigscript/testing"`. `npm run build` writes both and CI holds them to the same rule as `compiler.js`. | -| `tests/` | vitest. `script.test.ts` pins the names, the declarations, the runtime's argument handling, files and imports, the printer and the block logic; `script-structured.test.ts` compiles programs and asserts the simulation, and a file a part of the language does the same for it (`arrays`, `grids`, `callbacks`, `destructuring`, `functions`, `recursion`, `classes`, `strings`, `hash`, `leftovers`, `units`, `input`) — `hash` and `leftovers` also run each body in JavaScript itself and compare what was printed; `readme.test.ts` compiles every example of this file, and checks what an example says it prints; `python.test.ts` holds the Python to the compiler's IR version; `simulate-ir.test.ts` pins the program interpreter's contract with the game (frames, and how a number comes out); `service.test.ts` the apply and the part in saving, against a stand-in host and library; `eud-build.test.ts` builds golden maps through a plugin-eudplib checkout beside this repository when there is one; `refs.test.ts` the references and renames. Copies of Blizzard's own maps in `fixtures/maps/` (gitignored) make every trigger eject to script and run back to the same record. | +| `tests/` | vitest. `script.test.ts` pins the names, the declarations, the runtime's argument handling, files and imports, the printer and the block logic; `script-structured.test.ts` compiles programs and asserts the simulation, and a file a part of the language does the same for it (`arrays`, `grids`, `callbacks`, `destructuring`, `functions`, `recursion`, `classes`, `strings`, `hash`, `leftovers`, `units`, `input`); `hash` and `leftovers` also run each body in JavaScript itself and compare what was printed; `readme.test.ts` compiles every example of this file, and checks what an example says it prints; `simulate-ir.test.ts` pins the program interpreter's contract with the game (frames, and how a number comes out); `service.test.ts` the apply and the part in saving (the build step) against a stand-in host and library; `own-build.test.ts` the build of a map of our own from a scenario's bytes (the payload after the strings, the start-up, eudplib's fixes, the map's triggers, the input's properties slot, a refusal said, the maps of triggers and variables) and the built file walked as the game walks it (`ownWalk.ts`), its lines against the simulator's and its variables read through the map; `eud-build.test.ts` builds golden maps through a plugin-eudplib checkout beside this repository when there is one; `refs.test.ts` the references and renames. Copies of Blizzard's own maps in `fixtures/maps/` (gitignored) make every trigger eject to script and run back to the same record. | ### How the compiler is built `names.ts` turns the map into name tables (players, units, locations, switches, AI -scripts, and the fixed weapons, upgrades and technologies `stats()` takes); each entry's keys are an identifier derived from the display name first -(`Terran Marine` → `TerranMarine`), the display name itself second, then custom names — -unique per table by construction. `declarations.ts` generates the `.d.ts` from them plus +scripts, and the fixed weapons, upgrades and technologies `stats()` takes). Each entry's +keys are an identifier derived from the display name first (`Terran Marine` → +`TerranMarine`), the display name itself second, then custom names, unique per table by +construction. `declarations.ts` generates the `.d.ts` from them plus the library: values are branded literal types (`UnitType<0> = 0 & Brand<"unit">`; plain numbers still pass, a `Location` where a `UnitType` belongs does not), enumerated kinds are string unions of the canonical words, every condition and action is a `declare function` whose identifier is its `ConditionType` / `ActionType` key in camel case, and the whole -thing is declared twice — as globals and as the ambient module `"trigscript"`. +thing is declared twice: as globals and as the ambient module `"trigscript"`. `runtime.ts` is the same vocabulary as values: the tables as frozen objects and one function per condition and action that validates its arguments and returns a record. @@ -1715,27 +2308,27 @@ follow them even when they no longer type-check), and stops at the first type er then plans every `program()` body and every `game()` arrow (`hoist.ts`): the *game bindings* are the body's `let` / `var`, the parameters and the functions declared in it; a *hoisted expression* is a maximal subexpression that mentions none of those (and is not -`random()`, nor a call of a `game()` function — the callee's type carries a brand), and a +`random()`, nor a call of a `game()` function, whose type carries a brand), and a `const` of the body whose initialiser is hoistable is a build-time constant. The emit transformer appends its position to every `trigger()` call and replaces each planned arrow with a descriptor holding a function that returns the hoisted expressions as thunks and the constants as memoised thunks, every reference to a constant rewritten to a -call of its thunk — so a constant is computed when first needed and never in a pruned -branch. The emitted CommonJS is linked by `link.ts` — relative imports against the files, -`"trigscript"` to the runtime, the library's names in scope as globals — and run; an +call of its thunk, so a constant is computed when first needed and never in a pruned +branch. The emitted CommonJS is linked by `link.ts` (relative imports against the files, +`"trigscript"` to the runtime, the library's names in scope as globals) and run; an error the script throws is placed through the emitted source map. `game(descriptor)` returns a function that throws if the script calls it and carries the descriptor for the walker. `structured.ts` then walks each program body against the same plan, with the thunks the descriptor's function returned: where the plan says an expression was hoisted, the walker -takes its value — a number, a boolean, a condition, an action or a list of them. `let` → +takes its value: a number, a boolean, a condition, an action or a list of them. `let` → a variable of the IR (a number, a boolean, a unit, a text), a record of them (an object literal, an instance of a class) or an array, bound in a scope keyed by declaration node so shadowing and inlining resolve as the checker does; expressions become IR expressions as written; `&&` / `||` / `!` stay what they are, and the lowering short-circuits them; functions declared in the body and `game()` -functions are `call` nodes — inlined where a function is first met, the walker switching +functions are `call` nodes, inlined where a function is first met, the walker switching to the function's own plan, file and thunks for the duration, and from the second time on a call of one body kept in `Program.functions`, when the function compiles with every parameter a variable (the attempt's diagnostics are thrown away, and a first call already @@ -1746,26 +2339,22 @@ is unrolled like a `for…of`; an action with a variable argument carries the ex beside its record. The thunks are memoised in the bodies, so the script's build-time parts run once. -`python/trigscript.py` lowers a program to straight-line eudplib triggers with jumps -between labels. `sleep` stores the label to resume at in a state variable, sets a frame -counter and leaves the frame; the frame's entry counts the wait down and jumps to the -stored label. Arithmetic is 32 bits that wrap, and each comparison, division, shift -right, `min`, `max` and printed number reads them signed or unsigned as `numbers.ts` -marked it. An array that grows is a block of one heap the programs share — sizes double, -a block given back serves the next array of its size — and a made text is such a block -of bytes. A function that is called is one body with its parameters as variables; one -that calls itself keeps what a call would overwrite on a stack of its own, an array -beside the heap whose depth the script's Settings give. `docs/ir.md` says what each node -means to both backends. A per-player program loops over the -slots its owners name (All Players and a force resolved from the map's player settings) -who are in the game, its variables and state as twelve-slot arrays. `EUDVariable(n)` is a -cell's value at map load, not per run, so everything the lowering writes to starts from -`fresh()`; `tests/python.test.ts` refuses an unmarked one. A read is one `f_dwread_epd` -where a table of the game is the value (a player's deaths, kills, ore, gas; the player -bytes and the supply tables Magenta's probes verified; `hasLeft` is `f_playerexist`) and otherwise a search with the -condition itself, "at least" a bit at a time from the top, so it means what the condition -means. A text with values in it goes through eudplib's string buffer, or `f_eprintln` for -the middle of the screen — both show only on the computer of the player the text is for — -with the current player moved to each addressee and back. `random(n)` is `f_dwrand() % n`, -seeded once with `f_randomize()` when a program uses it. `simulateIr.ts` mirrors all of -it, wrap included, so that what Simulate shows is what the game does. +TrigScript's own build is `compiler/backend/` (see the `compiler/` row above): a program's +body becomes one graph of triggers, laid out after the map's strings. Arithmetic is 32 bits +that wrap, and each comparison, division, shift right, `min`, `max` and printed number reads +them signed or unsigned as `numbers.ts` marked it. An array that grows is a block of one heap +the programs share (sizes double, and a block given back serves the next array of its size), +and a made text is such a block of bytes; an array's cells sit beside the heap's bytes as +cell triggers, 72 bytes each, so that a read or a write is a jump into the cell. A function +that is called is one body with its parameters as variables; one that calls itself keeps +what a call would overwrite on a stack of its own, a region beside the heap whose depth the +script's Settings give. A per-player program is laid out once for each of its owners, with +variables of its own but for the shared ones. A read is the condition itself where a table +of the game is the value (a player's deaths, kills, ore, gas; the player bytes and the supply +tables) and otherwise a search with the condition, "at least" a bit at a time from the top, +so it means what the condition means. A text with values in it is written over a string of +the map's table as long as its template, which is then shown. `docs/ir.md` says what each +node means to the backend. `simulateIr.ts` mirrors all of it, wrap included, so that what +Simulate shows is what the game does. (Until TrigScript 4's step 17 a second lowering, +`python/trigscript.py`, built through eudplib what the backend did not take yet; step 16's +random whole programs, run both ways, held the two to the same lines before it went.) diff --git a/compile.ts b/compile.ts index 416d16f..430c8c8 100644 --- a/compile.ts +++ b/compile.ts @@ -19,7 +19,8 @@ * again: the script is never run on the main thread, where nothing could stop an endless * loop in it. */ -import type { CompileResult, ScriptFiles } from "./compiler/compiler"; +import type { CompileResult, OwnCheck, ScriptFiles } from "./compiler/compiler"; +import type { OwnBuildInput, OwnBuildOutcome } from "./compiler/backend/build"; import { ENTRY_URL } from "./compiler/entry"; import type { ScriptNames } from "./compiler/names"; import type { TestRunOptions } from "./compiler/testing"; @@ -41,6 +42,21 @@ export const libUrl = (dist: string = DEFAULT_DIST) => `${dist.replace(/\/+$/, " export const compilerUrl = (dist: string = DEFAULT_DIST) => dist === DEFAULT_DIST ? `https://cdn.jsdelivr.net/gh/scm-js/plugin-trigscript@v${VERSION}/dist/compiler.js` : `${dist.replace(/\/+$/, "")}/compiler.js`; +/** + * What runs a script's tests beside the lifted game (`dist/gametest.js`, from `compiler/gameTestEntry.ts`), at this + * release's own tag like the compiler: its text is handed to the engine's page (TrigScript 4, A2). + */ +export const gametestUrl = (dist: string = DEFAULT_DIST) => + dist === DEFAULT_DIST ? `https://cdn.jsdelivr.net/gh/scm-js/plugin-trigscript@v${VERSION}/dist/gametest.js` : `${dist.replace(/\/+$/, "")}/gametest.js`; + +/** What Remastered is known to allow (`data/allowlist.json`, copied into `dist/` by the build), for Find EUD Error: fetched only then. */ +export const allowlistUrl = (dist: string = DEFAULT_DIST) => + dist === DEFAULT_DIST ? `https://cdn.jsdelivr.net/gh/scm-js/plugin-trigscript@v${VERSION}/dist/allowlist.json` : `${dist.replace(/\/+$/, "")}/allowlist.json`; + +/** The Ogg Vorbis encoder (`compiler/vorbis.ts`, `dist/vorbis.wasm`), at this release's own tag: fetched only by a build that has a long clip to put in the map. */ +export const vorbisUrl = (dist: string = DEFAULT_DIST) => + dist === DEFAULT_DIST ? `https://cdn.jsdelivr.net/gh/scm-js/plugin-trigscript@v${VERSION}/dist/vorbis.wasm` : `${dist.replace(/\/+$/, "")}/vorbis.wasm`; + /** * What the worker is told to import. The editor's loader turns a fetched plugin into * `blob:` modules, and the compiler's own blob URL is the compiler the main thread would @@ -51,23 +67,40 @@ export const compilerUrl = (dist: string = DEFAULT_DIST) => export const workerModuleUrl = (dist: string = DEFAULT_DIST) => (ENTRY_URL.startsWith("blob:") && !blobRefused ? ENTRY_URL : compilerUrl(dist)); export const COMPILE_TIMEOUT_MS = 15_000; +/** A build's compile makes the script's sounds too — a long song mixed and encoded — and gets longer. */ +export const BUILD_TIMEOUT_MS = 60_000; export interface CompileInput { files: ScriptFiles; names: ScriptNames; /** Run the script's tests after the compile: the world they start from, and which of them. */ tests?: TestRunOptions; + /** Encode the sounds the map is to get (a build): a check leaves an Ogg Vorbis one as its name (`compiler/vorbis.ts`). */ + sounds?: boolean; + /** Ask whether TrigScript's own build takes the programs for this map (`CompileResult.ownRefusal`). */ + own?: OwnCheck; + /** Files that run as entries after `main.ts`, in order: what another plugin contributes to the build (`CompileOptions.entries`). */ + entries?: readonly string[]; } interface CompileRequest extends CompileInput { id: number; moduleUrl: string; libUrl: string; + vorbisUrl?: string; +} + +/** A map of our own built in the worker (`compiler/backend/build.ts`), off the main thread like a compile. */ +interface BuildRequest { + id: number; + moduleUrl: string; + libUrl: string; + build: OwnBuildInput; } interface CompileResponse { id: number; - result?: CompileResult; + result?: CompileResult | OwnBuildOutcome; error?: string; /** The worker could not load its compiler at all: every later request would fail the same way. */ fatal?: boolean; @@ -78,19 +111,32 @@ importScripts(${JSON.stringify(TS_URL)}); let loading = null; let lib = null; self.onmessage = async (e) => { - const { id, moduleUrl, libUrl, files, names, tests } = e.data; + const { id, moduleUrl, libUrl, vorbisUrl, files, names, tests, own, entries, build } = e.data; try { if (!loading) loading = import(moduleUrl); let mod; try { mod = await loading; } catch (err) { loading = null; postMessage({ id, error: String((err && err.message) || err), fatal: true }); return; } // The plugin fetched as one bundle is a blob too, but one that exports the plugin and not the compiler. if (typeof mod.compileScript !== "function") { loading = null; postMessage({ id, error: "the module has no compiler in it", fatal: true }); return; } + // A build of a map of our own: the compile's programs into the scenario's bytes, no TypeScript needed. + if (build) { + if (typeof mod.buildOwnMap !== "function") throw new Error("This compiler bundle cannot build a map of its own: update the plugin."); + postMessage({ id, result: mod.buildOwnMap(build) }); + return; + } if (lib === null) { const r = await fetch(libUrl); if (!r.ok) throw new Error("Could not load the standard library from " + libUrl + " (" + r.status + ")."); lib = await r.text(); } - postMessage({ id, result: mod.compileScript(self.ts, files, names, { lib, tests }) }); + // A check names the script's clips and makes none of them; a build makes them, the long ones encoded below. + const result = mod.compileScript(self.ts, files, names, { lib, tests, sounds: Boolean(vorbisUrl), own, entries }); + // A build's long clips encoded as Ogg Vorbis; a check's left as names, their samples not sent. + if (typeof mod.finishSounds === "function") { + const encoder = vorbisUrl && result.sounds.some((s) => s.vorbis) ? await mod.loadVorbis(vorbisUrl) : null; + mod.finishSounds(result.sounds, encoder); + } + postMessage({ id, result }); } catch (err) { postMessage({ id, error: String((err && err.message) || err) }); } @@ -113,7 +159,8 @@ let worker: Worker | null = null; let blobRefused = false; let unavailable: { reason: string; at: number } | null = null; let seq = 0; -const pending = new Map void; reject: (e: Error) => void; timer: ReturnType }>(); +/** What is in flight: a compile, which a newer compile supersedes, or a build, which nothing does. */ +const pending = new Map void; reject: (e: Error) => void; timer: ReturnType; build: boolean }>(); /** * The worker is a whole TypeScript instance (tens of MB of heap) and it is only busy while @@ -188,14 +235,14 @@ function breakWorker(reason: string) { } /** The script ran too long: drop this worker (the next request starts another) and say so. */ -function timeOut(id: number) { +function timeOut(id: number, ms: number) { const p = pending.get(id); if (!p) return; pending.delete(id); worker?.terminate(); worker = null; rejectAll("The compile was stopped."); - p.reject(new Error(`The script did not finish in ${COMPILE_TIMEOUT_MS / 1000} seconds. Is there an endless loop outside program()?`)); + p.reject(new Error(p.build ? `The build did not finish in ${ms / 1000} seconds.` : `The script did not finish in ${ms / 1000} seconds. Is there an endless loop outside program()?`)); } /** The worker, started if there is none. Throws `CompilerUnavailable` when one cannot be made, or could not a moment ago. */ @@ -240,18 +287,39 @@ export function compileInBackground(input: CompileInput, dist: string = DEFAULT_ let w: Worker; try { w = getWorker(); } catch (err) { return Promise.reject(err as Error); } const id = ++seq; - // Anything still in flight is stale now. + // Any compile still in flight is stale now (a build is not: it answers whatever is typed meanwhile). for (const [old, p] of pending) { + if (p.build) continue; pending.delete(old); clearTimeout(p.timer); p.reject(new CompileSuperseded()); } return new Promise((resolve, reject) => { - pending.set(id, { resolve, reject, timer: setTimeout(() => timeOut(id), COMPILE_TIMEOUT_MS) }); - const req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}) }; + const ms = input.sounds ? BUILD_TIMEOUT_MS : COMPILE_TIMEOUT_MS; + pending.set(id, { resolve: (r) => resolve(r as CompileResult), reject, timer: setTimeout(() => timeOut(id, ms), ms), build: false }); + const req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}), ...(input.sounds ? { vorbisUrl: vorbisUrl(dist) } : {}), ...(input.own ? { own: input.own } : {}), ...(input.entries ? { entries: input.entries } : {}) }; w.postMessage(req); }).catch((err: Error) => { if (err.message === RETRY) return compileInBackground(input, dist); throw err; }); } + +/** + * Build a map of our own in the background (`compiler/backend/build.ts`): the compile's programs laid out into the + * scenario's bytes, which the plugin's build step hands the editor. Not superseded by a compile, and supersedes none. + */ +export function buildInBackground(input: OwnBuildInput, dist: string = DEFAULT_DIST): Promise { + let w: Worker; + try { w = getWorker(); } catch (err) { return Promise.reject(err as Error); } + const id = ++seq; + return new Promise((resolve, reject) => { + const ms = BUILD_TIMEOUT_MS; + pending.set(id, { resolve: (r) => resolve(r as OwnBuildOutcome), reject, timer: setTimeout(() => timeOut(id, ms), ms), build: true }); + const req: BuildRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), build: input }; + w.postMessage(req); + }).catch((err: Error) => { + if (err.message === RETRY) return buildInBackground(input, dist); + throw err; + }); +} diff --git a/compiler/allowlist.ts b/compiler/allowlist.ts new file mode 100644 index 0000000..96e691f --- /dev/null +++ b/compiler/allowlist.ts @@ -0,0 +1,202 @@ +/** + * What Remastered is known to allow, and a run in the lifted game held against it (TrigScript 4, A1). + * + * The lifted game is 1.16.1: it says what an address means and what the triggers did with it, never whether + * Remastered's emulation of 1.16.1's memory would have let them. The allowlist says that, entry by entry, each with + * the evidence it rests on: a probe played in Remastered, Magenta's catalogue (itself verified by probes), or a + * failure seen in the game. `checkTrace` takes what a run touched (scm-sim's `scripts/exact-eud.ts --trace`) and + * says, by line, what Remastered was seen to refuse or ignore and what it has not been seen to do at all. + * + * A trace row is a dword and the bits that were looked at or changed (the record's mask); a row is held byte by + * byte, so a write of one byte under a mask needs only that byte allowed. + */ + +/** What Remastered does with a read or a write of an entry: lets it, ends the game, does nothing, or not known. */ +export type Verdict = "allowed" | "ends" | "ignored" | "unknown"; + +export interface AllowEntry { + /** What lies there ("unit table: hit points", "deaths table"). */ + name: string; + /** The first byte; with `stride`, the first byte of the first of `count` rows. */ + base: number; + stride?: number; + count?: number; + /** Bytes from `base` (within each row). */ + width: number; + read: Verdict; + write: Verdict; + /** What each verdict rests on, in words a person can follow up. */ + evidence: string[]; + /** A value that differs between the players' computers (the mouse, the screen): never to change the game itself. */ + local?: boolean; + /** "dump": read off Remastered's own EUD emulator rather than seen in a game; a played entry over the same byte wins. */ + from?: "dump"; + /** What a person needs besides the verdict ("a unit pointer: the game takes only the address of a unit"). */ + note?: string; +} + +export interface Allowlist { + version: 1; + entries: AllowEntry[]; + /** What Remastered does with an address no entry covers, and why (its emulator has no region there: the game ends). */ + outside?: { verdict: Verdict; evidence: string[] }; +} + +/** [origin, address, write (1) or read (0), mask, times] — origin as the build's map of triggers names it. */ +export type TraceRow = [string, number, number, number, number]; + +export interface Trace { + map?: string; + frames: number; + /** The map's own strings and the build's payload behind them, where Remastered keeps them: the map's own memory. */ + strings: { from: number; to: number }; + /** The lifted game's heap (what the game allocated: its trigger lists among it); absent in older traces. */ + heap?: { from: number; to: number }; + rows: TraceRow[]; +} + +export interface Finding { + /** "12:5" or "file.ts:12:5"; "" for a trigger no line made (a program's frame, the build's own); from a build before step 17, "fn:", "eudplib" or "?". */ + origin: string; + line: number | null; + write: boolean; + verdict: Exclude; + /** The first address of the kind found, how many distinct dwords and how many times in all. */ + address: number; + addresses: number; + times: number; + /** The entry that decided it, or the table of the game it lies in when no entry covers it. */ + entry: string | null; + evidence: string[]; +} + +export interface TraceCheck { + findings: Finding[]; + /** Accesses (rows) by what decided them. */ + counted: { own: number; heap: number; allowed: number; ends: number; ignored: number; unknown: number }; +} + +/** 1.16.1's tables by name, so that what no entry covers is still said in words, and a loop over every unit is one finding. */ +const TABLES = [ + { name: "unit table", base: 0x59cca8, stride: 336, count: 1700 }, + { name: "deaths table", base: 0x58a364, stride: 4, count: 12 * 228 }, +]; +function placeOf(address: number): string { + for (const t of TABLES) { + const off = address - t.base; + if (off >= 0 && off < t.stride * t.count) return t.stride > 4 ? `${t.name} +0x${(off % t.stride).toString(16)}` : t.name; + } + return ""; +} + +/** Whether a line of the script made the access (and not the build's own triggers, or a program's frame). */ +export function isScriptOrigin(origin: string): boolean { + return /^(.+:)?\d+:\d+$/.test(origin); +} + +function lineOf(origin: string): number | null { + const m = /(\d+):\d+$/.exec(origin); + return m && isScriptOrigin(origin) ? Number(m[1]) : null; +} + +/** The bytes of the dword at `address` that `mask` names. */ +function bytesOf(address: number, mask: number): number[] { + const out: number[] = []; + for (let k = 0; k < 4; k++) if ((mask >>> (8 * k)) & 0xff) out.push(address + k); + return out; +} + +function covers(e: AllowEntry, byte: number): boolean { + const off = byte - e.base; + if (off < 0) return false; + if (!e.stride) return off < e.width; + return off % e.stride < e.width && Math.floor(off / e.stride) < (e.count ?? 1); +} + +/** The entries by the 256-byte blocks they reach into, made once a list: the dump's entries cover most of the game. */ +const BLOCK = 8; +const indexes = new WeakMap>(); +function indexOf(list: Allowlist): Map { + let index = indexes.get(list); + if (index) return index; + index = new Map(); + for (const e of list.entries) { + const end = e.base + (e.stride ? e.stride * ((e.count ?? 1) - 1) : 0) + e.width; + for (let b = e.base >>> BLOCK; b <= (end - 1) >>> BLOCK; b++) { + const at = index.get(b); + if (at) at.push(e); else index.set(b, [e]); + } + } + indexes.set(list, index); + return index; +} + +/** How grave a verdict is when a row is summed up from its bytes: one byte not known makes the row not known. */ +const RANK: Record = { ends: 3, ignored: 2, unknown: 1, allowed: 0 }; +/** Which of several entries over one byte decides: a failure seen beats a success, a success beats no word at all. */ +const WEIGHT: Record = { ends: 3, ignored: 2, allowed: 1, unknown: 0 }; + +/** + * What the allowlist says of one byte, for a read or a write, and the entry that says it. What was played in + * Remastered decides first; the dump of its emulator decides where nothing played says more than "not known"; an + * address neither covers takes the list's `outside`. + */ +export function verdictOf(list: Allowlist, byte: number, write: boolean): { verdict: Verdict; entry: AllowEntry | null } { + let played: { verdict: Verdict; entry: AllowEntry | null } = { verdict: "unknown", entry: null }; + let dumped: { verdict: Verdict; entry: AllowEntry | null } = { verdict: "unknown", entry: null }; + for (const e of indexOf(list).get(byte >>> BLOCK) ?? []) { + if (!covers(e, byte)) continue; + const v = write ? e.write : e.read; + const best = e.from === "dump" ? dumped : played; + if (best.entry === null || WEIGHT[v] > WEIGHT[best.verdict]) { best.verdict = v; best.entry = e; } + } + if (played.entry && played.verdict !== "unknown") return played; + if (dumped.entry) return dumped; + if (played.entry) return played; + return { verdict: list.outside?.verdict ?? "unknown", entry: null }; +} + +/** + * Holds every row of a trace against the allowlist. The map's own memory is always allowed; the game's heap is + * allowed to the build's own triggers (the trigger lists live there, reached through the game's pointers) and unknown to + * a line of the script. Findings are grouped by origin, direction, verdict and the entry that decided them. + */ +export function checkTrace(trace: Trace, list: Allowlist): TraceCheck { + const counted = { own: 0, heap: 0, allowed: 0, ends: 0, ignored: 0, unknown: 0 }; + const groups = new Map }>(); + for (const [origin, address, write, mask, times] of trace.rows) { + if (address >= trace.strings.from && address < trace.strings.to) { counted.own++; continue; } + if (trace.heap && address >= trace.heap.from && address < trace.heap.to) { + if (!isScriptOrigin(origin)) { counted.heap++; continue; } + add(origin, address, write === 1, "unknown", null, ["the game's heap, read or written by a line of the script"], times); + continue; + } + let worst: { verdict: Verdict; entry: AllowEntry | null } = { verdict: "allowed", entry: null }; + for (const byte of bytesOf(address, mask)) { + const v = verdictOf(list, byte, write === 1); + if (RANK[v.verdict] > RANK[worst.verdict]) worst = v; + } + if (worst.verdict === "allowed") { counted.allowed++; continue; } + const evidence = worst.entry ? [...worst.entry.evidence, ...(worst.entry.note ? [worst.entry.note] : [])] : worst.verdict === list.outside?.verdict ? list.outside.evidence : []; + add(origin, address, write === 1, worst.verdict, worst.entry, evidence, times); + } + function add(origin: string, address: number, write: boolean, verdict: Verdict, entry: AllowEntry | null, evidence: string[], times: number) { + counted[verdict as "ends" | "ignored" | "unknown"]++; + const place = entry?.name ?? (placeOf(address) || null); + const key = `${origin}|${write ? 1 : 0}|${verdict}|${place ?? address >>> 12}`; + const g = groups.get(key); + if (g) { g.times += times; if (!g.seen.has(address)) { g.seen.add(address); g.addresses++; } if (address < g.address) g.address = address; return; } + groups.set(key, { origin, line: lineOf(origin), write, verdict: verdict as Finding["verdict"], address, addresses: 1, times, entry: place, evidence, seen: new Set([address]) }); + } + const findings = [...groups.values()].map(({ seen: _seen, ...f }) => f) + .sort((a, b) => RANK[b.verdict] - RANK[a.verdict] || (a.line ?? 1e9) - (b.line ?? 1e9) || a.address - b.address); + return { findings, counted }; +} + +/** A finding as one line for a person: where, what, and what it rests on. */ +export function describeFinding(f: Finding): string { + const where = f.line !== null ? `line ${f.line}` : f.origin === "" ? "a trigger of the build's own" : f.origin.startsWith("fn:") ? `the build's ${f.origin.slice(3)}` : f.origin === "?" ? "the build's start" : f.origin; + const what = `${f.write ? "writes" : "reads"} ${f.entry ?? "0x" + f.address.toString(16)}${f.addresses > 1 ? ` (${f.addresses} dwords from 0x${f.address.toString(16)})` : f.entry ? ` at 0x${f.address.toString(16)}` : ""}`; + const verdict = f.verdict === "ends" ? "Remastered ends the game on this" : f.verdict === "ignored" ? "Remastered does nothing with this" : "not seen in Remastered: needs a probe"; + return `${where}: ${what} — ${verdict}${f.evidence.length ? ` (${f.evidence.join("; ")})` : ""}`; +} diff --git a/compiler/api.ts b/compiler/api.ts index 339c7f2..1d5133b 100644 --- a/compiler/api.ts +++ b/compiler/api.ts @@ -12,6 +12,7 @@ import { ACTION_DEFS, CHOICES, CONDITION_DEFS, type ActionDef, type ArgDef, type ArgKind, type ConditionDef, } from "../vendor/triggerDefs"; +import { ORDER_OF_VERB } from "./orders"; import { ActionType, AllianceStatus, Comparison, ConditionType, Order, ResourceType, ScoreType, SetModifier, SwitchAction, SwitchState, TriggerFlag, UnitState, } from "../vendor/triggers"; @@ -78,6 +79,12 @@ export const CANONICAL: Partial> = { ], }; +/** The Order action's own number for an order of orders.* it gives (move 0, patrol 1, attack 2); a raw number as it is. */ +export function orderOfAction(value: number): number { + const verb = (Object.keys(ORDER_OF_VERB) as (keyof typeof ORDER_OF_VERB)[]).find((v) => ORDER_OF_VERB[v] === value); + return verb === undefined ? value : CANONICAL.order!.find(([, w]) => w === verb)![0]; +} + /** The canonical word for a value, else the table's label. */ export function choiceWord(kind: ArgKind, value: number): string | undefined { const hit = CANONICAL[kind]?.find(([v]) => v === value); @@ -111,11 +118,17 @@ export function argType(kind: ArgKind): string { case "unit": return "UnitType"; case "location": return "Location"; case "switch": return "Switch"; - case "text": case "wav": return "string"; + case "text": return "string"; + // A sound of the map (sounds.badapple, from the Sound Editor's list) or a path in the map's archive. + case "wav": return "Sound | Clip | string"; case "aiScript": return "AiScript | string"; case "count": return "Count"; case "textFlags": return "boolean"; case "number": case "amount": case "duration": case "percent": case "cuwp": case "slot": return "number"; + // orders.Move, orders.Patrol and orders.AttackMove are the action's three (`orderOfAction`); any other of orders.* is + // what a unit is doing, which the action cannot give. A raw number is a second signature (`declarations.ts`): in one + // union with it, TypeScript takes orders.Die for a number. + case "order": return "OrderKind | OrderOfAction"; default: return `${CHOICE_TYPES[kind] ?? "number"} | number`; } } diff --git a/compiler/backend/build.ts b/compiler/backend/build.ts new file mode 100644 index 0000000..3a7154b --- /dev/null +++ b/compiler/backend/build.ts @@ -0,0 +1,190 @@ +/** + * A map of our own, built from a scenario file (TrigScript 4, step 14: the build in the editor). What + * `scripts/build-own.mts` did with the editor's code, done on the CHK's bytes alone (`chk.ts`), so that the plugin's + * build step can do it on Save, Test Map and an export, in the compile worker, with no eudplib and no Python: + * + * 1. The map's facts: its size, who plays and in which force, its own triggers (the script's `trigger()` records + * among them, once applied), the unit types and the unit-properties slots in use. + * 2. eudplib's fixes to the map (`fixes.ts`), so that a map built either way is the same map. + * 3. The programs lowered whole (`own.ts`, `emitOwn`): every text known when built goes into the strings, one after + * another from the next index; every printed text and every action's text or sound file is interned; a text + * printed with values in it gets a string as long as its template, which is then written over that string. + * 4. The layout (`layout.ts`): the payload after the strings, where Remastered keeps them, and the start-up in TRIG. + * 5. The slot of unit properties the carriers of the players' input are made with, marked in transit. + * + * A program the emitter does not take is a refusal, said in the user's words with the line; the map is then not + * built this way (the editor builds it through eudplib while that is still there, or saves it without its programs). + */ +import type { CompileResult } from "../compiler"; +import type { TriggerRecord } from "../../vendor/triggers"; +import { Sections, addString, freeCuwpSlot, intern, markInTransit, readFacts, readStrings, readTriggers, unitTypesUsed, writeStrings } from "./chk"; +import { fixMap } from "./fixes"; +import { STRINGS, layout } from "./layout"; +import { emitOwn, type OwnOptions } from "./own"; +import { ownMaps, type OwnMaps } from "./maps"; +import type { MapPlayers } from "./emit"; +import type { Graph } from "./graph"; + +/** The Valkyrie: the carrier of the players' input unless the map's units or triggers name it. */ +const CARRIER = 58; +const UNIT_TYPES = 228; + +export interface OwnBuildInput { + /** The scenario as the editor would save it: the script applied. */ + chk: Uint8Array; + /** + * The compile. Its `triggers` are records the map's trigger list does not hold yet (a probe built under Node); + * the editor passes none, the block being in the list already. + */ + compiled: Pick; + /** The script's settings: the heap's cells, the stack's depth. */ + settings?: OwnOptions; + /** Leave the map's own triggers out (a probe built on a melee map, whose stock triggers would end the game). */ + dropMapTriggers?: boolean; + /** A dword written into every free cell of the payload (a probe's marker). */ + free?: number; + /** Say where each trigger and each variable is (`maps.ts`): for the tools that read a built map in the lifted game. */ + maps?: boolean; +} + +export interface OwnBuildStats { + runs: number; + /** The programs' runs, as "program 2 as P1". */ + runNames: string[]; + graphTriggers: number; + payloadBytes: number; + address: number; + /** The map's own triggers laid out, and the copies of them (one a player they run for). */ + mapTriggers: number; + mapTriggerCopies: number; + hyperTriggersLeftOut: number; + /** The map's own triggers for a player group the game runs no list for (Players 9 to 12, Foes, Allies, Neutral, Non-Allied Victory Players): laid out in no list, as in the game. */ + mapTriggersUnlisted: number; + startUpTriggers: number; + input: { keys: number; mouse: boolean; syncs: number; carrier: number; ring: number; rings: number; humans: number[]; cuwp: number; bits: number } | null; + /** How many strings the map had before, and has now. */ + stringsBefore: number; + stringsAfter: number; + /** The players whose lists the payload is linked into, and where each one's entry is: what a walk of the payload starts from. */ + players: number[]; + entries: number[]; + /** The string section's length before the payload: the payload's offsets count from there. */ + stringsBytes: number; +} + +export interface OwnBuild { + chk: Uint8Array; + /** What was done, for the Output panel: the fixes, the input, the counts. */ + lines: string[]; + stats: OwnBuildStats; + /** With `maps`: where the triggers and the variables are, and the same as lines of a build's log. */ + maps?: OwnMaps; + /** What the map maker should hear of beyond the log: triggers of the map's own that are left out or never run. */ + warnings: string[]; +} + +export type OwnBuildOutcome = OwnBuild | { refused: string }; + +export function buildOwnMap(input: OwnBuildInput): OwnBuildOutcome { + const s = new Sections(input.chk); + const lines: string[] = []; + // The map's facts come first, before the fixes change its bytes. + const facts = readFacts(s); + const players: MapPlayers = { slots: facts.slots, forces: facts.forces }; + const mapTriggers: TriggerRecord[] = input.dropMapTriggers ? [] : readTriggers(s); + const used = unitTypesUsed(s, [...mapTriggers, ...input.compiled.triggers]); + const carrier = used.has(CARRIER) ? [...Array(UNIT_TYPES).keys()].find((t) => !used.has(t)) ?? CARRIER : CARRIER; + const cuwp = freeCuwpSlot([...mapTriggers, ...input.compiled.triggers]); + // The strings: STRx as they are, or STR's read on as STRx (a Remastered file keeps its strings there). + const strx = s.get("STRx"); + const str = s.get("STR "); + if (!strx && !str) throw new Error("The map has no string table (STR or STRx)."); + const table = readStrings(strx ?? str!, !!strx); + const stringsBefore = table.entries.length; + const textBase = stringsBefore + 1; + lines.push(...fixMap(s)); + if (!strx) lines.push(`the strings written as STRx (Remastered's table), from STR`); + + const lowered = emitOwn(input.compiled, players, textBase, { humans: facts.humans, width: facts.width * 32, height: facts.height * 32, cuwp, carrier, triggers: mapTriggers }, input.settings); + if (typeof lowered === "string") return { refused: lowered }; + // The pool of texts: the map's next strings, one after another, as the programs count on. + lowered.texts.forEach((text, i) => { + if (addString(table, text) !== textBase + i) throw new Error("The pool of texts did not go into the map's strings one after another."); + }); + const graphs: Graph[] = lowered.runs.map((run) => run.graph); + const texts = new Map(); + const want = (text: string) => { if (!texts.has(text)) texts.set(text, intern(table, text)); }; + for (const t of [...graphs.flatMap((g) => g.triggers), ...lowered.triggers]) for (const a of t.acts) { + if (a.display !== undefined) want(a.display); + for (const x of [a.strings?.text, a.strings?.wav]) if (x && "text" in x) want(x.text); + if (a.amountText) want(a.amountText.text); + } + // A string for each text printed with values in it, as long as its template and three bytes more: once the strings + // are laid out, the template is written over it from its first dword's start, and the string's offset moved there + // (the game's own table of strings names only its first 64 KB in 1.16.1, so the template stays among them). + const templates = new Map(); + for (const t of graphs.flatMap((g) => g.templates ?? [])) { + if (t.at !== undefined || t.data) continue; + const name = `(TrigScript text ${t.id})`; + templates.set(t.id, addString(table, name + "\r".repeat(Math.max(0, t.bytes.length + 3 - name.length)))); + } + const written = writeStrings(table); + const strings = written.data; + const offsets = new DataView(strings.buffer, strings.byteOffset, strings.byteLength); + const templateAt = new Map(); + for (const t of graphs.flatMap((g) => g.templates ?? [])) { + if (t.at !== undefined || t.data) continue; + const index = templates.get(t.id)!; + const at = (STRINGS + written.offsets[index - 1] + 3) & ~3; + strings.set(t.bytes, at - STRINGS); + offsets.setUint32(4 * index, at - STRINGS, true); + templateAt.set(t.id, at); + } + const own = layout({ + runs: lowered.runs.map((run) => ({ graph: run.graph, owner: run.player })), + players: players.slots, + triggers: lowered.triggers, + strings: strings.length, + text: (t) => texts.get(t)!, + arrays: lowered.values, + template: (id) => templates.get(id)!, + templateAt, + ...(input.free !== undefined ? { free: input.free } : {}), + }); + const withPayload = new Uint8Array(strings.length + own.payload.length); + withPayload.set(strings); + withPayload.set(own.payload, strings.length); + if (strx) s.set("STRx", withPayload); + else s.replace("STR ", "STRx", withPayload); + s.set("TRIG", own.trig); + if (lowered.input) markInTransit(s, cuwp); + + const stats: OwnBuildStats = { + runs: lowered.runs.length, + runNames: lowered.runs.map((run) => `program ${run.program + 1} as P${run.player + 1}`), + graphTriggers: graphs.reduce((n, g) => n + g.triggers.length, 0), + payloadBytes: own.payload.length, + address: own.address, + mapTriggers: lowered.triggers.length, + mapTriggerCopies: lowered.triggers.reduce((n, t) => n + t.slots.length, 0), + hyperTriggersLeftOut: lowered.hyperTriggersLeftOut, + mapTriggersUnlisted: lowered.triggers.filter((t) => t.slots.length === 0).length, + startUpTriggers: own.trig.length / 2400, + input: lowered.input ? { keys: lowered.input.keys.length, mouse: lowered.input.mouse, syncs: lowered.input.syncs, carrier, ring: lowered.input.ring, rings: lowered.input.rings, humans: lowered.input.humans, cuwp, bits: lowered.input.bits } : null, + stringsBefore, + stringsAfter: table.entries.length, + players: players.slots, + entries: players.slots.map((p) => own.triggers.get(`@entry${p}`)!), + stringsBytes: strings.length, + }; + const maps = input.maps ? ownMaps(lowered, own, input.compiled.ir, strings.length, textBase) : undefined; + if (stats.input) lines.push(`input: ${stats.input.keys} keys, ${stats.input.mouse ? "the mouse, " : ""}${stats.input.syncs} sync slots; carriers of type ${carrier}, ${stats.input.ring} a ring, ${stats.input.rings} rings, for players ${stats.input.humans.map((h) => h + 1).join(", ")}; unit-properties slot ${cuwp}; ${stats.input.bits} bits a value`); + lines.push(`${stats.runs} run${stats.runs === 1 ? "" : "s"} (${stats.runNames.join(", ")}), ${stats.graphTriggers} triggers of the programs, payload ${stats.payloadBytes} bytes at 0x${stats.address.toString(16)}, ${stats.mapTriggers} trigger${stats.mapTriggers === 1 ? "" : "s"} of the map's own (${stats.mapTriggerCopies} copies${stats.hyperTriggersLeftOut ? `, ${stats.hyperTriggersLeftOut} hyper triggers left out` : ""}${stats.mapTriggersUnlisted ? `, ${stats.mapTriggersUnlisted} in no list` : ""}), start-up ${stats.startUpTriggers} triggers, ${stats.stringsAfter - stats.stringsBefore} strings added`); + // What the map maker should hear of, beyond the log: the editor shows each as a notice. + const warnings: string[] = []; + if (stats.hyperTriggersLeftOut) warnings.push(`${stats.hyperTriggersLeftOut} hyper trigger${stats.hyperTriggersLeftOut === 1 ? "" : "s"} of the map's own ${stats.hyperTriggersLeftOut === 1 ? "was" : "were"} left out: a map with a program in it goes through its triggers every frame as it is, and with hyper triggers in a list the game would hold it to every second frame.`); + if (stats.mapTriggersUnlisted) warnings.push(`${stats.mapTriggersUnlisted} trigger${stats.mapTriggersUnlisted === 1 ? "" : "s"} of the map's own ${stats.mapTriggersUnlisted === 1 ? "is" : "are"} for a player group the game runs no triggers for (Players 9 to 12, Foes, Allies, Neutral, Non-Allied Victory Players): ${stats.mapTriggersUnlisted === 1 ? "it" : "they"} never run, in this build as in the game.`); + return { chk: s.bytes(), lines, stats, warnings, ...(maps ? { maps } : {}) }; +} + +export const isRefused = (o: OwnBuildOutcome): o is { refused: string } => "refused" in o; diff --git a/compiler/backend/chk.ts b/compiler/backend/chk.ts new file mode 100644 index 0000000..b7312f8 --- /dev/null +++ b/compiler/backend/chk.ts @@ -0,0 +1,251 @@ +/** + * The scenario file as a map of our own reads and writes it (TrigScript 4, step 14: the build in the editor). The + * build takes the CHK the editor would save — the script applied, its `trigger()` records in the trigger list — and + * gives back the one the game is to load: the programs' payload after the strings, the start-up in TRIG, a slot of + * unit properties marked in transit when the players' input is read. Nothing of the editor's code is in reach of a + * plugin, so the handful of sections the build touches are read and written here, byte for byte: the chunks, the + * string table (STR or STRx), DIM, OWNR, FORC, UNIT, UPRP and UPUS. What the build does not touch is written back as + * it came. + */ +import { ActionType, decodeTriggers, type TriggerRecord } from "../../vendor/triggers"; + +export interface Chunk { name: string; data: Uint8Array } + +const latin1 = new TextDecoder("latin1"); +const utf8 = new TextEncoder(); + +/** + * The CHK's chunks in file order: a four-character name, a signed length, that many bytes. A chunk with a negative + * length (a protected map's trick) ends the reading, as the game's does; what follows is `trailing`, written back as + * it is. + */ +export function readChunks(bytes: Uint8Array): { chunks: Chunk[]; trailing: Uint8Array | null } { + const chunks: Chunk[] = []; + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + let pos = 0; + while (pos + 8 <= bytes.length) { + const name = latin1.decode(bytes.subarray(pos, pos + 4)); + const size = view.getInt32(pos + 4, true); + if (size < 0) break; + const end = Math.min(pos + 8 + size, bytes.length); + chunks.push({ name, data: bytes.slice(pos + 8, end) }); + pos = end; + } + return { chunks, trailing: pos < bytes.length ? bytes.slice(pos) : null }; +} + +export function writeChunks(chunks: readonly Chunk[], trailing: Uint8Array | null = null): Uint8Array { + const out = new Uint8Array(chunks.reduce((n, c) => n + 8 + c.data.length, 0) + (trailing?.length ?? 0)); + const view = new DataView(out.buffer); + let pos = 0; + for (const c of chunks) { + for (let i = 0; i < 4; i++) out[pos + i] = (c.name.charCodeAt(i) || 32) & 0xff; + view.setInt32(pos + 4, c.data.length, true); + out.set(c.data, pos + 8); + pos += 8 + c.data.length; + } + if (trailing) out.set(trailing, pos); + return out; +} + +/** The chunks as the build works on them: a name's last occurrence stands for it, as it does for the game. */ +export class Sections { + readonly chunks: Chunk[]; + readonly trailing: Uint8Array | null; + + constructor(bytes: Uint8Array) { + const read = readChunks(bytes); + this.chunks = read.chunks; + this.trailing = read.trailing; + } + + /** The last occurrence's bytes, or null. */ + get(name: string): Uint8Array | null { + for (let i = this.chunks.length - 1; i >= 0; i--) if (this.chunks[i].name === name) return this.chunks[i].data; + return null; + } + + /** Every occurrence's bytes one after another: how the game reads a repeated TRIG. */ + all(name: string): Uint8Array { + const parts = this.chunks.filter((c) => c.name === name).map((c) => c.data); + if (parts.length === 1) return parts[0]; + const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0)); + let at = 0; + for (const p of parts) { out.set(p, at); at += p.length; } + return out; + } + + /** Replace a name's last occurrence (the others, if any, are removed), or add it at the end. */ + set(name: string, data: Uint8Array): void { + let last = -1; + for (let i = this.chunks.length - 1; i >= 0; i--) if (this.chunks[i].name === name) { last = i; break; } + if (last < 0) { this.chunks.push({ name, data }); return; } + this.chunks[last] = { name, data }; + for (let i = last - 1; i >= 0; i--) if (this.chunks[i].name === name) this.chunks.splice(i, 1); + } + + /** Put `name` where `old` is (the last occurrence), and take `old` out. */ + replace(old: string, name: string, data: Uint8Array): void { + let last = -1; + for (let i = this.chunks.length - 1; i >= 0; i--) if (this.chunks[i].name === old) { last = i; break; } + if (last < 0) { this.set(name, data); return; } + this.chunks[last] = { name, data }; + for (let i = this.chunks.length - 1; i >= 0; i--) if (this.chunks[i].name === old || (this.chunks[i].name === name && i !== last)) this.chunks.splice(i, 1); + } + + bytes(): Uint8Array { + return writeChunks(this.chunks, this.trailing); + } +} + +/** + * The string table: `entries[i]` is string `i + 1`'s bytes, without the terminator; string 0 is no string. An STR + * section counts and offsets in two bytes, an STRx (Remastered) in four, both from the section's start. + */ +export interface StringTable { + entries: Uint8Array[]; +} + +export function readStrings(data: Uint8Array, extended: boolean): StringTable { + const width = extended ? 4 : 2; + const view = new DataView(data.buffer, data.byteOffset, data.byteLength); + if (data.length < width) return { entries: [] }; + const count = extended ? view.getUint32(0, true) : view.getUint16(0, true); + const entries: Uint8Array[] = []; + for (let i = 0; i < count; i++) { + const at = width * (1 + i); + const offset = at + width <= data.length ? (extended ? view.getUint32(at, true) : view.getUint16(at, true)) : 0; + if (!offset || offset >= data.length) { entries.push(new Uint8Array(0)); continue; } + let end = offset; + while (end < data.length && data[end] !== 0) end++; + entries.push(data.slice(offset, end)); + } + return { entries }; +} + +const same = (a: Uint8Array, b: Uint8Array) => a.length === b.length && a.every((x, i) => x === b[i]); + +/** The 1-based index of a text among the strings — the first that holds these bytes, or a new one at the end. */ +export function intern(table: StringTable, text: string): number { + const bytes = utf8.encode(text); + const i = table.entries.findIndex((e) => same(e, bytes)); + if (i >= 0) return i + 1; + table.entries.push(bytes); + return table.entries.length; +} + +/** A string of the table's own, never shared: a new entry whatever the others hold. */ +export function addString(table: StringTable, text: string): number { + table.entries.push(utf8.encode(text)); + return table.entries.length; +} + +/** + * The table as an STRx section, each string's bytes once (two strings alike share an offset, as the editor writes + * them), and where each string's bytes start — so that a template can be written over its own string + * (`compiler/backend/build.ts`). + */ +export function writeStrings(table: StringTable): { data: Uint8Array; offsets: number[] } { + const count = table.entries.length; + const header = 4 * (1 + count); + const placed = new Map(); + const offsets: number[] = []; + let size = header; + for (const e of table.entries) { + const key = latin1.decode(e); + const at = placed.get(key); + if (at !== undefined) { offsets.push(at); continue; } + placed.set(key, size); + offsets.push(size); + size += e.length + 1; + } + const data = new Uint8Array(size); + const view = new DataView(data.buffer); + view.setUint32(0, count, true); + const written = new Set(); + table.entries.forEach((e, i) => { + view.setUint32(4 * (1 + i), offsets[i], true); + if (written.has(offsets[i])) return; + written.add(offsets[i]); + data.set(e, offsets[i]); + }); + return { data, offsets }; +} + +/** The map's players as a map of our own needs them: who plays (OWNR 5 computer, 6 human) and in which force (FORC). */ +export interface MapFacts { + width: number; + height: number; + /** The slots a human or a computer plays, 0-based. */ + slots: number[]; + humans: number[]; + /** Each of the eight slots' force, 0-based. */ + forces: number[]; + /** The file's revision (VER). */ + version: number; +} + +export function readFacts(s: Sections): MapFacts { + const dim = s.get("DIM "); + if (!dim || dim.length < 4) throw new Error("The map has no size (DIM)."); + const view = new DataView(dim.buffer, dim.byteOffset, dim.byteLength); + const ownr = s.get("OWNR") ?? new Uint8Array(12); + const forc = s.get("FORC") ?? new Uint8Array(20); + const ver = s.get("VER "); + const types = [...Array(8).keys()].map((p) => ownr[p] ?? 0); + return { + width: view.getUint16(0, true), + height: view.getUint16(2, true), + slots: [0, 1, 2, 3, 4, 5, 6, 7].filter((p) => types[p] === 5 || types[p] === 6), + humans: [0, 1, 2, 3, 4, 5, 6, 7].filter((p) => types[p] === 6), + forces: [0, 1, 2, 3, 4, 5, 6, 7].map((p) => forc[p] ?? 0), + version: ver && ver.length >= 2 ? ver[0] | (ver[1] << 8) : 0, + }; +} + +/** The map's own triggers, every TRIG occurrence read on as the game reads them. */ +export function readTriggers(s: Sections): TriggerRecord[] { + const trig = s.get("TRIG"); + return trig ? decodeTriggers(s.all("TRIG")) : []; +} + +/** The unit types the map's placed units and its triggers name: a carrier of the players' input must be none of them. */ +export function unitTypesUsed(s: Sections, triggers: readonly TriggerRecord[]): Set { + const used = new Set(); + const unit = s.get("UNIT"); + if (unit) { + const view = new DataView(unit.buffer, unit.byteOffset, unit.byteLength); + for (let o = 0; o + 36 <= unit.length; o += 36) used.add(view.getUint16(o + 8, true)); + } + for (const t of triggers) { + for (const c of t.conditions) used.add(c.unitId); + for (const a of t.actions) used.add(a.unitId); + } + return used; +} + +export const CUWP_SLOTS = 64; +const CUWP_STRIDE = 20; +const IN_TRANSIT = 4; + +/** The first unit-properties slot (1-based) no Create Unit with Properties of the map's triggers names. */ +export function freeCuwpSlot(triggers: readonly TriggerRecord[]): number { + const used = new Set(); + for (const t of triggers) for (const a of t.actions) if (a.type === ActionType.CreateUnitWithProperties) used.add(a.target); + for (let slot = 1; slot <= CUWP_SLOTS; slot++) if (!used.has(slot)) return slot; + return CUWP_SLOTS; +} + +/** The slot (1-based) marked in transit in UPRP — the carriers of the players' input are made with it — and in use in UPUS where the map has one. */ +export function markInTransit(s: Sections, slot: number): void { + const uprp = new Uint8Array(Math.max(CUWP_SLOTS * CUWP_STRIDE, s.get("UPRP")?.length ?? 0)); + const had = s.get("UPRP"); + if (had) uprp.set(had); + const view = new DataView(uprp.buffer); + const at = (slot - 1) * CUWP_STRIDE; + view.setUint16(at, view.getUint16(at, true) | IN_TRANSIT, true); + view.setUint16(at + 14, view.getUint16(at + 14, true) | IN_TRANSIT, true); + s.set("UPRP", uprp); + const upus = s.get("UPUS"); + if (upus && upus.length >= slot) { const next = upus.slice(); next[slot - 1] = 1; s.set("UPUS", next); } +} diff --git a/compiler/backend/emit.ts b/compiler/backend/emit.ts new file mode 100644 index 0000000..ab41e52 --- /dev/null +++ b/compiler/backend/emit.ts @@ -0,0 +1,6066 @@ +/** + * The emitter (TrigScript 4, the backend): a program's body lowered whole to one graph of triggers (`graph.ts`, + * `emitBody`), which the layout (`layout.ts`) puts into the map. A statement it does not take is a refusal, said in + * the script's words with its line, and the map is not built: there is no other backend. (Until step 17 of the way + * off eudplib there was — the emitter took the loops it could and left the rest to a lowering in Python — and the + * shape of the graph, places and copies, dates from then.) + * + * Values are written where they are used. A number has *places*: its variable's cell, the dwords that hold it plus a + * constant for conditions to read as memory (a comparison reads its left side so, the constant making a signed order + * an unsigned one), and every number field of a trigger that reads it — a condition's number, an action's number. A + * write writes every place: `i += 1` with i read in four places is five actions in one trigger, where eudplib adds to + * i's cell and then routes the cell to each reader. `x = y + z` sets each of x's places from y and adds z to each, so + * y's places include the numbers of those actions, which y's own writes keep. A write is laid out after every write + * that reads its variable is (they make its places); a variable in a loop of such reads — `x = y` and `y = x` — or set + * from itself other than by adding is written into its cell alone and copied into the rest after. A place may hold any + * constant times the variable, so a product by a constant and a shift by one cost nothing more than a sum. + * + * The layout is the emitter's own: a loop tests whether to leave, which fails on every turn but the last, so a turn + * falls through one trigger whose conditions failed (13.4 ns); a branch is a trigger that sets its own next pointer to + * where it goes, put back by an action at the place it went to. + */ +import { STACK_DEPTH, type ArrayDecl, type BoolExpr, type Call, type FuncDecl, type NumExpr, type ProgramErrors, type ReadSource, type Stmt, type TableCell, type TextPart, type VarDecl, type At } from "../ir"; +import type { Range } from "../ranges"; +import { PlayerGroup, type ActionRecord, type ConditionRecord } from "../../vendor/triggers"; +import { actionDef } from "../../vendor/triggerDefs"; +import { OVERFLOW_FN, errorLine } from "./errors"; +import { AFTER_END, END, MOST_ACTS, MOST_CONDS, NOW, PASS_END, STACK, SWITCHES, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, checkGraph, nextOf, type Act, type ActPart, type Compare, type Cond, type Dword, type Graph, type Num, type Region, type Trigger } from "./graph"; + +/** The game's tick (frames since the game started), and the fields of the trigger a map of our own wakes by. */ +export const GAME_TICK = 0x57f23c; +export const WAKE_TICK = "@wake.tick", WAKE_AT = "@wake.at", WAKE_NEXT = "@wake.next"; +const FRAMES_PER_SECOND = 24; +const U32 = 2 ** 32; +const SIGN = 2 ** 31; +const MAX = U32 - 1; +const u = (n: number) => ((n % U32) + U32) % U32; + +/** a × b, wrapping at 32 bits, both read from 0 up. */ +const mul = (a: number, b: number) => u(Math.imul(a, b)); + +/** a / b or a % b as the simulator has them: towards zero, the remainder with a's sign, or (`unsigned`) read from 0 up; by 0, 0. */ +function quotient(op: "/" | "%", a: number, b: number, unsigned: boolean): number { + if (u(b) === 0) return 0; + const [x, y] = unsigned ? [u(a), u(b)] : [a | 0, b | 0]; + return op === "/" ? Math.trunc(x / y) : x % y; +} + +/** A number as the emitter has it: a constant plus variables, each times a constant (read from 0 up, wrapping). */ +interface Linear { c: number; terms: { id: string; k: number }[] } + +function scaled(l: Linear, by: number): Linear { + return { c: mul(l.c, by), terms: l.terms.map((t) => ({ id: t.id, k: mul(t.k, by) })) }; +} + +/** The same terms of one variable added together; none times 0. */ +function merged(c: number, terms: { id: string; k: number }[]): Linear { + const by = new Map(); + for (const t of terms) by.set(t.id, u((by.get(t.id) ?? 0) + t.k)); + return { c: u(c), terms: [...by].filter(([, k]) => k !== 0).map(([id, k]) => ({ id, k })) }; +} + +function linear(e: NumExpr, cell?: (e: Extract, sides?: [Linear, Linear]) => string | null): Linear | null { + switch (e.kind) { + case "const": return e.fn ? null : { c: u(e.value), terms: [] }; + case "var": return { c: 0, terms: [{ id: e.id, k: 1 }] }; + // (A cell the caller reads into a variable of its own first, when it can.) + case "element": { + const id = cell?.(e); + return id ? { c: 0, terms: [{ id, k: 1 }] } : null; + } + case "cast": return e.to === "fixed" ? null : linear(e.expr, cell); + // (A random number below a bound: the caller draws it first, when it can — the bound read.) + case "randomInt": { + if (!cell) return null; + const bound = linear(e.bound, cell); + if (!bound) return null; + const id = cell(e, [bound, { c: 0, terms: [] }]); + return id ? { c: 0, terms: [{ id, k: 1 }] } : null; + } + // (A product or a quotient of numbers with a fraction: the caller works it out first, when it can — its sides read.) + case "intrinsic": { + // (A square root, of one value: the caller works it out first, when it can.) + if (cell && e.name === "sqrt" && e.args.length === 1) { + const a = linear(e.args[0], cell); + const id = a && cell(e, [a, { c: 0, terms: [] }]); + return id ? { c: 0, terms: [{ id, k: 1 }] } : null; + } + if (!cell || (e.name !== "fmul" && e.name !== "fdiv") || e.args.length !== 2) return null; + const a = linear(e.args[0], cell), b = linear(e.args[1], cell); + if (!a || !b) return null; + const id = cell(e, [a, b]); + return id ? { c: 0, terms: [{ id, k: 1 }] } : null; + } + case "unary": { + const l = linear(e.expr, cell); + return l && scaled(l, MAX); + } + case "binary": { + // (A shift by 0 is the number itself; by 32 or more — the count read from 0 up, one below zero past it — `<<` and + // `>>>` leave nothing, as TrigScript has it where JavaScript shifts by the remainder.) + if ((e.op === ">>" || e.op === ">>>" || e.op === "<<") && e.right.kind === "const" && !e.right.fn) { + const count = u(e.right.value); + if (count === 0) return linear(e.left, cell); + // (Only of a side the emitter takes: a call in it is taken out first, as anywhere.) + if (count >= 32 && e.op !== ">>") return linear(e.left, cell) ? { c: 0, terms: [] } : null; + } + // (A shift right by a constant: the caller reads it into a variable of its own first, when it can.) + if ((e.op === ">>" || e.op === ">>>") && cell) { + const id = cell(e); + if (id) return { c: 0, terms: [{ id, k: 1 }] }; + } + const a = linear(e.left, cell), b = linear(e.right, cell); + if (!a || !b) return null; + if (e.op === "+") return merged(a.c + b.c, [...a.terms, ...b.terms]); + if (e.op === "-") return merged(a.c - b.c, [...a.terms, ...scaled(b, MAX).terms]); + // A product with a constant, a shift left by one: the same 32 bits as the multiplication. + if (e.op === "*" && b.terms.length === 0) return merged(mul(a.c, b.c), scaled(a, b.c).terms); + if (e.op === "*" && a.terms.length === 0) return merged(mul(a.c, b.c), scaled(b, a.c).terms); + // (A shift by a count that comes to a constant, `x >> (n - n)`: by 0 the number itself, of a constant worked out, + // by 32 or more as TrigScript has it.) + if ((e.op === ">>" || e.op === ">>>") && b.terms.length === 0) { + const count = u(b.c); + if (count === 0) return a; + if (a.terms.length === 0) return { c: u(count >= 32 ? (e.op === ">>" ? (a.c | 0) >> 31 : 0) : e.op === ">>" ? (a.c | 0) >> count : a.c >>> count), terms: [] }; + if (count >= 32 && e.op === ">>>") return { c: 0, terms: [] }; + } + if (e.op === "<<" && b.terms.length === 0) { + if (u(b.c) >= 32) return { c: 0, terms: [] }; // (a, read above, is one the emitter takes) + const by = u(2 ** b.c); + return merged(mul(a.c, by), scaled(a, by).terms); + } + if ((e.op === "/" || e.op === "%") && a.terms.length === 0 && b.terms.length === 0) return { c: u(quotient(e.op, a.c, b.c, !!e.unsigned)), terms: [] }; + // (A product of two values, a quotient, a remainder: the caller works it out into a variable of its own first, + // when it can — its sides already read, so that what they read comes first.) + if (cell && (e.op === "*" || e.op === "/" || e.op === "%" || e.op === "&" || e.op === "|" || e.op === "^" || ((e.op === "<<" || e.op === ">>" || e.op === ">>>") && b.terms.length > 0))) { + const id = cell(e, [a, b]); + if (id) return { c: 0, terms: [{ id, k: 1 }] }; + } + return null; + } + default: return null; + } +} + +/** + * One variable plus a constant, or a constant: what a comparison's side can be — or a variable's bits under a constant + * mask (`x & K`), which a condition reads as they are, against a constant. + */ +type Side = { id: string; c: number; mask?: number } | { k: number }; +function side(e: NumExpr): Side | null { + if (e.kind === "binary" && e.op === "&") { + const [v, k] = e.right.kind === "const" ? [e.left, e.right] : e.left.kind === "const" ? [e.right, e.left] : [null, null]; + const l = v && linear(v); + if (l && k && k.kind === "const" && l.c === 0 && l.terms.length === 1 && l.terms[0].k === 1) return { id: l.terms[0].id, c: 0, mask: u(k.value) }; + return null; + } + const l = linear(e); + if (!l) return null; + if (l.terms.length === 0) return { k: l.c }; + if (l.terms.length === 1 && l.terms[0].k === 1) return { id: l.terms[0].id, c: l.c }; + return null; +} + +// ── what a map of our own refuses, said ── + +/** Why an array is not one a map of our own keeps (`nativeArray`), in a script's words. */ +function arrayWhy(a: ArrayDecl): string | undefined { + if (a.texts) return "an array of texts"; + if (a.dynamic || a.through) return "an array that grows"; + if (a.kind !== "number" && a.kind !== "boolean") return `an array of ${a.kind}s`; + return undefined; +} + +/** + * The first thing in a statement a map of our own refused that its emitter never takes, in a script's words: the + * reason a refusal gives when the checks that refused it gave none ("a product of two values", "the action Create + * Unit"). Inner parts before the whole, in the order they are written; undefined when every part is one the emitter + * takes somewhere, and the reason lies in how they are put together. + */ +export function unlowered(s: Stmt, decls: Map, arrays: Map): string | undefined { + const variable = (id: string): string | undefined => { + const d = decls.get(id); + if (!d) return undefined; + if (d.kind === "unit") return "a unit kept in a variable"; + // (A program with `try` or `throw` keeps the message of the error in flight: what it is refused for is the error.) + if (d.kind === "text") return d.name === "(error message)" ? "an error thrown or caught (try, throw)" : d.name.startsWith("(") ? "a text made while the game runs" : "a text kept in a variable"; + if (d.task !== undefined) return "a variable of a task's"; + if (d.held) return "a variable a closure keeps"; + return undefined; + }; + const array = (id: string): string | undefined => { + const a = arrays.get(id); + return a && arrayWhy(a); + }; + const plainSide = (e: NumExpr): boolean => linear(e) !== null || e.kind === "element"; + const isConst = (e: NumExpr): boolean => linear(e)?.terms.length === 0; + const binary = (e: Extract): string | undefined => { + if (linear(e) || !plainSide(e.left) || !plainSide(e.right)) return undefined; + const byConst = isConst(e.right); + switch (e.op) { + case "<<": case ">>": case ">>>": return byConst ? `a shift by a constant (${e.op}) as a value` : undefined; + default: return undefined; + } + }; + const boolOnly = new Set(["compare", "and", "or", "not", "test", "cond", "random", "running", "unitAlive", "unitSame", "unitFlag", "edge", "textCompare", "textTest"]); + // Expressions: what each kind is, when the emitter never takes it. + const node = (o: Record): string | undefined => { + switch (o.kind) { + case "binary": return binary(o as Extract); + case "intrinsic": { + const name = o.name as string; + if (name === "fmul" || name === "fdiv" || name === "sqrt" || name === "sin" || name === "cos" || name === "atan2") return undefined; + return name === "min" || name === "max" || name === "abs" ? undefined : `Math.${name}`; + } + case "read": { + const r = o.read as { source: string; record?: { type: number }; fact?: string; of?: string }; + if (r.source === "condition") return undefined; + if (r.source === "player") return undefined; + return undefined; + } + case "cond": return undefined; + case "tableRead": return (o.cell as TableCell).special ? `a read of ${(o.cell as { name: string }).name}` : undefined; + case "input": return `the players' input (${(o.input as { source: string }).source})`; + case "length": return array(o.array as string) ? "the length of an array that grows" : undefined; + case "pop": return "pop()"; + case "running": return "a task's running"; + case "taskHandle": return "a task"; + case "edge": return `${o.edge as string}()`; + case "unitField": return `a unit's ${o.field as string}`; + case "unitPart": return "a unit's place in the game's memory"; + case "unitAlive": return "whether a unit is alive"; + case "unitSame": return "two units compared"; + case "unitFlag": return `a unit's flag ${o.flag as string}`; + case "unitNull": case "unitVar": case "pick": case "unitAt": case "selected": return "a unit"; + case "textLength": return "a text's length"; + case "textIndexOf": return "a search in a text"; + case "textCode": return "a character's code"; + case "textCompare": return "two texts compared"; + case "textTest": return `a text's ${o.test as string}`; + case "textSlice": return "a part of a text (slice)"; + case "textPad": return "a text padded (padStart, padEnd)"; + case "textRepeat": return "a text repeated"; + case "textTernary": return "a ?: of texts"; + case "template": return "a text made while the game runs"; + case "textCall": { + // (A method of a text the front end writes as a function of its own — `trim()` — or a function of the script's.) + const name = (o.call as { name: string }).name; + if (!TEXT_METHODS.has(name.split(".").pop()!)) return `a function that gives back a text (${name})`; + return name.includes(".") ? `${name}()` : `a text's ${name}()`; + } + case "textVar": case "textOf": case "textAt": case "built": + return "a text of the program's"; + case "ternary": { + const t = o.whenTrue as { kind: string; value?: unknown; id?: string }; + const boolean = boolOnly.has(t.kind) || typeof t.value === "boolean" || (t.kind === "var" && decls.get(t.id!)?.kind === "boolean"); + return boolean ? "a ?: of booleans" : undefined; + } + case "var": return typeof o.id === "string" ? variable(o.id) : undefined; + case "element": return array(o.array as string); + default: return undefined; + } + }; + // Statements: what each kind is, before what is in it. + const statement = (t: Stmt): string | undefined => { + switch (t.kind) { + case "action": { + if (gameAction(t)) return undefined; + if (t.record.type === WAIT_ACTION) return "the action Wait (in a program it holds only its own trigger and takes the player's wait from Transmissions: a program waits with sleep())"; + if (t.record.type === SET_DEATHS_ACTION && t.record.player > LAST_GROUP) return "a write of memory (setMemory())"; + const name = `the action ${actionDef(t.record.type)?.name ?? t.record.type}`; + return t.variables?.length ? `${name}, its fields from values` : t.text ? `${name}, its text the program's` : name; + } + case "tableWrite": return t.cell.special === "name" && (t.value as { kind: string }).kind !== "text" ? `a write of ${t.cell.name} made while the game runs` : undefined; + case "centerLocation": return undefined; + case "select": return "select()"; + case "syncSend": return "sync()"; + case "push": return "push()"; + case "pop": return "pop()"; + case "setLength": return "an array's length set"; + case "assignText": case "storeText": case "releaseText": return "a text of the program's"; + case "textLoop": return "a loop over a text"; + case "buildText": return "JSX made while the game runs"; + case "assignUnit": return "a unit kept in a variable"; + case "unitLoop": return t.source ? `units an event gives (${t.source})` : "a loop over units"; + case "unitWrite": return `a unit's ${t.field} written`; + case "unitDo": return `a unit's verb (${t.verb.do})`; + case "start": { + // (`on.*` is a task that listens: say which.) + const on = t.listen?.source ? { created: "on.created", died: "on.died", killed: "on.killed", ordered: "on.order", morphed: "on.morphed" }[t.listen.source] : t.listen?.inputs?.length ? (t.listen.inputs[0].source === "chat" ? "on.chat" : "on.key") : undefined; + return on ? `an event handled (${on})` : "a task started"; + } + case "cancel": return "a task cancelled"; + case "parallel": return t.mode === "race" ? "Promise.race or Promise.any" : "Promise.all"; + case "try": return "try"; + case "throw": return "throw"; + case "uncaught": return "an error nothing catches"; + case "print": return undefined; + case "declare": case "assign": case "assignBool": return variable(t.kind === "declare" ? t.decl.id : t.target); + case "declareArray": case "store": return array(t.array); + default: return undefined; + } + }; + let found: string | undefined; + const walk = (x: unknown): void => { + if (found || !x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(walk); return; } + const o = x as Record; + // (A statement says what it is before what is in it; an expression after, so that the innermost part is named.) + const isStmt = "label" in o && typeof o.kind === "string" && STATEMENT_KINDS.has(o.kind); + if (isStmt) found = statement(o as unknown as Stmt); + // (What is read of a unit says more than the unit: `u.hp` is "a unit's hp", not "a unit kept in a variable".) + else if (typeof o.kind === "string" && UNIT_READS.has(o.kind)) found = node(o); + for (const [k, v] of Object.entries(o)) if (k !== "at" && k !== "original" && v && typeof v === "object") walk(v); + if (!found && !isStmt && typeof o.kind === "string") found = node(o); + }; + walk(s); + return found; +} + +/** What is read of a unit, and what is done to a text: named before the unit or the text it is done to. */ +const UNIT_READS = new Set(["unitField", "unitFlag", "unitAlive", "unitSame", "unitPart", "textLength", "textIndexOf", "textCode", "textCompare", "textTest", "textSlice", "textPad", "textRepeat", "textTernary", "textCall"]); +/** The methods of a text the front end writes as functions of its own (`textCall`). */ +const TEXT_METHODS = new Set(["toUpperCase", "toLowerCase", "toLocaleUpperCase", "toLocaleLowerCase", "trim", "trimStart", "trimEnd", "replace", "replaceAll", "split", "join", "at", "charAt", "concat", "parseInt", "Number"]); +/** Set Deaths, which `setMemory()` is when its player is past the groups (26). */ +const SET_DEATHS_ACTION = 45, LAST_GROUP = 26; +const WAIT_ACTION = 4, TRANSMISSION_ACTION = 7, SWITCH_CONDITION = 11, ALWAYS_CONDITION = 22, NEVER_CONDITION = 23; +/** + * A map of our own (TrigScript 4, step 5 of the way off eudplib): the game's conditions and actions it lays out as the + * script wrote them — `memory()` and `setMemory()` at an address known when the map is built among them, a Deaths and + * a Set Deaths whose player is the address's EPD; no action with a field from a value or a text the program made; not + * Wait, which would hold the owner's whole trigger list. + */ +const gameAction = (s: Extract): boolean => + (!s.text || (s.text.kind === "template" && MADE_TEXT.has(s.record.type)) || (s.text.kind === "text" && s.record.type === SET_DEATHS_ACTION)) && s.record.type !== WAIT_ACTION && fieldsTaken(s); +/** + * The actions whose text may be made while the game runs (a template filled before it, as a print's): Transmission, + * Display Text, the mission objectives and the leaderboards' labels — the game reads such a text again whenever it + * draws, from the template the action names, one for each place in the script it is written. + */ +const MADE_TEXT = new Set([7, 9, 12, 17, 18, 19, 20, 21, 33, 34, 35, 36, 37, 40]); +/** + * Where an action's field from a value goes (`Emitter.action`): the dword of the record its place is, by the part of + * an action `graph.ts` names it by. The unit type, the action's kind and the count share one; a value in it is the + * type, or the count times 2^24, or (a 32-bit unit type, `Rewriter.action`'s) both. + */ +const FIELD_PART: Partial> = { location: "mask", text: "text", wav: "wav", time: "time", player: "dest", target: "value", unitId: "modifier", modifier: "modifier" }; +/** Every field from a value is one a place can be, each one variable times a constant plus a constant, one a dword. */ +function fieldsTaken(s: Extract): boolean { + const vs = (s.variables ?? []).filter((v) => linear(v.expr)?.terms.length !== 0); + if (!(s.variables ?? []).every((v) => FIELD_PART[v.field] && (linear(v.expr)?.terms.length ?? 2) <= 1)) return false; + return new Set(vs.map((v) => FIELD_PART[v.field])).size === vs.length; +} +/** + * The actions after which Bring and Command read the counts they found before: the game keeps a table of them while the + * queries stay of one kind (and, for Bring, one location), throwing it away at the start of a pass, at a query of the + * other kind, and in the actions that kill or remove units — not in these (Create Unit with Properties, Move Unit, + * Create Unit, Give Units). The Python side's `MOVES_UNITS`; its `forget_counts` covers Bring alone. + */ +const MOVES_UNITS = new Set([11, 39, 44, 48]); +/** + * A number of the game's read into a variable (a map of our own, step 5 part 3): what a condition can be asked. Where + * the game keeps it for one player — deaths, kills, ore, gas — the dword is read where it is; otherwise the condition + * is asked a bit at a time from the top (`Emitter.readGame`). The Python side's `read_condition`. + */ +const DEATHS_TABLE = 0x58a364, KILLS_TABLE = 0x5878a4, ORE_TABLE = 0x57f0f0, GAS_TABLE = 0x57f120; +/** An address as the player field of a Deaths names it. */ +const epdOf = (addr: number) => u((addr - DEATHS_TABLE) / 4); +const CURRENT_PLAYER = 13, DEATHS_TYPE = 15, KILL_TYPE = 5, ACCUMULATE_TYPE = 4, OPPONENTS_TYPE = 14; +const gameRead = (r: ReadSource): r is Extract => r.source === "condition"; +/** + * Where the game keeps a condition's number for one player, when it does: its address, and the bits of it the condition + * reads (a masked Deaths, `memory()` with a mask: its location field, 'SC' in its last). A Deaths of a player past the + * groups is `memory()`: the dword its player field is the EPD of. + */ +function tableOf(rec: ConditionRecord, owner: number | undefined): { at: number; mask: number } | undefined { + const masked = rec.type === DEATHS_TYPE && rec.mask === MASKED_MARK; + const mask = masked ? u(rec.location) : U32 - 1; + if (rec.type === DEATHS_TYPE && rec.player > LAST_GROUP && rec.unitId < 228) return { at: u(DEATHS_TABLE + 4 * (rec.unitId * 12 + rec.player)), mask }; + const p = rec.player === CURRENT_PLAYER ? owner : rec.player <= 11 ? rec.player : undefined; + if (p === undefined || (masked && rec.type !== DEATHS_TYPE)) return undefined; + if ((rec.type === DEATHS_TYPE || rec.type === KILL_TYPE) && rec.unitId < 228) return { at: (rec.type === DEATHS_TYPE ? DEATHS_TABLE : KILLS_TABLE) + 4 * (rec.unitId * 12 + p), mask }; + if (rec.type === ACCUMULATE_TYPE && (rec.resource === 0 || rec.resource === 1)) return { at: (rec.resource === 0 ? ORE_TABLE : GAS_TABLE) + 4 * p, mask }; + return undefined; +} +/** The mark a masked condition carries in its last two bytes ('SC'), its mask in its location field. */ +const MASKED_MARK = 0x4353; +/** A player's bytes (slot, race) and the supply tables, by race (36 dwords apart), as the Python side reads them. */ +const PLAYER_BYTES = { slot: 0x57f1b4, race: 0x57f1c0 } as const; +const SUPPLY_TABLES = { provided: 0x582144, used: 0x582174, max: 0x5821a4 } as const; +/** A dword of memory, or its bits of a mask, read as a value: a masked Deaths whose player is the dword's EPD. */ +function memoryRead(addr: number, mask: number | undefined, where: { at: At; label: string }): NumExpr { + const record: ConditionRecord = { location: mask ?? 0, player: u((addr - DEATHS_TABLE) / 4), amount: 0, unitId: 0, comparison: 0, type: DEATHS_TYPE, resource: 0, flags: 0, mask: mask === undefined ? 0 : MASKED_MARK }; + return { kind: "read", read: { source: "condition", record }, ...where }; +} + +/** The conditions that read that table: Command, Bring, Command the Most (at), Command the Least (at). */ +const COUNTING = new Set([2, 3, 6, 7, 16, 17]); +/** + * What throws the table away after such an action, in its own trigger: Kill Unit of Player 12's units of type 228, + * which no unit is (played in the lifted game, 2026-10-20: Command and Bring both count again after it, and Player 12's + * minerals stay). + */ +const FORGET_COUNTS: ActionRecord = { location: 0, text: 0, wav: 0, time: 0, player: 11, target: 0, unitId: 228, type: 22, modifier: 0, flags: 0x10, padding: 0, mask: 0 }; +/** + * A print at the centre of the screen (`Emitter.centre`): the game's line of errors (the thirteenth of the text lines, + * 218 bytes each), the game's first free unit (0 makes Create Unit fail with "Cannot create more units", which shows + * that line), and the player at this computer. + */ +const CENTRE_LINE = 0x640b60 + 218 * 12, CENTRE_BYTES = 218, FIRST_FREE_UNIT = 0x628438, LOCAL_PLAYER = 0x512684; +/** The players' trigger lists: 12 bytes each, +4 the last node, +8 the first (the list's own end, ~(list + 4), when empty). */ +export const TRIGGER_LISTS = 0x51a280; +/** Where the game keeps the current player: whom Display Text shows to, and what "the current player" names. */ +const CURRENT_PLAYER_ADDRESS = 0x6509b0; +/** The game's unit table: its places, how far apart, where the first one is (`watch`). */ +const UNIT_PLACES = 1700, UNIT_STRIDE = 336, UNIT_TABLE_ADDRESS = 0x59cca8, UNIT_TABLE_EPD = (UNIT_TABLE_ADDRESS - 0x58a364) / 4; +/** The events pass's variables the watch writes (`units.ts`'s): the record's EPD, the trigger's first condition's number, the boundary. */ +const EVENT_RECORD = "@ev.e", EVENT_FIELDS = "@ev.wf", EVENT_BOUNDARY = "@ev.be"; +/** The handler's word to the watch's gate (`units.ts`'s): how many units came on, whether any came on or went, and a bit for each player that had one. */ +const EVENT_MADE_N = "@ev.madeN", EVENT_TOUCHED = "@ev.touch", EVENT_OWNERS = "@ev.own"; +/** + * The game's counts the gate watches (TrigScript 4, step 11's quiet frames): a dword a player (0 to 11) of units and of + * buildings, counted as the unit is made and taken off as it starts to die or is removed — the "Units" and "Buildings" + * of the score screen (0x488CF6 in 1.16.1: men and every other unit but an egg, a cocoon and a lurker egg in the one, + * buildings in the other; incomplete ones counted). + */ +const UNIT_COUNTS = 0x581de4, BUILDING_COUNTS = 0x581f34, GATE_PLAYERS = 12; +/** + * The map's player settings, as a map of our own needs them (TrigScript 4, step 7): the slots a human or a computer + * plays (0-based, P1 to P8), and each slot's force (0 to 3). Who a program runs for, who All Players and a force are, + * whether a player can have left. + */ +export interface MapPlayers { slots: number[]; forces: number[] } +/** With no map: the eight slots, all in the first force (a new map's settings). */ +export const ANY_PLAYERS: MapPlayers = { slots: [0, 1, 2, 3, 4, 5, 6, 7], forces: [0, 0, 0, 0, 0, 0, 0, 0] }; + +/** + * The players a group stands for, a slot each, as the Python side's `group_slots` has them: a player is itself; All + * Players and a force the slots of the map a human or a computer plays (of that force). Null for anything else. + */ +export function groupSlots(group: number, players: MapPlayers): number[] | null { + if (group >= 0 && group < 8) return [group]; + if (group === PlayerGroup.AllPlayers) return [...players.slots]; + if (group >= PlayerGroup.Force1 && group <= PlayerGroup.Force4) return players.slots.filter((p) => players.forces[p] === group - PlayerGroup.Force1); + return null; +} + +/** The players a print goes to, a slot each (a map of our own): the owner for the current player, else the group's. */ +function playersOf(to: number, owner: number | undefined, players: MapPlayers = ANY_PLAYERS): number[] | null { + if (to === PlayerGroup.CurrentPlayer) return [owner ?? 0]; + return groupSlots(to, players); +} + +/** A unit type's flingy, and the flingy tables a top speed is written into (the Python side's). */ +const UNITS_FLINGY = 0x6644f8, FLINGY_CONTROL = 0x6c9858, FLINGY_SPEED = 0x6c9ef8, FLINGY_ACCELERATION = 0x6c9c78, FLINGY_HALT = 0x6c9930; +/** A player's minimap colour, this far after their colour (the Python side's `MINIMAP_COLOR_OFFSET`). */ +const MINIMAP_COLOR_OFFSET = 0x581dd6 - 0x581d76; +/** The game's locations in memory (MRGN): 20 bytes each — left, top, right, bottom, then the rest. */ +const MRGN = 0x58dc60; +/** The condition types whose comparison is a number's at least / at most, which one condition turns around. */ +const COUNTED = new Set([1, 2, 3, 4, 5, 12, 14, 15, 21]); + +/** + * A `max` or `min` against a constant that the value ranges already settle, as its other side: the front end stops an + * action's amount and count at 0 (`max(n, 0)`), which a counter never below it does not need. + */ +function settled(e: NumExpr): NumExpr { + if (e.kind !== "intrinsic" || (e.name !== "max" && e.name !== "min") || e.args.length !== 2 || e.unsigned) return e; + const [x, c] = e.args; + const r = (x as NumExpr & { range?: readonly [number, number] }).range; + if (c.kind !== "const" || !r) return e; + return (e.name === "max" ? r[0] >= c.value : r[1] <= c.value) ? settled(x) : e; +} + +/** About the bytes a printed text's template takes (`Emitter.template`): its texts, 12 for a number and 8 more for a fraction, a char 1, and the end. */ +function centreBytes(parts: TextPart[]): number { + return parts.reduce((n, p) => n + (p.kind === "text" ? new TextEncoder().encode(p.text).length : p.kind === "number" ? 15 + (p.fixed ? 9 : 0) : 1), 1); +} + +/** Statements with the blocks around them taken away, and the remarks. */ +function flatten(list: Stmt[]): Stmt[] { + return list.flatMap((t) => (t.kind === "block" ? flatten(t.body) : t.kind === "remark" ? [] : [t])); +} + +/** A condition of the game's in a trigger (`Cond.game`). */ +const gameCond = (r: ConditionRecord): Cond => ({ dword: { game: 0 }, compare: "atLeast", value: 0, game: r }); + +/** + * The conditions that hold exactly when a condition of the game's is `when`: itself; turned around, the one condition + * that holds when it does not — at least n against at most n − 1, a switch set against cleared — or null when there is + * none (exactly n, Command the Most, …). Always and Never are known either way. + */ +function gameConds(r: ConditionRecord, when: boolean): Cond[] | "true" | "false" | null { + // (A disabled one as it is: the game decides what it makes of it.) + if (r.flags & 0x02) return when ? [gameCond(r)] : null; + if (r.type === ALWAYS_CONDITION) return when ? "true" : "false"; + if (r.type === NEVER_CONDITION) return when ? "false" : "true"; + if (when) return [gameCond(r)]; + if (r.mask) return null; + if (r.type === SWITCH_CONDITION && (r.comparison === 2 || r.comparison === 3)) return [gameCond({ ...r, comparison: 5 - r.comparison })]; + if (!COUNTED.has(r.type)) return null; + const n = r.amount >>> 0; + if (r.comparison === 0) return n === 0 ? "false" : [gameCond({ ...r, comparison: 1, amount: n - 1 })]; + if (r.comparison === 1) return n === U32 - 1 ? "false" : [gameCond({ ...r, comparison: 0, amount: n + 1 })]; + return null; +} + +/** The kinds of statement, told apart from the expressions of the same name (`pop`, `call`). */ +export const STATEMENT_KINDS = new Set(["declare", "assignText", "storeText", "releaseText", "textLoop", "assign", "declareArray", "store", "push", "setLength", "assignBool", "assignUnit", "unitLoop", "unitWrite", "unitDo", "tableWrite", "if", "while", "do", "for", "unrolled", "switch", "break", "continue", "return", "sleep", "parallel", "start", "cancel", "action", "syncSend", "select", "centerLocation", "print", "buildText", "block", "try", "throw", "uncaught", "remark", "watch"]); + +// ── what the emitter takes ── + +class Scope { + /** Every variable a taken statement reads or writes, and those it declares. */ + read = new Set(); + written = new Set(); + declared = new Set(); + /** Why the last statement looked at was not taken. */ + why = ""; + /** Why each statement it refuses is refused (`refuse`), and whether that is a part it never takes. */ + whyOf = new Map(); + /** A map of our own: the slot of the program's owner, the current player while it runs. */ + owner: number | undefined; + /** A map of our own: the map's player settings. */ + players: MapPlayers = ANY_PLAYERS; + /** Whether a condition taken reads the game's counts, which an action that makes or moves units leaves behind (`MOVES_UNITS`). */ + counts = false; + readonly decls: Map; + constructor(decls: Map) { + this.decls = decls; + } + + num(e: NumExpr): boolean { + const l = linear(e); + if (!l) return this.no(`a ${e.kind === "binary" ? `${e.op} ` : ""}${e.kind}`); + for (const t of l.terms) { + if (!this.ok(t.id)) return this.no(`the variable ${this.decls.get(t.id)?.name ?? t.id}`); + this.read.add(t.id); + } + return true; + } + + bool(e: BoolExpr): boolean { + switch (e.kind) { + case "const": return true; + case "compare": { + if (e.unsigned === "left" || e.unsigned === "right") return this.bool(plain(e)); + // (A map of our own: a cell of an array it keeps against a constant, as 32 bits — `Rewriter.cellCompare`'s.) + if (e.left.kind === "element") { + const t = e.right.kind === "const" && e.unsigned === true ? this.cellAt(e.left.array, e.left.index) : null; + if (!t?.jump) return this.no("a cell compared otherwise than with a constant, as 32 bits"); + for (const x of t.index.terms) this.read.add(x.id); + return true; + } + const sm = signedMask(e); + if (sm) return this.bool(sm); + const a = side(e.left), b = side(e.right); + if (!a || !b) return this.no("a comparison of more than a variable and a constant"); + // (Bits under a mask, against a constant only.) + if (("mask" in a && a.mask !== undefined && !("k" in b)) || ("mask" in b && b.mask !== undefined && !("k" in a))) return this.no("bits under a mask against a variable"); + // (A signed reading of bits under a mask that has the sign bit is not one condition.) + if (e.unsigned !== true && e.op !== "==" && e.op !== "!=" && [a, b].some((x) => "mask" in x && x.mask !== undefined && x.mask >= SIGN)) return this.no("bits under a mask with the sign bit, ordered as signed"); + for (const x of [a, b]) if ("id" in x) { + if (!this.ok(x.id)) return this.no(`the variable ${this.decls.get(x.id)?.name ?? x.id}`); + this.read.add(x.id); + } + return true; + } + case "test": return this.bool(notZero(e.expr)); + case "var": return this.bool(notZero({ kind: "var", id: e.id })); + case "and": case "or": return e.items.every((x) => this.bool(x)); + case "not": return this.bool(e.expr); + case "cond": + if (e.epd) { + const l = linear(e.epd); + if (!l || l.terms.length > 1 || !l.terms.every((t) => this.ok(t.id))) return this.no("a condition's player it does not lower"); + for (const t of l.terms) this.read.add(t.id); + } + if (e.amount) { + const l = linear(e.amount.expr); + if (!l || l.terms.length > 1 || !l.terms.every((t) => this.ok(t.id))) return this.no("a condition's amount it does not lower"); + for (const t of l.terms) this.read.add(t.id); + } + if (COUNTING.has(e.record.type)) this.counts = true; + return true; + default: return this.no(`a ${e.kind}`); + } + } + + no(why: string): false { + this.why ||= why; + return false; + } + + ok(id: string): boolean { + if (isTemp(id)) return true; + const d = this.decls.get(id); + // (A boolean is a cell holding 1 or 0, as a number is.) + // (A `u8` / `u16` in a map of our own: every store into it stopped at its top first, `Rewriter.top`.) + // (A task's variable in a map of our own: each slot's own, `planTasks`.) + return !!d && (d.kind === "number" || d.kind === "boolean") && !d.held; + } + + stmts(list: Stmt[], inLoop: boolean): boolean { + return list.every((s) => this.stmt(s, inLoop)); + } + + /** The statements the emitter does not take (`refuse`): `emitBody` says the first of them, and why. */ + refused = new Set(); + functions = new Map(); + private named?: Set; + /** What the called functions name — their variables, parameters and results — worked out once. */ + inFunctions(): Set { + if (this.named) return this.named; + const out = new Set(); + const named = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(named); return; } + if (!x || typeof x !== "object") return; + const o = x as { kind?: string; id?: string; target?: string }; + if ((o.kind === "var" && typeof o.id === "string") || (o.kind === "assign" && typeof o.target === "string")) out.add((o.id ?? o.target)!); + for (const v of Object.values(o)) if (v && typeof v === "object") named(v); + }; + for (const f of this.functions.values()) { named(f.body); for (const p of f.params) out.add(p.id); if (f.result) out.add(f.result.decl.id); } + return (this.named = out); + } + /** The program's arrays, by id. */ + arrays = new Map(); + /** + * The statements whose value reads cells of lists known when the map is built: each cell read into a variable of + * the emitter's own first (`reads`), the value a sum of those and the program's variables. + */ + tables = new Map(); + /** A map of our own: the statements that set a variable to a number of the game's (`Emitter.readGame`). */ + gameReads = new Map(); + /** + * A number of the game's as a statement's value: a read, or (a byte of memory) its bits shifted down. `epd`: of the + * dword at a value's EPD (one variable plus a constant). + */ + readOf(e: NumExpr): { rec: ConditionRecord; shift: number; epd?: Linear } | undefined { + // (Or its bits moved up: times a power of two, a shift below 0 — a dword's bytes put where they go in another.) + const up = e.kind === "binary" && e.op === "*" && e.left.kind === "read" && e.right.kind === "const" && e.right.value > 1 && 2 ** Math.round(Math.log2(e.right.value)) === e.right.value; + const read = e.kind === "read" ? e : e.kind === "binary" && (e.op === ">>>" || up) && e.left.kind === "read" && e.right.kind === "const" ? e.left : undefined; + if (!read || read.kind !== "read" || !gameRead(read.read)) return undefined; + const by = read === e ? 0 : ((e as Extract).right as { value: number }).value; + const shift = read === e ? 0 : up ? -Math.round(Math.log2(by)) : by; + if (up && !read.read.epd) return undefined; + if (!read.read.epd) return { rec: read.read.record, shift }; + const epd = linear(read.read.epd); + if (!epd || epd.terms.length > 1 || !epd.terms.every((t) => this.ok(t.id))) return undefined; + for (const t of epd.terms) this.read.add(t.id); + return { rec: read.read.record, shift, epd }; + } + cells = 0; + /** The stores into cells of the program's arrays the emitter makes: the array, the index, the value (cells first). */ + stores = new Map(); + /** + * The variables the plan is likely to copy into their places rather than write there: each set from more than a sum + * (a product, a quotient, a cell, a call's result) or from itself times anything but 1. A field holding a multiple of + * one is filled a bit at a time on each copy, so a product reads it only where it is (`Emitter.product`). + */ + heavy = new Set(); + /** + * A map of our own: a called function's body as the emitter takes it (rewritten once, `emitBody`), and whether each + * function met so far is taken (`walking` while its body is looked at: a call of it then is a call of itself). + */ + bodyOf?: (id: string) => Stmt[] | undefined; + taken = new Map(); + /** What a `return` gives back where it stands: the kind of the function or inlined call around it, innermost last. */ + returning: string[] = []; + /** Whether a call that may come back into its own function is taken: the program then keeps a stack. */ + recursion = false; + /** A map of our own whose program throws or catches: where the error in flight is kept (`ownErrors`). */ + errors?: ProgramErrors; + /** A map of our own: each `start`'s slots (`planTasks`), and the starts that run once — one slot, never busy. */ + slots = new Map(); + once = new Set(); + + /** + * A call in a map of our own: its arguments written into the function's parameters, which are variables like any + * other, and its result copied into the call's own; a called function's body looked at once, an inlined call's body + * where it is. A call that may come back into the function it is in keeps that function's variables on the stack + * around it (`saves`): numbers and booleans, not an array's handle. + */ + call(c: Call): boolean { + if (c.saves) { + if (c.saves.arrays.length) return this.no(`a growing array declared in ${c.saves.within}, which calls itself`); + for (const id of c.saves.vars) if (!this.ok(id)) return this.no(`the variable ${this.decls.get(id)?.name ?? id} of ${c.saves.within}, which calls itself`); + this.recursion = true; + // (A program that keeps errors throws a RangeError where the stack runs out: the function that sets it.) + if (this.errors) { + if (!this.function(OVERFLOW_FN)) return false; + this.written.add(errorLine(this.errors)); + } + } + for (const p of c.params) { + const d = p.decl; + if (!this.ok(d.id)) return this.no(`the parameter ${d.name}, a ${d.kind}`); + if (d.kind === "boolean" ? !this.test(p.init as BoolExpr) : !this.lowers(p.init as NumExpr)) return this.no(`the value given to ${d.name}`); + if (d.kind !== "boolean") this.num(p.init as NumExpr); + this.declared.add(d.id); + this.written.add(d.id); + } + if (c.result) { + if (!this.ok(c.result.decl.id)) return this.no(`a call that gives back a ${c.result.kind}`); + this.heavy.add(c.result.decl.id); + this.declared.add(c.result.decl.id); + this.written.add(c.result.decl.id); + } + if (c.fn !== undefined) return this.function(c.fn); + this.returning.push(c.result?.kind ?? "void"); + try { + return this.stmts(c.body, false); + } finally { + this.returning.pop(); + } + } + + /** A called function: its body, looked at once (a call of it inside that body is taken as the body is). */ + function(id: string): boolean { + const f = this.functions.get(id), state = this.taken.get(id); + if (state === "walking") return true; + if (state !== undefined) return state || this.no(`a call of ${f?.name ?? id}`); + const body = this.bodyOf?.(id); + if (!f || !body) return this.no(`a call of ${id}`); + // (A called function is laid out once, outside every coroutine: one that waits could not say whose turn it gives back.) + if (this.slots.size && holdsKind(body, "sleep")) return this.no(`a call of ${f.name}, which waits, in a program with tasks`); + this.taken.set(id, "walking"); + let ok = true; + if (f.result) { + if (!this.ok(f.result.decl.id)) ok = this.no(`${f.name}, which gives back a ${f.result.kind}`); + this.declared.add(f.result.decl.id); + this.written.add(f.result.decl.id); + } + this.returning.push(f.result?.kind ?? "void"); + ok = ok && this.stmts(body, false); + this.returning.pop(); + this.taken.set(id, ok); + return ok; + } + + /** A task's call (a map of our own): its parameters, the slot's own variables, which the start writes; its body. */ + taskCall(c: Call): boolean { + for (const p of c.params) { + const d = p.decl; + if (!this.ok(d.id)) return this.no(`the parameter ${d.name}, a ${d.kind}`); + if (d.kind === "boolean" ? !this.test(p.init as BoolExpr) : !this.lowers(p.init as NumExpr)) return this.no(`the value given to ${d.name}`); + if (d.kind !== "boolean") this.num(p.init as NumExpr); + this.declared.add(d.id); + this.written.add(d.id); + } + // (What a task gives back nothing reads: its `return` only ends it.) + this.returning.push("void"); + try { + return this.stmts(c.body, false); + } finally { + this.returning.pop(); + } + } + + /** + * A part of a printed text a map of our own takes: a text, a whole number (signed or a `u32`) the emitter lowers, a + * byte from one (a colour code chosen as the map is played). Not yet a fixed number, a player's name or colour, a + * text of the program's. + */ + printed(p: TextPart): boolean { + if (p.kind === "text") return true; + if (p.kind === "pick") { + if ((p.by.kind !== "var" && p.by.kind !== "const") || !p.cases.every((c) => c.when.kind === "const" && (!c.upTo || c.upTo.kind === "const")) || !this.lowers(p.by)) return this.no("a printed text picked by a value it does not lower"); + this.num(p.by); + return true; + } + if (p.kind !== "number" && p.kind !== "char") return this.no(`a printed ${p.kind === "value" ? "text of the program's" : p.kind === "name" ? "player's name" : "player's colour"}`); + if (!this.lowers(p.expr)) return this.no("a printed value it does not lower"); + this.num(p.expr); + this.prints = true; + return true; + } + + /** Whether a text printed with a number in it is taken: the map then has the routine that writes one as digits. */ + prints = false; + + /** A `return` of a function or an inlined call the emitter lays out: its value one the emitter lowers. */ + returned(s: Extract): boolean { + const kind = this.returning[this.returning.length - 1]; + if (s.value === undefined || kind === "void") return true; + if (kind === "boolean") return this.test(s.value as BoolExpr) || this.no("a value given back that it does not lower"); + if (kind !== "number" || !this.lowers(s.value as NumExpr)) return this.no("a value given back that it does not lower"); + this.num(s.value as NumExpr); + return true; + } + + /** + * `fmul` / `fdiv` of numbers the emitter lowers: which of the Python side's functions (part 3) gives the same bits, + * and its arguments. A constant side goes into the function (one above zero; below, the statement is the Python + * side's, whose negation after is not this). + */ + /** A number the emitter lowers with cells of lists read first; its reads noted. Null when it does not. */ + withCells(e: NumExpr): { reads: CellRead[]; value: Linear } | null { + const reads: CellRead[] = []; + const cell = (el: Extract, sides?: [Linear, Linear]): string | null => { + if (el.kind === "randomInt") { + // (A map of our own: drawn from the game's own random bits, `Emitter.randomNumber`; the bound in `a`.) + if (!sides || !sides[0].terms.every((t) => t.id.startsWith("@cell") || this.ok(t.id))) return null; + const id = `@cell${this.cells++}`; + const ra = (el.bound as NumExpr & { range?: Range }).range; + reads.push({ id, arith: { op: "random", a: sides[0], b: sides[1], unsigned: true, ...(ra ? { ra } : {}) } }); + return id; + } + if (el.kind === "intrinsic") { + // (A map of our own: the emitter's own fixed product and quotient, `Emitter.arith`.) + if (!sides || (el.name !== "fmul" && el.name !== "fdiv" && el.name !== "sqrt") || ![...sides[0].terms, ...sides[1].terms].every((t) => t.id.startsWith("@cell") || this.ok(t.id))) return null; + const id = `@cell${this.cells++}`; + const range = (x: NumExpr | undefined) => (x as NumExpr & { range?: Range } | undefined)?.range; + const [ra, rb, r] = [range(el.args[0]), range(el.args[1]), range(el)]; + // (`unsigned` on a square root: of a whole number read from 0 up.) + // (`ratio` on a quotient: the rewriter's, of a magnitude over a larger one.) + reads.push({ id, arith: { op: el.name, a: sides[0], b: sides[1], unsigned: el.name === "sqrt" && !!el.unsigned, ...((el as { ratio?: boolean }).ratio ? { ratio: true } : {}), ...(ra ? { ra } : {}), ...(rb ? { rb } : {}), ...(r ? { r } : {}) } }); + return id; + } + if (el.kind === "element") { + const t = this.table(el); + if (!t) return null; + const id = `@cell${this.cells++}`; + reads.push({ id, table: t }); + return id; + } + if (el.op === "*" || el.op === "/" || el.op === "%" || el.op === "&" || el.op === "|" || el.op === "^" || (sides && (el.op === "<<" || el.op === ">>" || el.op === ">>>"))) { + // (A map of our own: the emitter's own product, division and bits, `Emitter.arith`.) + if (!sides || ![...sides[0].terms, ...sides[1].terms].every((t) => t.id.startsWith("@cell") || this.ok(t.id))) return null; + const id = `@cell${this.cells++}`; + const range = (x: NumExpr) => (x as NumExpr & { range?: Range }).range; + const [ra, rb, r] = [range(el.left), range(el.right), range(el)]; + reads.push({ id, arith: { op: el.op, a: sides[0], b: sides[1], unsigned: !!el.unsigned, ...(ra ? { ra } : {}), ...(rb ? { rb } : {}), ...(r ? { r } : {}) } }); + return id; + } + // A shift right by a constant (its count & 31): in a map of our own, what it shifts read first when it is more than + // a sum (a product shifted); by a value, `arith` above. + const by = linear(el.right); + if (!by || by.terms.length) return null; + // (`>>` by 32 or more fills with the sign, as `>> 31` does.) + const k = el.op === ">>" ? Math.min(31, by.c) : by.c; + if (k < 1 || k > 31) return null; + const of = linear(el.left, cell); + if (!of || !of.terms.every((t) => t.id.startsWith("@cell") || this.ok(t.id))) return null; + const id = `@cell${this.cells++}`; + // (Value ranges: a number never below zero and at most `top` has only top's bits to test, and its >> is >>>.) + const r = (el.left as NumExpr & { range?: [number, number] }).range; + const top = r && r[0] >= 0 ? r[1] : undefined; + reads.push({ id, shift: { of, by: k, signed: el.op === ">>" && top === undefined, ...(top !== undefined ? { top } : {}) } }); + return id; + }; + const value = linear(e, cell); + if (!value || !value.terms.every((t) => t.id.startsWith("@cell") || this.ok(t.id))) return null; + for (const t of [...value.terms, ...reads.flatMap((r) => (r.table ? r.table.index.terms : r.shift ? r.shift.of.terms : [...r.arith!.a.terms, ...r.arith!.b.terms]))]) if (!t.id.startsWith("@cell")) this.read.add(t.id); + return { reads, value }; + } + + /** A cell of one of the program's arrays, at an index the emitter lowers (`cellAt`); null otherwise. */ + table(e: NumExpr): Table | null { + return e.kind === "element" ? this.cellAt(e.array, e.index) : null; + } + + /** + * A cell of an array of a map of our own, at an index the emitter lowers: the array's region of cell triggers (a + * window's, the array it is a window on, at its offset), a guard that the index is inside the array (a window: its + * own length). A list known when the map is built whose values have few bits between them is read a bit at a time, + * where it is, as in the Python side's maps. Null for an array whose cells are not a region's: one that grows, holds + * texts, or is a task's. + */ + cellAt(array: string, at: NumExpr): Table | null { + const a = this.arrays.get(array); + const index = linear(at); + if (!a || !index || !index.terms.every((t) => this.ok(t.id)) || !nativeArray(a)) return null; + // (What the value ranges know of the index, as the Python side has it: inside, and there is nothing to test.) + const r = (at as NumExpr & { range?: readonly [number, number] }).range; + const safe = !!r && r[0] >= 0 && r[1] < a.length; + if (a.slice) { + const of = this.arrays.get(a.slice.of); + if (!of || !nativeArray(of) || of.slice || !this.ok(a.slice.offset)) return null; + return { array: of, index: merged(index.c, [...index.terms, { id: a.slice.offset, k: 1 }]), jump: true, guard: index, limit: a.length, ...(safe ? { safe } : {}) }; + } + if (a.values) { + const bits = a.values.reduce((m, v) => (m | (v >>> 0)) >>> 0, 0).toString(2).replace(/0/g, "").length; + if (bits <= TABLE_BITS) return { array: a, index }; + } + return { array: a, index, jump: true, ...(safe ? { safe } : {}) }; + } + + /** + * A statement the emitter does not take: why, in the script's words, noted against it — what in it the emitter never + * takes (`unlowered`), else how it is put together (`why`, from the checks since the last refusal) — and the walk + * goes on, so that `emitBody` can say the first that holds a part never taken. + */ + refuse(s: Stmt): true { + const named = unlowered(s, this.decls, this.arrays); + this.whyOf.set(s, { why: named ?? this.why, never: named !== undefined }); + this.why = ""; + this.refused.add(s); + return true; + } + + stmt(s: Stmt, inLoop: boolean): boolean { + switch (s.kind) { + case "remark": return true; + case "textBlock": + if (!this.ok(s.decl.id)) return this.refuse(s); + this.declared.add(s.decl.id); + this.written.add(s.decl.id); + return true; + case "showText": + if (playersOf(s.to, this.owner, this.players) === null) return this.refuse(s); + return true; + case "block": return this.stmts(s.body, inLoop); + case "declare": { + if (!this.ok(s.decl.id)) return this.refuse(s); + if (s.decl.kind === "boolean") { + if (!this.test(s.init as BoolExpr)) return this.refuse(s); + this.declared.add(s.decl.id); + this.written.add(s.decl.id); + return true; + } + const r = this.readOf(s.init as NumExpr); + if (r) { + this.gameReads.set(s, r); + if (COUNTING.has(r.rec.type)) this.counts = true; + this.declared.add(s.decl.id); + this.written.add(s.decl.id); + return true; + } + if (!this.lowers(s.init as NumExpr)) { + const t = this.withCells(s.init as NumExpr); + if (!t) return this.refuse(s); + this.tables.set(s, t); + } else this.num(s.init as NumExpr); + this.weigh(s.decl.id, s.init as NumExpr, this.tables.get(s)); + this.declared.add(s.decl.id); + this.written.add(s.decl.id); + return true; + } + case "assign": { + if (!this.ok(s.target)) return this.refuse(s); + const r = this.readOf(s.value); + if (r) { + this.gameReads.set(s, r); + if (COUNTING.has(r.rec.type)) this.counts = true; + this.written.add(s.target); + return true; + } + if (!this.lowers(s.value)) { + const t = this.withCells(s.value); + if (!t) return this.refuse(s); + this.tables.set(s, t); + } else this.num(s.value); + this.weigh(s.target, s.value, this.tables.get(s)); + this.written.add(s.target); + return true; + } + case "assignBool": { + if (!this.ok(s.target) || !this.test(s.value)) return this.refuse(s); + this.written.add(s.target); + return true; + } + case "switch": { + const v = side(s.value); + if (!v || ("mask" in v && v.mask !== undefined) || s.cases.some((c) => c.value !== null && typeof c.value !== "number")) return this.refuse(s); + if ("id" in v) { + if (!this.ok(v.id)) return this.refuse(s); + this.read.add(v.id); + } + // (A break inside ends the switch; a continue is the loop's around it.) + return s.cases.every((c) => this.stmts(c.body, true)); + } + case "unrolled": return s.iterations.every((it) => this.stmts(it, true)); + case "if": return this.test(s.cond) ? this.stmts(s.then, inLoop) && this.stmts(s.else ?? [], inLoop) : this.refuse(s); + case "while": return (!s.cond || this.test(s.cond)) ? this.stmts(s.body, true) : this.refuse(s); + case "do": return this.stmts(s.body, true) && this.stmts(s.before ?? [], true) && (this.test(s.cond) || this.refuse(s)); + case "for": { + if (s.cond && !this.test(s.cond)) return this.refuse(s); + return this.stmts(s.update, true) && this.stmts(s.body, true); + } + case "store": { + const cell = this.cellAt(s.array, s.index); + if (!cell?.jump) return this.no("a store into an array a map of our own does not keep") || this.refuse(s); + const value: { reads: CellRead[]; value: Linear } | null = this.lowers(s.value as NumExpr) ? { reads: [], value: linear(s.value as NumExpr)! } : this.withCells(s.value as NumExpr); + if (!value) return this.refuse(s); + for (const t of [...cell.index.terms, ...value.value.terms]) if (!t.id.startsWith("@cell")) this.read.add(t.id); + this.stores.set(s, { array: cell.array, index: cell.index, ...value, ...(cell.guard ? { guard: cell.guard, limit: cell.limit } : {}), ...(cell.safe ? { safe: true } : {}) }); + return true; + } + case "break": case "continue": return inLoop; + case "sleep": return true; + case "print": + // (Display Text shows to the current player: a text for others goes to each player of its group in turn — + // Players 1 to 8, All Players, a force: the map's slots.) + if (playersOf(s.to, this.owner, this.players) === null) return this.no("a text shown to a group of players other than P1 to P8, All Players and the forces") || this.refuse(s); + if (s.position === "center" && centreBytes(s.parts) > CENTRE_BYTES) return this.no(`a text at the centre longer than its line (${CENTRE_BYTES} bytes)`) || this.refuse(s); + if ((s.position === "chat" || s.position === "center") && s.parts.every((p) => this.printed(p))) return true; + return this.refuse(s); + case "call": return this.call(s.call) || this.refuse(s); + case "action": { + if (!gameAction(s)) return this.refuse(s); + if (s.text?.kind === "template" && !s.text.parts.every((p) => this.printed(p))) return this.refuse(s); + for (const v of s.variables ?? []) for (const t of linear(v.expr)!.terms) { + if (!this.ok(t.id)) return this.refuse(s); + this.read.add(t.id); + } + return true; + } + case "declareArray": { + // A map of our own: the cells set from the list or the one value (`fill`), each a number the emitter lowers. + const a = this.arrays.get(s.array); + if (!a || !nativeArray(a) || a.slice || a.values) return this.refuse(s); + for (const v of [...(s.init ?? []), ...(s.fill ? [s.fill] : [])]) { + if (!this.lowers(v as NumExpr)) return this.no("a cell's value it does not lower") || this.refuse(s); + this.num(v as NumExpr); + } + return true; + } + // (A `return` at the top of a program ends it: nothing to give back.) + case "return": return (this.returning.length > 0 ? this.returned(s) : s.value === undefined) || this.refuse(s); + // (A map of our own: the slots `planTasks` made — a listener's, `on.*`'s, as any other's.) + case "start": { + const slots = this.slots.get(s); + return (!!slots && slots.every((slot) => this.taskCall(slot.call))) || this.refuse(s); + } + case "cancel": return (this.lowers(s.task) && (this.num(s.task), true)) || this.refuse(s); + case "watch": return this.stmts(s.handler, false) || this.refuse(s); + case "parallel": return s.branches.every((b) => this.stmts(b, false)) || this.refuse(s); + // (Where a `throw` goes is the emitter's, `Emitter.handler`: the program keeps the error in flight.) + case "try": return (!!this.errors && this.stmts(s.body, inLoop) && this.stmts(s.catch, inLoop)) || this.refuse(s); + case "throw": return !!this.errors || this.refuse(s); + default: return this.refuse(s); + } + } + + /** + * A write of `target`: heavy (`heavy`) when it reads the target times anything but 1 (the plan copies it), or reads a + * scratch cell a copy fills — a product's or a quotient's, an array's cell kept where it is — through a routed copy a + * place (56 ns each, where a sum's or a shift's write costs 10). + */ + weigh(target: string, value: NumExpr, t?: { reads: CellRead[] }): void { + const l = linear(value); + if ((l && l.terms.some((x) => x.id === target && x.k !== 1)) || t?.reads.some((r) => r.arith || r.table?.jump)) this.heavy.add(target); + } + + /** Whether the emitter lowers this number (without noting anything). */ + lowers(e: NumExpr): boolean { + const l = linear(e); + return !!l && l.terms.every((t) => this.ok(t.id)); + } + + /** Whether the emitter lowers this test; its reads noted when it does. */ + test(e: BoolExpr): boolean { + const why = this.why; + const ok = this.bool(e); + if (!ok) this.why = why; + return ok; + } +} + +// ── the layout ── + +/** A write of a variable, laid out where the statement was, expanded once every place of the variable is known. */ +/** `cold`: written where only an error goes (`Emitter.cold`): what it reads is refreshed from its cell before it, never a place of it. */ +interface Write { target: string; value: Linear; at?: At; cold?: boolean } +/** An action of an expanded write: it writes `into`; a variable it reads plus a constant is in its own number. */ +interface WriteAct { into: Place; modifier: "set" | "add"; value: Num; reads?: Read } +/** + * What an action's or a condition's number holds: the variable times `scale`, plus `add` (wrapping) — plus, with + * `base`, the EPD of that array's first cell (a condition's player field reading a cell at an index). + */ +interface Read { id: string; scale: number; add: number; base?: string } +/** + * A cell of an array at an index the emitter lowers: of a list known when the map is built, read a bit at a time where + * it is; or (`jump`) of an EUDVArray, the walk sent into the cell, which writes its value out through eudplib's getter. + */ +/** + * `guard`, `limit`: the index a window is read at is inside it when `guard` is below `limit` (unset: the array's own). + * `safe`: the value ranges know the index is inside, and nothing is tested. + */ +interface Table { array: ArrayDecl; index: Linear; jump?: boolean; guard?: Linear; limit?: number; safe?: boolean } +/** A sum the emitter lowers shifted right by a constant: `>>` keeps the sign, `>>>` reads from 0 up. */ +/** `top`: the most the shifted number can be, when value ranges know it never below zero (only its bits are tested). */ +interface Shift { of: Linear; by: number; signed: boolean; top?: number } +/** + * A product of two numbers the emitter lowers, or a quotient or a remainder of two (TrigScript 4, step 4 of the way off + * eudplib): `unsigned` reads both sides from 0 up (`u32`), else as JavaScript's numbers, a quotient towards zero and a + * remainder with the dividend's sign; by 0 both are 0, as the simulator has them. `ra`, `rb`, `r`: what the value + * ranges know of each side and of the answer. + */ +interface Arith { op: "*" | "/" | "%" | "&" | "|" | "^" | "<<" | ">>" | ">>>" | "fmul" | "fdiv" | "random" | "sqrt"; ratio?: boolean; a: Linear; b: Linear; unsigned: boolean; ra?: Range; rb?: Range; r?: Range } +/** The switch random numbers are drawn through (256, the last, 0-based): put back as it was after each draw. */ +const RANDOM_SWITCH = 255; +/** A variable times a constant plus a constant: what a field holding a multiple of a sum reads. */ +interface Multiple { id: string; k: number; c: number } +/** + * The most bits a product's tested side may have for the other side's multiples to be fields of its own (`product`): + * each one more place of that side, which its writes fill. + */ +const SCALED_BITS = 8; +/** How many bits a number of this range has, read from 0 up: 32 when it may be below zero. */ +const width = (r?: Range) => (r && r[0] >= 0 ? (r[1] <= 0 ? 0 : 32 - Math.clz32(r[1])) : 32); +/** Something a value reads first, into a variable of the emitter's own: a cell, a shift, a product or a quotient. */ +type CellRead = { id: string; table?: Table; shift?: Shift; arith?: Arith }; +/** A store into a cell of one of the program's arrays: its index, and its value with the cells it reads first. */ +interface Store { array: ArrayDecl; index: Linear; reads: CellRead[]; value: Linear; guard?: Linear; limit?: number; safe?: boolean } +/** (`at` on a copy or a call: the statement it is for, when its trigger is another's.) */ +/** (`label`: every place of `target` set to where `to` is — where a function that calls itself goes back to.) */ +/** (`route`: a variable's cell into these dwords, and nothing else of its places.) */ +type Item = { act: Act } | { write: Write } | { fill: { temp: string; target: string; apart?: boolean; top?: number } } | { copy: string; at?: At } | { bump: { target: string; add: number } } | { label: { target: string; to: string } } | { pointer: { target: string; to: Dword } } | { route: { from: string; into: Dword[]; add?: number } }; +/** + * `keep`: an empty trigger that stays (a jump whose next pointer a place sets). `guarded`: an `if` in one trigger (its + * conditions, its statements' actions), which may be followed by the copies of what it writes — a copy whether or not + * the conditions held, which writes back what the places hold when they did not. + */ +/** `off`: a trigger laid out done (`Trigger.off`): a task's slot or a branch's, switched on by what starts it. */ +interface Proto { label: string; conds: Cond[]; items: Item[]; next?: string; at?: At; depth?: number; keep?: boolean; guarded?: boolean; guardOf?: Stmt; cold?: boolean; off?: boolean } + +/** + * A coroutine (TrigScript 4, step 11): where its wake keeps the frame it sleeps until and where it goes on from — the + * fields a sleep writes — the action a resume puts its wake back with, where its turn goes when it sleeps, and what its + * end lays out. The program's wake is its run's `@wake` (`layout.ts`); a task's slot and a branch of a `Promise.all` or + * `race` have a trigger of their own, "the count of frames has reached W: go to R", passed by while done. + */ +interface Co { tick: Dword; at: Dword; reset: Act; yieldTo: string; end: () => void } + +/** + * One of a `start`'s slots, as `emitBody` plans them: the task's call as the slot runs it (variables of its own but in + * the first slot), its trigger in the tasks' turn, where its body starts, its generation and the number it is known by + * while it runs (`slot | task << 8 | generation << 16`; 0 when nothing is in it). + */ +interface TaskSlot { call: Call; check: string; entry: string; gen: string; handle: string } +type StartStmt = Extract; + +/** + * A place of a variable: a dword that holds it times `scale` plus `add` (wrapping) — known (its cell, a home, a + * condition's number) or the number of an action of an expanded write, found when the triggers are cut. + */ +interface Place { dword?: Dword; act?: WriteAct; scale: number; add: number; base?: string; synced?: boolean } + +/** + * The byte a printed text's template fills where nothing goes: 0x0D, a carriage return, which StarCraft draws as + * nothing — eudplib pads its strings to whole dwords with it, and its own prints carry it around every number. + */ +const PAD = 0x0d; + +/** + * The most bits a list's values may have between them for the emitter to read its cells where they are: a trigger a + * bit, each 13 ns when the bit is clear and 23 when set, against about 200 ns for the Python side's read of a cell. + */ +const TABLE_BITS = 6; + +/** The most cells a map of our own's stack has: a trigger of 72 bytes each (4.7 MB at this). */ +const STACK_CELLS_MOST = 65536; + +/** The EPD between one cell's value and the next's in a region of cell triggers (72 bytes a cell). */ +const VARRAY_STRIDE_EPD = 18; + +/** An array a map of our own keeps as a region of cell triggers, read by a jump into a cell (`Scope.cellAt`'s). */ +function inRegion(arrays: Map, id: string): boolean { + const a = arrays.get(id); + if (!a || !nativeArray(a)) return false; + if (a.slice) { + const of = arrays.get(a.slice.of); + return !!of && nativeArray(of) && !of.slice; + } + if (a.values) return a.values.reduce((m, v) => (m | (v >>> 0)) >>> 0, 0).toString(2).replace(/0/g, "").length > TABLE_BITS; + return true; +} + +/** + * An array a map of our own keeps in a region of cell triggers (or reads where it is: a list of few bits): of numbers or + * booleans, one for everyone — not one that grows, holds texts or is a task's. A window on one (a row of a grid) is the + * array it is a window on. A `u8` / `u16` one is a cell a number, as the Python side's is: each store stopped at its + * top first (`Rewriter.top`). + */ +function nativeArray(a: ArrayDecl): boolean { + // (A task's: in place, its start having one slot — `planTasks`.) + return (a.kind === "number" || a.kind === "boolean") && !a.dynamic && !a.texts && !a.through; +} + +/** A constant plus an array's first cell's EPD, as an action's number. */ +/** (A base `addr:` is the address of an array's first cell, a trigger; any other the EPD of its first cell's value.) */ +const num = (n: number, base?: string): Num => (!base ? n : base.startsWith("addr:") ? { addrOf: base.slice(5), plus: n } : { epdOf: base, plus: n }); + +/** + * How many dwords a printed number's field takes: 3 (12 bytes, the most a 32-bit number and its sign need), or fewer + * when the value ranges (`ranges.ts`) know the number never takes more characters than 4 or 8 — a `?:` of 0 and 1 is + * one character, so a line of them stays within the game's text line. + */ +function fieldWords(p: Extract): 1 | 2 | 3 { + const range = (p.expr as NumExpr & { range?: [number, number] }).range; + if (!range || p.fixed) return 3; + // (An unsigned print of a value that may be below zero reads it from 0 up: ten digits.) + if (p.unsigned && range[0] < 0) return 3; + const chars = Math.max(String(range[0]).length, String(range[1]).length); + return chars <= 4 ? 1 : chars <= 8 ? 2 : 3; +} + +class Emitter { + protos: Proto[] = []; + n = 0; + places = new Map(); + /** Every write of each variable. */ + writes = new Map(); + /** The branches to put back when the walk reaches a label: the branch, and the label it goes to otherwise. */ + restores = new Map(); + /** The runs of triggers a doubling trigger walks, each to be laid out one after another (`Graph.rows`). */ + rows: string[][] = []; + homes = new Set(); + /** A comparison's number that reads a variable: a place of it once the condition is in its trigger. */ + condReads = new Map(); + /** A condition whose player field is a place of what it reads: the index of a table's cell. */ + condPlayers = new Map(); + /** A jump into an EUDVArray's cell: its trigger's next pointer, a place of the index. */ + jumpReads = new Map(); + /** A store's action: its destination a place of the index, its number (when not a constant) a place of the value. */ + actPlaces = new Map>>(); + /** The variables of the emitter's own (a table's index, a pinned sum): declared by no statement, a cell each all the same. */ + own = new Set(); + /** The arrays whose cells are read where they are, and the EPD from one cell's value to the next's. */ + arrays = new Map(); + loops: { cont: string; exit: string }[] = []; + /** The statement being laid out: where its triggers say they come from (the cost of a line, the map of triggers). */ + at?: At; + /** The variables copied whatever the plan says: a table's target with more places than a trigger has actions. */ + forced = new Set(); + /** The variables of the emitter's own that stand for one of the program's in a field (`pin`): what each stands for. */ + pins = new Map(); + /** The variables a function's result goes into: their cells take it, and a copy fills their other places. */ + results = new Set(); + /** The emitter's own variables that are a function's parameter: their one place is its cell. */ + /** The table targets whose places outgrew a trigger, for the emitter to try again with them copied. */ + crowded = new Set(); + /** The `if`s laid out as a branch, not in one trigger (`guarded`): those that did not fit in one, found by a build before. */ + unguarded = new Set(); + /** The `if`s in one trigger whose actions did not fit in it. */ + misfits = new Set(); + /** + * A map of our own: the functions a call has reached, each laid out once after the program (`functions()`) — where + * it starts, and the trigger it ends at, whose next pointer every call sets to where it goes on — and those still + * to lay out. `depth`: the most loops around a call of it, which its triggers count as theirs. + */ + fns = new Map(); + pending: string[] = []; + /** The loops around the function being laid out, at its deepest call. */ + depthBase = 0; + /** The function or inlined call being laid out, innermost last: the variable its `return` writes, where it goes. */ + frames: { result?: { id: string; kind: string }; end: string }[] = []; + /** Where an error thrown here goes: the `catch` of each `try` around, in the function being laid out, innermost last. */ + handlers: string[] = []; + /** The variables whose cells nothing reads: a label or a pointer set for them is set at their other places only. */ + cellless = new Set(); + /** + * Inside statements only an error goes through — those that end in a `throw`, and a `catch` (a map of our own): what + * they read of a variable is refreshed from its cell where it is read, as a routed variable's is, never a place that + * every write of the variable keeps — a throw's message made from a parameter cost every call of the function an + * action and a copy, for an error that seldom comes. + */ + cold = 0; + /** The coroutine being laid out: the program's (`emitBody`), a task's slot (`tasks()`), a branch (`parallel()`). */ + co!: Co; + /** The starts laid out, by task number, and those whose slots' bodies are still to lay out; inside a task's body, how deep. */ + pools = new Map(); + pendingTasks: number[] = []; + inTask = 0; + /** Whether a task starts one (`again`): the tasks' turn goes round again, for it to have its first turn in this frame too. */ + again = false; + /** Where the program's turn goes when it sleeps or ends: the tasks' turn (or the run's end, with no task). */ + get tasksLabel(): string { return `${this.key}:tasks`; } + get againVar(): string { return `${this.key}.again`; } + /** + * Of each function an error comes out of from a trigger of its own (`thrown`), the actions of the calls that point + * that trigger's next pointer at where the error goes from each: where every call names the same place, the + * trigger is given it when built, and the calls set nothing (`settleThrown`). + */ + thrownSets = new Map(); + thrownProtos = new Map(); + private throwing?: Set; + /** + * A map of our own whose functions call themselves: a stack each, by function — its id, the variable of the + * emitter's own that holds the cells in use (`sp`: what addresses the stack — a store's destination, a cell's + * fields, a jump into one — are places of it), the most cells a frame takes — and, for them all, how many frames + * are on the stacks (`depth`) and how deep they may go (the script's setting). A stack apart for each function + * keeps the places of its `sp` to its own calls: one for them all had a place at every call of every function, and + * every call wrote them all. + */ + stacks = new Map(); + stackDepth = STACK_DEPTH; + /** The called function being laid out (`functions()`): a frame keeps where it goes back to. */ + current?: string; + /** Of the function being laid out: what is still to be read after each of its calls that may come back. */ + live = new Map>(); + get depth(): string { return `${this.key}.depth`; } + + /** The stack of the function a frame is the function's. */ + stackOf(fn: string): { id: string; sp: string; frame: number } { + let st = this.stacks.get(fn); + if (!st) { + this.stacks.set(fn, (st = { id: `${STACK}:${fn}`, sp: `${this.key}.sp.${fn}`, frame: 0 })); + this.own.add(st.sp); + } + return st; + } + + readonly key: string; + readonly scope: Scope; + constructor(key: string, scope: Scope) { + this.key = key; + this.scope = scope; + } + + label(what: string): string { + return `${this.key}.${what}${this.n++}`; + } + + placesOf(id: string): Place[] { + let p = this.places.get(id); + if (!p) { + // (Every variable's cell is the layout's, the emitter's own included, and can be copied from.) + this.places.set(id, (p = [{ dword: { var: id }, scale: 1, add: 0 }])); + } + return p; + } + + /** A dword that holds the variable plus `add`, for conditions to read as memory. */ + home(id: string, add: number): Dword { + add = u(add); + if (add === 0) return this.placesOf(id)[0].dword!; + const name = `${this.key}.h.${id}.${add}`; + if (!this.homes.has(name)) { + this.homes.add(name); + this.placesOf(id).push({ dword: { data: name }, scale: 1, add }); + } + return { data: name }; + } + + push(p: Omit & { label?: string }): Proto { + const t: Proto = { label: p.label ?? this.label("t"), conds: p.conds, items: p.items, next: p.next, at: p.at ?? this.at, depth: this.depthBase + this.loops.length, ...(p.keep ? { keep: true } : {}), ...(p.guarded ? { guarded: true } : {}), ...(p.guardOf ? { guardOf: p.guardOf } : {}), ...(this.cold ? { cold: true } : {}), ...(p.off ? { off: true } : {}) }; + this.protos.push(t); + return t; + } + + /** A label the walk can be sent to, here: an empty trigger (puts-back and writes join it; dropped when it stays empty). */ + land(label: string): void { + this.push({ label, conds: [], items: [] }); + } + + /** The last trigger, if it takes more actions (no conditions, the walk going on to what is next); or a new one. */ + open(): Proto { + const last = this.protos[this.protos.length - 1]; + if (last && last.conds.length === 0 && last.next === undefined) { + // (An empty trigger a label stands at takes the line of what fills it.) + if (!last.items.length) last.at = this.at; + return last; + } + return this.push({ conds: [], items: [] }); + } + + write(target: string, value: Linear, copy = true, into: Item[] = this.open().items, copies = into): void { + const w: Write = { target, value, ...(this.at ? { at: this.at } : {}), ...(this.cold ? { cold: true } : {}) }; + // (The copy is there for a variable that turns out to need one: the rest leave it out.) + into.push({ write: w }); + if (copy) copies.push({ copy: target, ...(this.at ? { at: this.at } : {}) }); + let list = this.writes.get(target); + if (!list) this.writes.set(target, (list = [])); + list.push(w); + } + + /** The walk goes to `to` from here. */ + goto(to: string): void { + const last = this.protos[this.protos.length - 1]; + if (last && last.next === undefined && last.conds.length === 0) last.next = to; + else this.push({ conds: [], items: [], next: to }); + } + + /** A trigger whose conditions all holding send the walk to `to`; otherwise it goes on. (`named`: its label, when its fields are places.) */ + branch(conds: Cond[], to: string, at?: At, named?: string): void { + const label = named ?? this.label("b"), after = this.label("a"); + this.push({ label, conds, items: [{ act: { dword: { at: label, field: "next" }, modifier: "set", value: { label: to } } }], next: after, at }); + let r = this.restores.get(to); + if (!r) this.restores.set(to, (r = [])); + r.push({ branch: label, to: after }); + this.land(after); + } + + // ── comparisons ── + + /** The conditions that all hold exactly when `op` holds of the two sides, or null when one trigger cannot say it. */ + conds(op: CompareOp, a: Side, b: Side, unsigned: boolean): Cond[] | "true" | "false" | null { + // (Equal or not is the same of the 32 bits signed or not: no bias, the variable's own cell.) + const d = unsigned || op === "==" || op === "!=" ? 0 : SIGN; + if ("k" in a && "k" in b) { + const x = u(a.k + d), y = u(b.k + d); + const r = op === "<" ? x < y : op === "<=" ? x <= y : op === ">" ? x > y : op === ">=" ? x >= y : op === "==" ? x === y : x !== y; + return r ? "true" : "false"; + } + if ("k" in a) return this.conds(FLIP[op], b, a, unsigned); + if (a.mask !== undefined) { + // The bits under the mask, read from 0 up, against the constant: a signed reading of them is the same when the + // mask leaves the sign bit out (a constant below zero is then below them all). + let k = (b as { k: number }).k; + if (op === "==" || op === "!=") k = u(k); + else { + if (!unsigned && a.mask >= SIGN) return null; + if (!unsigned && (k | 0) < 0) return (op === ">" || op === ">=") ? "true" : "false"; + } + // (Bits the mask has not cannot match.) + if ((op === "==" || op === "!=") && (k & ~a.mask) !== 0) return op === "!=" ? "true" : "false"; + const mem: Dword = this.home(a.id, 0); + const one = (compare: Compare, value: number): Cond[] => [{ dword: mem, compare, value, mask: a.mask }]; + switch (op) { + case "<": return k === 0 ? "false" : one("atMost", k - 1); + case "<=": return one("atMost", k); + case ">": return k === MAX ? "false" : one("atLeast", k + 1); + case ">=": return one("atLeast", k); + case "==": return one("exactly", k); + case "!=": return k === 0 ? one("atLeast", 1) : null; + } + } + if ("k" in b === false && (b as { mask?: number }).mask !== undefined) return null; + const mem = this.home(a.id, a.c + d); + if ("k" in b) { + const k = u(b.k + d); + const one = (compare: Compare, value: number): Cond[] => [{ dword: mem, compare, value }]; + switch (op) { + case "<": return k === 0 ? "false" : one("atMost", k - 1); + case "<=": return one("atMost", k); + case ">": return k === MAX ? "false" : one("atLeast", k + 1); + case ">=": return one("atLeast", k); + case "==": return one("exactly", k); + case "!=": return k === 0 ? one("atLeast", 1) : null; + } + } + // Two variables: b plus a constant in the condition's number (one less for <, one more for >, with a second + // condition keeping b off the end where that would wrap). + const read = (compare: Compare, add: number): Cond => { + const c: Cond = { dword: mem, compare, value: 0 }; + this.condReads.set(c, { id: b.id, scale: 1, add: u(b.c + d + add) }); + return c; + }; + switch (op) { + case "<=": return [read("atMost", 0)]; + case ">=": return [read("atLeast", 0)]; + case "==": return [read("exactly", 0)]; + case "<": return [read("atMost", -1), { dword: this.home(b.id, b.c + d), compare: "atLeast", value: 1 }]; + case ">": return [read("atLeast", 1), { dword: this.home(b.id, b.c + d), compare: "atMost", value: MAX - 1 }]; + case "!=": return null; + } + } + + /** + * The condition that holds when a cell of an array a map of our own keeps is so against a constant, as 32 bits + * (`Rewriter.cellCompare`): read where the index says, by its player field — the EPD of the first cell's number plus + * 18 a cell, a place of the index. What tests the index inside the array comes before it, in its trigger or before it. + */ + cellConds(e: Extract, when: boolean): Cond[] | "true" | "false" | null { + const el = e.left as Extract; + const t = this.scope.cellAt(el.array, el.index)!; + this.region(t.array); + const op = when ? e.op : INVERSE[e.op], k = u((e.right as { value: number }).value); + if (op === "!=" && k !== 0) return null; + const index = this.pin(t.index, "cx"); + const one = (compare: Compare, value: number): Cond[] => { + const c: Cond = { dword: { element: t.array.id }, compare, value }; + this.condPlayers.set(c, { id: index, scale: VARRAY_STRIDE_EPD, add: 0, base: t.array.id }); + return [c]; + }; + switch (op) { + case "<": return k === 0 ? "false" : one("atMost", k - 1); + case "<=": return one("atMost", k); + case ">": return k === MAX ? "false" : one("atLeast", k + 1); + case ">=": return one("atLeast", k); + case "==": return one("exactly", k); + case "!=": return one("atLeast", 1); + } + } + + /** The conditions of one trigger that hold exactly when `e` is `when`, or null. */ + single(e: BoolExpr, when: boolean): Cond[] | "true" | "false" | null { + if (e.kind === "const") return e.value === when ? "true" : "false"; + if (e.kind === "not") return this.single(e.expr, !when); + if (e.kind === "test") return this.single(notZero(e.expr), when); + if (e.kind === "var") return this.single(notZero({ kind: "var", id: e.id }), when); + if (e.kind === "cond") return this.condsOf(e, when); + if ((e.kind === "and" && when) || (e.kind === "or" && !when)) { + const parts = e.items.map((x) => this.single(x, when)); + if (parts.some((p) => p === null)) return null; + if (parts.includes("false")) return "false"; + const conds = parts.filter((p): p is Cond[] => Array.isArray(p)).flat(); + return conds.length === 0 ? "true" : conds.length <= MOST_CONDS ? conds : null; + } + if (e.kind !== "compare") return null; + if (e.unsigned === "left" || e.unsigned === "right") return this.single(plain(e), when); + if (e.left.kind === "element") return this.cellConds(e, when); + const sm = signedMask(e); + if (sm) return this.single(sm, when); + return this.conds(when ? e.op : INVERSE[e.op], side(e.left)!, side(e.right)!, e.unsigned === true); + } + + /** The walk goes to `to` when `e` is `when`, and on otherwise. */ + jump(e: BoolExpr, when: boolean, to: string, at?: At): void { + switch (e.kind) { + case "const": + if (e.value === when) this.goto(to); + return; + case "not": return this.jump(e.expr, !when, to, at); + case "test": return this.jump(notZero(e.expr), when, to, at); + case "var": return this.jump(notZero({ kind: "var", id: e.id }), when, to, at); + case "and": case "or": { + if ((e.kind === "and") !== when) { + // Any item not so goes to `to`: when every item's conditions for being so fit in one trigger, that trigger + // skips past a goto (one trigger where a branch an item would be more); else an item at a time. + const parts = e.items.map((x) => this.single(x, !when)); + if (e.items.length > 1 && parts.every((p) => p !== null)) { + if (parts.includes("false")) return this.goto(to); + const conds = parts.filter((p): p is Cond[] => Array.isArray(p)).flat(); + if (conds.length === 0) return; + if (conds.length <= MOST_CONDS) { + const past = this.label("p"); + this.branch(conds, past, at); + this.goto(to); + this.land(past); + return; + } + } + for (const x of e.items) this.jump(x, when, to, at); + return; + } + // Every item must be so: one trigger when their conditions fit in one. + const parts = e.items.map((x) => this.single(x, when)); + if (parts.every((p) => p !== null)) { + if (parts.includes("false")) return; + const conds = parts.filter((p): p is Cond[] => Array.isArray(p)).flat(); + if (conds.length === 0) return this.goto(to); + if (conds.length <= MOST_CONDS) return this.branch(conds, to, at); + } + const past = this.label("p"); + for (let i = 0; i < e.items.length - 1; i++) this.jump(e.items[i], !when, past, at); + this.jump(e.items[e.items.length - 1], when, to, at); + this.land(past); + return; + } + case "compare": { + if (e.unsigned === "left" || e.unsigned === "right") return this.jump(plain(e), when, to, at); + const sm = signedMask(e); + if (sm) return this.jump(sm, when, to, at); + const c = this.single(e, when); + if (c === "false") return; + if (c === "true") return this.goto(to); + if (c) return this.branch(c, to, at); + // != when true, == when false: below, or above — of the 32 bits as they are, which is the same. + this.jump({ ...e, op: "<", unsigned: true }, true, to, at); + this.jump({ ...e, op: ">", unsigned: true }, true, to, at); + return; + } + case "cond": { + const c = this.condsOf(e, when); + if (c === "false") return; + if (c === "true") return this.goto(to); + if (c) return this.branch(c, to, at); + // (Not so, of a condition no other one turns around: past `to` when it holds.) + const past = this.label("p"); + this.branch(this.condsOf(e, true) as Cond[], past, at); + this.goto(to); + this.land(past); + return; + } + default: throw new Error(`emitter: ${e.kind}`); + } + } + + // ── statements ── + + stmts(list: Stmt[]): void { + const cold = !!this.scope.errors && throwsAtEnd(list); + if (cold) this.cold++; + try { + for (const s of list) this.stmt(s); + } finally { + if (cold) this.cold--; + } + } + + stmt(s: Stmt): void { + const outer = this.at, cold = this.cold; + if (s.at) this.at = s.at; + // (A loop may turn many times on an error's way too: it, its test and its body, are not cold.) + if (s.kind === "while" || s.kind === "for" || s.kind === "do") this.cold = 0; + try { + this.statement(s); + } finally { + this.at = outer; + this.cold = cold; + } + } + + statement(s: Stmt): void { + switch (s.kind) { + case "remark": return; + case "textBlock": { + // The block a string of zeros among the templates; the variable its EPD, as an action writes a label's. + this.block(s.block, s.bytes, s.text, s.data); + const t = `${this.key}.tb${this.n++}`; + this.open().items.push({ act: { dword: { temp: t }, modifier: "set", value: { epd: { template: s.block, word: 0 } } } }, { fill: { temp: t, target: s.decl.id } }); + return; + } + case "showText": { + this.block(s.block, s.bytes); + const show: Act = { dword: { game: 0 }, modifier: "set", value: 0, show: s.block }; + const players = playersOf(s.to, this.scope.owner, this.scope.players)!, owner = this.scope.owner ?? 0; + const current = (p: number): Item => ({ act: { dword: { game: CURRENT_PLAYER_ADDRESS }, modifier: "set", value: p } }); + this.open().items.push(...(players.length === 1 && players[0] === owner ? [{ act: show }] : [...players.flatMap((p) => [current(p), { act: { ...show } }]), current(owner)])); + return; + } + case "block": return this.stmts(s.body); + } + if (s.kind === "declare" && s.decl.kind === "boolean") return this.setBool(s.decl.id, s.init as BoolExpr); + if (s.kind === "assignBool") return this.setBool(s.target, s.value); + if (s.kind === "call") return this.call(s.call); + if (s.kind === "action") return this.action(s); + const read = this.scope.gameReads.get(s); + if (read && (s.kind === "declare" || s.kind === "assign")) return read.epd ? this.readAt(s.kind === "declare" ? s.decl.id : s.target, read.rec, read.epd, s.at, read.shift) : this.readGame(s.kind === "declare" ? s.decl.id : s.target, read.rec, s.at, read.shift); + if (s.kind === "declareArray") return this.declareArray(s); + if (s.kind === "return") return this.ret(s); + const store = this.scope.stores.get(s); + if (store) return this.store(store); + const cells = this.scope.tables.get(s); + if (cells && (s.kind === "declare" || s.kind === "assign")) { + const target = s.kind === "declare" ? s.decl.id : s.target; + // (A map of our own: a value that is one cell of an array it keeps, read straight into the target.) + const [only] = cells.reads; + if (cells.reads.length === 1 && only.table?.jump && cells.value.c === 0 && cells.value.terms.length === 1 && cells.value.terms[0].id === only.id && cells.value.terms[0].k === 1) { + return this.readCell(target, only.table, s.at); + } + // (A product or a quotient alone, worked out straight into the target.) + if (cells.reads.length === 1 && only.arith && cells.value.c === 0 && cells.value.terms.length === 1 && cells.value.terms[0].id === only.id && cells.value.terms[0].k === 1) { + return this.arith(target, only.arith, new Map(), s.at); + } + // The cells first, each into a variable of the emitter's own; then the value, a sum of them and the rest. + const own = new Map(cells.reads.map((r) => [r.id, `${this.key}.${r.id.slice(1)}`])); + for (const r of cells.reads) { + this.own.add(own.get(r.id)!); + // (One a scratch cell is copied into — a product, a quotient, a cell jumped into — is heavy for what reads it.) + if (r.arith || r.table?.jump) this.scope.heavy.add(own.get(r.id)!); + if (r.table) this.readTable(own.get(r.id)!, r.table, s.at); + else if (r.shift) this.readShift(own.get(r.id)!, { ...r.shift, of: { c: r.shift.of.c, terms: r.shift.of.terms.map((t) => ({ id: own.get(t.id) ?? t.id, k: t.k })) } }, s.at); + else this.arith(own.get(r.id)!, r.arith!, own, s.at); + } + const value = { c: cells.value.c, terms: cells.value.terms.map((t) => ({ id: own.get(t.id) ?? t.id, k: t.k })) }; + return this.write(target, value); + } + switch (s.kind) { + case "declare": return this.write(s.decl.id, linear(s.init as NumExpr)!); + case "assign": return this.write(s.target, linear(s.value)!); + case "if": { + // (A test that is a constant — the library's `if (false)` around a branch a call never takes — lays out the side + // it takes, alone.) + if (s.cond.kind === "const") return this.stmts(s.cond.value ? s.then : s.else ?? []); + // A map of our own: actions of the game's and writes that read nothing but their target, under conditions + // that fit in one trigger — that trigger, its actions run when they hold (no branch, no puts-back); the copies + // of what it writes after it. + const then = flatten(s.then); + const guard = !this.unguarded.has(s) && !s.else?.length && then.length && then.length <= MOST_ACTS / 4 && then.every((t) => this.guards(t)) ? this.single(s.cond, true) : null; + if (guard === "false") return; + if (Array.isArray(guard)) { + const items: Item[] = [], copies: Item[] = []; + for (const t of then) { + const outer = this.at; + if (t.at) this.at = t.at; + if (t.kind === "action") items.push({ act: { dword: { game: 0 }, modifier: "set", value: 0, record: t.record, ...(t.at ? { at: t.at } : {}) } }, ...this.forgets(t.record)); + else if (t.kind === "assign") this.write(t.target, linear(t.value)!, true, items, copies); + this.at = outer; + } + this.push({ conds: guard, items: [...items, ...copies], at: s.at, guarded: true, guardOf: s }); + return; + } + const other = this.label("else"), done = this.label("fi"); + this.jump(s.cond, false, s.else?.length ? other : done, s.at); + this.stmts(s.then); + if (s.else?.length) { + this.goto(done); + this.land(other); + this.stmts(s.else); + } + this.land(done); + return; + } + case "while": case "for": { + const head = this.label("loop"), cont = this.label("cont"), exit = this.label("exit"); + // What of the test nothing in the loop changes is tested once, on the way in (a u32 counter against a signed + // bound is the bound's sign and the counter's place: the sign does not change). + // (Everything the loop may write: a boolean set from a test, what a call in it writes, all the way down.) + // (Not in a loop that sleeps: while it sleeps, a task or a branch may write anything it reads.) + const sleeps = holdsKind(s.body, "sleep") || (s.kind === "for" && holdsKind(s.update, "sleep")); + const { once, every } = !s.cond ? { once: null, every: null } : sleeps ? { once: null, every: s.cond } : split(s.cond, writes({ kind: "block", body: [...s.body, ...(s.kind === "for" ? s.update : [])], at: s.at }, this.scope.functions)); + if (once) this.jump(once, false, exit, s.at); + this.land(head); + if (every) this.jump(every, false, exit, s.at); + this.loops.push({ cont, exit }); + this.stmts(s.body); + this.land(cont); + if (s.kind === "for") this.stmts(s.update); + this.loops.pop(); + this.goto(head); + this.land(exit); + return; + } + case "do": { + const head = this.label("loop"), cont = this.label("cont"), exit = this.label("exit"); + this.land(head); + this.loops.push({ cont, exit }); + this.stmts(s.body); + this.loops.pop(); + this.land(cont); + // (What the test needs computed first, at every turn's end — a `continue` lands here too.) + if (s.before) this.stmts(s.before); + this.jump(s.cond, false, exit, s.at); + this.goto(head); + this.land(exit); + return; + } + case "switch": { + // A test of the value against each case, in order; the bodies one after another (a case falls into the next). + const end = this.label("swend"), at = s.at; + const labels = s.cases.map(() => this.label("case")); + s.cases.forEach((c, i) => { + if (c.value !== null) this.jump({ kind: "compare", op: "==", left: s.value, right: { kind: "const", value: c.value }, at, label: s.label }, true, labels[i], at); + }); + const fallback = s.cases.findIndex((c) => c.value === null); + this.goto(fallback >= 0 ? labels[fallback] : end); + const outer = this.loops[this.loops.length - 1]; + this.loops.push({ cont: outer?.cont ?? end, exit: end }); + s.cases.forEach((c, i) => { + this.land(labels[i]); + this.stmts(c.body); + }); + this.loops.pop(); + this.land(end); + return; + } + case "unrolled": { + // Each turn as it is: a continue goes to the next, a break past the last. + const end = this.label("unend"); + for (const it of s.iterations) { + const next = this.label("unnext"); + this.loops.push({ cont: next, exit: end }); + this.stmts(it); + this.loops.pop(); + this.land(next); + } + this.land(end); + return; + } + case "sleep": { + return this.sleepIn(this.co, s.cycles ?? Math.max(1, Math.round(((s.ms ?? 0) * FRAMES_PER_SECOND) / 1000))); + } + case "print": return this.print(s); + case "break": return this.goto(this.loops[this.loops.length - 1].exit); + case "continue": return this.goto(this.loops[this.loops.length - 1].cont); + case "start": return this.start(s); + case "watch": return this.watch(s); + case "cancel": return this.cancel(s); + case "parallel": return this.parallel(s); + case "try": { + // The body, with its `catch` as where an error thrown in it goes; the `catch` after it, reached only that way. + // (Nothing of it is done while the game runs until an error is thrown.) + const caught = this.label("catch"), end = this.label("tend"); + this.handlers.push(caught); + this.stmts(s.body); + this.handlers.pop(); + this.goto(end); + this.land(caught); + this.cold++; + this.stmts(s.catch); + this.cold--; + this.land(end); + return; + } + // (The error in flight is set by the statements before it, the line it was thrown at among them: `ownErrors`.) + case "throw": return this.goto(this.handler()); + default: throw new Error(`emitter: a ${s.kind} it does not lower reached the lowering`); + } + } + + /** + * A cell of a list known when the map is built, into `target`: the index into a variable of the emitter's own, the + * target set to 0, and for each bit any of the values has, a trigger whose conditions are the index inside the list + * (read from 0 up: one below zero is past the end, which reads 0) and that bit of the cell — read where it is, the + * condition's player field the cell's EPD, a place of the index — adding the bit to every place of the target. The + * index is tested first: the game stops at a trigger's first condition that fails, so a cell past the end is never + * read. + */ + readTable(target: string, t: Table, at?: At): void { + if (t.jump) return this.readCell(target, t, at); + // (The list's values one after another, a dword each: the layout puts them so.) + const stride = 1; + this.arrays.set(t.array.id, stride); + const index = `${this.key}.i${this.n++}`; + this.own.add(index); + this.write(index, t.index, false); + this.write(target, { c: 0, terms: [] }, false); + const bits = t.array.values!.reduce((m, v) => (m | (v >>> 0)) >>> 0, 0); + const inside: Cond = { dword: this.home(index, 0), compare: "atMost", value: t.array.values!.length - 1 }; + for (let j = 0; j < 32; j++) { + if (!((bits >>> j) & 1)) continue; + const cell: Cond = { dword: { element: t.array.id }, compare: "atLeast", value: 1, mask: u(2 ** j) }; + this.condPlayers.set(cell, { id: index, scale: stride, add: 0, base: t.array.id }); + this.push({ conds: [{ ...inside }, cell], items: [{ bump: { target, add: u(2 ** j) } }], at }); + } + this.open().items.push({ copy: target, ...(this.at ? { at: this.at } : {}) }); + } + + /** + * A sum shifted right by a constant, into `target`: the sum into a variable of the emitter's own (or, a variable + * alone, read in its cell), the target set to 0, and for each bit that comes down a trigger testing it and adding + * its new place's worth to every place of the target — for `>>`, the sign bit its weight below zero. From the top, + * eight bits a group, a group with none of its bits set is jumped over (a small number skips most), as the Python + * side's shift does. + */ + readShift(target: string, sh: Shift, at?: At): void { + let home: Dword; + if (sh.of.c === 0 && sh.of.terms.length === 1 && sh.of.terms[0].k === 1 && !this.own.has(sh.of.terms[0].id)) home = this.placesOf(sh.of.terms[0].id)[0].dword!; + else { + const of = `${this.key}.s${this.n++}`; + this.own.add(of); + this.write(of, sh.of, false); + home = this.home(of, 0); + } + this.write(target, { c: 0, terms: [] }, false); + const highest = sh.top === undefined ? 31 : Math.min(31, Math.max(0, sh.top).toString(2).length - 1); + if (highest < sh.by) { + // (Every bit that would come down is 0.) + this.open().items.push({ copy: target, ...(this.at ? { at: this.at } : {}) }); + return; + } + for (let top = highest; top >= sh.by; top -= 8) { + const bits = []; + for (let j = top; j >= Math.max(sh.by, top - 7); j--) bits.push(j); + const group = bits.reduce((m, j) => (m + 2 ** j), 0); + const past = this.label("g"); + this.branch([{ dword: home, compare: "exactly", value: 0, mask: u(group) }], past, at); + for (const j of bits) { + const add = j === 31 && sh.signed ? u(-(2 ** (31 - sh.by))) : u(2 ** (j - sh.by)); + this.push({ conds: [{ dword: home, compare: "atLeast", value: 1, mask: u(2 ** j) }], items: [{ bump: { target, add } }], at }); + } + this.land(past); + } + this.open().items.push({ copy: target }); + } + + /** A boolean set from a test: 1 when it holds, 0 when not. */ + /** + * A condition of the game's as conditions that hold exactly when it is `when` (`gameConds`); one whose amount is a + * value (`cond.amount`) has it as a place of the value, turned around one more or one less where the value's range + * keeps the amount inside 0 to 2^32 − 1. + */ + condsOf(e: Extract, when: boolean): Cond[] | "true" | "false" | null { + if (e.epd) { + // (The player field a place of the value: the dword at its EPD. A condition turned around is not one.) + const p = linear(e.epd)!; + const c = e.amount ? this.condsOf({ ...e, epd: undefined }, when) : gameConds(e.record, when); + if (!Array.isArray(c) || c.length !== 1) return null; + if (p.terms.length) this.condPlayers.set(c[0], { id: p.terms[0].id, scale: p.terms[0].k, add: u(p.c) }); + else c[0].game = { ...c[0].game!, player: u(p.c) }; + return c; + } + if (!e.amount) return gameConds(e.record, when); + const l = linear(e.amount.expr)!, [t] = l.terms, add = e.amount.add; + const r = (e.amount.expr as NumExpr & { range?: readonly [number, number] }).range; + const one = (comparison: number, more: number): Cond[] => { + const c = gameCond({ ...e.record, comparison, amount: 0 }); + this.condReads.set(c, { id: t.id, scale: t.k, add: u(l.c + add + more) }); + return [c]; + }; + if (when) return one(e.record.comparison, 0); + if (e.record.comparison === 0) return r && r[0] + add >= 1 ? one(1, -1) : null; + if (e.record.comparison === 1) return r && r[1] + add <= U32 - 2 ? one(0, 1) : null; + return null; + } + + /** A statement an `if` may guard in its one trigger: an action of the game's, or a write that reads only its target. */ + guards(t: Stmt): boolean { + if (this.scope.tables.has(t)) return false; + if (t.kind === "action") return gameAction(t) && !t.variables?.length && !t.text && t.record.type !== TRANSMISSION_ACTION; + if (t.kind !== "assign" || isTemp(t.target) || this.own.has(t.target) || this.results.has(t.target)) return false; + const l = linear(t.value); + return !!l && l.terms.every((x) => x.id === t.target && x.k === 1); + } + + /** + * An action of the game's, as the script wrote it, among the trigger's others. After one that makes or moves units, + * in a program that asks Bring or Command, Kill Unit of no unit (`FORGET_COUNTS`), in the same trigger. + */ + action(s: Extract): void { + // (Bring and Command counted again, written by the rewriter after a location moved: only where something counts.) + if (s.record === FORGET_COUNTS) { + if (this.scope.counts) this.open().items.push({ act: { dword: { game: 0 }, modifier: "set", value: 0, record: FORGET_COUNTS } }); + return; + } + // A field from a value is a place of its variable, kept by its writes: the action reads it where it is, and costs + // nothing more when it runs. The record holds the rest; in the dword the type, the kind and the count share, the + // place's constant is what is not the value. Its trigger is one of its own, the action first, so the place's index + // holds. + const record: ActionRecord = { ...s.record }; + const vars = (s.variables ?? []).map((v) => ({ ...v, l: linear(v.expr)! })); + for (const v of vars) if (!v.l.terms.length) (record as unknown as Record)[v.field] = v.l.c & (v.bits === 32 ? -1 : 2 ** v.bits - 1); + const varied = vars.filter((v) => v.l.terms.length); + const word = (f: string) => varied.some((v) => v.field === f); + const places: Partial> = {}; + for (const v of varied) { + const [t] = v.l.terms; + let scale = 1, add = 0; + if (v.field === "unitId" || v.field === "modifier") { + add = u((word("unitId") ? 0 : record.unitId) + record.type * 2 ** 16 + (word("modifier") ? 0 : record.modifier) * 2 ** 24); + if (v.field === "modifier") scale = 2 ** 24; + } + (record as unknown as Record)[v.field] = 0; + places[FIELD_PART[v.field]!] = { id: t.id, scale: mul(scale, t.k), add: u(mul(scale, v.l.c) + add) }; + } + // (A text made while the game runs: its template filled first, the action's text field naming it.) + const textOf = s.text?.kind === "template" ? this.template(s.text.parts) : undefined; + // (A Set Deaths whose amount is a text's number — `Rewriter.tableWrite`'s name — the shift its mask's.) + const amountText = s.text?.kind === "text" && record.type === SET_DEATHS_ACTION ? { text: s.text.text, shift: record.location ? 31 - Math.clz32(record.location & -record.location) : 0 } : undefined; + const act: Act = { dword: { game: 0 }, modifier: "set", value: 0, record, ...(textOf !== undefined ? { textOf } : {}), ...(amountText ? { amountText } : {}) }; + const items: Item[] = [{ act }, ...this.forgets(record)]; + if (record.type === TRANSMISSION_ACTION) return this.transmission(act, varied.length ? places : undefined); + if (varied.length) { + this.actPlaces.set(act, places); + // (Several in a row share a trigger: the indexes of their actions hold while nothing but actions is in it.) + const last = this.protos[this.protos.length - 1]; + if (last && last === this.fielded && !last.items.some((it) => !("act" in it)) && last.items.length + items.length <= MOST_ACTS) last.items.push(...items); + else this.fielded = this.push({ conds: [], items }); + } else this.open().items.push(...items); + } + + /** The trigger the last action with fields from values was put in (`action`), which the next one may share. */ + fielded?: Proto; + + /** + * A Transmission holds the rest of its trigger until its time is over — the walk goes on to the next trigger — and + * marks itself started, so that the game steps over it the next time its trigger is reached, going on from it: every + * other Transmission of a loop never showed (eudplib's do not). So it is the first action of a trigger of its own, + * and the trigger after it puts its flags and the trigger's current action back, as they were before it ran: each + * time the program reaches it, it shows. (A Transmission while another one's time runs is still held, as the game + * holds it.) + */ + transmission(act: Act, places?: Partial>): void { + if (places) this.actPlaces.set(act, places); + const tx = this.push({ conds: [], items: [{ act }] }); + this.push({ conds: [], items: [ + { act: { dword: { at: tx.label, field: { act: 0, part: "flags" } }, modifier: "set", value: act.record!.flags, mask: 0xff } }, + { act: { dword: { at: tx.label, field: "current" }, modifier: "set", value: 0, mask: 0xff000000 } }, + ] }); + } + + /** + * A number of the game's into `target`. Kept where the game keeps it (one player's deaths, kills, ore or gas): read + * a bit at a time from the top, where it is — a masked condition each — entered at the highest group of eight bits + * it has, so that a number below 65 536 is read in 16 bits (eudplib's `f_dwread_epd` reads 32). Otherwise the + * condition asked a bit at a time from the top, its amount a place of what is found so far plus the bit, so each + * bit is one trigger whose condition holds when the bit is set (12 bits for a count of units, which never reaches + * 4 096; 4 for the opponents; 32 otherwise, as the Python side's). + */ + readGame(target: string, rec: ConditionRecord, at?: At, shift = 0): void { + emitterCounts.reads++; + const key = `${this.key}.rg${this.n++}`; + const table = tableOf(rec, this.scope.owner); + if (table !== undefined) { + const out = `${key}.o`, d: Dword = { game: table.at }, whole = table.mask === U32 - 1; + // A cell of the deaths table whose number is a player group's (12 to 26: Player 2's Ghosts, Player 1's Vultures) + // cannot be named by its EPD — the game reads the group's cell for that player field — so it is asked as the + // game's own Deaths condition of that player and unit, the mask in its record (step 13; found by its probe). + const index = rec.type === DEATHS_TYPE && table.at >= DEATHS_TABLE && table.at < DEATHS_TABLE + 4 * 12 * 228 ? (table.at - DEATHS_TABLE) / 4 : -1; + const byRecord = index >= 12 && index <= LAST_GROUP; + const cond = (compare: Compare, value: number, mask?: number): Cond => (byRecord + ? { dword: { game: 0 }, compare, value, game: { ...rec, player: index % 12, unitId: Math.floor(index / 12), comparison: compare === "atLeast" ? 0 : 1, amount: value, location: mask ?? 0, mask: mask === undefined ? 0 : MASKED_MARK } } + : { dword: d, compare, value, ...(mask === undefined ? {} : { mask }) }); + this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], at }); + // (The bits of the mask from the top, each moved down `shift`; entered at the highest group of eight from the + // lowest that has a bit set: below 2^8, 2^16, 2^24 of the field — the masked dword at most that — from its bit 7, + // 15 or 23.) + const bits = Array.from({ length: 32 }, (_, b) => 31 - b).filter((b) => table.mask & 2 ** b); + const lo = bits[bits.length - 1], cuts = [8, 16, 24].map((g) => lo + g).filter((c) => c <= bits[0]); + const starts = new Map(cuts.map((c) => [c - 1, this.label("rgfrom")])); + for (const c of cuts) this.branch([cond("atMost", u(2 ** c - 2 ** lo), whole ? undefined : table.mask)], starts.get(c - 1)!, at); + for (const b of bits) { + if (starts.has(b)) this.land(starts.get(b)!); + this.push({ conds: [cond("atLeast", 1, u(2 ** b))], items: [{ act: { dword: { temp: out }, modifier: "add", value: u(2 ** (b - shift)) } }], at }); + } + this.open().items.push({ fill: { temp: out, target } }); + return; + } + const bits = rec.type === 2 || rec.type === 3 ? 12 : rec.type === OPPONENTS_TYPE ? 4 : 32; + const found = `${key}.f`; + this.own.add(found); + this.write(found, { c: 0, terms: [] }, false); + for (let b = bits - 1; b >= 0; b--) { + const c = gameCond({ ...rec, comparison: 0, amount: 0 }); + this.condReads.set(c, { id: found, scale: 1, add: u(2 ** b) }); + this.push({ conds: [c], items: [{ bump: { target: found, add: u(2 ** b) } }], at }); + } + this.write(target, { c: 0, terms: [{ id: found, k: 1 }] }); + } + + /** + * The dword at a value's EPD (a map of our own, `compiler/backend/texts.ts`), or its bits of the record's mask, into + * `target`, each moved down `shift`: a trigger a bit, from the top, each condition's player field a place of the + * value; entered, as `readGame` enters a table's, at the highest group of eight the masked dword reaches. + */ + readAt(target: string, rec: ConditionRecord, epd: Linear, at?: At, shift = 0): void { + emitterCounts.reads++; + const key = `${this.key}.ra${this.n++}`; + const mask = rec.mask ? rec.location >>> 0 : U32 - 1, whole = mask === U32 - 1; + const out = `${key}.o`, d: Dword = { game: 0 }; + const placed = (c: Cond): Cond => { + if (epd.terms.length) this.condPlayers.set(c, { id: epd.terms[0].id, scale: epd.terms[0].k, add: u(epd.c) }); + else c.dword = { game: u(DEATHS_TABLE + 4 * epd.c) }; + return c; + }; + this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], at }); + const bits = Array.from({ length: 32 }, (_, b) => 31 - b).filter((b) => mask & 2 ** b); + const lo = bits[bits.length - 1], cuts = [8, 16, 24].map((g) => lo + g).filter((c) => c <= bits[0]); + const starts = new Map(cuts.map((c) => [c - 1, this.label("rafrom")])); + for (const c of cuts) this.branch([placed({ dword: d, compare: "atMost", value: u(2 ** c - 2 ** lo), ...(whole ? {} : { mask }) })], starts.get(c - 1)!, at); + for (const b of bits) { + if (starts.has(b)) this.land(starts.get(b)!); + // (A bit moved past the top is lost, as a product's is.) + if (b - shift > 31) continue; + this.push({ conds: [placed({ dword: d, compare: "atLeast", value: 1, mask: u(2 ** b) })], items: [{ act: { dword: { temp: out }, modifier: "add", value: u(2 ** (b - shift)) } }], at }); + } + this.open().items.push({ fill: { temp: out, target } }); + } + + /** After an action that makes or moves units, in a program that asks Bring or Command: the counts thrown away. */ + forgets(r: ActionRecord): Item[] { + return MOVES_UNITS.has(r.type) && this.scope.counts ? [{ act: { dword: { game: 0 }, modifier: "set", value: 0, record: FORGET_COUNTS } }] : []; + } + + setBool(target: string, cond: BoolExpr): void { + const no = this.label("bno"), done = this.label("bdone"); + this.jump(cond, false, no, this.at); + this.write(target, { c: 1, terms: [] }); + this.goto(done); + this.land(no); + this.write(target, { c: 0, terms: [] }); + this.land(done); + } + + /** + * A call (a map of our own). The arguments are written into the function's parameters — into every place each has + * in the function's triggers, as any write is — in order; the rewriter has already taken into variables of its own + * any value a later argument's call could change. A called function: the trigger with the arguments also sets the + * next pointer of the trigger the function ends at to here, and goes to the function's first; back here, the + * function's result is copied into the call's own. An inlined call: its body where it stands, its result 0 until a + * `return` writes it (as the Python side has it). + */ + call(c: Call): void { + if (!c.saves) this.arguments(c); + if (c.fn !== undefined) { + const f = this.scope.functions.get(c.fn)!; + let fn = this.fns.get(c.fn); + if (!fn) { + this.fns.set(c.fn, (fn = { entry: this.label("fn"), exit: this.label("fx"), depth: 0 })); + this.pending.push(c.fn); + } + fn.depth = Math.max(fn.depth, this.depthBase + this.loops.length); + const back = this.label("back"); + // An error out of a function that never comes back into itself goes on from a trigger of the function's own, + // whose next pointer the call points at where the error goes from here: nothing is looked at on the way back. + // After a call of one that may come back, and after a call that keeps a frame — which takes it back first — the + // error in flight is looked at. + const throws = this.mayThrow(c.fn), routed = throws && !f.recursive; + if (routed && !fn.thrown) fn.thrown = this.label("fthrow"); + // (A function that calls itself also keeps where it goes back to in a variable, which a frame keeps: the frame + // of a call that may come back is the function's the call is in.) + const ra = f.recursive ? this.returnOf(c.fn, fn.exit) : null; + const within = c.saves && this.current !== undefined ? this.fns.get(this.current) : undefined; + if (c.saves && !within) throw new Error(`emitter: a call of ${f.name} that keeps a frame, outside a function`); + const kept = c.saves ? this.keep(c, this.returnOf(this.current!, within!.exit)) : null; + if (kept) this.arguments(c); + const items = this.open().items; + items.push(ra ? { label: { target: ra, to: back } } : { act: { dword: nextOf(fn.exit), modifier: "set", value: { label: back } } }); + if (routed) { + const to = kept ? back : this.handler(), item: Item = { act: { dword: nextOf(fn.thrown!), modifier: "set", value: { label: to } } }; + items.push(item); + let sets = this.thrownSets.get(c.fn); + if (!sets) this.thrownSets.set(c.fn, (sets = [])); + sets.push({ items, item, to }); + } + this.goto(fn.entry); + this.land(back); + if (kept) this.bringBack(kept); + if (throws && (!routed || kept)) this.jump(this.inFlight(), true, this.handler(), this.at); + if (c.result && f.result) { + // What the function gives back goes into its cell alone, and each call reads it from there: as places, a + // return would write every call's, and each of those actions would be a place of what the return reads — + // the parameters, then what every call passes them — so places would multiply from call to call. + this.results.add(f.result.decl.id); + this.write(c.result.decl.id, { c: 0, terms: [{ id: f.result.decl.id, k: 1 }] }); + } + return; + } + const end = this.label("iend"); + if (c.result) this.write(c.result.decl.id, { c: 0, terms: [] }); + this.frames.push({ ...(c.result ? { result: { id: c.result.decl.id, kind: c.result.kind } } : {}), end }); + this.stmts(c.body); + this.frames.pop(); + this.land(end); + } + + /** + * The arguments into the parameters, in order. A call that may come back into the function it is in sets that + * function's own parameters, which a later argument may read: then every argument goes into a variable of the + * emitter's own first. + */ + arguments(c: Call): void { + const set = (id: string, kind: string, value: NumExpr | BoolExpr) => (kind === "boolean" ? this.setBool(id, value as BoolExpr) : this.write(id, linear(value as NumExpr)!)); + const params = new Set(c.params.map((p) => p.decl.id)); + const crossed = c.params.some((p, i) => i > 0 && [...readIn(p.init as NumExpr | BoolExpr)].some((id) => params.has(id) && c.params.findIndex((q) => q.decl.id === id) < i)); + if (!crossed) { + for (const p of c.params) set(p.decl.id, p.decl.kind, p.init as NumExpr | BoolExpr); + return; + } + const temps = c.params.map((p) => { + const t = `${this.key}.arg${this.n++}`; + this.own.add(t); + set(t, p.decl.kind, p.init as NumExpr | BoolExpr); + return t; + }); + c.params.forEach((p, i) => this.write(p.decl.id, { c: 0, terms: [{ id: temps[i], k: 1 }] })); + } + + /** + * Where a function that calls itself goes back to, as a variable whose places are the next pointer of the trigger + * the function ends at and the number of each store that keeps it on the stack: every call of the function sets + * them all to where it goes on (a label), and after a call that may come back a cell of the stack puts them back. + */ + returnOf(fn: string, exit: string): string { + const ra = `${fn}.(goes back to)`; + if (!this.places.has(ra)) this.placesOf(ra).push({ dword: nextOf(exit), scale: 1, add: 0 }); + return ra; + } + + /** + * Before a call that may come back into the function it is in (a map of our own): too deep, the program says so and + * stops for good, as the Python side's build does; otherwise the function's variables and where it goes back to + * onto the stack — one trigger, a Set Deaths each, its destination a place of `sp`, its number a place of what it + * keeps — and `sp` and `depth` on. + */ + keep(c: Call, ra: string): { vars: string[]; stack: { id: string; sp: string } } { + // (Only what is read again before it is written: `saves` lists every variable of the function.) + const live = this.live.get(c); + if (!live) throw new Error(`emitter: a call of ${c.name ?? "a function"} that keeps a frame, not looked at`); + const vars = [...c.saves!.vars.filter((id) => live.has(id)), ra]; + const stack = this.stackOf(this.current!); + stack.frame = Math.max(stack.frame, vars.length); + this.own.add(this.depth); + const room = this.label("room"); + this.jump({ kind: "compare", op: "<", left: { kind: "var", id: this.depth }, right: { kind: "const", value: this.stackDepth }, unsigned: true, at: this.at!, label: "" }, true, room, this.at); + const line = this.at?.line ?? "?"; + if (this.scope.errors) { + // A program that keeps errors throws a RangeError, as JavaScript does: where the call is, before anything of it + // is kept. + this.call({ fn: OVERFLOW_FN, name: "(stack overflow)", params: [], body: [], at: this.at!, label: "" }); + this.write(errorLine(this.scope.errors), { c: this.at?.line ?? 0, terms: [] }); + this.goto(this.handler()); + } else { + this.open().items.push({ act: { dword: { game: 0 }, modifier: "set", value: 0, display: `\x06TrigScript: stack overflow in ${c.saves!.within}, line ${line} - ${this.stackDepth} calls deep. The program has stopped.` } }); + this.co.end(); + } + this.land(room); + // (Each variable through one of the emitter's own, written just before: a store's number is a place no refresh + // reaches, and the variable itself may have to be read from its cell where it is used.) + const kept = vars.map((id) => (id === ra ? id : this.pin({ c: 0, terms: [{ id, k: 1 }] }, "kv"))); + const store = this.push({ conds: [], items: [] }); + kept.forEach((id, j) => { + const act: Act = { dword: { element: stack.id }, modifier: "set", value: 0 }; + this.actPlaces.set(act, { dest: { id: stack.sp, scale: 18, add: 18 * j, base: stack.id }, value: { id, scale: 1, add: 0 } }); + store.items.push({ act }); + }); + // (A trigger of its own after it: an action's place is its index in its trigger, which the writes of `sp` would + // push past 64.) + this.push({ conds: [], items: [] }); + this.write(stack.sp, { c: vars.length, terms: [{ id: stack.sp, k: 1 }] }, false); + this.write(this.depth, { c: 1, terms: [{ id: this.depth, k: 1 }] }, false); + return { vars, stack }; + } + + /** + * After it: `sp` and `depth` back, and each cell of the frame into a scratch cell — a trigger that sets the scratch + * cell to 0, points the stack cell's action at it and its next pointer back here, and goes to it (its own next + * pointer a place of `sp`) — and the scratch cell into every place of the variable. + */ + bringBack(kept: { vars: string[]; stack: { id: string; sp: string } }): void { + const { id: stack, sp } = kept.stack; + this.write(sp, { c: u(-kept.vars.length), terms: [{ id: sp, k: 1 }] }, false); + this.write(this.depth, { c: MAX, terms: [{ id: this.depth, k: 1 }] }, false); + kept.vars.forEach((id, j) => { + const temp = `${this.key}.st${this.n++}`, after = this.label("got"), jump = this.label("pop"); + const point: Act = { dword: { element: stack }, modifier: "set", value: { epd: { temp } } }; + const back: Act = { dword: { element: stack }, modifier: "set", value: { label: after } }; + this.actPlaces.set(point, { dest: { id: sp, scale: 18, add: 18 * j - 1, base: stack } }); + this.actPlaces.set(back, { dest: { id: sp, scale: 18, add: 18 * j - 86, base: stack } }); + this.push({ label: jump, conds: [], items: [{ act: { dword: { temp }, modifier: "set", value: 0 } }, { act: point }, { act: back }], next: after, keep: true }); + this.jumpReads.set(jump, { id: sp, scale: 72, add: 72 * j, base: `addr:${stack}` }); + this.land(after); + this.open().items.push({ fill: { temp, target: id } }); + }); + } + + /** + * The routine that writes a number as digits (a map of our own), called by each print with a number in it: where + * its two ways in are (a signed number, a `u32`), the trigger it ends at (its next pointer the call's), the variable + * the number is handed in, and the three dwords it writes the digits into — 12 bytes, the number at their right end, + * 0x0D (which draws nothing) before it — which the print then copies into its text. + */ + itoa?: { signed: string; unsigned: string; exit: string; v: string; r: string; neg: string; out: string[] }; + /** The printed texts' templates (`Graph.templates`). */ + templates: { id: string; bytes: number[]; at?: number; data?: boolean }[] = []; + + /** + * A text printed with values in it (a map of our own). Its template is laid out when the map is built: the text, + * and for each number a field on a dword's start — 12 bytes, or 4 or 8 when the value ranges know the number never + * takes more characters than that (`fieldWords`) — the bytes before it to the dword's start 0x0D (they draw nothing); + * for each byte from a number (a colour code), one byte. Nothing in the text moves, however wide the number is: the + * routine writes the number at the right end of its three dwords, 0x0D before it, and the print copies the last of + * them into the field. A print hands each number to the routine, writes each byte where it goes (0x0D for a 0, which + * writes nothing), and shows the template with Display Text. (The rewriter has already put the values in the order + * JavaScript works them out.) The game's text line holds 218 bytes: a line of many numbers with 12-byte fields was cut + * short in the lifted game (step 17's `game-lowering` probe, 24 one-digit numbers). + */ + /** + * A print at the centre of the screen, as eudplib's `f_eprintln`: for each player it is for, with that player the + * current player — the game's error "Cannot create more units" raised (Create Unit for the current player with the + * game's first free unit taken away, then put back: the line of errors shown at the centre), and on that player's + * own computer alone the text written into that line (the template is the line itself: `template`'s `at`). + */ + centre(s: Extract): void { + const players = playersOf(s.to, this.scope.owner, this.scope.players)!, owner = this.scope.owner ?? 0; + const current = (p: number): Item => ({ act: { dword: { game: CURRENT_PLAYER_ADDRESS }, modifier: "set", value: p } }); + for (const p of players) { + if (p !== owner) this.open().items.push(current(p)); + // The error raised: the first free unit read, taken away for the Create Unit, put back. + const free = `${this.key}.free${this.n++}`; + this.own.add(free); + this.readGame(free, { location: 0, player: u((FIRST_FREE_UNIT - DEATHS_TABLE) / 4), amount: 0, unitId: 0, comparison: 0, type: 15, resource: 0, flags: 0, mask: 0 }, s.at); + const back: Act = { dword: { game: FIRST_FREE_UNIT }, modifier: "set", value: 0 }; + this.actPlaces.set(back, { value: { id: free, scale: 1, add: 0 } }); + const create: ActionRecord = { location: 64, text: 0, wav: 0, time: 0, player: CURRENT_PLAYER, target: 0, unitId: 0, type: 44, modifier: 1, flags: 0x10, padding: 0, mask: 0 }; + this.push({ conds: [], items: [{ act: { dword: { game: FIRST_FREE_UNIT }, modifier: "set", value: 0 } }, { act: { dword: { game: 0 }, modifier: "set", value: 0, record: create } }, { act: back }], at: s.at }); + // The text into the line, on that player's computer alone. + const here = this.label("centre"), past = this.label("centred"); + this.branch([{ dword: { game: LOCAL_PLAYER }, compare: "exactly", value: p }], here, s.at); + this.goto(past); + this.land(here); + this.template(s.parts, CENTRE_LINE); + this.land(past); + } + if (players.some((p) => p !== owner)) this.open().items.push(current(owner)); + } + + print(s: Extract): void { + if (s.position === "center") return this.centre(s); + const known = s.parts.every((p) => p.kind === "text"); + const show: Act = known + ? { dword: { game: 0 }, modifier: "set", value: 0, display: s.parts.map((p) => (p.kind === "text" ? p.text : "")).join("") } + : { dword: { game: 0 }, modifier: "set", value: 0, show: this.template(s.parts) }; + // (For others than the owner: Display Text with each of them the current player in turn — it shows on the computer + // of the player who is, and of nobody when nobody plays that slot — then the owner again.) + const players = playersOf(s.to, this.scope.owner, this.scope.players)!; + const owner = this.scope.owner ?? 0; + const current = (p: number): Item => ({ act: { dword: { game: CURRENT_PLAYER_ADDRESS }, modifier: "set", value: p } }); + this.open().items.push(...(players.length === 1 && players[0] === owner ? [{ act: show }] : [...players.flatMap((p) => [current(p), { act: { ...show } }]), current(owner)])); + } + + /** + * A text with values in it written into a template of its own (`Graph.templates`), each value as it is now: its id. + * `at`: the template is the game's memory there (the line of a print at the centre), its bytes written first. + */ + /** A block of `bytes` zeros — or holding `text`, then zeros — among the templates, once (`textBlock`, `showText`). */ + block(id: string, bytes: number, text?: string, data?: boolean): void { + if (this.templates.some((t) => t.id === id)) return; + const content = text ? [...new TextEncoder().encode(text)] : []; + this.templates.push({ id, bytes: [...content, ...Array(Math.max(1, 4 * Math.ceil(bytes / 4) - content.length)).fill(0)], ...(data ? { data: true } : {}) }); + } + + template(parts: TextPart[], at?: number): string { + const id = `${this.key}.text${this.n++}`; + const bytes: number[] = []; + const enc = new TextEncoder(); + const values: { part: TextPart; at: number }[] = []; + const fixedAt = new Map(); + for (const p of parts) { + if (p.kind === "text") bytes.push(...enc.encode(p.text)); + else if (p.kind === "number" && p.fixed) { + // A number with a fraction: its sign's byte, its whole part's field, and eight bytes for the point and four places. + const sign = bytes.length; + bytes.push(PAD); + while (bytes.length % 4) bytes.push(PAD); + values.push({ part: p, at: bytes.length }); + bytes.push(...Array(12).fill(PAD)); + fixedAt.set(p, { sign, frac: bytes.length }); + bytes.push(...Array(8).fill(PAD)); + } else if (p.kind === "number") { + while (bytes.length % 4) bytes.push(PAD); + values.push({ part: p, at: bytes.length }); + bytes.push(...Array(4 * fieldWords(p)).fill(PAD)); + } else if (p.kind === "char") { + values.push({ part: p, at: bytes.length }); + bytes.push(PAD); + } else if (p.kind === "pick") { + // A field as long as its longest text, on dwords of its own, each case's text written whole into it. + while (bytes.length % 4) bytes.push(PAD); + values.push({ part: p, at: bytes.length }); + const most = Math.max(0, ...p.cases.map((c) => enc.encode(c.text).length)); + bytes.push(...Array(4 * Math.ceil(most / 4)).fill(PAD)); + } + } + bytes.push(0); + this.templates.push({ id, bytes, ...(at !== undefined ? { at } : {}) }); + if (at !== undefined) { + // (Every dword of it as built, the fields' padding too, before the values are written into their fields.) + const words: Item[] = []; + for (let w = 0; 4 * w < bytes.length; w++) words.push({ act: { dword: { template: id, word: w }, modifier: "set", value: [0, 1, 2, 3].reduce((n, k) => n + (bytes[4 * w + k] ?? 0) * 2 ** (8 * k), 0) } }); + for (let i = 0; i < words.length; i += MOST_ACTS) this.push({ conds: [], items: words.slice(i, i + MOST_ACTS) }); + } + for (const { part, at } of values) { + if (part.kind === "number") { + const it = this.digits(); + const f = fixedAt.get(part); + if (f) this.write(it.v, { c: 0, terms: [{ id: this.fixedParts(id, linear(part.expr)!, f.sign, f.frac), k: 1 }] }, false); + else this.write(it.v, linear(part.expr)!, false); + const back = this.label("printed"); + this.open().items.push({ act: { dword: nextOf(it.exit), modifier: "set", value: { label: back } } }); + this.goto(part.unsigned || f ? it.unsigned : it.signed); + this.land(back); + // (The routine's last dwords into the field: the number is at their right end.) + const words = f ? 3 : fieldWords(part); + it.out.slice(3 - words).forEach((from, w) => this.open().items.push({ route: { from, into: [{ template: id, word: at / 4 + w }] } })); + continue; + } + if (part.kind === "pick") { + // For each case, a test of it and then its text's dwords, the field's padding after it, set. + const size = 4 * Math.ceil(Math.max(0, ...part.cases.map((c) => enc.encode(c.text).length)) / 4); + for (const c of part.cases) { + const text = [...enc.encode(c.text)]; + while (text.length < size) text.push(PAD); + const words: Item[] = []; + for (let w = 0; 4 * w < size; w++) words.push({ act: { dword: { template: id, word: at / 4 + w }, modifier: "set", value: text.slice(4 * w, 4 * w + 4).reduce((n, b, j) => n + b * 2 ** (8 * j), 0) } }); + const past = this.label("picked"); + const is: BoolExpr = c.upTo + ? { kind: "and", items: [{ kind: "compare", op: ">=", left: part.by, right: c.when, at: this.at!, label: "" }, { kind: "compare", op: "<=", left: part.by, right: c.upTo, at: this.at!, label: "" }] } + : { kind: "compare", op: "==", left: part.by, right: c.when, at: this.at!, label: "" }; + this.jump(is, false, past, this.at); + for (let i = 0; i < words.length; i += MOST_ACTS) this.push({ conds: [], items: words.slice(i, i + MOST_ACTS) }); + this.land(past); + } + continue; + } + if (part.kind !== "char") continue; + // The byte: the number's low eight bits into its place in the dword (a mask), a place of the number; 0x0D when + // they are 0. + const word: Dword = { template: id, word: at >> 2 }, shift = 2 ** (8 * (at & 3)), mask = u(0xff * shift); + const from = this.alone(linear(part.expr)!, "ch"); + const set: Act = { dword: word, modifier: "set", value: 0, mask }; + this.actPlaces.set(set, { value: { id: from, scale: shift, add: 0 } }); + this.push({ conds: [], items: [{ act: set }] }); + this.push({ conds: [{ dword: word, compare: "exactly", value: 0, mask }], items: [{ act: { dword: word, modifier: "set", value: u(PAD * shift), mask } }] }); + } + return id; + } + + /** + * A number with a fraction printed, as the simulator's `fixedText`: its magnitude's whole part (into a variable of + * the emitter's own, which the digits routine writes into the field before), the four places below the point rounded + * half up — the low sixteen bits times 10 000 and 32 768 over 65 536, a constant added a bit as a product by a constant + * is (`fixedByConst`), 10 000 carried into the whole part — and the minus sign when the number is below zero and not + * shown as 0. The places go into the eight bytes after the field as '.' and four digits, a trigger each for 8, 4, 2 + * and 1 times a digit's power of ten; the zeros at their end, and the point when there is nothing after it, 0x0D, + * which draws nothing. Returns the whole part's variable. + */ + fixedParts(id: string, value: Linear, signAt: number, fracAt: number): string { + emitterCounts.fixed++; + const key = `${this.key}.pf${this.n++}`, m = `${key}.m`, low = `${key}.l`, neg = `${key}.neg`, whole = `${key}.w`, places = `${key}.p`; + this.own.add(whole); + this.own.add(places); + const at = this.at, from = this.beginFed(at); + const byteOf = (b: number) => ({ dword: { template: id, word: b >> 2 } as Dword, shift: 2 ** (8 * (b & 3)) }); + // |v|, and whether it was below zero. + this.push({ conds: [], items: [{ act: { dword: { temp: neg }, modifier: "set", value: 0 } }, { act: { dword: { temp: low }, modifier: "set", value: 32768 } }], at }); + this.bySign(m, this.multiple(value, "pv"), true, (minus) => { + if (minus) this.open().items.push({ act: { dword: { temp: neg }, modifier: "set", value: 1 } }); + }, at); + this.write(whole, { c: 0, terms: [] }, false); + this.write(places, { c: 0, terms: [] }, false); + for (let j = 31; j >= 16; j--) this.push({ conds: [{ dword: { temp: m }, compare: "atLeast", value: 1, mask: u(2 ** j) }], items: [{ bump: { target: whole, add: 2 ** (j - 16) } }], at }); + for (let j = 15; j >= 0; j--) { + const hi = Math.floor((10000 * 2 ** j) / 65536), lo = (10000 * 2 ** j) % 65536; + this.push({ conds: [{ dword: { temp: m }, compare: "atLeast", value: 1, mask: 2 ** j }], items: [...(hi ? [{ bump: { target: places, add: hi } } as Item] : []), { act: { dword: { temp: low }, modifier: "add", value: lo } }], at }); + } + for (let k = 20; k >= 16; k--) this.push({ conds: [{ dword: { temp: low }, compare: "atLeast", value: 1, mask: 2 ** k }], items: [{ bump: { target: places, add: 2 ** (k - 16) } }], at }); + this.push({ conds: [{ dword: this.home(places, 0), compare: "exactly", value: 10000 }], items: [{ bump: { target: whole, add: 1 } }, { bump: { target: places, add: u(-10000) } }], at }); + // The sign's byte: '-' below zero, unless what is shown is 0. + const sign = byteOf(signAt), smask = u(0xff * sign.shift); + this.push({ conds: [], items: [{ act: { dword: sign.dword, modifier: "set", value: u(PAD * sign.shift), mask: smask } }], at }); + for (const shown of [whole, places]) this.push({ conds: [{ dword: { temp: neg }, compare: "exactly", value: 1 }, { dword: this.home(shown, 0), compare: "atLeast", value: 1 }], items: [{ act: { dword: sign.dword, modifier: "set", value: u(0x2d * sign.shift), mask: smask } }], at }); + // The point and the places, or nothing. + const [f0, f1] = [{ template: id, word: fracAt >> 2 } as Dword, { template: id, word: (fracAt >> 2) + 1 } as Dword]; + const none = this.label("pfnone"), done = this.label("pfdone"); + this.push({ conds: [], items: [{ act: { dword: f0, modifier: "set", value: PAD * 0x01010101 } }, { act: { dword: f1, modifier: "set", value: PAD * 0x01010101 } }], at }); + this.branch([{ dword: this.home(places, 0), compare: "exactly", value: 0 }], none, at); + // ('.', then '0' for each place: d3 d2 d1 in the first dword after the point, d0 in the second.) + this.push({ conds: [], items: [{ act: { dword: f0, modifier: "set", value: 0x2e + 0x30 * 0x100 + 0x30 * 0x10000 + 0x30 * 0x1000000 } }, { act: { dword: f1, modifier: "set", value: 0x30 + PAD * 0x010101 * 0x100 } }], at }); + const digit = (p: number) => (p === 0 ? { dword: f1, shift: 1 } : { dword: f0, shift: 2 ** (8 * (4 - p)) }); + for (let p = 3; p >= 0; p--) for (const k of [8, 4, 2, 1]) { + const step = k * 10 ** p; + if (step > 9999) continue; + const d = digit(p); + this.push({ conds: [{ dword: this.home(places, 0), compare: "atLeast", value: step }], items: [{ bump: { target: places, add: u(-step) } }, { act: { dword: d.dword, modifier: "add", value: k * d.shift } }], at }); + } + // Zeros at the end, from the last place back: each one nothing when the one after it is. + for (let p = 0; p <= 2; p++) { + const d = digit(p), mask = u(0xff * d.shift), after = p === 0 ? [] : [{ dword: digit(p - 1).dword, compare: "exactly", value: u(PAD * digit(p - 1).shift), mask: u(0xff * digit(p - 1).shift) } as Cond]; + this.push({ conds: [...after, { dword: d.dword, compare: "exactly", value: u(0x30 * d.shift), mask }], items: [{ act: { dword: d.dword, modifier: "set", value: u(PAD * d.shift), mask } }], at }); + } + this.goto(done); + this.land(none); + this.land(done); + this.routeFed(from); + return whole; + } + + /** The routine that writes a number as digits: its labels and variables, made the first time a print needs it. */ + digits(): NonNullable { + if (!this.itoa) { + const v = (name: string) => `${this.key}.(digits ${name})`; + this.itoa = { signed: this.label("itoa"), unsigned: this.label("itoau"), exit: this.label("itoax"), v: v("of"), r: v("left"), neg: v("minus"), out: [v("0"), v("1"), v("2")] }; + this.own.add(this.itoa.r); + this.own.add(this.itoa.neg); + } + return this.itoa; + } + + /** + * The routine, laid out once after the program and its functions. The three dwords set to 0x0D all through; the + * number's size into `left` (a signed one's sign apart); how many digits it has, by comparisons with powers of ten + * (four at most); then, from that many digits down, each digit's '0' (its byte from 0x0D up to 0x30, all in the + * trigger the comparisons go to, with the minus sign before the first digit when there is one) and its value: a + * trigger each for 8, 4, 2 and 1 times the digit's power of ten, which takes it from `left` and adds it to the + * digit's byte when `left` holds that much. About four triggers a digit; eudplib's build divides by ten for each. + */ + emitDigits(): void { + const it = this.itoa; + if (!it) return; + const set = (id: string, c: number, terms: { id: string; k: number }[] = []) => this.write(id, { c: u(c), terms }, false); + this.land(it.signed); + set(it.neg, 0); + set(it.r, 0, [{ id: it.v, k: 1 }]); + const go = this.label("itoago"); + this.jump({ kind: "compare", op: "<", left: { kind: "var", id: it.v }, right: { kind: "const", value: 0 }, at: this.at!, label: "" } as BoolExpr, false, go); + set(it.r, 0, [{ id: it.v, k: MAX }]); + set(it.neg, 1); + this.goto(go); + this.land(it.unsigned); + set(it.neg, 0); + set(it.r, 0, [{ id: it.v, k: 1 }]); + this.land(go); + for (const w of it.out) set(w, PAD * 0x01010101); + // How many digits: k when `left` is at least 10^(k-1). + const width = Array.from({ length: 11 }, (_, k) => this.label(`itoak${k}`)); + const tree = (lo: number, hi: number): void => { + if (lo === hi) return this.goto(width[lo]); + const mid = (lo + hi + 1) >> 1, high = this.label("itoahi"); + this.jump({ kind: "compare", op: ">=", left: { kind: "var", id: it.r }, right: { kind: "const", value: 10 ** (mid - 1) }, unsigned: true, at: this.at!, label: "" } as BoolExpr, true, high); + tree(lo, mid - 1); + this.land(high); + tree(mid, hi); + }; + tree(1, 10); + // The byte of digit p (of 10^p) is 11 - p: the dword (11 - p) >> 2, its byte (11 - p) & 3. + const at = (b: number) => ({ word: b >> 2, shift: 2 ** (8 * (b & 3)) }); + const ladder = Array.from({ length: 10 }, (_, p) => this.label(`itoap${p}`)); + for (let k = 10; k >= 1; k--) { + this.land(width[k]); + const zeros = [0, 0, 0]; + for (let p = 0; p < k; p++) zeros[at(11 - p).word] += (0x30 - PAD) * at(11 - p).shift; + zeros.forEach((add, w) => { if (add) this.open().items.push({ bump: { target: it.out[w], add: u(add) } }); }); + const sign = at(11 - k); + this.push({ conds: [{ dword: this.home(it.neg, 0), compare: "atLeast", value: 1 }], items: [{ bump: { target: it.out[sign.word], add: u((0x2d - PAD) * sign.shift) } }] }); + this.goto(ladder[k - 1]); + } + for (let p = 9; p >= 0; p--) { + this.land(ladder[p]); + const { word, shift } = at(11 - p); + for (const m of [8, 4, 2, 1]) { + const step = m * 10 ** p; + if (step >= U32) continue; + this.push({ conds: [{ dword: this.home(it.r, 0), compare: "atLeast", value: step }], items: [{ bump: { target: it.r, add: u(-step) } }, { bump: { target: it.out[word], add: m * shift } }] }); + } + } + this.push({ label: it.exit, conds: [], items: [], keep: true }); + } + + /** A `return`: the value into the result of the function or inlined call around it, and on to its end. */ + ret(s: Extract): void { + const frame = this.frames[this.frames.length - 1]; + // At the top of the program: its end, for good. + if (!frame) { this.goto(END); return; } + const r = frame.result; + if (r && r.kind === "boolean" && s.value !== undefined) this.setBool(r.id, s.value as BoolExpr); + else if (r) this.write(r.id, s.value === undefined ? { c: 0, terms: [] } : linear(s.value as NumExpr)!); + this.goto(frame.end); + } + + // ── coroutines and tasks (a map of our own: TrigScript 4, step 11) ── + + /** + * A sleep of a coroutine: the frame it wakes at — the map's count of frames now plus these, one copy — and where it + * goes on, written into its wake; then its turn is given back. Where it goes on puts its wake back as it was, and goes + * on to `then` when there is one. + */ + sleepIn(co: Co, frames: number, then?: string): void { + const resume = this.label("wake"); + this.open().items.push({ route: { from: NOW, into: [co.tick], add: frames } }, { act: { dword: co.at, modifier: "set", value: { label: resume } } }); + this.goto(co.yieldTo); + this.push({ label: resume, conds: [], items: [{ act: co.reset }], ...(then ? { next: then } : {}) }); + } + + /** + * The events pass's watch (`units.ts`): a trigger for each of the game's unit places, whose conditions read the + * place's record where it is against what the trigger saw there last — its own conditions' numbers, 0 for a place + * never handed out. They are walked from the place the game will hand out next (the entry's next pointer, moved down + * as places are first handed out) up to place 1 699 and then to place 0: the places the game has handed out, newest + * first. A place as it was is the one trigger: its conditions hold, and it puts the trigger before it back and goes + * on to the next. One that changed goes to its stub — the record's EPD into `@ev.e`, the trigger's first condition's + * number into `@ev.wf`, where to go on into `RET`, the place handed out after it into `NEXT` — and to the handler, + * laid out once, after which the boundary's check moves the entry down when the place was the one handed out next. + */ + watch(s: Extract): void { + const W = (k: number) => `${this.key}:w${k}`, S = (k: number) => `${this.key}:s${k}`; + const entry = this.label("ev"), handler = this.label("eh"), back = this.label("er"), exit = this.label("ex"), stays = this.label("eb"), move = this.label("emv"); + // The gate (`s.gate`: no orders or types watched): one trigger before the walk whose conditions hold when nothing + // can have changed since the watch last ran — the head of the game's list of free places and the place behind it + // (a place handed out moves one or the other: every unit made pops the head, a place freed goes in behind it), and + // each player's counts of units and of buildings (taken off as a unit starts to die or is removed). All holding, + // the walk passes the watch by; otherwise it goes through it, and afterwards the gate's numbers are read again + // from the game — the head and the place behind it when a unit came on, the counts of each player the handler + // marked, or every one of them when the gate opened and the handler found nothing (a unit given away, a morph). + // A number wrong only costs the next frames the watch: the gate holds only when all of them are right. + const gated = !!s.gate, skip = this.label("egs"); + const G = { head: `${this.key}.egH`, behind: `${this.key}.egQ`, next: `${this.key}.egN`, units: (p: number) => `${this.key}.egU${p}`, buildings: (p: number) => `${this.key}.egB${p}`, failed: `${this.key}.egw` }; + const players = Array.from({ length: GATE_PLAYERS }, (_, p) => p); + // (The gate in four triggers, each a group of its numbers — the head and the place behind it; the units of players + // 1 to 6; of 7 to 12; the buildings — so that when the handler finds nothing, the group that failed is read again + // and no other: a place freed moves the one behind the head, three reads, where every number would be twenty-seven.) + const groups: { label: string; conds: Cond[]; fields: [string, number, "value" | "player", number][] }[] = gated ? [ + { label: this.label("eg"), conds: [{ dword: { game: FIRST_FREE_UNIT }, compare: "exactly", value: 0 }, { dword: { game: DEATHS_TABLE }, compare: "exactly", value: 0 }], fields: [[G.head, 0, "value", 0], [G.behind, 1, "player", epdOf(4)], [G.next, 1, "value", 0]] }, + ...[players.slice(0, 6), players.slice(6)].map((ps) => ({ label: this.label("eg"), conds: ps.map((p): Cond => ({ dword: { game: UNIT_COUNTS + 4 * p }, compare: "exactly", value: 0 })), fields: ps.map((p, i): [string, number, "value" | "player", number] => [G.units(p), i, "value", 0]) })), + { label: this.label("eg"), conds: players.map((p): Cond => ({ dword: { game: BUILDING_COUNTS + 4 * p }, compare: "exactly", value: 0 })), fields: players.map((p): [string, number, "value" | "player", number] => [G.buildings(p), p, "value", 0]) }, + ] : []; + if (gated) { + this.own.add(G.failed); + groups.forEach((g, i) => { + // Its conditions all holding go on to the next group, or past the watch; one failing says which group and goes + // into the watch. + const on = i + 1 < groups.length ? groups[i + 1].label : skip; + if (i) this.land(g.label); + this.branch(g.conds, on, this.at, i ? g.label + "t" : g.label); + this.write(G.failed, { c: i + 1, terms: [] }); + this.goto(entry); + // (The group's numbers as variables of the watch's own, each a place of a condition's field and nothing else.) + for (const [id, cond, part, add] of g.fields) { + this.own.add(id); + this.cellless.add(id); + this.placesOf(id).push({ dword: { at: i ? g.label + "t" : g.label, field: { cond, part } }, scale: 1, add }); + } + }); + } + // (Variables whose places are next pointers and numbers, their cells read by nothing: what a stub sets is the + // places. Where the handler goes on to, and the place handed out after this one: the numbers of the two actions of + // `move` that point the walk's start and the handler's way on at it.) + const RET = `${this.key}.evRet`, NEXT = `${this.key}.evNext`; + for (const v of [RET, NEXT]) this.own.add(v); + for (const v of [RET, NEXT, EVENT_FIELDS]) this.cellless.add(v); + this.placesOf(RET).push({ dword: nextOf(back), scale: 1, add: 0 }); + this.placesOf(NEXT).push({ dword: { at: move, field: { act: 0, part: "value" } }, scale: 1, add: 0 }, { dword: { at: move, field: { act: 1, part: "value" } }, scale: 1, add: 0 }); + // (After place k in the walk: k + 1, then 0 after 1 699, the end after 0; before it, k − 1, and 1 699 before 0.) + const after = (k: number) => (k === 0 ? exit : k === UNIT_PLACES - 1 ? W(0) : W(k + 1)); + const before = (k: number) => (k === 0 ? UNIT_PLACES - 1 : k - 1); + // (The place handed out after it: 1 699 after 0, then down; none after 1.) + const handedNext = (k: number) => (k === 0 ? UNIT_PLACES - 1 : Math.max(1, k - 1)); + const putBack = (k: number): Item => ({ act: { dword: nextOf(W(k)), modifier: "set", value: { label: S(k) } } }); + // (Kept, though empty: their next pointers are written.) + this.push({ label: entry, conds: [], items: [], next: W(0), keep: true }); + for (let k = 0; k < UNIT_PLACES; k++) { + const record = UNIT_TABLE_ADDRESS + UNIT_STRIDE * k; + this.push({ label: W(k), conds: s.fields.map((f): Cond => ({ dword: { game: record + 4 * f.dword }, compare: f.compare, value: 0, ...(f.mask !== undefined ? { mask: f.mask } : {}) })), items: [putBack(before(k)), { act: { dword: nextOf(W(k)), modifier: "set", value: { label: after(k) } } }], next: S(k) }); + const items: Item[] = [putBack(before(k))]; + this.write(EVENT_RECORD, { c: UNIT_TABLE_EPD + (UNIT_STRIDE / 4) * k, terms: [] }, true, items); + items.push({ pointer: { target: EVENT_FIELDS, to: { at: W(k), field: { cond: 0 } } } }, { label: { target: RET, to: after(k) } }, { label: { target: NEXT, to: W(handedNext(k)) } }); + this.push({ label: S(k), conds: [], items, next: handler }); + } + this.land(handler); + this.stmts(s.handler); + // The place the game hands out next is the boundary (`EVENT_BOUNDARY` its distance from place 0, 0 to begin with): + // when it is handed out, the walk starts at the next one from now on, and goes on from there now — it was not + // walked in this frame. + const record: BoolExpr = { kind: "compare", op: "==", left: { kind: "var", id: EVENT_RECORD }, right: { kind: "binary", op: "+", left: { kind: "var", id: EVENT_BOUNDARY }, right: { kind: "const", value: UNIT_TABLE_EPD }, at: this.at!, label: "" }, at: this.at!, label: "" }; + this.jump(record, false, stays, this.at); + this.push({ label: move, conds: [], items: [{ act: { dword: nextOf(entry), modifier: "set", value: 0 } }, { act: { dword: nextOf(back), modifier: "set", value: 0 } }] }); + const first = this.label("e0"), moved = this.label("em"); + this.jump({ kind: "compare", op: "==", left: { kind: "var", id: EVENT_RECORD }, right: { kind: "const", value: UNIT_TABLE_EPD }, at: this.at!, label: "" }, true, first, this.at); + this.write(EVENT_BOUNDARY, { c: u(-UNIT_TABLE_EPD - UNIT_STRIDE / 4), terms: [{ id: EVENT_RECORD, k: 1 }] }); + this.goto(moved); + this.land(first); + this.write(EVENT_BOUNDARY, { c: (UNIT_STRIDE / 4) * (UNIT_PLACES - 1), terms: [] }); + this.land(moved); + this.land(stays); + this.push({ label: back, conds: [], items: [], next: W(0), keep: true }); + this.push({ label: exit, conds: [], items: [putBack(0)] }); + if (gated) { + // The gate's numbers read again from the game, as far as this frame's changes reach. + const WHOLE: ConditionRecord = { location: 0, player: 0, amount: 0, unitId: 0, comparison: 0, type: DEATHS_TYPE, resource: 0, flags: 0, mask: 0 }; + const COUNT: ConditionRecord = { ...WHOLE, location: 0xffff, mask: MASKED_MARK }; + const headAgain = () => { + this.readAt(G.head, WHOLE, { c: epdOf(FIRST_FREE_UNIT), terms: [] }, this.at); + // (The head's address over 4, into the player field with the EPD of 4 added: the EPD of its next pointer.) + this.readAt(G.behind, WHOLE, { c: epdOf(FIRST_FREE_UNIT), terms: [] }, this.at, 2); + this.readAt(G.next, WHOLE, { c: epdOf(4), terms: [{ id: G.behind, k: 1 }] }, this.at); + }; + const countsAgain = (p: number) => { + this.readAt(G.units(p), COUNT, { c: epdOf(UNIT_COUNTS + 4 * p), terms: [] }, this.at); + this.readAt(G.buildings(p), COUNT, { c: epdOf(BUILDING_COUNTS + 4 * p), terms: [] }, this.at); + }; + const found = this.label("egf"), rearmed = this.label("egr"); + this.branch([{ dword: { var: EVENT_TOUCHED }, compare: "atLeast", value: 1 }], found, this.at); + // Nothing found: the group that failed, read again. + const groupRead: (() => void)[] = [headAgain, () => players.slice(0, 6).forEach((p) => this.readAt(G.units(p), COUNT, { c: epdOf(UNIT_COUNTS + 4 * p), terms: [] }, this.at)), () => players.slice(6).forEach((p) => this.readAt(G.units(p), COUNT, { c: epdOf(UNIT_COUNTS + 4 * p), terms: [] }, this.at)), () => players.forEach((p) => this.readAt(G.buildings(p), COUNT, { c: epdOf(BUILDING_COUNTS + 4 * p), terms: [] }, this.at))]; + groupRead.forEach((read, i) => { + const yes = this.label("egg"), no = this.label("egn"); + this.branch([{ dword: { var: G.failed }, compare: "exactly", value: i + 1 }], yes, this.at); + this.goto(no); + this.land(yes); + read(); + this.land(no); + }); + this.goto(rearmed); + this.land(found); + const noneMade = this.label("egm"); + this.branch([{ dword: { var: EVENT_MADE_N }, compare: "atMost", value: 0 }], noneMade, this.at); + headAgain(); + this.land(noneMade); + for (const p of players) { + const other = this.label("ego"); + this.branch([{ dword: { var: EVENT_OWNERS }, compare: "atMost", value: 0, mask: 2 ** p }], other, this.at); + countsAgain(p); + this.land(other); + } + this.land(rearmed); + } + this.land(skip); + } + + /** A trigger of a slot's or a branch's that says whether nothing runs in it (done) or something does. */ + private flags(check: string, on: boolean): Item { + return { act: { dword: { at: check, field: "flags" }, modifier: "set", value: on ? TRIGGER_ON : TRIGGER_OFF } }; + } + + /** + * A task into a free slot of its start: the arguments as they are now into the slot's parameters, its generation one + * more, the number it is known by, and its trigger in the tasks' turn switched on, due now, at the body's start. With + * every slot running nothing starts: the game says so once, and the number is 0, as the Python side's build does. A + * start that runs once has one slot, which is free. + */ + start(s: StartStmt): void { + const slots = this.scope.slots.get(s)!; + if (!this.pools.has(s.task)) { + this.pools.set(s.task, { start: s, slots }); + this.pendingTasks.push(s.task); + } + if (this.inTask) this.again = true; + if (this.scope.once.has(s)) { + this.arguments(slots[0].call); + return this.startSlot(s, slots[0], 0); + } + // (The arguments into variables of the emitter's own first: the slot is chosen after.) + const temps = slots[0].call.params.map((p) => { + const t = `${this.key}.ta${this.n++}`; + this.own.add(t); + if (p.decl.kind === "boolean") this.setBool(t, p.init as BoolExpr); + else this.write(t, linear(p.init as NumExpr)!); + return t; + }); + const done = this.label("started"), into = slots.map(() => this.label("slot")); + slots.forEach((slot, k) => this.branch([{ dword: { at: slot.check, field: "flags" }, compare: "atLeast", value: 8, mask: 8 }], into[k], this.at)); + const said = `${this.key}.said${s.task}`, quiet = this.label("quiet"); + this.own.add(said); + this.jump({ kind: "compare", op: ">=", left: { kind: "var", id: said }, right: { kind: "const", value: 1 }, unsigned: true, at: this.at!, label: "" }, true, quiet, this.at); + this.open().items.push({ act: { dword: { game: 0 }, modifier: "set", value: 0, display: `\x06TrigScript: ${s.call.name ?? "a task"} did not start, line ${this.at?.line ?? "?"} - all ${slots.length} of this start's tasks are running.` } }); + this.write(said, { c: 1, terms: [] }); + this.land(quiet); + if (s.handle) this.write(s.handle, { c: 0, terms: [] }); + this.goto(done); + slots.forEach((slot, k) => { + this.land(into[k]); + slot.call.params.forEach((p, j) => (p.decl.kind === "boolean" ? this.setBool(p.decl.id, { kind: "var", id: temps[j] }) : this.write(p.decl.id, { c: 0, terms: [{ id: temps[j], k: 1 }] }))); + this.startSlot(s, slot, k); + this.goto(done); + }); + this.land(done); + } + + private startSlot(s: StartStmt, slot: TaskSlot, k: number): void { + // Its generation one more, from 1 again past 65 535; the number it is known by from it. + const past = this.label("gen"); + this.jump({ kind: "compare", op: "<", left: { kind: "var", id: slot.gen }, right: { kind: "const", value: 0xffff }, unsigned: true, at: this.at!, label: "" }, true, past, this.at); + this.write(slot.gen, { c: 0, terms: [] }); + this.land(past); + this.write(slot.gen, { c: 1, terms: [{ id: slot.gen, k: 1 }] }); + this.write(slot.handle, { c: k + 256 * s.task, terms: [{ id: slot.gen, k: 65536 }] }); + this.open().items.push( + { act: { dword: { at: slot.check, field: { cond: 0 } }, modifier: "set", value: 0 } }, + { act: { dword: { at: slot.check, field: { act: 0, part: "value" } }, modifier: "set", value: { label: slot.entry } } }, + this.flags(slot.check, true), + ); + if (this.inTask) this.write(this.againVar, { c: 1, terms: [] }); + if (s.handle) this.write(s.handle, { c: 0, terms: [{ id: slot.handle, k: 1 }] }); + } + + /** `t.cancel()`: the slot whose task the number names, while that task is in it, switched off — it is not resumed again. */ + cancel(s: Extract): void { + const h = s.task; + for (const slot of [...this.scope.slots.values()].flat()) { + const past = this.label("nc"); + this.jump({ kind: "and", items: [{ kind: "compare", op: ">=", left: h, right: { kind: "const", value: 1 }, unsigned: true, at: this.at!, label: "" }, { kind: "compare", op: "==", left: h, right: { kind: "var", id: slot.handle }, at: this.at!, label: "" }] }, false, past, this.at); + this.open().items.push(this.flags(slot.check, false)); + this.write(slot.handle, { c: 0, terms: [] }); + this.land(past); + } + } + + /** + * `Promise.all` and `Promise.race`: each branch a coroutine of its own, with a trigger that wakes it, all due as the + * statement is reached, each run in order until it sleeps; then, until all have ended (or one has, a race), the + * coroutine around sleeps a frame at a time and runs again those still running, in order — as the simulator does. A + * race goes on from the end of the first branch to end; the others are switched off where they are. + */ + parallel(s: Extract): void { + const outer = this.co, race = s.mode === "race"; + const checks = s.branches.map(() => this.label("br")), nexts = checks.map((c) => `${c}:after`), entries = checks.map(() => this.label("bre")); + const dispatch = this.label("disp"), test = this.label("ptest"), after = this.label("pafter"); + const items = this.open().items; + checks.forEach((c, j) => items.push( + { act: { dword: { at: c, field: { cond: 0 } }, modifier: "set", value: 0 } }, + { act: { dword: { at: c, field: { act: 0, part: "value" } }, modifier: "set", value: { label: entries[j] } } }, + this.flags(c, true), + )); + this.land(dispatch); + checks.forEach((c, j) => { + this.push({ label: c, conds: [{ dword: { var: NOW }, compare: "atLeast", value: 0 }], items: [{ act: { dword: nextOf(c), modifier: "set", value: { label: entries[j] } } }], next: nexts[j], off: true }); + this.push({ label: nexts[j], conds: [], items: [], next: j + 1 < checks.length ? checks[j + 1] : test }); + }); + this.land(test); + if (!race) { + // (All ended: on at once.) + const wait = this.label("pwait"); + for (const c of checks) this.branch([{ dword: { at: c, field: "flags" }, compare: "atMost", value: 0, mask: 8 }], wait, this.at); + this.goto(after); + this.land(wait); + } + this.sleepIn(outer, 1, dispatch); + s.branches.forEach((b, j) => { + const reset: Act = { dword: nextOf(checks[j]), modifier: "set", value: { label: nexts[j] } }; + this.push({ label: entries[j], conds: [], items: [{ act: reset }] }); + this.co = { tick: { at: checks[j], field: { cond: 0 } }, at: { at: checks[j], field: { act: 0, part: "value" } }, reset, yieldTo: nexts[j], end: outer.end }; + this.stmts(b); + this.co = outer; + this.open().items.push(...(race ? checks : [checks[j]]).map((c) => this.flags(c, false))); + this.goto(race ? after : nexts[j]); + }); + this.land(after); + } + + /** + * Each start's slots' bodies, laid out once each after the program, as a called function's are: the task's call, its + * coroutine the slot's — its wake the slot's trigger in the tasks' turn — and at its end the slot switched off and the + * number it was known by 0. + */ + tasks(): void { + for (let task = this.pendingTasks.shift(); task !== undefined; task = this.pendingTasks.shift()) { + for (const slot of this.pools.get(task)!.slots) { + const after = `${slot.check}:after`; + const reset: Act = { dword: nextOf(slot.check), modifier: "set", value: { label: after } }; + const end = () => { + this.open().items.push(this.flags(slot.check, false)); + this.write(slot.handle, { c: 0, terms: [] }); + this.goto(after); + }; + const outer = { co: this.co, handlers: this.handlers, cold: this.cold, loops: this.loops, frames: this.frames, current: this.current, depthBase: this.depthBase }; + this.co = { tick: { at: slot.check, field: { cond: 0 } }, at: { at: slot.check, field: { act: 0, part: "value" } }, reset, yieldTo: after, end }; + this.handlers = []; + this.cold = 0; + this.loops = []; + this.frames = []; + this.current = undefined; + this.depthBase = 0; + this.inTask++; + this.push({ label: slot.entry, conds: [], items: [{ act: reset }] }); + const done = this.label("tend"); + this.frames.push({ end: done }); + this.stmts(slot.call.body); + this.frames.pop(); + this.land(done); + end(); + this.inTask--; + Object.assign(this, outer); + } + } + } + + /** + * The tasks' turn, after the program's: each start's slots, in the order of the starts' numbers and then of their + * slots — a slot that is free passed by as done, one asleep a condition that fails, one awake sent into its body — and, + * when a task can start one, round again while one was started, for it to have its first turn in this frame too. + * Returns where it starts. + */ + turnOfTasks(): string | undefined { + const slots = [...this.pools.values()].sort((a, b) => a.start.task - b.start.task).flatMap((p) => p.slots); + const round = this.label("again"); + this.push({ label: this.tasksLabel, conds: [], items: [], next: slots.length ? slots[0].check : END }); + slots.forEach((slot, i) => { + this.push({ label: slot.check, conds: [{ dword: { var: NOW }, compare: "atLeast", value: 0 }], items: [{ act: { dword: nextOf(slot.check), modifier: "set", value: { label: slot.entry } } }], next: `${slot.check}:after`, off: true }); + this.push({ label: `${slot.check}:after`, conds: [], items: [], next: i + 1 < slots.length ? slots[i + 1].check : this.again ? round : END }); + }); + if (this.again) { + // (A jump is put back where it lands: not at the graph's end, which is no trigger of its own.) + const more = this.label("more"); + this.land(round); + this.own.add(this.againVar); + this.jump({ kind: "compare", op: ">=", left: { kind: "var", id: this.againVar }, right: { kind: "const", value: 1 }, unsigned: true, at: this.at!, label: "" }, true, more, this.at); + this.goto(END); + this.land(more); + this.write(this.againVar, { c: 0, terms: [] }); + this.goto(this.tasksLabel); + } + return slots[0]?.check; + } + + /** + * Where an error thrown here goes: the `catch` of the nearest `try` around it, or out of the function being laid out + * — from its trigger for errors (`thrown`), or, for one that may come back into itself, from its end, where the call + * looks at the error in flight. (A program's body is all inside a `try` of the front end's.) + */ + handler(): string { + const h = this.handlers[this.handlers.length - 1]; + if (h !== undefined) return h; + const fn = this.current !== undefined ? this.fns.get(this.current) : undefined; + if (!fn) throw new Error("emitter: an error with nothing to go to"); + return fn.thrown ?? fn.exit; + } + + /** Whether a variable has a cell its reads can be refreshed from where they are (a map of our own: all but a function's arguments). */ + hasCell(id: string): boolean { + const d = this.placesOf(id)[0].dword; + return !!d && "var" in d; + } + + /** Whether an error is in flight: its class's code is not 0. */ + inFlight(): BoolExpr { + return { kind: "compare", op: ">=", left: { kind: "var", id: this.scope.errors!.thrown }, right: { kind: "const", value: 1 }, unsigned: true, at: this.at!, label: "" }; + } + + /** + * Whether an error may come out of a called function: one with a `throw` outside every `try` of its own, or a call + * that may run out of stack, or a call of such a function — worked out for every function the program reaches at + * once, until nothing more comes in. + */ + mayThrow(fn: string): boolean { + if (!this.scope.errors) return false; + if (!this.throwing) { + const out = new Set(); + const escapes = (x: unknown): boolean => { + if (!x || typeof x !== "object") return false; + if (Array.isArray(x)) return x.some(escapes); + const o = x as Record; + if (o.kind === "throw" || o.saves) return true; + // (What the body of a `try` throws, its `catch` takes; what the `catch` throws goes on.) + if (o.kind === "try") return escapes(o.catch); + if (typeof o.fn === "string" && Array.isArray(o.params) && out.has(o.fn)) return true; + return Object.values(o).some((v) => v && typeof v === "object" && escapes(v)); + }; + const ids = [...this.scope.taken.keys()]; + for (let grew = true; grew; ) { + grew = false; + for (const id of ids) if (!out.has(id) && escapes(this.scope.bodyOf!(id))) { out.add(id); grew = true; } + } + this.throwing = out; + } + return this.throwing.has(fn); + } + + /** + * A function whose calls all point its trigger for errors at the same place: that trigger goes there as built, and + * the calls' actions go. + */ + settleThrown(): void { + for (const [id, sets] of this.thrownSets) { + const proto = this.thrownProtos.get(id); + if (!proto || new Set(sets.map((x) => x.to)).size !== 1) continue; + for (const x of sets) x.items.splice(x.items.indexOf(x.item), 1); + proto.next = sets[0].to; + } + } + + /** + * The functions the calls reached, each laid out once after the program (which never falls into them): its body, + * what it gives back off its end (0), and the trigger it ends at — empty, kept, its next pointer the call's. A + * function called inside loops counts its triggers as that deep, for what weighs a variable's reads. + */ + functions(): void { + for (let id = this.pending.shift(); id !== undefined; id = this.pending.shift()) { + const f = this.scope.functions.get(id)!, body = this.scope.bodyOf!(id)!, fn = this.fns.get(id)!; + const outer = this.depthBase; + this.depthBase = fn.depth; + this.current = id; + this.live = f.recursive ? liveAfterCalls(body) : new Map(); + this.land(fn.entry); + const result = f.result ? { id: f.result.decl.id, kind: f.result.kind } : undefined; + this.frames.push({ ...(result ? { result } : {}), end: fn.exit }); + // (No `try` of the caller's is around the function's body: an error leaves it first. Nor is it cold for its call.) + const handlers = this.handlers, cold = this.cold; + this.handlers = []; + this.cold = 0; + this.stmts(body); + this.handlers = handlers; + this.cold = cold; + this.frames.pop(); + const last = body.filter((s) => s.kind !== "remark").at(-1); + if (result && last?.kind !== "return") this.write(result.id, { c: 0, terms: [] }); + this.push({ label: fn.exit, conds: [], items: [], keep: true, at: f.at }); + // (Its trigger for errors, its next pointer the call's, as the end's is.) + if (fn.thrown) this.thrownProtos.set(id, this.push({ label: fn.thrown, conds: [], items: [], keep: true, at: f.at })); + this.depthBase = outer; + } + } + + // ── products and quotients (step 4 of the way off eudplib) ── + + /** + * A product, a quotient or a remainder (`Arith`) into `target`; the variables of the emitter's own its sides read are + * named in `own` (the reads before it). + */ + arith(target: string, x: Arith, own: Map, at?: At): void { + const rename = (l: Linear): Linear => ({ c: l.c, terms: l.terms.map((t) => ({ id: own.get(t.id) ?? t.id, k: t.k })) }); + const a = rename(x.a), b = rename(x.b); + this.top = x.r && x.r[0] >= 0 ? x.r[1] : undefined; + if (this.top !== undefined && this.own.has(target)) this.tops.set(target, this.top); + // (The target read while its scratch cells are worked in — `x = x * y` — keeps them apart.) + const apart = [...a.terms, ...b.terms].some((t) => t.id === target); + if (x.op === "random") return this.randomNumber(target, a, x.ra, apart, at); + if (x.op === "sqrt") return this.squareRoot(target, a, x.unsigned, x.ra, apart, at); + if (x.op === "*") return this.product(target, a, b, x.ra, x.rb, apart, at); + if (x.op === "fmul") return a.terms.length === 0 || b.terms.length === 0 ? this.fixedByConst(target, a.terms.length ? a : b, u(a.terms.length ? b.c : a.c), a.terms.length ? x.ra : x.rb, apart, at) : this.fixedProduct(target, a, b, x.ra, x.rb, apart, at); + if (x.op === "fdiv" && x.ratio) return this.divide(target, "/", a, b, true, x.ra, x.rb, apart, at, "ratio"); + if (x.op === "fdiv") { + if (b.terms.length === 0) return this.fixedOverConst(target, a, u(b.c), x.ra, apart, at); + return this.divide(target, "/", a, b, false, x.ra, x.rb, apart, at, a.terms.length === 0 ? "constant" : "fixed"); + } + if (x.op === "&" || x.op === "|" || x.op === "^") return this.bits(target, x.op, a, b, apart, at); + if (x.op === "<<") return this.shiftLeft(target, a, b, x.rb, apart, at); + if (x.op === ">>" || x.op === ">>>") return this.shiftRight(target, x.op, a, b, x.ra, apart, at); + if (b.terms.length === 0) return this.divideByConst(target, x.op, a, b.c, x.unsigned, x.ra, apart, at); + return this.divide(target, x.op, a, b, x.unsigned, x.ra, x.rb, apart, at); + } + + /** A sum as a variable times a constant plus a constant, for a field to hold a multiple of: one of the emitter's own when it has more than one variable. */ + multiple(l: Linear, name: string): Multiple { + if (l.terms.length === 1) return { id: l.terms[0].id, k: l.terms[0].k, c: l.c }; + return { id: this.alone(l, name), k: 1, c: 0 }; + } + + /** + * An action setting, adding or taking away `by` times `m` at a dword: its number a place of m's variable, kept by its + * writes — or, for a variable the plan copies (`Scope.heavy`), whose copies would fill such a place at every write, + * its cell routed into the number as the routine starts (`fed`, `routeFed`: 56 ns once a use). + */ + times(dword: Dword, m: Multiple, by: number, modifier: "set" | "add" | "subtract" = "add"): Act { + const act: Act = { dword, modifier, value: 0 }; + if (this.scope.heavy.has(m.id) && mul(m.k, by) === 1) this.fed.set(act, { id: m.id, add: mul(m.c, by) }); + else this.actPlaces.set(act, { value: { id: m.id, scale: mul(m.k, by), add: mul(m.c, by) } }); + return act; + } + + /** A condition comparing a dword with `m`, its number read as `times` reads one. */ + against(dword: Dword, compare: Compare, m: Multiple): Cond { + const cond: Cond = { dword, compare, value: 0 }; + if (this.scope.heavy.has(m.id) && m.k === 1) this.fed.set(cond, { id: m.id, add: m.c }); + else this.condReads.set(cond, { id: m.id, scale: m.k, add: m.c }); + return cond; + } + + /** + * The most each variable of the emitter's own a product or a quotient fills can be, when value ranges know it never + * below zero: a copy of it into a field holding a multiple goes over only those bits (`product`'s narrow sides). + */ + tops = new Map(); + /** The most the answer of the product or the quotient being laid out can be, when never below zero: its fill's copy's. */ + top?: number; + + /** The actions' and conditions' numbers a routine reads a heavy variable by, to route its cell into (`routeFed`). */ + fed = new Map(); + /** Dwords a routine sets to a heavy variable as it starts (`setTo`): its cell routed into each. */ + fedDwords: { id: string; add: number; dword: Dword }[] = []; + + /** A dword set to `m` as the routine starts: its variable's cell routed there (a heavy one, `fed`), or set from a place of it. */ + setTo(dword: Dword, m: Multiple, at?: At): void { + if (this.scope.heavy.has(m.id) && m.k === 1) this.fedDwords.push({ id: m.id, add: m.c, dword }); + else this.push({ conds: [], items: [{ act: this.times(dword, m, 1, "set") }], at }); + } + + /** + * A routine's start: an empty trigger, where `routeFed` puts the routes into the fields its triggers made after it + * read heavy variables by. Returns its index among the triggers. + */ + beginFed(at?: At): number { + this.push({ conds: [], items: [], at }); + return this.protos.length - 1; + } + routeFed(from: number): void { + const into = new Map(), adds: Act[] = []; + const take = (x: Act | Cond, field: Dword) => { + const f = this.fed.get(x); + if (!f) return; + into.set(f.id, [...(into.get(f.id) ?? []), field]); + if (f.add) adds.push({ dword: field, modifier: "add", value: f.add }); + this.fed.delete(x); + }; + for (const p of this.protos.slice(from + 1)) { + p.conds.forEach((c, k) => take(c, { at: p.label, field: { cond: k } })); + let k = 0; + for (const it of p.items) if ("act" in it) take(it.act, { at: p.label, field: { act: k++, part: "value" } }); + } + for (const f of this.fedDwords.splice(0)) { + into.set(f.id, [...(into.get(f.id) ?? []), f.dword]); + if (f.add) adds.push({ dword: f.dword, modifier: "add", value: f.add }); + } + this.protos[from].items.unshift(...[...into].map(([id, dwords]) => ({ route: { from: id, into: dwords } }) as Item), ...adds.map((act) => ({ act }) as Item)); + } + + /** + * The doubling trigger of a product or a quotient: each of `cells` — its own actions' numbers, in order — added to + * itself (an action whose number is its own destination), and its next pointer moved on a trigger, so that each time + * it runs it goes to the step after the one it went to before: the steps are a run of triggers laid out one after + * another, and no trigger before each says where to go. + */ + doubler(R: string, cells: Dword[], first: string, at?: At): void { + this.push({ label: R, conds: [], items: [...cells.map((d) => ({ act: { dword: d, modifier: "add", value: 0 } as Act })), { act: { dword: nextOf(R), modifier: "add", value: TRIGGER_BYTES } }], next: first, keep: true, at }); + } + + /** + * Into a run of `n` steps (a doubling trigger's) at the first that matters: the doubling trigger pointed at it — it + * moves itself on from there — and the walk sent there. From the top, a group of eight steps whose bits are all clear + * (`atLeast(k)` the condition that the number tested has a bit at k or above) is jumped over. + */ + enter(n: number, step: (i: number) => string, R: string, atLeast: (k: number) => Cond, at?: At): void { + const entries: [string, number][] = []; + let g = 0; + for (; g + 8 < n; g += 8) { + const e = this.label("he"); + this.branch([atLeast(n - 8 - g)], e, at); + entries.push([e, g]); + } + const last = this.label("he"); + this.goto(last); + entries.push([last, g]); + for (const [label, i] of entries) this.push({ label, conds: [], items: [{ act: { dword: nextOf(R), modifier: "set", value: { label: step(i) } } }], next: step(i), at }); + } + + /** A trigger whose conditions holding run its actions and send the walk to `to`; otherwise it goes on. */ + branchDoing(conds: Cond[], acts: Act[], to: string, at?: At): void { + const label = this.label("b"), after = this.label("a"); + this.push({ label, conds, items: [...acts.map((act) => ({ act })), { act: { dword: nextOf(label), modifier: "set", value: { label: to } } }], next: after, at }); + let r = this.restores.get(to); + if (!r) this.restores.set(to, (r = [])); + r.push({ branch: label, to: after }); + this.land(after); + } + + /** + * What the doubling trigger's actions hold, out into scratch cells: each action pointed at its cell, set to 0 first — + * or, when `negative` holds, to MAX with the action made a subtraction and 1 added after: the negation (without the 1, + * `complement`: the bits flipped) — the move of + * its next pointer pointed at a cell of no use, the trigger run once more, and all pointed back. + */ + takeOut(R: string, advance: number | null, outs: { act: number; temp: string }[], negative: Cond[] | null, at?: At, complement = false): void { + const field = (act: number, part: "value" | "dest" | "modifier"): Dword => ({ at: R, field: { act, part } }); + const run = (minus: boolean) => { + const back = this.label("tb"), pre: Act[] = [], post: Act[] = []; + for (const o of outs) { + pre.push({ dword: { temp: o.temp }, modifier: "set", value: minus ? MAX : 0 }, { dword: field(o.act, "dest"), modifier: "set", value: { epd: { temp: o.temp } } }); + post.push({ dword: field(o.act, "dest"), modifier: "set", value: { epd: field(o.act, "value") } }); + if (minus) { + pre.push({ dword: field(o.act, "modifier"), modifier: "set", value: 9 * 2 ** 24, mask: 0xff000000 }); + post.push({ dword: field(o.act, "modifier"), modifier: "set", value: 8 * 2 ** 24, mask: 0xff000000 }); + // (MAX less it is its bits flipped, `complement`; plus 1, its negation.) + if (!complement) post.push({ dword: { temp: o.temp }, modifier: "add", value: 1 }); + } + } + if (advance !== null) pre.push({ dword: field(advance, "dest"), modifier: "set", value: { epd: { temp: `${R}.nowhere` } } }); + pre.push({ dword: nextOf(R), modifier: "set", value: { label: back } }); + if (advance !== null) post.push({ dword: field(advance, "dest"), modifier: "set", value: { epd: nextOf(R) } }); + this.push({ conds: [], items: pre.map((act) => ({ act })), next: R, at }); + this.push({ label: back, conds: [], items: post.map((act) => ({ act })), at }); + }; + if (!negative) return run(false); + const minus = this.label("tm"), joined = this.label("tj"); + this.branch(negative, minus, at); + run(false); + this.goto(joined); + this.land(minus); + run(true); + this.land(joined); + } + + /** + * a × b, wrapping at 32 bits (the same bits signed or not). The side the value ranges leave fewer bits is tested a + * bit at a time where it is (a masked condition costs Remastered nothing over a plain one), and the other is added for + * each bit set — one of two ways: + * - A tested side of a few bits (`SCALED_BITS`) times one variable the plan does not copy (`Scope.heavy`): a trigger + * a bit, adding the variable times the bit — a field each, a place of it its writes keep (13 ns a bit, 23 set). + * - Otherwise Horner's way: the product so far doubles in a doubling trigger (`doubler`) that walks a run of triggers, + * a bit each, which add the other side when the bit is set: read in a field of each (about 46 ns a bit), or, for a + * side the plan may copy, in one shared trigger a flag sends the walk through, so that its cell is routed into one + * field (56 ns) and not one a bit (about 69 ns a bit). A side below zero is taken as its magnitude and the + * product negated as it comes out; groups of eight leading zeros are jumped over. + * eudplib's product routes its operand through a call twice a bit, about 95 ns. + */ + product(target: string, a: Linear, b: Linear, ra: Range | undefined, rb: Range | undefined, apart: boolean, at?: At): void { + emitterCounts.products++; + const [t, s, rt] = width(rb) <= width(ra) ? [b, a, rb] : [a, b, ra]; + const signed = !rt || rt[0] < 0, w = signed ? 32 : width(rt); + if (w === 0) return this.write(target, { c: 0, terms: [] }); + // (A variable the plan does not copy keeps its multiples where it is written; so does a scratch value a product or a + // quotient filled whose ranges leave it few bits, its copy into them a trigger a bit it can have.) + const sTop = s.terms.length === 1 ? this.tops.get(s.terms[0].id) : undefined; + const clean = s.terms.length === 1 && !this.scope.heavy.has(s.terms[0].id), narrow = sTop !== undefined && width([0, sTop]) <= SCALED_BITS; + const temp = `${this.key}.mp${this.n++}`, from = this.beginFed(at); + const fill = () => { + this.routeFed(from); + this.open().items.push({ fill: { temp, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + }; + if ((clean || narrow) && !signed && w <= SCALED_BITS) { + const T = this.home(this.alone(t, "mt"), 0), S = this.multiple(s, "ms"); + this.open().items.push({ act: { dword: { temp }, modifier: "set", value: 0 } }); + for (let j = w - 1; j >= 0; j--) this.push({ conds: [{ dword: T, compare: "atLeast", value: 1, mask: u(2 ** j) }], items: [{ act: this.times({ temp }, S, u(2 ** j)) }], at }); + return void fill(); + } + const neg = `${this.key}.mn${this.n++}`; + let T: Dword; + if (signed) { + // (Its magnitude: the side, or MAX less it plus 1 — read at its own scale, so that no field holds its negation.) + const m = this.multiple(t, "mt"), mt = `${this.key}.mm${this.n++}`; + this.setTo({ temp: mt }, m, at); + this.push({ conds: [], items: [{ act: { dword: { temp: neg }, modifier: "set", value: 0 } }], at }); + this.push({ conds: [{ dword: { temp: mt }, compare: "atLeast", value: 1, mask: SIGN }], items: [{ act: { dword: { temp: mt }, modifier: "set", value: MAX } }, { act: this.times({ temp: mt }, m, 1, "subtract") }, { act: { dword: { temp: mt }, modifier: "add", value: 1 } }, { act: { dword: { temp: neg }, modifier: "set", value: 1 } }], at }); + T = { temp: mt }; + } else T = this.home(this.alone(t, "mt"), 0); + const S = this.multiple(s, "ms"); + const R = this.label("hr"), A = this.label("ha"), done = this.label("hdone"); + const acc: Dword = { at: R, field: { act: 0, part: "value" } }; + const flag = `${this.key}.hf${this.n++}`, shared = !clean; + const B = Array.from({ length: w }, () => this.label("hb")); + this.push({ conds: [], items: [{ act: { dword: acc, modifier: "set", value: 0 } }, ...(shared ? [{ act: { dword: { temp: flag }, modifier: "set", value: 0 } } as Item, { act: { dword: nextOf(A), modifier: "set", value: { label: R } } } as Item] : [])], at }); + this.enter(w, (i) => B[i], R, (k) => ({ dword: T, compare: "atLeast", value: 2 ** k }), at); + const bit = (label: string, i: number, next: string) => this.push({ label, conds: [{ dword: T, compare: "atLeast", value: 1, mask: u(2 ** (w - 1 - i)) }], items: [{ act: shared ? { dword: { temp: flag }, modifier: "set", value: 1 } : this.times(acc, S, 1) }], next, at }); + for (let i = 0; i + 1 < w; i++) bit(B[i], i, shared ? A : R); + if (shared) { + // (The last bit's place in the run points the shared addition at the end, and goes on to the last bit, laid out after.) + const last = this.label("hlast"); + this.push({ label: B[w - 1], conds: [], items: [{ act: { dword: nextOf(A), modifier: "set", value: { label: done } } }], next: last, at }); + bit(last, w - 1, A); + this.push({ label: A, conds: [{ dword: { temp: flag }, compare: "exactly", value: 1 }], items: [{ act: this.times(acc, S, 1) }, { act: { dword: { temp: flag }, modifier: "set", value: 0 } }], next: R, at }); + } else bit(B[w - 1], w - 1, done); + this.rows.push(B); + this.doubler(R, [acc], B[0], at); + this.land(done); + this.takeOut(R, 1, [{ act: 0, temp }], signed ? [{ dword: { temp: neg }, compare: "exactly", value: 1 }] : null, at); + fill(); + } + + /** + * a & b, a | b or a ^ b as a value, the left side copied into a scratch cell and changed by masked writes — the game + * works a masked write on the bits under its mask alone, of the dword and of the number both, so a masked addition's + * carry past its mask is lost. With a constant k: `&` k's clear bits set to 0, `|` its set bits to 1, `^` k added to + * the even bits and to the odd bits apart (each sum's bit is the two bits' exclusive or). Of two values the same, the + * right side being the number of the additions (`^`), or the mask of the write — a mask is a field like any number — + * flipped first for `&` by those two additions of all ones. eudplib's own & of two variables is the same write, and + * its ^ the same additions, through calls; a trigger a bit was 3× slower. + */ + bits(target: string, op: "&" | "|" | "^", a: Linear, b: Linear, apart: boolean, at?: At): void { + emitterCounts.bits++; + const EVEN = 0x55555555, ODD = 0xaaaaaaaa; + const from = this.beginFed(at), temp = `${this.key}.bw${this.n++}`; + const masked = (act: Act, mask: number): Act => Object.assign(act, { mask }); + if (a.terms.length === 0 || b.terms.length === 0) { + const [x, k] = b.terms.length === 0 ? [a, u(b.c)] : [b, u(a.c)]; + this.setTo({ temp }, this.multiple(x, "bx"), at); + const acts: Act[] = op === "&" ? [{ dword: { temp }, modifier: "set", value: 0, mask: u(~k) }] + : op === "|" ? [{ dword: { temp }, modifier: "set", value: k, mask: k }] + : [{ dword: { temp }, modifier: "add", value: k, mask: EVEN }, { dword: { temp }, modifier: "add", value: k, mask: ODD }]; + this.push({ conds: [], items: acts.map((act) => ({ act })), at }); + } else { + const B = this.multiple(b, "bb"); + this.setTo({ temp }, this.multiple(a, "ba"), at); + if (op === "^") this.push({ conds: [], items: [{ act: masked(this.times({ temp }, B, 1, "add"), EVEN) }, { act: masked(this.times({ temp }, B, 1, "add"), ODD) }], at }); + else { + const W = this.label("bw"), mask: Dword = { at: W, field: { act: 0, part: "mask" } }; + this.setTo(mask, B, at); + if (op === "&") this.push({ conds: [], items: [{ act: { dword: mask, modifier: "add", value: MAX, mask: EVEN } }, { act: { dword: mask, modifier: "add", value: MAX, mask: ODD } }], at }); + this.push({ label: W, conds: [], items: [{ act: { dword: { temp }, modifier: "set", value: op === "&" ? 0 : MAX, mask: 0 } }], at }); + } + } + this.routeFed(from); + this.open().items.push({ fill: { temp, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** + * Runs of empty triggers a doubling trigger walks (`doubler`), one run for each bit the count can have, the run of bit + * k 2^k triggers long and one more that leaves it: each trigger goes back to the doubling trigger, which doubles and + * moves on to the next, so a run doubles 2^k times. The runs the count's bits are clear for are jumped over: `count` + * doublings in all, at about 37 ns each. + */ + doublings(R: string, count: Dword, most: number, at?: At): void { + for (let k = Math.min(4, Math.max(0, 31 - Math.clz32(Math.max(1, most)))); k >= 0; k--) { + const skip = this.label("ds"), run = Array.from({ length: 2 ** k + 1 }, () => this.label("dr")); + this.branch([{ dword: count, compare: "exactly", value: 0, mask: 2 ** k }], skip, at); + this.push({ conds: [], items: [{ act: { dword: nextOf(R), modifier: "set", value: { label: run[0] } } }], next: run[0], at }); + run.forEach((label, i) => this.push({ label, conds: [], items: [], next: i < 2 ** k ? R : skip, keep: true, at })); + this.rows.push(run); + this.land(skip); + } + } + + /** + * a << n by a value: a in a doubling trigger, doubled (n & 31) times (`doublings`), and taken out. eudplib's left + * shift by a value is a loop of routed additions of the number to itself, about 80 ns each. + */ + shiftLeft(target: string, a: Linear, n: Linear, rn: Range | undefined, apart: boolean, at?: At): void { + emitterCounts.shifts++; + const from = this.beginFed(at), temp = `${this.key}.sl${this.n++}`, count = `${this.key}.sc${this.n++}`; + const R = this.label("sr"), cell: Dword = { at: R, field: { act: 0, part: "value" } }; + this.setTo(cell, this.multiple(a, "sa"), at); + this.setTo({ temp: count }, this.multiple(n, "sn"), at); + const done = this.label("sdone"); + // (By 32 or more, read from 0 up: nothing left.) + const small = !!rn && rn[0] >= 0 && rn[1] < 32; + if (!small) this.branchDoing([{ dword: { temp: count }, compare: "atLeast", value: 32 }], [{ dword: cell, modifier: "set", value: 0 }], done, at); + this.doublings(R, { temp: count }, small ? rn![1] : 31, at); + this.goto(done); + this.doubler(R, [cell], done, at); + this.land(done); + this.takeOut(R, 1, [{ act: 0, temp }], null, at); + this.routeFed(from); + this.open().items.push({ fill: { temp, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** + * a >>> n or a >> n by a value: Horner's way over a's bits from the top — the answer doubling in a doubling trigger + * that also counts, a trigger a bit adding it when set — for the 32 − (n & 31) bits that stay: a test shared by every + * bit ends the walk when the count says. `>>` of a number below zero works on its bits flipped and flips the answer's + * as it comes out. About 69 ns a bit; eudplib's right shift by a value is 2^n by 32 comparisons and a division, about 7 µs. + */ + shiftRight(target: string, op: ">>" | ">>>", a: Linear, n: Linear, ra: Range | undefined, apart: boolean, at?: At): void { + emitterCounts.shifts++; + const from = this.beginFed(at), A = this.multiple(a, "sa"), key = `${this.key}.s${this.n++}`; + const temp = `${key}.o`, am = `${key}.a`, neg = `${key}.neg`; + const R = this.label("sr"), T = this.label("st"), done = this.label("sdone"); + const cell: Dword = { at: R, field: { act: 0, part: "value" } }, count: Dword = { temp: `${key}.c` }; + const negA = op === ">>" && !(ra && ra[0] >= 0); + this.setTo({ temp: am }, A, at); + this.setTo(count, this.multiple(n, "sn"), at); + this.push({ conds: [], items: [{ act: { dword: cell, modifier: "set", value: 0 } }, { act: { dword: { temp: neg }, modifier: "set", value: 0 } }], at }); + // (By 32 or more, read from 0 up: `>>` fills with the sign as by 31, `>>>` leaves nothing — no bit stays.) + const zero = this.label("szero"), joined = this.label("sjoin"); + if (op === ">>") this.push({ conds: [{ dword: count, compare: "atLeast", value: 32 }], items: [{ act: { dword: count, modifier: "set", value: 31 } }], at }); + else this.branch([{ dword: count, compare: "atLeast", value: 32 }], zero, at); + if (negA) { + // (Below zero: its bits flipped, MAX less it.) + this.push({ conds: [{ dword: { temp: am }, compare: "atLeast", value: 1, mask: SIGN }], items: [{ act: { dword: { temp: am }, modifier: "set", value: MAX } }, { act: this.times({ temp: am }, A, 1, "subtract") }, { act: { dword: { temp: neg }, modifier: "set", value: 1 } }], at }); + } + // The bits from the top; the count, from n & 31, reaches 31 at the last bit that stays. + const B = Array.from({ length: 32 }, () => this.label("sb")); + this.push({ conds: [], items: [{ act: { dword: nextOf(R), modifier: "set", value: { label: B[0] } } }], next: B[0], at }); + B.forEach((label, i) => this.push({ label, conds: [{ dword: { temp: am }, compare: "atLeast", value: 1, mask: u(2 ** (31 - i)) }], items: [{ act: { dword: cell, modifier: "add", value: 1 } }], next: T, at })); + this.rows.push(B); + this.push({ label: T, conds: [{ dword: count, compare: "exactly", value: 31 }], items: [{ act: { dword: nextOf(T), modifier: "set", value: { label: done } } }], next: R, at }); + let r = this.restores.get(done); + if (!r) this.restores.set(done, (r = [])); + r.push({ branch: T, to: R }); + // (The doubling trigger also counts.) + this.push({ label: R, conds: [], items: [{ act: { dword: cell, modifier: "add", value: 0 } }, { act: { dword: count, modifier: "add", value: 1 } }, { act: { dword: nextOf(R), modifier: "add", value: TRIGGER_BYTES } }], next: B[0], keep: true, at }); + this.land(done); + this.takeOut(R, 2, [{ act: 0, temp }], negA ? [{ dword: { temp: neg }, compare: "exactly", value: 1 }] : null, at, true); + if (op === ">>>") { + this.goto(joined); + this.land(zero); + this.push({ conds: [], items: [{ act: { dword: { temp }, modifier: "set", value: 0 } }], at }); + this.land(joined); + } + this.routeFed(from); + this.open().items.push({ fill: { temp, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** + * a / c or a % c by a constant c: eudplib's division chain by a constant, written where it is used instead of called — + * a trigger for each bit the quotient can have, taking c times that bit from the remainder when it fits — with only + * as many steps as the value ranges leave the dividend, and from the top a group of eight steps that cannot take + * anything jumped over. Read as JavaScript's numbers, the magnitude is divided, by a chain for a dividend below zero + * and one for the rest, each gathering the answer with its sign in one scratch cell: a quotient bit by bit, plus or + * minus; a remainder below zero as the dividend plus what the chain takes away. + */ + divideByConst(target: string, op: "/" | "%", a: Linear, c: number, unsigned: boolean, ra: Range | undefined, apart: boolean, at?: At): void { + emitterCounts.quotients++; + const d = unsigned ? u(c) : Math.abs(c | 0); + if (d === 0) return this.write(target, { c: 0, terms: [] }); + const negC = !unsigned && (c | 0) < 0, negA = !unsigned && (!ra || ra[0] < 0); + const most = unsigned ? (ra && ra[0] >= 0 ? ra[1] : MAX) : ra ? Math.max(Math.abs(ra[0]), Math.abs(ra[1])) : SIGN; + const from = this.beginFed(at), A = this.multiple(a, "da"); + const key = `${this.key}.d${this.n++}`, out = `${key}.o`, rem = `${key}.r`; + let top = -1; + while (top < 31 && d * 2 ** (top + 1) <= most) top++; + // A map of our own: a remainder of a dividend never below zero and less than four times the divisor — a counter + // kept inside a ring, `k = (k + 373) % 1000` — worked out in the target itself: the dividend written into every + // place of it, then each step taken from every place where the target's cell holds that much. No scratch cell, and + // no copy of one into the target's places after (a trigger a bit, for a place that holds a multiple of it). + if (op === "%" && !negA && top <= 1 && !this.results.has(target)) { + this.write(target, a); + for (let i = top; i >= 0; i--) { + const p = this.push({ conds: [{ dword: this.home(target, 0), compare: "atLeast", value: d * 2 ** i }], items: [], at, guarded: true }); + this.write(target, { c: u(-d * 2 ** i), terms: [{ id: target, k: 1 }] }, true, p.items); + } + return; + } + /** The chain; `minus`: the dividend below zero (its magnitude in `rem`). */ + const chain = (minus: boolean) => { + // (The remainder of a dividend not below zero is worked in the answer's own cell.) + const reg = op === "%" && !minus ? out : rem; + for (let hi = top; hi >= 0; hi -= 8) { + const lo = Math.max(0, hi - 7), past = this.label("dg"); + if (top > 7) this.branch([{ dword: { temp: reg }, compare: "atMost", value: d * 2 ** lo - 1 }], past, at); + for (let i = hi; i >= lo; i--) { + const x = d * 2 ** i, acts: Act[] = [{ dword: { temp: reg }, modifier: "subtract", value: x }]; + if (op === "/") acts.push({ dword: { temp: out }, modifier: "add", value: u((minus !== negC ? -1 : 1) * 2 ** i) }); + else if (minus) acts.push({ dword: { temp: out }, modifier: "add", value: x }); + this.push({ conds: [{ dword: { temp: reg }, compare: "atLeast", value: x }], items: acts.map((act) => ({ act })), at }); + } + this.land(past); + } + }; + if (op === "/" || negA) this.setTo({ temp: rem }, A, at); + if (op === "%") this.setTo({ temp: out }, A, at); + else this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], at }); + if (!negA) chain(false); + else { + const below = this.label("dn"), done = this.label("dd"); + this.branch([{ dword: { temp: rem }, compare: "atLeast", value: 1, mask: SIGN }], below, at); + chain(false); + this.goto(done); + this.land(below); + // (The magnitude: MAX less the dividend, plus 1.) + this.push({ conds: [], items: [{ act: { dword: { temp: rem }, modifier: "set", value: MAX } }, { act: this.times({ temp: rem }, A, 1, "subtract") }, { act: { dword: { temp: rem }, modifier: "add", value: 1 } }], at }); + chain(true); + this.land(done); + } + this.routeFed(from); + this.open().items.push({ fill: { temp: out, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** + * The square root of a fixed number, √(v × 65 536) rounded down (0 below zero), or `whole`, of a whole number read + * from 0 up, √(v × 2^32) (from 2^30 up, the largest fixed number): a bit of the root a step, from the top, as the + * Python side's `root4` — but where its remainder is compared with four times the root so far, a variable copied and + * compared each step, here the root is kept at the place its bits end up (ρ = r × 2^(K − s) after s of K steps), so it + * only ever gains a constant, and the remainder ψ doubles in a doubling trigger instead. Each step: the next two + * bits of v added to ψ at half its scale (a trigger each, tested where v is), then one trigger shared by every step + * comparing ψ with Q = ρ + 2^(K − s − 2) (its condition's number) and taking Q from it (its action's) when it fits; + * that trigger goes on to a pair of triggers of the step's own, laid out one after the other, the second of them when + * it took Q (it moves its own next pointer on by a trigger, and each of the pair moves it on to the next step's pair): + * they add the step's constants to the two copies of Q and, the second, the bit to the root. Then ψ doubles. ψ stays + * below 2^32 for every root a fixed number has. Steps whose bits of v are all clear are jumped over four at a time, + * from the top. About 120 ns a step while v's bits come in, 100 after; eudplib's is about 280 ns a step. + */ + squareRoot(target: string, v: Linear, whole: boolean, rv: Range | undefined, apart: boolean, at?: At): void { + emitterCounts.fixed++; + const from = this.beginFed(at), V = this.multiple(v, "qv"); + const key = `${this.key}.q${this.n++}`, out = `${key}.o`, vc = `${key}.v`; + const done = this.label("qdone"); + this.setTo({ temp: vc }, V, at); + this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], at }); + const known = !!rv && rv[0] >= 0, top = whole ? 2 ** 30 - 1 : 2 ** 31 - 1; + const most = known ? Math.min(rv![1], top) : top; + // Past what a root holds (a whole number from 2^30 up), the largest; below zero (a fixed number), or 0, 0. + if (whole && !(known && rv![1] <= top)) this.branchDoing([{ dword: { temp: vc }, compare: "atLeast", value: 2 ** 30 }], [{ dword: { temp: out }, modifier: "set", value: 0x7fffffff }], done, at); + if (!whole && !known) this.branch([{ dword: { temp: vc }, compare: "atLeast", value: SIGN }], done, at); + this.branch([{ dword: { temp: vc }, compare: "exactly", value: 0 }], done, at); + if (most >= 1) { + // P pairs of v's bits, then (the zeros of × 65 536 or × 2^32) 8 or 16 more steps: K bits of root. + const P = Math.ceil(width([0, most]) / 2), K = P + (whole ? 16 : 8); + const lam = (j: number) => (j <= K ? 2 ** (K - j) : 1); + const R = this.label("qr"), C = this.label("qc"); + const psi: Dword = { at: R, field: { act: 0, part: "value" } }; + const copies: Dword[] = [{ at: C, field: { cond: 0 } }, { at: C, field: { act: 0, part: "value" } }]; + const slots = Array.from({ length: K }, () => this.label("qs")), low = Array.from({ length: P }, () => this.label("ql")); + const pairs = Array.from({ length: K }, () => [this.label("qn"), this.label("qt")]); + // Into the steps at the first whose bits of v are not all clear (of a group of four), with ρ 0 so far. + const starts: number[] = []; + for (let j = 0; j < P; j += 4) starts.push(j); + const entries = starts.map(() => this.label("qe")); + for (let k = 0; k + 1 < starts.length; k++) this.branch([{ dword: { temp: vc }, compare: "atLeast", value: 4 ** (P - starts[k + 1]) }], entries[k], at); + this.goto(entries[entries.length - 1]); + starts.forEach((j, k) => this.push({ label: entries[k], conds: [], items: [ + { act: { dword: psi, modifier: "set", value: 0 } }, + ...copies.map((d) => ({ act: { dword: d, modifier: "set", value: lam(j + 2) } }) as Item), + // (The doubling trigger moves its next pointer on before the walk follows it: pointed at this step's first.) + { act: { dword: nextOf(R), modifier: "set", value: { label: slots[j] } } }, + { act: { dword: nextOf(C), modifier: "set", value: { label: pairs[j][0] } } }, + ], next: slots[j], at })); + // The steps' first triggers, one after the other (the doubling trigger walks them): the high bit of the step's + // pair of v, then (elsewhere) the low. + for (let s = 0; s < K; s++) { + if (s < P) this.push({ label: slots[s], conds: [{ dword: { temp: vc }, compare: "atLeast", value: 1, mask: u(2 ** (2 * P - 1 - 2 * s)) }], items: [{ act: { dword: psi, modifier: "add", value: lam(s + 1) } }], next: low[s], keep: true, at }); + else this.push({ label: slots[s], conds: [], items: [], next: C, keep: true, at }); + } + for (let s = 0; s < P; s++) this.push({ label: low[s], conds: [{ dword: { temp: vc }, compare: "atLeast", value: 1, mask: 2 ** (2 * P - 2 - 2 * s) }], items: [{ act: { dword: psi, modifier: "add", value: lam(s + 2) } }], next: C, at }); + // The shared step: Q taken from ψ when it fits, and the walk on to the second of the step's pair. + this.push({ label: C, conds: [{ dword: psi, compare: "atLeast", value: 0 }], items: [{ act: { dword: psi, modifier: "subtract", value: 0 } }, { act: { dword: nextOf(C), modifier: "add", value: TRIGGER_BYTES } }], next: pairs[0][0], keep: true, at }); + // The pairs: Q's two copies to the next step's (ρ less or more, the next 2^(K − s − 3)); the root's bit. + for (let s = 0; s < K; s++) { + const last = s === K - 1, d = lam(s + 3) - lam(s + 2), next = last ? done : R; + const adds = (by: number): Item[] => (last || by === 0 ? [] : copies.map((c) => ({ act: { dword: c, modifier: "add", value: u(by) } }) as Item)); + this.push({ label: pairs[s][0], conds: [], items: [...adds(d), ...(last ? [] : [{ act: { dword: nextOf(C), modifier: "add", value: 2 * TRIGGER_BYTES } } as Item])], next, keep: true, at }); + this.push({ label: pairs[s][1], conds: [], items: [...adds(d + lam(s + 1)), { act: { dword: { temp: out }, modifier: "add", value: lam(s + 1) } }, ...(last ? [] : [{ act: { dword: nextOf(C), modifier: "add", value: TRIGGER_BYTES } } as Item])], next, keep: true, at }); + } + // ψ doubled, and on to the next step's first trigger. + this.push({ label: R, conds: [], items: [{ act: { dword: psi, modifier: "add", value: 0 } }, { act: { dword: nextOf(R), modifier: "add", value: TRIGGER_BYTES } }], next: slots[0], keep: true, at }); + this.rows.push(slots, pairs.flat()); + } + this.land(done); + this.routeFed(from); + this.open().items.push({ fill: { temp: out, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** + * random(n): a whole number from 0 to n − 1 (n read from 0 up; 0 by 0 or 1), each as likely, from the game's own + * random numbers — Set Switch to a random state is one bit of them, the same on every player's machine, read where the + * game keeps the switch. As many bits as n − 1 has are drawn, from the top, and drawn again when they make n or more + * (fewer than two draws on average; a power of two never): a constant n's count of bits known when the map is built, a + * value's found by a search of its top bit, which the bits are entered at. About 28 ns a bit. The switch (256, the + * last) is put back as it was. eudplib's random numbers are a generator of its own seeded from a switch once, with + * two products a draw and then a division by n. + */ + randomNumber(target: string, n: Linear, rn: Range | undefined, apart: boolean, at?: At): void { + emitterCounts.random++; + if ((n.terms.length === 0 && u(n.c) <= 1) || (n.terms.length && rn && rn[0] >= 0 && rn[1] <= 1)) return this.write(target, { c: 0, terms: [] }); + const sw = RANDOM_SWITCH, word: Dword = { game: SWITCHES + 4 * (sw >> 5) }, bit = u(2 ** (sw & 31)); + const from = this.beginFed(at), key = `${this.key}.rn${this.n++}`, out = `${key}.o`, saved = `${key}.s`, nm = `${key}.m`; + // The switch as it was. + this.push({ conds: [], items: [{ act: { dword: { temp: saved }, modifier: "set", value: 0 } }], at }); + this.push({ conds: [{ dword: word, compare: "atLeast", value: 1, mask: bit }], items: [{ act: { dword: { temp: saved }, modifier: "set", value: 1 } }], at }); + const draw = (b: number, label?: string) => { + this.push({ ...(label ? { label } : {}), conds: [], items: [{ act: { dword: { game: 0 }, modifier: "set", value: 0, randomize: sw } }], at }); + this.push({ conds: [{ dword: word, compare: "atLeast", value: 1, mask: bit }], items: [{ act: { dword: { temp: out }, modifier: "add", value: u(2 ** b) } }], at }); + }; + const done = this.label("rndone"); + if (n.terms.length === 0) { + const limit = u(n.c), k = width([0, limit - 1]), again = this.label("rnagain"); + this.push({ label: again, conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], at }); + for (let b = k - 1; b >= 0; b--) draw(b); + if (limit & (limit - 1)) this.branch([{ dword: { temp: out }, compare: "atLeast", value: limit }], again, at); + } else { + // n − 1, its top bit searched; the bits from there down, and again from there when they make n or more. + const N = this.multiple(n, "rnb"), retry = this.label("rnretry"), zero = this.label("rnzero"); + this.setTo({ temp: nm }, N, at); + this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], at }); + this.branch([{ dword: { temp: nm }, compare: "atMost", value: 1 }], zero, at); + this.push({ conds: [], items: [{ act: { dword: { temp: nm }, modifier: "add", value: MAX } }], at }); + const most = rn && rn[0] >= 0 ? rn[1] - 1 : MAX, k = width([0, most]); + const pairs = Array.from({ length: k }, () => this.label("rnbit")); + const leaf = (t: number) => this.push({ conds: [], items: [{ act: { dword: nextOf(retry), modifier: "set", value: { label: pairs[k - 1 - t] } } }], next: pairs[k - 1 - t], at }); + const tree = (lo: number, hi: number) => { + if (lo === hi) return void leaf(lo); + const mid = (lo + hi + 1) >> 1, high = this.label("rnhi"); + this.branch([{ dword: { temp: nm }, compare: "atLeast", value: 2 ** mid }], high, at); + tree(lo, mid - 1); + this.land(high); + tree(mid, hi); + }; + tree(0, k - 1); + for (let b = k - 1; b >= 0; b--) draw(b, pairs[k - 1 - b]); + const fits = this.against({ temp: out }, "atLeast", N); + const back = this.label("rnback"); + this.push({ label: back, conds: [fits], items: [{ act: { dword: nextOf(back), modifier: "set", value: { label: retry } } }], next: done, at }); + this.restores.set(retry, [...(this.restores.get(retry) ?? []), { branch: back, to: done }]); + this.push({ label: retry, conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], next: pairs[0], keep: true, at }); + this.land(zero); + } + this.land(done); + this.push({ conds: [{ dword: { temp: saved }, compare: "exactly", value: 1 }], items: [{ act: { dword: word, modifier: "set", value: bit, mask: bit } }], at }); + this.push({ conds: [{ dword: { temp: saved }, compare: "exactly", value: 0 }], items: [{ act: { dword: word, modifier: "set", value: 0, mask: bit } }], at }); + this.routeFed(from); + this.open().items.push({ fill: { temp: out, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** + * The magnitude of a sum, into a scratch cell where it is tested and worked: the sum, or — read below zero — MAX less + * it, plus 1 (read at its own scale: no field holds its negation). Each chain `body` makes is laid out twice when the + * sum may be below zero: `minus` says which, so that a constant it adds carries the answer's sign. + */ + bySign(cell: string, m: Multiple, below: boolean, body: (minus: boolean) => void, at?: At): void { + this.setTo({ temp: cell }, m, at); + if (!below) return body(false); + const minus = this.label("fn"), done = this.label("fd"); + this.branch([{ dword: { temp: cell }, compare: "atLeast", value: 1, mask: SIGN }], minus, at); + body(false); + this.goto(done); + this.land(minus); + this.push({ conds: [], items: [{ act: { dword: { temp: cell }, modifier: "set", value: MAX } }, { act: this.times({ temp: cell }, m, 1, "subtract") }, { act: { dword: { temp: cell }, modifier: "add", value: 1 } }], at }); + body(true); + this.land(done); + } + + /** + * x × c / 65 536 towards zero, of a number with a fraction and one written in the script (both raw cells): the Python + * side's way, written where it is used. Each bit of |x| adds c times its place, split at the point — the whole part + * into the answer, the part below it gathered apart, whose whole part is added at the end (the answer is the + * product's, not a sum of rounded parts) — as many bits as the ranges leave |x|, groups of eight clear ones jumped over. + */ + fixedByConst(target: string, x: Linear, c: number, rx: Range | undefined, apart: boolean, at?: At): void { + emitterCounts.fixed++; + const m = Math.abs(c | 0); + if (m === 0) return this.write(target, { c: 0, terms: [] }); + const negC = (c | 0) < 0, negX = !(rx && rx[0] >= 0); + const most = rx ? Math.max(Math.abs(rx[0]), Math.abs(rx[1])) : SIGN, top = width([0, most]) - 1; + const parts = Array.from({ length: top + 1 }, (_, j) => [u(Number((BigInt(m) << BigInt(j)) >> 16n) % 2 ** 32), Number((BigInt(m) << BigInt(j)) & 0xffffn)]); + const lowBits = width([0, parts.reduce((s, [, lo]) => s + lo, 0)]); + const from = this.beginFed(at), key = `${this.key}.fm${this.n++}`, out = `${key}.o`, low = `${key}.l`, xm = `${key}.x`; + this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }, { act: { dword: { temp: low }, modifier: "set", value: 0 } }], at }); + this.bySign(xm, this.multiple(x, "fx"), negX, (minus) => { + const s = minus !== negC ? MAX : 1; + for (let hi = top; hi >= 0; hi -= 8) { + const bits = Array.from({ length: Math.min(8, hi + 1) }, (_, i) => hi - i), past = this.label("fg"); + if (top > 7) this.branch([{ dword: { temp: xm }, compare: "exactly", value: 0, mask: u(bits.reduce((g, j) => g + 2 ** j, 0)) }], past, at); + for (const j of bits) { + const [h, l] = parts[j], acts: Act[] = []; + if (h) acts.push({ dword: { temp: out }, modifier: "add", value: mul(h, s) }); + if (l) acts.push({ dword: { temp: low }, modifier: "add", value: l }); + if (acts.length) this.push({ conds: [{ dword: { temp: xm }, compare: "atLeast", value: 1, mask: u(2 ** j) }], items: acts.map((act) => ({ act })), at }); + } + this.land(past); + } + for (let k = 16; k < lowBits; k++) this.push({ conds: [{ dword: { temp: low }, compare: "atLeast", value: 1, mask: u(2 ** k) }], items: [{ act: { dword: { temp: out }, modifier: "add", value: mul(2 ** (k - 16), s) } }], at }); + }, at); + this.routeFed(from); + this.open().items.push({ fill: { temp: out, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** + * x × 65 536 / c towards zero, of a number with a fraction and one written in the script: the Python side's way, + * written where it is used. Long division with c times each place compared and taken away, a constant each: the whole + * part's quotient bits straight to their place 16 higher; then the remainder, below c, moved up as far as stays under + * 2^32 (a bit tested, its new place added) and divided again, until the sixteen bits below the point are found. + */ + fixedOverConst(target: string, x: Linear, c: number, rx: Range | undefined, apart: boolean, at?: At): void { + emitterCounts.fixed++; + const m = Math.abs(c | 0); + if (m === 0) return this.write(target, { c: 0, terms: [] }); + const negC = (c | 0) < 0, negX = !(rx && rx[0] >= 0); + const most = rx ? Math.max(Math.abs(rx[0]), Math.abs(rx[1])) : SIGN, top = width([0, most]) - 1; + const cTop = width([0, m]) - 1, chunk = Math.max(1, 31 - cTop); + const from = this.beginFed(at), key = `${this.key}.fq${this.n++}`, out = `${key}.o`, rem = `${key}.r`; + this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], at }); + this.bySign(rem, this.multiple(x, "fx"), negX, (minus) => { + const s = minus !== negC ? MAX : 1; + const step = (i: number, weight: number) => { + const acts: Act[] = [{ dword: { temp: rem }, modifier: "subtract", value: m * 2 ** i }]; + if (u(weight)) acts.push({ dword: { temp: out }, modifier: "add", value: mul(u(weight), s) }); + this.push({ conds: [{ dword: { temp: rem }, compare: "atLeast", value: m * 2 ** i }], items: acts.map((act) => ({ act })), at }); + }; + // The whole part, from the highest multiple of c |x| can reach. + const wholeTop = top >= cTop ? Math.min(31 - cTop, top - cTop) : -1; + for (let hi = wholeTop; hi >= 0; hi -= 8) { + const lo = Math.max(0, hi - 7), past = this.label("fg"); + if (wholeTop > 7) this.branch([{ dword: { temp: rem }, compare: "atMost", value: m * 2 ** lo - 1 }], past, at); + for (let i = hi; i >= lo; i--) step(i, 2 ** (i + 16)); + this.land(past); + } + // Below the point: the remainder, below c, moved up k places at a time (a bit from its top down, each landing + // where nothing is yet), and divided again. + for (let done = 0; done < 16;) { + const k = Math.min(chunk, 16 - done); + for (let bit = cTop; bit >= 0; bit--) this.push({ conds: [{ dword: { temp: rem }, compare: "atLeast", value: 1, mask: u(2 ** bit) }], items: [{ act: { dword: { temp: rem }, modifier: "add", value: u(2 ** (bit + k) - 2 ** bit) } }], at }); + done += k; + for (let i = k - 1; i >= 0; i--) step(i, 2 ** (i + 16 - done)); + } + }, at); + this.routeFed(from); + this.open().items.push({ fill: { temp: out, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** + * a × b / 65 536 towards zero, of two numbers with a fraction. With t the side the ranges leave fewer bits and s the + * other, of the magnitudes, t × s / 65 536 is th × s + tl × sh plus tl × sl / 65 536 (h and l the halves above and + * below the point): one loop of as many steps as tl has bits (sixteen at most) reads a bit of th and the same bit of + * tl at once — t doubled a step in a doubling trigger, bits 31 and 15 tested where they now are — and doubles two sums + * there, w gathering th × s + tl × sh and z gathering tl × sl, whose bits from 16 up are added to w at the end. The + * Python side's loop, with s, sh and sl set into the two triggers that add them and nothing routed a step. + */ + fixedProduct(target: string, a: Linear, b: Linear, ra: Range | undefined, rb: Range | undefined, apart: boolean, at?: At): void { + emitterCounts.fixed++; + const bitsOf = (r?: Range) => (r ? width([0, Math.max(Math.abs(r[0]), Math.abs(r[1]))]) : 32); + const [t, s, rt, rs] = bitsOf(rb) <= bitsOf(ra) ? [b, a, rb, ra] : [a, b, ra, rb]; + const wt = bitsOf(rt), ws = bitsOf(rs), steps = Math.min(16, Math.max(1, wt)); + const negT = !(rt && rt[0] >= 0), negS = !(rs && rs[0] >= 0); + const from = this.beginFed(at), T = this.multiple(t, "ft"), S = this.multiple(s, "fs"); + const key = `${this.key}.fp${this.n++}`, out = `${key}.o`, neg = `${key}.neg`, count = `${key}.c`; + const R = this.label("fr"), SA = this.label("fa"), SB = this.label("fb"), E = this.label("fe"), done = this.label("fdone"); + const w: Dword = { at: R, field: { act: 0, part: "value" } }, z: Dword = { at: R, field: { act: 1, part: "value" } }, x: Dword = { at: R, field: { act: 2, part: "value" } }; + const high = wt > 16, sHasHigh = ws > 16, head = high ? SA : SB; + // (SB adds sh to w only when s can have bits from 16 up; sl to z always.) + const sAll: Dword = { at: SA, field: { act: 0, part: "value" } }, sHigh: Dword = { at: SB, field: { act: 0, part: "value" } }, sLow: Dword = { at: SB, field: { act: sHasHigh ? 1 : 0, part: "value" } }; + // |t| into the doubling trigger's cell, |s| into the triggers that add it (sl masked; sh from |s|'s bits from 16 up). + this.setTo(x, T, at); + const sTo = high ? sAll : sLow; + this.setTo(sTo, S, at); + if (high) this.setTo(sLow, S, at); + this.push({ conds: [], items: [{ act: { dword: w, modifier: "set", value: 0 } }, { act: { dword: z, modifier: "set", value: 0 } }, ...(sHasHigh ? [{ act: { dword: sHigh, modifier: "set", value: 0 } } as Item] : []), { act: { dword: { temp: count }, modifier: "set", value: 0 } }, { act: { dword: { temp: neg }, modifier: "set", value: 0 } }, { act: { dword: nextOf(R), modifier: "set", value: { label: head } } }], at }); + const negate = (d: Dword, m: Multiple, flag: number) => this.push({ conds: [{ dword: d, compare: "atLeast", value: 1, mask: SIGN }], items: [{ act: { dword: d, modifier: "set", value: MAX } }, { act: this.times(d, m, 1, "subtract") }, { act: { dword: d, modifier: "add", value: 1 } }, { act: { dword: { temp: neg }, modifier: "add", value: flag } }], at }); + if (negT) negate(x, T, 1); + if (negS) { + // (Its sign tested once, in the first field: the other's negation goes with it.) + const pos = this.label("fsp"); + this.branch([{ dword: sTo, compare: "atMost", value: SIGN - 1 }], pos, at); + this.push({ conds: [], items: [...(high ? [sAll, sLow] : [sLow]).flatMap((d) => [{ act: { dword: d, modifier: "set", value: MAX } } as Item, { act: this.times(d, S, 1, "subtract") } as Item, { act: { dword: d, modifier: "add", value: 1 } } as Item]), { act: { dword: { temp: neg }, modifier: "add", value: 2 } }], at }); + this.land(pos); + } + if (sHasHigh) for (let k = Math.min(31, ws - 1); k >= 16; k--) this.push({ conds: [{ dword: sTo, compare: "atLeast", value: 1, mask: u(2 ** k) }], items: [{ act: { dword: sHigh, modifier: "add", value: 2 ** (k - 16) } }], at }); + this.push({ conds: [], items: [{ act: { dword: sLow, modifier: "set", value: 0, mask: 0xffff0000 } }], next: head, at }); + // The loop: the adds for the bits now at the top of tl (and of th), the end, the doublings. + const lowBit = 2 ** (Math.min(16, Math.max(1, wt)) - 1); + if (high) this.push({ label: SA, conds: [{ dword: x, compare: "atLeast", value: 1, mask: SIGN }], items: [{ act: { dword: w, modifier: "add", value: 0 } }], next: SB, at }); + this.push({ label: SB, conds: [{ dword: x, compare: "atLeast", value: 1, mask: u(lowBit) }], items: [...(sHasHigh ? [{ act: { dword: w, modifier: "add", value: 0 } } as Item] : []), { act: { dword: z, modifier: "add", value: 0 } }], next: E, at }); + this.push({ label: E, conds: [{ dword: { temp: count }, compare: "exactly", value: steps - 1 }], items: [{ act: { dword: nextOf(E), modifier: "set", value: { label: done } } }], next: R, at }); + let r = this.restores.get(done); + if (!r) this.restores.set(done, (r = [])); + r.push({ branch: E, to: R }); + this.push({ label: R, conds: [], items: [{ act: { dword: w, modifier: "add", value: 0 } }, { act: { dword: z, modifier: "add", value: 0 } }, { act: { dword: x, modifier: "add", value: 0 } }, { act: { dword: { temp: count }, modifier: "add", value: 1 } }], next: head, keep: true, at }); + // z's bits from 16 up into w; w out, negated when one side was below zero. + this.land(done); + for (let k = 31; k >= 16; k--) this.push({ conds: [{ dword: z, compare: "atLeast", value: 1, mask: u(2 ** k) }], items: [{ act: { dword: w, modifier: "add", value: 2 ** (k - 16) } }], at }); + // (The doubling trigger moves no next pointer of its own: its count adds 1 while w comes out, which nothing reads.) + this.takeOut(R, null, [{ act: 0, temp: out }], negT || negS ? [{ dword: { temp: neg }, compare: "atLeast", value: 1 }, { dword: { temp: neg }, compare: "atMost", value: 2 }] : null, at); + this.routeFed(from); + this.open().items.push({ fill: { temp: out, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** + * a / b or a % b of two values: long division with the remainder doubling (`doubler`, which also doubles the + * quotient) — a step a bit of the dividend's magnitude: the bit added to the remainder, then one trigger shared by + * every step taking b from it when it fits and adding 1 to the quotient. b's magnitude is set into that trigger's two + * fields before the steps — two, whatever the number of steps, no place of b in each. A dividend below zero is read as itself less 1, whose clear bits are its magnitude's set ones (a run of + * steps of its own); the answer is negated as it comes out. Groups of eight leading zeros are jumped over. About 80 ns + * a step, and only as many steps as the dividend has bits; eudplib's division routes the divisor into two fields of + * each of 32 steps first, doubling it each time, whatever the numbers: about 6.5 µs. + */ + divide(target: string, op: "/" | "%", a: Linear, b: Linear, unsigned: boolean, ra: Range | undefined, rb: Range | undefined, apart: boolean, at?: At, dividend: "value" | "fixed" | "constant" | "ratio" = "value"): void { + emitterCounts.quotients++; + // (`fixed`: a × 65 536 / b of numbers with a fraction — the dividend's bits, then sixteen clear ones; `constant`: of + // a constant over a value, the constant's bits, known; `ratio`: a × 65 536 / b of two numbers from 0 up, a at most + // b — a step for the whole of a, its remainder a or 0, then the sixteen clear ones: 17 steps.) + const D = dividend === "constant" ? BigInt(Math.abs(a.c | 0)) << 16n : 0n; + if (dividend === "constant" && D === 0n) return this.write(target, { c: 0, terms: [] }); + const signed = !unsigned, negA = signed && dividend !== "constant" && (!ra || ra[0] < 0), negB = signed && (!rb || rb[0] < 0); + const from = this.beginFed(at), Am: Multiple = dividend === "constant" ? { id: "", k: 0, c: 0 } : this.multiple(a, "va"), Bm = this.multiple(b, "vb"); + const key = `${this.key}.v${this.n++}`, out = `${key}.o`, neg = `${key}.neg`, am = `${key}.a`; + const R = this.label("vr"), T = this.label("vt"), done = this.label("vdone"), joined = this.label("vout"); + const rem: Dword = { at: R, field: { act: 0, part: "value" } }, q: Dword = { at: R, field: { act: 1, part: "value" } }; + const cells = op === "/" ? [rem, q] : [rem]; + // b straight into the shared step's two fields (and tested there), a into the cell its bits are tested in. + const divisor: Dword[] = [{ at: T, field: { cond: 0 } }, { at: T, field: { act: 0, part: "value" } }]; + for (const f of divisor) this.setTo(f, Bm, at); + if (dividend !== "constant" && dividend !== "ratio") this.setTo({ temp: am }, Am, at); + // (The shared step goes back to the doubling trigger until the last step points it at the end.) + this.push({ conds: [], items: [{ act: { dword: { temp: neg }, modifier: "set", value: dividend === "constant" && (a.c | 0) < 0 ? 1 : 0 } }, { act: { dword: nextOf(T), modifier: "set", value: { label: R } } }, ...cells.map((d) => ({ act: { dword: d, modifier: "set", value: 0 } as Act }))], at }); + // By 0, 0; read from 0 up, a divisor past 2^31 fits once at most (and doubling the remainder could pass 32 bits); + // below zero, its magnitude. + const zero = this.label("vz"), big = this.label("vbig"); + this.branch([{ dword: divisor[0], compare: "exactly", value: 0 }], zero, at); + if (unsigned && !(rb && rb[0] >= 0)) this.branch([{ dword: divisor[0], compare: "atLeast", value: SIGN + 1 }], big, at); + if (negB) { + const bdone = this.label("vbd"); + this.branch([{ dword: divisor[0], compare: "atMost", value: SIGN - 1 }], bdone, at); + this.push({ conds: [], items: [...divisor.flatMap((f) => [{ act: { dword: f, modifier: "set", value: MAX } as Act }, { act: this.times(f, Bm, 1, "subtract") }, { act: { dword: f, modifier: "add", value: 1 } as Act }]), { act: { dword: { temp: neg }, modifier: "add", value: 2 } }], at }); + this.land(bdone); + } + // The dividend's bits, and into the steps: n of a, and of a fixed quotient sixteen more, all N of the steps. + const most = unsigned ? (ra && ra[0] >= 0 ? ra[1] : MAX) : ra ? Math.max(Math.abs(ra[0]), Math.abs(ra[1])) : SIGN; + const n = dividend === "constant" ? D.toString(2).length : dividend === "ratio" ? 1 : Math.max(1, width([0, most])); + const N = n + (dividend === "fixed" || dividend === "ratio" ? 16 : 0); + const plus = Array.from({ length: N }, () => this.label("vb")), minus = negA ? Array.from({ length: N }, () => this.label("vbm")) : []; + if (dividend === "constant") { + // Where the divisor first fits: steps before i (2^i ≤ |b|, a group of four of its bits at a time) take nothing, so + // the remainder starts as the constant's first i bits. + const zeroQ = this.label("vzq"); + for (let g = 7; g >= 0; g--) { + const e = this.label("ve"), i = 4 * g; + this.branch([{ dword: divisor[0], compare: "atLeast", value: 2 ** i }], e, at); + const after = this.label("va"); + this.goto(after); + if (i >= N) this.push({ label: e, conds: [], items: [], next: zeroQ, keep: true, at }); + // (The steps are entered at their bit, past the doubling before it: the remainder twice the first i bits.) + else this.push({ label: e, conds: [], items: [{ act: { dword: rem, modifier: "set", value: Number(D >> BigInt(N - i)) * 2 } }, { act: { dword: nextOf(R), modifier: "set", value: { label: plus[i] } } }], next: plus[i], at }); + this.land(after); + } + this.goto(zero); + this.land(zeroQ); + this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], next: joined, at }); + } else if (dividend === "ratio") { + // (a into the remainder, and straight to the shared step: the doubling trigger, pointed at the first step's place, + // moves on to the second.) + this.setTo(rem, Am, at); + this.push({ conds: [], items: [{ act: { dword: nextOf(R), modifier: "set", value: { label: plus[0] } } }], next: T, at }); + } else if (negA) { + const below = this.label("van"); + this.branch([{ dword: { temp: am }, compare: "atLeast", value: 1, mask: SIGN }], below, at); + this.enter(n, (i) => plus[i], R, (k) => ({ dword: { temp: am }, compare: "atLeast", value: 2 ** k }), at); + this.land(below); + this.push({ conds: [], items: [{ act: { dword: { temp: am }, modifier: "subtract", value: 1 } }, { act: { dword: { temp: neg }, modifier: "add", value: 1 } }], at }); + this.enter(n, (i) => minus[i], R, (k) => ({ dword: { temp: am }, compare: "atMost", value: MAX - 2 ** k }), at); + } else this.enter(n, (i) => plus[i], R, (k) => ({ dword: { temp: am }, compare: "atLeast", value: 2 ** k }), at); + // A step's bit, in its run; the last step's place in the run is a trigger pointing the shared step at the end, which + // goes on to the last bit, laid out after. + const lastBits: [string, boolean][] = []; + const bit = (label: string, set: boolean, i: number): Proto => { + // (A known bit: 1 added, or nothing; past a's bits, a fixed quotient's sixteen clear ones: nothing.) + if (dividend === "constant" || dividend === "ratio" || i >= n) { + const one = dividend === "constant" && ((D >> BigInt(N - 1 - i)) & 1n) === 1n; + return this.push({ label, conds: [], items: one ? [{ act: { dword: rem, modifier: "add", value: 1 } }] : [], next: T, keep: true, at }); + } + return this.push({ + label, conds: [{ dword: { temp: am }, compare: set ? "atLeast" : "exactly", value: set ? 1 : 0, mask: u(2 ** (n - 1 - i)) }], + items: [{ act: { dword: rem, modifier: "add", value: 1 } }], next: T, at, + }); + }; + for (const [list, set] of [[plus, true], [minus, false]] as const) list.forEach((label, i) => { + if (i + 1 < N) return void bit(label, set, i); + const last = this.label("vlast"); + this.push({ label, conds: [], items: [{ act: { dword: nextOf(T), modifier: "set", value: { label: done } } }], next: last, at }); + lastBits.push([last, set]); + }); + for (const [label, set] of lastBits) bit(label, set, N - 1); + this.rows.push(...[plus, minus].filter((r) => r.length)); + this.push({ label: T, conds: [{ dword: rem, compare: "atLeast", value: 0 }], items: [{ act: { dword: rem, modifier: "subtract", value: 0 } }, ...(op === "/" ? [{ act: { dword: q, modifier: "add", value: 1 } } as Item] : [])], next: R, at }); + this.doubler(R, cells, plus[0], at); + this.land(done); + const negative: Cond[] = op === "/" ? [{ dword: { temp: neg }, compare: "atLeast", value: 1 }, { dword: { temp: neg }, compare: "atMost", value: 2 }] : [{ dword: { temp: neg }, compare: "atLeast", value: 1, mask: 1 }]; + this.takeOut(R, cells.length, [{ act: op === "/" ? 1 : 0, temp: out }], signed ? negative : null, at); + this.goto(joined); + if (unsigned && !(rb && rb[0] >= 0)) { + // (Read from 0 up: the quotient 1 when b fits, the remainder a less b then.) + this.land(big); + this.push({ conds: [], items: [{ act: op === "/" ? { dword: { temp: out }, modifier: "set", value: 0 } : this.times({ temp: out }, Am, 1, "set") }], at }); + const fits = this.against({ temp: am }, "atLeast", Bm); + this.push({ conds: [fits], items: [{ act: op === "/" ? { dword: { temp: out }, modifier: "set", value: 1 } : this.times({ temp: out }, Bm, 1, "subtract") }], at }); + this.goto(joined); + } + this.land(zero); + this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], at }); + this.land(joined); + this.routeFed(from); + this.open().items.push({ fill: { temp: out, target, ...(apart ? { apart } : {}), ...(this.top !== undefined ? { top: this.top } : {}) } }); + } + + /** A map of our own's regions of cell triggers for its arrays, by id: one for each array a statement reaches. */ + regions = new Map(); + region(a: ArrayDecl): void { + if (!this.regions.has(a.id)) this.regions.set(a.id, { id: a.id, cells: Math.max(1, a.length), ...(a.values ? { values: a.values.map(u) } : {}) }); + } + + /** A sum as one variable: itself when it is one, else a variable of the emitter's own it is written into. */ + alone(l: Linear, name: string): string { + if (l.c === 0 && l.terms.length === 1 && l.terms[0].k === 1) return l.terms[0].id; + const id = `${this.key}.${name}${this.n++}`; + this.own.add(id); + this.write(id, l, false); + return id; + } + + /** + * A sum as one variable whose places will be fields no refresh reaches — a store's destination or number, a jump's + * next pointer, a frame's cell: `alone`'s on the Python side; in a map of our own always a variable of the emitter's + * own, written just before. Where that stands for a variable of the program's, the graph puts the variable itself + * back in the fields (`fuse`) unless the plan copies it: the variable of the emitter's own is there for the few whose + * places would run into the thousands, which the plan copies and so reads where they are used. + */ + pin(l: Linear, name: string): string { + const id = `${this.key}.${name}${this.n++}`; + this.own.add(id); + this.write(id, l, false); + if (l.c === 0 && l.terms.length === 1 && l.terms[0].k === 1) this.pins.set(id, l.terms[0].id); + return id; + } + + /** A variable of the emitter's own (`pin`) replaced by the variable it stands for, everywhere: its places, its write gone. */ + fuse(own: string, by: string, pinned: Set): void { + const was = this.placesOf(own), home = was[0].dword!, to = this.placesOf(by); + to.push(...was.slice(1)); + this.places.delete(own); + const key = JSON.stringify(home); + for (const p of this.protos) { + p.conds = p.conds.map((c) => (JSON.stringify(c.dword) === key ? { ...c, dword: to[0].dword! } : c)); + p.items = p.items.filter((it) => !("write" in it && it.write.target === own)); + } + this.writes.delete(own); + const rename = (r: R | undefined): R | undefined => (r && r.id === own ? { ...r, id: by } : r); + for (const [label, r] of this.jumpReads) this.jumpReads.set(label, rename(r)!); + for (const [act, ap] of this.actPlaces) this.actPlaces.set(act, Object.fromEntries(Object.entries(ap).map(([part, r]) => [part, rename(r)]))); + for (const [c, r] of this.condReads) this.condReads.set(c, rename(r)!); + for (const [c, r] of this.condPlayers) this.condPlayers.set(c, rename(r)!); + this.own.delete(own); + pinned.delete(own); + pinned.add(by); + } + + /** + * A cell of an array a map of our own keeps, into `target`: a trigger that sets a scratch cell to 0, points the cell + * trigger's action at it and its next pointer back here, and goes to it — the three a place of the index (its + * destination and next pointer 18 EPD a cell apart, the cell 72 bytes) — and then the scratch cell into every place + * of the target (`graph` reads the cell straight into the target instead when its cell is its only place). An index + * outside the array (read from 0 up; a window's own index against its own length) reads 0; one the value ranges know + * is inside is not tested. + */ + readCell(target: string, t: Table, at?: At): void { + const a = t.array; + this.region(a); + const index = this.pin(t.index, "ci"); + const temp = `${this.key}.ct${this.n++}`, after = this.label("cget"), out = this.label("cout"), jump = this.label("cjmp"); + if (!t.safe) { + const guard = t.guard ? this.alone(t.guard, "cg") : index; + this.jump({ kind: "compare", op: ">=", left: { kind: "var", id: guard }, right: { kind: "const", value: t.limit ?? a.length }, unsigned: true, at: at!, label: "" } as BoolExpr, true, out, at); + } + const point: Act = { dword: { element: a.id }, modifier: "set", value: { epd: { temp } } }; + const back: Act = { dword: { element: a.id }, modifier: "set", value: { label: after } }; + this.actPlaces.set(point, { dest: { id: index, scale: VARRAY_STRIDE_EPD, add: -1, base: a.id } }); + this.actPlaces.set(back, { dest: { id: index, scale: VARRAY_STRIDE_EPD, add: -86, base: a.id } }); + this.push({ label: jump, conds: [], items: [{ act: { dword: { temp }, modifier: "set", value: 0 } }, { act: point }, { act: back }], next: after, keep: true, at }); + this.jumpReads.set(jump, { id: index, scale: 72, add: 0, base: `addr:${a.id}` }); + if (!t.safe) { + this.land(out); + this.push({ conds: [], items: [{ act: { dword: { temp }, modifier: "set", value: 0 } }], next: after, at }); + } + this.land(after); + this.open().items.push({ fill: { temp, target } }); + } + + /** + * An array declared (a map of our own): each cell set to its value from the list, or all to the one value (`fill`) — + * a constant as it is, anything else into a variable of the emitter's own whose places the cells' actions are. 64 to + * a trigger, each trigger of them alone: an action's place is its index in its trigger. + */ + declareArray(s: Extract): void { + const a = this.scope.arrays.get(s.array)!; + this.region(a); + const fill = s.fill !== undefined ? linear(s.fill as NumExpr)! : null; + const fillFrom = fill && fill.terms.length ? this.pin(fill, "fv") : null; + const acts: Act[] = []; + for (let i = 0; i < a.length; i++) { + const v = fill ?? (s.init?.[i] !== undefined ? linear(s.init[i] as NumExpr)! : { c: 0, terms: [] }); + const from = fill ? fillFrom : v.terms.length ? this.pin(v, "iv") : null; + const act: Act = { dword: { cell: a.id, index: i }, modifier: "set", value: from ? 0 : v.c }; + if (from) this.actPlaces.set(act, { value: { id: from, scale: 1, add: 0 } }); + acts.push(act); + } + for (let i = 0; i < acts.length; i += MOST_ACTS) this.push({ conds: [], items: acts.slice(i, i + MOST_ACTS).map((act) => ({ act })) }); + this.push({ conds: [], items: [] }); + } + + /** + * A store into a cell of an array: one trigger, its condition the index inside the array (read from 0 up: one below + * zero is past the end, where a store does nothing), its action setting the cell — the action's destination a place + * of the index (the EPD of the first cell's value, plus the index times the EPD between cells), its number a place + * of the value. An index or a value that is more than a variable goes into a variable of the emitter's own first. + */ + store(st: Store): void { + const stride = VARRAY_STRIDE_EPD; + this.region(st.array); + const index = this.pin(st.index, "si"); + // A map of our own: the cell itself plus a constant or a variable — `xs[i] += 1`, `hp[i] -= damage` — is that added + // to the cell where it is: one action, the cell not read. + const same = (a: Linear, b: Linear) => u(a.c) === u(b.c) && a.terms.length === b.terms.length && a.terms.every((t) => b.terms.some((x) => x.id === t.id && u(x.k) === u(t.k))); + // (And another cell — `q.x += q.dx`, a field of a record and another's — read first, straight into the addition.) + const self = st.reads.find((r) => r.table?.jump && !r.table.guard && r.table.array.id === st.array.id && same(r.table.index, st.index)); + const rest = self && st.reads.length <= 2 && st.value.terms.filter((t) => t.id === self.id).length === 1 && st.value.terms.find((t) => t.id === self.id)!.k === 1 ? { c: st.value.c, terms: st.value.terms.filter((t) => t.id !== self.id) } : undefined; + const other = st.reads.find((r) => r !== self); + if (self && rest && rest.terms.length <= 1 && (!other || (rest.terms.length === 1 && rest.terms[0].id === other.id && !!other.table))) { + let by = rest.terms[0]; + if (other) { + const id = `${this.key}.${other.id.slice(1)}`; + this.own.add(id); + if (other.table!.jump) this.scope.heavy.add(id); + this.readTable(id, other.table!, this.at); + by = { id, k: by.k }; + } + const from = by ? this.pin({ c: 0, terms: [{ id: by.id, k: 1 }] }, "sv") : null; + const act: Act = { dword: { element: st.array.id }, modifier: "add", value: from ? 0 : rest.c }; + this.actPlaces.set(act, { dest: { id: index, scale: stride, add: 0, base: st.array.id }, ...(from ? { value: { id: from, scale: by.k, add: rest.c } } : {}) }); + if (st.safe) { this.push({ conds: [], items: [{ act }] }); return; } + const g = st.guard ? this.alone(st.guard, "sg") : index; + this.push({ conds: [{ dword: this.home(g, 0), compare: "atMost", value: (st.limit ?? st.array.length) - 1 }], items: [{ act }] }); + return; + } + const own = new Map(st.reads.map((r) => [r.id, `${this.key}.${r.id.slice(1)}`])); + for (const r of st.reads) { + this.own.add(own.get(r.id)!); + if (r.arith || r.table?.jump) this.scope.heavy.add(own.get(r.id)!); + if (r.table) this.readTable(own.get(r.id)!, r.table, this.at); + else if (r.shift) this.readShift(own.get(r.id)!, { ...r.shift, of: { c: r.shift.of.c, terms: r.shift.of.terms.map((t) => ({ id: own.get(t.id) ?? t.id, k: t.k })) } }, this.at); + else this.arith(own.get(r.id)!, r.arith!, own, this.at); + } + const value: Linear = { c: st.value.c, terms: st.value.terms.map((t) => ({ id: own.get(t.id) ?? t.id, k: t.k })) }; + let from: string | null = null, plus = 0; + // (One variable once and a constant, in a map of our own: the store's number a place of the variable, plus the + // constant — no variable of its own for the sum.) + if (value.terms.length === 1 && value.terms[0].k === 1) { from = this.pin({ c: 0, terms: value.terms }, "sv"); plus = value.c; } + else if (value.terms.length) from = this.pin(value, "sv"); + const act: Act = { dword: { element: st.array.id }, modifier: "set", value: from ? 0 : value.c }; + this.actPlaces.set(act, { dest: { id: index, scale: stride, add: 0, base: st.array.id }, ...(from ? { value: { id: from, scale: 1, add: plus } } : {}) }); + // (A window's guard is its own index against its own length. One the value ranges know is inside tests nothing, + // and the writes after it go in its trigger.) + if (st.safe) { this.push({ conds: [], items: [{ act }] }); return; } + const guard = st.guard ? this.alone(st.guard, "sg") : index; + this.push({ conds: [{ dword: this.home(guard, 0), compare: "atMost", value: (st.limit ?? st.array.length) - 1 }], items: [{ act }] }); + } + + // ── the writes, once every place is known ── + + /** + * The variables that are copied into their places after each write, and the order the others' writes are expanded + * in. A write of x reads y into an action for each place of x, and each of those is a place of y: so y's writes are + * expanded after x's. Where that goes round — x set from itself other than by adding, x and y reading each other — + * the variables of the loop are copied instead: their writes go to their cells alone, and a copy after each write + * fills the rest of their places (a routed read each, as eudplib's every read is). + */ + plan(pinned = new Set()): { copied: Set; order: string[] } { + const readers = new Map>(); // v → the targets whose writes read v + const copied = new Set([...this.forced, ...this.results, ...[...this.scope.decls.values()].filter((d) => d.routed).map((d) => d.id)]); + for (const [target, ws] of this.writes) for (const w of ws) { + const self = w.value.terms.filter((t) => t.id === target); + if (self.length && self[0].k !== 1) copied.add(target); + for (const t of w.value.terms) { + // (A write only an error goes through reads its values from their cells: no place of them, `cold`.) + if (t.id === target || (w.cold && this.hasCell(t.id))) continue; + if (!readers.has(t.id)) readers.set(t.id, new Set()); + readers.get(t.id)!.add(target); + } + } + // Every loop of the graph of reads (Tarjan's strongly connected parts of more than one variable) broken by copies. + // The Python side's loops copy every variable of a part (a copy there is a routed read a place). A map of our own + // copies as few as it can — a copy there is a trigger a bit — the variable whose writes are fewest, each weighed by + // the loops around it, and then the part again without it: calls make such loops whenever what a function gives + // back reaches its own parameters (`pick(a, pick(b, c))`, `i = next(i)`), and one copy breaks each. + const strong = (nodes: Set): string[][] => { + const index = new Map(), low = new Map(), stack: string[] = [], on = new Set(), out: string[][] = []; + let n = 0; + const connect = (v: string): void => { + index.set(v, n); low.set(v, n++); stack.push(v); on.add(v); + for (const w of readers.get(v) ?? []) { + if (!nodes.has(w)) continue; + if (!index.has(w)) { connect(w); low.set(v, Math.min(low.get(v)!, low.get(w)!)); } + else if (on.has(w)) low.set(v, Math.min(low.get(v)!, index.get(w)!)); + } + if (low.get(v) === index.get(v)) { + const part: string[] = []; + for (let w = stack.pop()!; ; w = stack.pop()!) { on.delete(w); part.push(w); if (w === v) break; } + out.push(part); + } + }; + for (const v of nodes) if (!index.has(v)) connect(v); + return out; + }; + const weight = new Map(); + for (const p of this.protos) for (const it of p.items) if ("write" in it) weight.set(it.write.target, (weight.get(it.write.target) ?? 0) + 4 ** (p.depth ?? 0)); + const breakUp = (nodes: Set): void => { + for (const part of strong(nodes)) { + if (part.length < 2) continue; + // (Copying the program's variables of a loop breaks it: the emitter's own have no cell to copy from.) + const real = part.filter((x) => !this.own.has(x) && !isTemp(x)); + const candidates = real.length ? real : part; + const pick = candidates.reduce((a, b) => ((weight.get(b) ?? 0) < (weight.get(a) ?? 0) ? b : a)); + copied.add(pick); + breakUp(new Set(part.filter((x) => x !== pick))); + } + }; + breakUp(new Set([...this.writes.keys(), ...readers.keys()].filter((v) => !copied.has(v)))); + // The rest in order: a variable after every target whose writes read it, the copied ones being no target (their + // writes have one place each, whatever their places are). + const out: string[] = [...copied].filter((v) => this.writes.has(v)), done = new Set(out); + const visit = (v: string): void => { + if (done.has(v)) return; + done.add(v); + for (const t of readers.get(v) ?? []) if (!copied.has(t)) visit(t); + out.push(v); + }; + for (const v of this.writes.keys()) visit(v); + // A map of our own: places counted in that order — a variable's own, and for each write that reads it, one for + // each place of the target (one, a target that is copied) — and a variable that would have more than a trigger's + // worth copied instead, so that it is read where it is used (routed). Every place of a target that reads it is a + // place of it, so along a run of variables each set from the one before, places multiply; arrays and the values + // functions give back make such runs, which reached thousands of places before. (Not a variable a store's or a + // jump's fields hold: those are never refreshed where they are read.) + { + const count = new Map(); + for (const v of out) { + let n = this.places.get(v)?.length ?? 1; + for (const t of readers.get(v) ?? []) for (const w of this.writes.get(t) ?? []) { + const k = w.value.terms.filter((x) => x.id === v).length; + n += copied.has(t) ? k : k * (count.get(t) ?? 1); + } + count.set(v, n); + if (n > MOST_ACTS && !pinned.has(v) && !this.scope.decls.get(v)?.everywhere) copied.add(v); + } + // A variable written in a loop and read only outside it — a sum a loop adds to and a print reads once after it — is + // kept in its cell alone and read where it is used (graph(): routed): each write one action, not one for each + // place, and a value read from a cell into it no copy into each. Its writes against what refreshing its places + // where they are read would cost, each weighed by how deep in the loops it is (four turns of the loop outside + // for one inside), in actions: a write is one a place; a refresh of a place about five (a trigger pointing the cell, + // the cell's own, one back), of a place that holds a multiple of it about forty (a trigger a bit it can have). + const protoBy = new Map(this.protos.map((p) => [p.label, p])); + const depth = (label: string) => 4 ** (protoBy.get(label)?.depth ?? 0); + // (Not what a called function names: its body is laid out outside the loops it is called from, so its reads look + // fewer than they are — its parameters', its result's, and the program's variables it reads.) + const inFunctions = this.scope.inFunctions(); + const writesAt = new Map(); + for (const p of this.protos) for (const it of p.items) if ("write" in it) writesAt.set(it.write, 4 ** (p.depth ?? 0)); + for (const v of out) { + // (Only the program's own variables: what the emitter makes for itself — a routine's working cells — it lays out + // as it needs them.) + if (copied.has(v) || pinned.has(v) || this.own.has(v) || isTemp(v) || !this.scope.decls.has(v) || inFunctions.has(v) || this.scope.decls.get(v)?.everywhere) continue; + const ps = this.places.get(v) ?? []; + const n = count.get(v) ?? 1; + if (n <= 2) continue; + let read = 0, deepestRead = 0; + for (const pl of ps.slice(1)) { + const d = pl.dword && "at" in pl.dword ? depth(pl.dword.at) : 1; + deepestRead = Math.max(deepestRead, d); + read += d * (pl.scale === 1 ? 5 : 40); + } + // (Read in another's write, it is refreshed into each place of that one — its cell alone, when that one is copied + // — at that one's multiple of it.) + for (const t of readers.get(v) ?? []) for (const w of this.writes.get(t) ?? []) { + const k = w.value.terms.filter((x) => x.id === v); + if (!k.length) continue; + deepestRead = Math.max(deepestRead, writesAt.get(w) ?? 1); + const into = copied.has(t) ? [{ scale: 1 }] : this.places.get(t) ?? [{ scale: 1 }]; + for (const pl of into) for (const x of k) read += (writesAt.get(w) ?? 1) * (u(pl.scale * x.k) === 1 ? 5 : 40); + } + // (Only when every read is outside the deepest loop it is written in, as a sum's is: one written and read in the + // same loop pays a refresh at each turn for each write it saves.) + const deepestWrite = Math.max(0, ...(this.writes.get(v) ?? []).map((w) => writesAt.get(w) ?? 1)); + if (deepestRead < deepestWrite && read < (weight.get(v) ?? 0) * (n - 1)) copied.add(v); + } + } + return { copied, order: out }; + } + + /** + * A write expanded: for every place of the target, the actions that make it the new value — the place's multiple of + * each part. Only adds and sets: the game's subtract stops at 0, so a part taken away is its negation added. + */ + expand(w: Write, copied: boolean): WriteAct[] { + const out: WriteAct[] = []; + // x = x + …: added where it is. (A copied x may read itself otherwise: its write has its cell alone, and the + // places it reads x from are filled by the copy after.) + const inPlace = w.value.terms.some((t) => t.id === w.target && t.k === 1); + const terms = inPlace ? w.value.terms.filter((t) => t.id !== w.target) : w.value.terms; + for (const into of copied ? this.placesOf(w.target).slice(0, 1) : this.placesOf(w.target)) { + const c = mul(into.scale, w.value.c); + const parts = terms.map((t) => ({ id: t.id, scale: mul(into.scale, t.k) })).filter((t) => t.scale !== 0); + if (inPlace) { + if (c) out.push({ into, modifier: "add", value: c }); + } else if (parts.length) { + const first = parts.shift()!; + out.push({ into, modifier: "set", value: 0, reads: { id: first.id, scale: first.scale, add: u(c + into.add), ...(into.base ? { base: into.base } : {}) } }); + } else out.push({ into, modifier: "set", value: num(u(c + into.add), into.base) }); + for (const t of parts) out.push({ into, modifier: "add", value: 0, reads: { id: t.id, scale: t.scale, add: 0 } }); + } + return out; + } + + graph(entries: string[]): Graph | string { + // A comparison's number is a place of what it reads: its trigger keeps its label and the condition its index. + // (In a trigger only an error goes through, refreshed from the variable's cell before it instead: `coldConds`.) + const coldConds = new Map>(); + for (const p of this.protos) p.conds.forEach((c, k) => { + const r = this.condReads.get(c); + if (r && p.cold && this.hasCell(r.id)) { + const of = coldConds.get(p.label) ?? new Map(); + coldConds.set(p.label, of.set(r.id, [...(of.get(r.id) ?? []), { dword: { at: p.label, field: { cond: k } }, scale: r.scale, add: r.add }])); + } else if (r) this.placesOf(r.id).push({ dword: { at: p.label, field: { cond: k } }, scale: r.scale, add: r.add }); + const q = this.condPlayers.get(c); + if (q) this.placesOf(q.id).push({ dword: { at: p.label, field: { cond: k, part: "player" } }, scale: q.scale, add: q.add, base: q.base }); + }); + // A store's destination and number are places of its index and value: its trigger keeps its label (it has a + // condition), its actions follow the puts-back that land there. A jump's next pointer is a place of its index. + const pinned = new Set(); + for (const [label, r] of this.jumpReads) { + this.placesOf(r.id).push({ dword: { at: label, field: "next" }, scale: r.scale, add: r.add, base: r.base }); + pinned.add(r.id); + } + for (const p of this.protos) { + let k = this.restores.get(p.label)?.length ?? 0; + for (const it of p.items) { + if (!("act" in it)) continue; + const ap = this.actPlaces.get(it.act); + if (ap) { + for (const [part, r] of Object.entries(ap) as [ActPart, Read][]) { + this.placesOf(r.id).push({ dword: { at: p.label, field: { act: k, part } }, scale: r.scale, add: r.add, ...(r.base ? { base: r.base } : {}) }); + pinned.add(r.id); + } + } + k++; + } + } + // The variables of the emitter's own standing for one of the program's in a field, the variable itself there + // instead, unless the plan copies it. + const first = this.plan(pinned); + let fused = false; + for (const [own, by] of this.pins) { + if (first.copied.has(by) || first.copied.has(own) || !this.writes.has(own)) continue; + this.fuse(own, by, pinned); + fused = true; + } + const { copied, order } = fused ? this.plan(pinned) : first; + // Each target's writes expanded once all its places are known — the writes that read it come first in the + // order, and each action that reads a variable is a place of it. + const expanded = new Map(); + /** The actions of writes only an error goes through that read a variable: refreshed from its cell before them. */ + const coldActs = new Set(); + for (const v of order) for (const w of this.writes.get(v) ?? []) { + const acts = this.expand(w, copied.has(v)); + expanded.set(w, acts); + for (const a of acts) { + if (!a.reads) continue; + if (w.cold && this.hasCell(a.reads.id)) coldActs.add(a); + else this.placesOf(a.reads.id).push({ act: a, scale: a.reads.scale, add: a.reads.add, ...(a.reads.base ? { base: a.reads.base } : {}) }); + } + } + // Every fall-through said, so that dropping a trigger leaves the one before it going where it went. + this.protos.forEach((p, i) => { if (p.next === undefined) p.next = this.protos[i + 1]?.label ?? END; }); + // Empty triggers dropped: their labels stand for where they went. (Not the first when it goes elsewhere than the + // trigger after it — the walk comes into the graph at its first trigger, so the jump would be lost: `let done = + // false` as a program's first statement set it true.) + const alias = new Map(); + const kept: Proto[] = []; + for (const [i, p] of this.protos.entries()) { + const entry = i === 0 && p.next !== this.protos[1]?.label; + if (p.conds.length === 0 && p.items.length === 0 && !p.keep && !entry && !this.restores.get(p.label)?.length) alias.set(p.label, p.next!); + else kept.push(p); + } + const resolve = (l: string): string => { + while (alias.has(l)) l = alias.get(l)!; + return l; + }; + // Routed variables: a variable whose writes would each be copied into its places (a call's result, one an island + // writes, one copied for a loop of reads) is instead read where it is used — the dwords a trigger is about to read + // refreshed from its cell just before it — when that is cheaper: its reads against its writes times its places, + // each weighed by how deep in the graph's loops it is (a loop inside counted as four turns of the one outside). + const protoOf = new Map(), protoBy = new Map(this.protos.map((p) => [p.label, p])); + for (const p of this.protos) for (const it of p.items) if ("write" in it) protoOf.set(it.write, p); + const writeOf = new Map(); + for (const [w, acts] of expanded) for (const a of acts) writeOf.set(a, w); + const weight = (p?: Proto) => 4 ** (p?.depth ?? 0); + const written = new Map(); + const wrote = (id: string, by: number) => written.set(id, (written.get(id) ?? 0) + by); + for (const p of this.protos) for (const it of p.items) { + if ("copy" in it && copied.has(it.copy)) wrote(it.copy, weight(p)); + } + const routed = new Set(); + for (const [id, w] of written) { + const ps = this.places.get(id) ?? []; + if (ps.length <= 1 || pinned.has(id) || ps.some((p) => p.synced)) continue; + let read = 0; + for (const p of ps.slice(1)) { + if (p.act) read += weight(protoOf.get(writeOf.get(p.act)!)); + else if (p.dword && "at" in p.dword) read += weight(protoBy.get(p.dword.at)); + else if (p.dword && "data" in p.dword) for (const q of this.protos) if (q.conds.some((c) => "data" in c.dword && c.dword.data === (p.dword as { data: string }).data)) read += weight(q); + } + if (read < w * (ps.length - 1) || this.scope.decls.get(id)?.routed) routed.add(id); + } + // What a trigger's conditions read of routed variables: the places refreshed before it. + const condReads = (p: Proto): Map => { + const out = new Map(coldConds.get(p.label) ?? []); + for (const id of routed) for (const pl of this.places.get(id)!.slice(1)) { + const d = pl.dword; + const here = d && (("at" in d && d.at === p.label && typeof d.field === "object" && "cond" in d.field) || ("data" in d && p.conds.some((c) => "data" in c.dword && c.dword.data === d.data))); + if (here) out.set(id, [...(out.get(id) ?? []), pl]); + } + return out; + }; + // A trigger with conditions that reads routed variables is entered through its refreshes: what goes to it goes to them. + const entry = new Map(); + for (const p of kept) if (p.conds.length && condReads(p).size) entry.set(p.label, `${p.label}.in`); + const to = (l: string): string => { + const r = resolve(l); + return entry.get(r) ?? r; + }; + const cellOf = new Map(); + for (const [id, ps] of this.places) cellOf.set(ps[0], id); + // The triggers: puts-back and actions in order, cut at 64 and around the copies. + const triggers: Trigger[] = []; + const at = new Map(); + const later: { t: Trigger; k: number; w: WriteAct }[] = []; + const copies: { t: Trigger; id: string }[] = []; + const refreshes: { t: Trigger; places: (Place | WriteAct)[] }[] = []; + const fills: { t: Trigger; target: string; apart?: boolean }[] = []; + /** The copies into dwords of their own (`route`): their `into` is theirs, not the variable's places. */ + const routes = new Set(); + /** The actions whose fields are places (a store's): their trigger is the one the proto's label names. */ + const fixed = new Set(); + let n = 0; + for (const p of kept) { + type Piece = { acts: (Act | WriteAct)[] } | { fill: { temp: string; target: string; apart?: boolean; top?: number } } | { copy: string; at?: At; top?: number } | { refresh: string; places: (Place | WriteAct)[] } | { route: { from: string; into: Dword[]; add?: number } }; + const pieces: Piece[] = [{ acts: [] }]; + const acts = () => { + const last = pieces[pieces.length - 1]; + if ("acts" in last) return last.acts; + const fresh = { acts: [] }; + pieces.push(fresh); + return fresh.acts; + }; + for (const r of this.restores.get(p.label) ?? []) acts().push({ dword: { at: r.branch, field: "next" }, modifier: "set", value: { label: to(r.to) } }); + for (const it of p.items) { + if ("act" in it) { + const a: Act = { ...it.act, value: typeof it.act.value === "object" && "label" in it.act.value ? { label: to(it.act.value.label) } : it.act.value }; + if (this.actPlaces.has(it.act)) fixed.add(a); + acts().push(a); + } + else if ("write" in it) acts().push(...expanded.get(it.write)!); + else if ("bump" in it) { + const ps = this.placesOf(it.bump.target); + if (!copied.has(it.bump.target) && ps.length > MOST_ACTS - 1) this.crowded.add(it.bump.target); + for (const into of copied.has(it.bump.target) ? ps.slice(0, 1) : ps) acts().push({ into, modifier: "add", value: mul(into.scale, it.bump.add) } as WriteAct); + } + else if ("fill" in it) pieces.push({ fill: it.fill }); + else if ("route" in it) pieces.push({ route: it.route }); + else if ("label" in it) { + for (const pl of this.placesOf(it.label.target).slice(this.cellless.has(it.label.target) ? 1 : 0)) { + if (!pl.dword || pl.scale !== 1 || pl.add !== 0) throw new Error(`emitter: a place of ${it.label.target} that is no dword of its own`); + acts().push({ dword: pl.dword, modifier: "set", value: { label: to(it.label.to) } }); + } + } + else if ("pointer" in it) { + // (Every place of a variable set to a dword's EPD: the events pass's, `watch`.) + for (const pl of this.placesOf(it.pointer.target).slice(this.cellless.has(it.pointer.target) ? 1 : 0)) { + if (!pl.dword || pl.scale !== 1 || pl.add !== 0) throw new Error(`emitter: a place of ${it.pointer.target} that is no dword of its own`); + acts().push({ dword: pl.dword, modifier: "set", value: { epd: it.pointer.to } }); + } + } + else if ("copy" in it) { + // (A variable never below zero and at most its `top`: only the bits it can have.) + const top = this.scope.decls.get(it.copy)?.top; + if (copied.has(it.copy) && !routed.has(it.copy)) pieces.push({ copy: it.copy, ...(top !== undefined ? { top } : {}), ...(it.at ? { at: it.at } : {}) }); + } + } + if (p.conds.length && (pieces.slice(1).some((x) => !(p.guarded && "copy" in x)) || !("acts" in pieces[0]) || pieces[0].acts.length > MOST_ACTS)) { + // (An `if` in one trigger whose writes turned out to have more places than a trigger holds: once more, as a branch.) + if (p.guardOf) this.misfits.add(p.guardOf); + return this.crowded.size || this.misfits.size ? "crowded" : "a branch with too many actions"; + } + // Routed variables: an action that reads one has its number refreshed from the cell just before its trigger + // (again after a write of the variable earlier in the same run of actions); conditions likewise. + const cut: Piece[] = []; + if (p.conds.length) for (const [id, ps] of condReads(p)) cut.push({ refresh: id, places: ps }); + for (const piece of pieces) { + if (!("acts" in piece) || p.conds.length) { cut.push(piece); continue; } + let cur: (Act | WriteAct)[] = [], need = new Map(), dirty = new Set(); + const coldRead = new Set([...piece.acts].filter((a): a is WriteAct => coldActs.has(a as WriteAct)).map((a) => a.reads!.id)); + const flush = () => { + for (const [id, list] of need) cut.push({ refresh: id, places: list }); + if (cur.length) cut.push({ acts: cur }); + cur = []; + need = new Map(); + dirty = new Set(); + }; + // A write at a time: every value it reads is the one from before it (its own target's too), so what it reads + // is refreshed before its first action, and what it writes is stale for the writes after it. + const groups: (Act | WriteAct)[][] = []; + for (const a of piece.acts) { + const w = "into" in a ? writeOf.get(a as WriteAct) : undefined; + const last = groups[groups.length - 1]; + if (w && last && last.length && writeOf.get(last[0] as WriteAct) === w) last.push(a); + else groups.push([a]); + } + for (const group of groups) { + const reads = group.filter((a): a is WriteAct => "into" in a && !!a.reads && (routed.has(a.reads.id) || coldActs.has(a as WriteAct))); + // (A refresh is a trigger before the actions it is for: not before an action whose fields are places, which + // keeps the label.) + if (reads.some((a) => dirty.has(a.reads!.id)) || (reads.length && cur.some((a) => fixed.has(a as Act)))) flush(); + for (const a of reads) need.set(a.reads!.id, [...(need.get(a.reads!.id) ?? []), a]); + cur.push(...group); + for (const a of group) { + const owner = "into" in a ? cellOf.get(a.into) : undefined; + if (owner && (routed.has(owner) || coldRead.has(owner))) dirty.add(owner); + } + } + flush(); + } + if (!cut.length) cut.push({ acts: [] }); + pieces.splice(0, pieces.length, ...cut); + // Each piece one trigger or more: the first takes the label and the conditions, each goes to the one after it. + const made: Trigger[] = []; + const label = () => (made.length ? `${this.key}.cut${n++}` : entry.get(p.label) ?? p.label); + for (const piece of pieces) { + if ("refresh" in piece) { + const top = this.scope.decls.get(piece.refresh)?.top; + const t: Trigger = { label: label(), conds: [], acts: [], copy: { from: piece.refresh, into: [], ...(top !== undefined ? { top } : {}) }, ...(p.at ? { at: p.at } : {}) }; + refreshes.push({ t, places: piece.places }); + made.push(t); + continue; + } + if ("route" in piece) { + const t: Trigger = { label: label(), conds: [], acts: [], copy: { from: piece.route.from, into: piece.route.into.map((dword) => ({ dword, add: piece.route.add ?? 0 })) }, ...(p.at ? { at: p.at } : {}) }; + routes.add(t); + made.push(t); + continue; + } + if ("fill" in piece) { + const t: Trigger = { label: label(), conds: [], acts: [], copy: { from: `@${piece.fill.temp}`, into: [], ...(piece.fill.top !== undefined ? { top: piece.fill.top } : {}) }, ...(p.at ? { at: p.at } : {}) }; + fills.push({ t, target: piece.fill.target, ...(piece.fill.apart ? { apart: true } : {}) }); + made.push(t); + continue; + } + if ("copy" in piece) { + const at = piece.at ?? p.at; + const t: Trigger = { label: label(), conds: [], acts: [], copy: { from: piece.copy, into: [], ...(piece.top !== undefined ? { top: piece.top } : {}) }, ...(at ? { at } : {}) }; + made.push(t); + continue; + } + for (let i = 0; i === 0 || i < piece.acts.length; i += MOST_ACTS) { + if (i === 0 && piece.acts.length === 0 && made.length) break; + // (A trigger with conditions keeps its own label, entered through its refreshes when it has any.) + const t: Trigger = { label: p.conds.length && i === 0 ? p.label : label(), conds: p.conds.length && i === 0 ? p.conds.map((c) => ({ ...c })) : [], acts: [], ...(p.at ? { at: p.at } : {}), ...(p.off && p.conds.length && i === 0 ? { off: true } : {}) }; + piece.acts.slice(i, i + MOST_ACTS).forEach((a, k) => { + if ("into" in a) { + at.set(a, { at: t.label, field: { act: k, part: "value" } }); + later.push({ t, k, w: a }); + const from = writeOf.get(a as WriteAct)?.at; + t.acts.push({ dword: { game: 0 }, modifier: a.modifier, value: a.value, ...(from && from !== t.at ? { at: from } : {}) }); + } else t.acts.push(a); + }); + made.push(t); + } + } + // A copy goes on to the trigger after it; the others say where they go. The last goes where the proto went. + const last = made[made.length - 1]; + if (last.copy) made.push({ label: `${this.key}.cut${n++}`, conds: [], acts: [] }); + made.forEach((t, i) => { + if (t.copy) { if (!routes.has(t) && !refreshes.some((r) => r.t === t) && !fills.some((f) => f.t === t)) copies.push({ t, id: t.copy.from }); } + else t.next = i === made.length - 1 ? to(p.next!) : made[i + 1].label; + }); + triggers.push(...made); + } + // Where each write's action writes, now that every place has its dword. + const dwordOf = (p: Place): Dword => p.dword ?? at.get(p.act!)!; + for (const { t, k, w } of later) t.acts[k].dword = dwordOf(w.into); + // A routed variable's refreshes: the numbers and the conditions' places about to be read. + for (const { t, places } of refreshes) for (const pl of places) { + if ("into" in pl) t.copy!.into.push({ dword: at.get(pl)!, add: num(pl.reads!.add, pl.reads!.base), ...(pl.reads!.scale !== 1 ? { scale: pl.reads!.scale } : {}) }); + else t.copy!.into.push({ dword: dwordOf(pl), add: num(pl.add, pl.base), ...(pl.scale !== 1 ? { scale: pl.scale } : {}) }); + } + // A scratch cell into every place of what reads it (a variable of the emitter's own: its home too); into a routed + // variable's cell alone, which its places are refreshed from where they are read. + // A target with no place but its cell has the array's cell read straight into it: no scratch cell, no copy. + const scratch = new Map(), tested = new Map(), routedInto = new Map(); + for (const x of triggers) { + for (const a of x.acts) { + const temp = "temp" in a.dword ? a.dword.temp : typeof a.value === "object" && "epd" in a.value && "temp" in a.value.epd ? a.value.epd.temp : undefined; + if (temp !== undefined) scratch.set(temp, [...(scratch.get(temp) ?? []), a]); + } + // (A product's or a quotient's scratch cell is also tested as it is worked in, and a variable may be routed into it.) + for (const c of x.conds) if ("temp" in c.dword) tested.set(c.dword.temp, [...(tested.get(c.dword.temp) ?? []), c]); + for (const c of x.copy?.into ?? []) if ("temp" in c.dword) routedInto.set(c.dword.temp, [...(routedInto.get(c.dword.temp) ?? []), c]); + } + // (What reads a variable's cell where it is: a condition, an action's number pointed at it, a copy or a route of it.) + const cellsRead = new Set(); + for (const x of triggers) { + for (const c of x.conds) cellsRead.add(JSON.stringify(c.dword)); + for (const a of x.acts) if (typeof a.value === "object" && "epd" in a.value) cellsRead.add(JSON.stringify(a.value.epd)); + if (x.copy) cellsRead.add(JSON.stringify({ var: x.copy.from })); + } + const plainPlace = (p: Place) => p.scale === 1 && p.add === 0 && !p.base; + // (A map of our own: a variable of the emitter's own whose cell nothing reads — a sum a store's number is worked out + // in — is written there for nothing: what its writes read into that cell is no place to fill. The write stays, and + // writes what it will.) + const homeOf = new Map(); + for (const [id, ps] of this.places) if (this.own.has(id) && ps[0]?.dword && !cellsRead.has(JSON.stringify(ps[0].dword))) homeOf.set(ps[0], id); + const dead = (pl: Place) => !!pl.act && homeOf.has(pl.act.into); + for (const { t, target, apart } of fills) { + const ps = this.placesOf(target).filter((pl, i) => i === 0 || !dead(pl)); + // The scratch cell folded into the one dword that takes its value: the target's cell when that is its only place, + // or — a variable of the emitter's own nothing reads at its cell — the one field that reads it (a write's number): + // worked in where it is read, no copy. (`apart`: the scratch cell is worked in while the target is still read — a + // product of the target — and stays one of its own.) + // (Or a field that holds it plus a constant — a store of `xs[i] + 1` — when nothing tests the scratch cell or + // routes into it: the scratch's 0 to start from is that constant.) + // (A map of our own only: a cell trigger of its own adds its number in, where the Python side's reads may set it. + // Not when a masked write works the scratch cell — `&`, `|`, `^` by a constant: a `set 0` under a mask would + // become a set of the constant under it, and put the constant's bits there: `(v & 0xffff) − 1025` came out as + // v's low half under −1025's high half, step 12's Open list.) + const fromConst = (p: Place) => p.scale === 1 && !p.base && !tested.get(t.copy!.from.slice(1))?.length && !routedInto.get(t.copy!.from.slice(1))?.length && !(scratch.get(t.copy!.from.slice(1)) ?? []).some((a) => a.mask !== undefined); + const lonePlace = !apart && ps.length === 1 && ps[0].dword && plainPlace(ps[0]) ? ps[0] + : !apart && ps.length === 2 && (this.own.has(target) || !!this.scope.decls.get(target)?.scoped) && (plainPlace(ps[1]) || fromConst(ps[1])) && ps[0].dword && !cellsRead.has(JSON.stringify(ps[0].dword)) ? ps[1] + : undefined; + const lone = lonePlace ? dwordOf(lonePlace) : undefined; + if (lone) { + const home = lone; + for (const a of scratch.get(t.copy!.from.slice(1)) ?? []) { + if ("temp" in a.dword) { a.dword = home; if (lonePlace!.add && a.modifier === "set" && a.value === 0) a.value = u(lonePlace!.add); } + else a.value = { epd: home }; + } + for (const c of tested.get(t.copy!.from.slice(1)) ?? []) c.dword = home; + for (const c of routedInto.get(t.copy!.from.slice(1)) ?? []) c.dword = home; + delete t.copy; + continue; + } + for (const p of routed.has(target) ? ps.slice(0, 1) : ps) t.copy!.into.push({ dword: dwordOf(p), add: num(p.add, p.base), ...(p.scale !== 1 ? { scale: p.scale } : {}) }); + } + // A copied variable's copies: every place but its cell. + for (const { t, id } of copies) for (const p of this.placesOf(id).slice(1)) { + t.copy!.into.push({ dword: dwordOf(p), add: num(p.add, p.base), ...(p.scale !== 1 ? { scale: p.scale } : {}) }); + } + // Every way out of the graph passes one trigger at its end, where the branches that went there are put back. + const exit = `${this.key}.end`; + for (const t of triggers) if (t.next === END) t.next = exit; + for (const t of triggers) for (const a of t.acts) if (typeof a.value === "object" && "label" in a.value && a.value.label === END) a.value = { label: exit }; + triggers.push({ label: exit, conds: [], acts: (this.restores.get(END) ?? []).map((r) => ({ dword: { at: r.branch, field: "next" }, modifier: "set", value: { label: to(r.to) === END ? exit : to(r.to) } })), next: END }); + // Triggers that do nothing dropped (a fill read straight into its target, the one after a copy that ends a proto): + // what went to one goes where it went. Not the end, nor one whose fields something writes (where a function goes + // back to). + const fielded = new Set(); + const fields = (x: unknown): void => { + if (!x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(fields); return; } + const o = x as Record; + if (typeof o.at === "string" && "field" in o) fielded.add(o.at); + for (const v of Object.values(o)) if (v && typeof v === "object") fields(v); + }; + for (const t of triggers) fields([t.acts, t.conds, t.copy?.into]); + for (const ps of this.places.values()) for (const p of ps) if (p.dword) fields(p.dword); + // (A copy has no next pointer: it goes on to the trigger after it, so one that does nothing and goes elsewhere + // stays after one.) + const on = new Map(); + let falls = false; + // (Nor one made to stay: an empty trigger of a run a doubling trigger walks, whose place in the run is its being there.) + const stays = new Set(this.protos.filter((p) => p.keep).map((p) => p.label)); + // (Nor the first when it goes elsewhere than the trigger after it: the walk comes into the graph there.) + triggers.forEach((t, i) => { + const entry = i === 0 && t.next !== undefined && t.next !== triggers[1]?.label; + const idle = t.label !== exit && !entry && !t.conds.length && !t.acts.length && !t.copy && !fielded.has(t.label) && !stays.has(t.label); + if (idle && (t.next === undefined || !falls)) on.set(t.label, t.next ?? triggers[i + 1].label); + else falls = t.next === undefined; + }); + if (on.size) { + const past = (l: string): string => { while (on.has(l)) l = on.get(l)!; return l; }; + const left = triggers.filter((t) => !on.has(t.label)); + triggers.splice(0, triggers.length, ...left); + for (const t of triggers) { + if (typeof t.next === "string") t.next = past(t.next); + for (const a of t.acts) if (typeof a.value === "object" && "label" in a.value) a.value = { label: past(a.value.label) }; + } + } + joinRuns(triggers, new Set([exit, triggers[0]?.label, ...stays, ...this.rows.flat()].filter((l): l is string => !!l))); + // What the graph reads that was written before it, copied into its places on the way in. + const graph: Graph = { entries: [], triggers, ...(this.arrays.size ? { arrays: [...this.arrays].map(([id, stride]) => ({ id, stride })) } : {}), ...(this.rows.length ? { rows: this.rows } : {}) }; + for (const id of entries) for (const p of this.placesOf(id)) { + // (A routed variable's places are refreshed where they are read.) + if (routed.has(id)) continue; + const d = dwordOf(p); + if (p.synced || ("var" in d && d.var === id && p.add === 0 && p.scale === 1 && !p.base)) continue; + graph.entries.push({ from: id, into: d, add: num(p.add, p.base), ...(p.scale !== 1 ? { scale: p.scale } : {}) }); + } + const problems = checkGraph(graph); + return problems.length ? problems.join("; ") : graph; + } +} + +type CompareOp = "<" | "<=" | ">" | ">=" | "==" | "!="; +const FLIP = { "<": ">", "<=": ">=", ">": "<", ">=": "<=", "==": "==", "!=": "!=" } as const; +const INVERSE = { "<": ">=", "<=": ">", ">": "<=", ">=": "<", "==": "!=", "!=": "==" } as const; +/** A comparison of two numbers as 32 bits. */ +const compareU32 = (op: CompareOp, x: number, y: number): boolean => { + const [a, b] = [u(x), u(y)]; + return op === "<" ? a < b : op === "<=" ? a <= b : op === ">" ? a > b : op === ">=" ? a >= b : op === "==" ? a === b : a !== b; +}; +const notZero = (e: NumExpr): BoolExpr => ({ kind: "compare", op: "!=", left: e, right: { kind: "const", value: 0 }, at: { line: 0, column: 0 } as At, label: "" }); + +/** + * What is still to be read after each call that may come back, in a function's body (a map of our own keeps no more + * of `saves` than that on the stack): the variables that some way on from the call reads before it writes them. Worked + * backwards over the statements — a loop until nothing more comes in, a `break` from what follows its loop, a + * `continue` from its loop's top, a `return` from its value and where it goes (an inlined call's, on after the call). + * A call is read from (its arguments) and writes its result, and its parameters unless it is one that may come back: + * that one takes back after it what it keeps, the parameters it sets among them. What the function it calls does to + * this one's variables does not count otherwise: a function's variables are its own. + */ +/** + * A map of our own: a trigger with no conditions that the walk only ever reaches by falling from the one before it — + * which has no conditions either, and goes on to it — is that one's actions after its own, in one trigger: the walk + * passes one where it passed two. Not when either's next pointer is written by anything (a branch, a jump into a cell, + * where a function goes back to), nor a copy, nor `keep` (the entry, the end, a kept one, a row's); + * a field of the one taken in is the same field of the one that takes it, its action moved on by that one's actions. + */ +function joinRuns(triggers: Trigger[], keep: Set): void { + const reached = new Map(); + const see = (l: string) => reached.set(l, (reached.get(l) ?? 0) + 1); + const nextWritten = new Set(); + const scan = (x: unknown): void => { + if (!x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(scan); return; } + const o = x as Record; + if (typeof o.at === "string" && (o.field === "next" || o.field === "current")) nextWritten.add(o.at); + if (typeof o.label === "string" && Object.keys(o).length === 1) see(o.label); + for (const v of Object.values(o)) if (v && typeof v === "object") scan(v); + }; + for (const t of triggers) { if (t.next !== undefined) see(t.next); scan([t.conds, t.acts, t.copy?.into]); } + const plain = (t: Trigger) => !t.conds.length && !t.copy && !nextWritten.has(t.label); + // (Each one taken in noted — the trigger it is now, its first action's place there — and the fields renamed after, + // in one walk.) + const moved = new Map(); + for (let i = 0; i + 1 < triggers.length;) { + const a = triggers[i], b = triggers[i + 1]; + const falls = a.next === undefined || a.next === b.label; + if (!falls || !plain(a) || !plain(b) || keep.has(b.label) || (reached.get(b.label) ?? 0) > (a.next === b.label ? 1 : 0) || a.acts.length + b.acts.length > MOST_ACTS) { i++; continue; } + moved.set(b.label, { into: a.label, shift: a.acts.length }); + // (What was taken into `b` before is `a`'s now, further on.) + for (const [l, m] of moved) if (m.into === b.label) moved.set(l, { into: a.label, shift: m.shift + a.acts.length }); + a.acts.push(...b.acts); + a.next = b.next; + if (b.next === undefined) delete a.next; + triggers.splice(i + 1, 1); + } + if (!moved.size) return; + const move = (x: unknown): void => { + if (!x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(move); return; } + const o = x as Record; + const m = typeof o.at === "string" ? moved.get(o.at) : undefined; + if (m && o.field && typeof o.field === "object" && "act" in (o.field as object)) { + o.at = m.into; + o.field = { ...(o.field as { act: number; part: ActPart }), act: (o.field as { act: number }).act + m.shift }; + } + for (const v of Object.values(o)) if (v && typeof v === "object") move(v); + }; + for (const t of triggers) move([t.conds, t.acts, t.copy?.into]); +} + +export function liveAfterCalls(body: Stmt[]): Map> { + const out = new Map>(); + /** What is live where a `break`, a `continue`, a `return` and an error go. */ + type Flow = { brk: Set; cont: Set; ret: Set; thr: Set }; + const union = (...xs: Set[]) => new Set(xs.flatMap((x) => [...x])); + const reads = (e: unknown) => (e && typeof e === "object" ? readIn(e as NumExpr) : new Set()); + const seq = (list: Stmt[], after: Set, flow: Flow): Set => { + let live = after; + for (let i = list.length - 1; i >= 0; i--) live = stmt(list[i], live, flow); + return live; + }; + const loop = (test: BoolExpr | undefined, body: Stmt[], update: Stmt[], after: Set, testFirst: boolean, flow: Flow): Set => { + // The top of a turn: (the test, then) the body, the update, and round again — until nothing more comes in. + const tested = (top: Set) => union(test ? reads(test) : new Set(), after, top); + let top = new Set(); + for (;;) { + const cont = seq(update, testFirst ? tested(top) : tested(top), { ...flow, brk: after, cont: after }); + const next = seq(body, cont, { ...flow, brk: after, cont }); + const grown = [...next].some((id) => !top.has(id)); + top = union(top, next); + if (!grown) return testFirst ? tested(top) : top; + } + }; + const stmt = (s: Stmt, after: Set, flow: Flow): Set => { + switch (s.kind) { + case "declare": case "assign": case "assignBool": { + const target = s.kind === "declare" ? s.decl.id : s.target; + const live = new Set(after); + live.delete(target); + return union(live, reads(s.kind === "declare" ? s.init : s.value)); + } + case "store": return union(after, reads(s.index), reads(s.value)); + case "call": { + const c = s.call; + // (Recorded on every pass of a loop around it: the last is the one that holds.) + // (What the `catch` it may throw into reads, too: the frame is taken back before the error goes there.) + if (c.saves) out.set(c, union(after, flow.thr)); + let live = new Set(after); + if (c.result) live.delete(c.result.decl.id); + // An inlined call's body where it is: its `return` goes on to what follows the call. + if (c.fn === undefined) live = seq(c.body, live, { brk: live, cont: live, ret: live, thr: flow.thr }); + // (A call that may come back sets parameters that are this function's own, and takes back after it what it + // keeps: what was live after it is so before it too.) + if (!c.saves) for (const p of c.params) live.delete(p.decl.id); + return union(live, flow.thr, ...c.params.map((p) => reads(p.init))); + } + case "return": return union(reads(s.value), flow.ret); + case "throw": return flow.thr; + case "try": return seq(s.body, after, { ...flow, thr: seq(s.catch, after, flow) }); + case "if": return union(reads(s.cond), seq(s.then, after, flow), seq(s.else ?? [], after, flow)); + case "block": return seq(s.body, after, flow); + case "while": return loop(s.cond, s.body, [], after, true, flow); + case "for": return loop(s.cond, s.body, s.update, after, true, flow); + case "do": return loop(s.cond, s.body, [], after, false, flow); + case "break": return flow.brk; + case "continue": return flow.cont; + case "switch": { + // Each case falls into the next; a break leaves the switch, a continue is the loop's around it. Any case may + // be where it starts, or none. + const entries: Set[] = []; + let live = after; + for (let i = s.cases.length - 1; i >= 0; i--) entries.push((live = seq(s.cases[i].body, live, { ...flow, brk: after }))); + return union(reads(s.value), after, ...entries); + } + case "unrolled": { + let live = after; + for (let i = s.iterations.length - 1; i >= 0; i--) live = seq(s.iterations[i], live, { ...flow, brk: after, cont: live }); + return live; + } + // (Anything else a map of our own takes reads what it reads and writes nothing of a function's own.) + default: return union(after, reads(s)); + } + }; + const none = new Set(); + seq(body, none, { brk: none, cont: none, ret: none, thr: none }); + return out; +} + +/** + * Every variable a statement may write: what it sets or declares, a call's result and parameters, and what the + * functions it calls write, all the way down. + */ +function writes(s: Stmt, functions: Map): Set { + const out = new Set(), seen = new Set(); + const walk = (x: unknown): void => { + if (!x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(walk); return; } + const o = x as Record; + if (typeof o.target === "string") out.add(o.target); + const d = o.decl as VarDecl | undefined; + if (d && typeof d === "object" && typeof d.id === "string") out.add(d.id); + if (o.was && typeof o.was === "object") out.add((o.was as VarDecl).id); + if (typeof o.fn === "string" && !seen.has(o.fn)) { seen.add(o.fn); walk(functions.get(o.fn)?.body); } + for (const v of Object.values(o)) if (v && typeof v === "object") walk(v); + }; + walk(s); + return out; +} + +/** Whether a statement may store into an array: itself, or a function it calls, all the way down. */ +function stores(s: Stmt, functions: Map): boolean { + const seen = new Set(); + const walk = (x: unknown): boolean => { + if (!x || typeof x !== "object") return false; + if (Array.isArray(x)) return x.some(walk); + const o = x as Record; + if (o.kind === "store" || o.kind === "declareArray" || o.kind === "push" || o.kind === "pop" || o.kind === "setLength") return true; + if (typeof o.fn === "string" && !seen.has(o.fn)) { seen.add(o.fn); if (walk(functions.get(o.fn)?.body)) return true; } + return Object.values(o).some((v) => v && typeof v === "object" && walk(v)); + }; + return walk(s); +} + +/** Whether a piece of the IR holds a statement or an expression of this kind. */ +function holdsKind(x: unknown, kind: string): boolean { + if (!x || typeof x !== "object") return false; + if (Array.isArray(x)) return x.some((y) => holdsKind(y, kind)); + const o = x as Record; + return o.kind === kind || Object.entries(o).some(([k, v]) => k !== "original" && v && typeof v === "object" && holdsKind(v, kind)); +} + +/** + * The tasks of a map of our own (TrigScript 4, step 11): each `start`'s slots — one when the start can run only once + * (in no loop, nowhere twice, in no task of more than one slot), else as many as its `max`, each slot after the first a + * copy of the task's call whose variables of the task's are its own (as the simulator swaps them) — and the program + * with `running` and `taskHandle` worked out against the slots: a number is a task's while one slot's number is it. + * Or why not. The new variables' declarations go into `known`. + */ +function planTasks(root: Stmt, key: string, known: Map, arrays: Map): { root: Stmt; slots: Map; once: Set } | string { + // Which starts run more than once, and which start each is inside. + const seen = new Map(), many = new Set(), parent = new Map(); + const visit = (x: unknown, looped: boolean, task: Stmt | null): void => { + if (!x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach((y) => visit(y, looped, task)); return; } + const o = x as Record; + if (o.kind === "start" && typeof o.task === "number" && o.call) { + const st = o as unknown as StartStmt; + seen.set(st, (seen.get(st) ?? 0) + 1); + if (looped || seen.get(st)! > 1) many.add(st); + parent.set(st, task); + visit(st.call.params, looped, task); + visit(st.call.body, false, st); + return; + } + const loop = o.kind === "while" || o.kind === "for" || o.kind === "do" || o.kind === "unitLoop" || o.kind === "textLoop"; + for (const [k, v] of Object.entries(o)) if (k !== "original" && v && typeof v === "object") visit(v, looped || loop, task); + }; + visit(root, false, null); + for (const [st, p] of parent) if (p && many.has(p)) return `a task started inside a task of more than one slot (line ${st.at.line})`; + // (A slot's arrays would be its own, as its variables are: not yet.) + for (const st of many) { + const a = [...arrays.values()].find((x) => x.task === (st as StartStmt).task); + if (a) return `an array of a task that runs in more than one slot at a time (${a.name}, line ${st.at.line})`; + } + // The slots: their numbers' variables, and for each slot after the first, the task's variables renamed. + // (Declared as the program starts: what is worked out from them — a slot's number from its generation — is kept in + // places, which a write sets.) + const plans = new Map(), declares: Stmt[] = []; + for (const st of parent.keys()) { + const s = st as StartStmt; + const n = many.has(st) ? s.max : 1; + const handles = Array.from({ length: n }, (_, k) => `@slot${s.task}.${k}.id`); + plans.set(s.task, { n, handles }); + for (let k = 0; k < n; k++) for (const id of [`@slot${s.task}.${k}.gen`, handles[k]]) { + const decl: VarDecl = { id, name: "(task)", kind: "number", shared: false, at: s.at }; + known.set(id, decl); + declares.push({ kind: "declare", decl, init: { kind: "const", value: 0 }, at: s.at, label: "" }); + } + } + const every = [...plans.values()].flatMap((p) => p.handles); + const slots = new Map(), once = new Set(); + // (Copied where it is rewritten: the program's IR is the simulator's too, and each run's.) + const remake = (x: unknown, handle: NumExpr, rename: Map): unknown => { + if (Array.isArray(x)) return x.map((y) => remake(y, handle, rename)); + if (!x || typeof x !== "object") return typeof x === "string" && rename.has(x) ? rename.get(x) : x; + const o = x as Record; + if (o.kind === "taskHandle") return handle; + if (o.kind === "running") { + const h = remake(o.task, handle, rename) as NumExpr, at = (o.at ?? { file: "", line: 0, column: 0 }) as At; + if (!every.length) return { kind: "const", value: false }; + return { kind: "and", items: [{ kind: "compare", op: ">=", left: h, right: { kind: "const", value: 1 }, unsigned: true, at, label: "" }, { kind: "or", items: every.map((id) => ({ kind: "compare", op: "==", left: h, right: { kind: "var", id }, at, label: "" })) }] }; + } + if (o.kind === "start" && typeof o.task === "number" && o.call) { + const s = o as unknown as StartStmt, plan = plans.get(s.task)!; + const calls = plan.handles.map((h, k) => { + // (Slot k's own variables: the task's, `id~k`.) + const own = new Map(rename); + if (k) for (const d of declsIn(s.call)) if (d.task === s.task) own.set(d.id, `${d.id}~${k}`); + const call = remake(s.call, { kind: "var", id: h }, own) as Call; + for (const d of declsIn(call)) if (!known.has(d.id)) known.set(d.id, d); + return call; + }); + const made = { ...s, call: calls[0] } as StartStmt; + slots.set(made, calls.map((call, k) => ({ call, check: `${key}:task${s.task}.${k}`, entry: `${key}:task${s.task}.${k}:body`, gen: `@slot${s.task}.${k}.gen`, handle: plan.handles[k] }))); + if (!many.has(s)) once.add(made); + return made; + } + const out: Record = {}; + for (const [k, v] of Object.entries(o)) out[k] = remake(v, handle, rename); + return out; + }; + const made = remake(root, { kind: "const", value: 0 }, new Map()) as Stmt; + return { root: declares.length ? { kind: "block", body: [...declares, made], at: made.at } : made, slots, once }; +} + +/** Every declaration in a piece of the IR: a `declare`'s, a loop's, a call's parameters and result. */ +function declsIn(x: unknown, out: VarDecl[] = []): VarDecl[] { + if (Array.isArray(x)) { x.forEach((y) => declsIn(y, out)); return out; } + if (!x || typeof x !== "object") return out; + const o = x as Record; + const d = o.decl as VarDecl | undefined; + if (d && typeof d === "object" && typeof d.id === "string" && typeof d.kind === "string") out.push(d); + for (const [k, v] of Object.entries(o)) if (k !== "decl" && v && typeof v === "object") declsIn(v, out); + return out; +} + +/** Whether statements end in a `throw`: all they do is on an error's way. */ +function throwsAtEnd(list: Stmt[]): boolean { + const last = list.filter((s) => s.kind !== "remark").at(-1); + return last?.kind === "throw" || (last?.kind === "block" && throwsAtEnd(last.body)); +} + +/** Whether a value or a test reads a cell of an array. */ +function readsCell(x: unknown): boolean { + if (!x || typeof x !== "object") return false; + if (Array.isArray(x)) return x.some(readsCell); + const o = x as Record; + return o.kind === "element" || Object.values(o).some((v) => v && typeof v === "object" && readsCell(v)); +} + +/** Every variable a test reads. */ +function readIn(e: BoolExpr | NumExpr, into = new Set()): Set { + if (e.kind === "var") into.add(e.id); + for (const v of Object.values(e)) { + if (Array.isArray(v)) v.forEach((x) => x && typeof x === "object" && "kind" in x && readIn(x as BoolExpr, into)); + else if (v && typeof v === "object" && "kind" in v) readIn(v as BoolExpr, into); + } + return into; +} + +/** A loop's test as what can be tested once (every part of an `and` that reads nothing the loop writes) and the rest. */ +function split(cond: BoolExpr, written: Set): { once: BoolExpr | null; every: BoolExpr | null } { + const c = cond.kind === "compare" ? plain(cond) : cond; + if (c.kind !== "and") return { once: null, every: c }; + // (A part that reads a cell of an array is tested every turn: what the loop stores is no variable it writes.) + const fixed = c.items.filter((x) => !readsCell(x) && [...readIn(x)].every((id) => !written.has(id))); + const rest = c.items.filter((x) => !fixed.includes(x)); + const all = (items: BoolExpr[]): BoolExpr | null => (items.length === 0 ? null : items.length === 1 ? items[0] : { kind: "and", items }); + return { once: all(fixed), every: all(rest) }; +} + +/** + * A comparison of a `u32` with a signed number, as two of one reading: a signed number below zero is below every + * `u32`, so `x < y` with x the `u32` is `y >= 0 && x < y` read from 0 up, `x > y` is `y < 0 || x > y`, and so on. + */ +function plain(e: Extract): BoolExpr { + if (e.unsigned !== "left" && e.unsigned !== "right") return e; + const signedSide = e.unsigned === "left" ? e.right : e.left; + const zero: NumExpr = { kind: "const", value: 0 }; + const negative: BoolExpr = { ...e, op: "<", left: signedSide, right: zero, unsigned: false }; + const notNegative: BoolExpr = { ...e, op: ">=", left: signedSide, right: zero, unsigned: false }; + const asU32: BoolExpr = { ...e, unsigned: true }; + // Which way the signed side being below zero settles it: for "left" (the right side signed) the left is the larger. + const leftLarger = e.unsigned === "left"; + const holdsWhenNegative = e.op === "!=" || (leftLarger ? e.op === ">" || e.op === ">=" : e.op === "<" || e.op === "<="); + return holdsWhenNegative ? { kind: "or", items: [negative, asU32] } : { kind: "and", items: [notNegative, asU32] }; +} + +/** + * An ordered comparison, read signed, of a variable's bits under a mask that has the sign bit, against a constant — + * as comparisons read from 0 up: with the sign bit set the bits are below zero (a constant below zero is then compared + * with them from 0 up, one at zero or above is above them), with it clear they are at or above zero. Null when it is + * not one. + */ +function signedMask(e: Extract): BoolExpr | null { + if (e.unsigned === true || e.unsigned === "left" || e.unsigned === "right" || e.op === "==" || e.op === "!=") return null; + let [l, r, op] = [side(e.left), side(e.right), e.op] as [Side | null, Side | null, CompareOp]; + if (l && "k" in l && r && "mask" in r && r.mask !== undefined) [l, r, op] = [r, l, FLIP[op] as CompareOp]; + if (!l || !r || !("mask" in l) || l.mask === undefined || l.mask < SIGN || !("k" in r)) return null; + const k = r.k | 0; + const where = { at: e.at, label: e.label }; + const bits: NumExpr = { kind: "binary", op: "&", left: { kind: "var", id: l.id }, right: { kind: "const", value: l.mask }, at: e.at, label: "" }; + const top: NumExpr = { kind: "binary", op: "&", left: { kind: "var", id: l.id }, right: { kind: "const", value: SIGN }, at: e.at, label: "" }; + const zero: NumExpr = { kind: "const", value: 0 }; + const below: BoolExpr = { kind: "compare", op: "!=", left: top, right: zero, unsigned: true, ...where }; + const above: BoolExpr = { kind: "compare", op: "==", left: top, right: zero, unsigned: true, ...where }; + const asU32: BoolExpr = { kind: "compare", op, left: bits, right: { kind: "const", value: u(k) }, unsigned: true, ...where }; + const less = op === "<" || op === "<="; + // With the sign bit set: a constant below zero is compared from 0 up; one at zero or above settles it. + const neg: BoolExpr = k < 0 ? asU32 : { kind: "const", value: less }; + // With it clear: one at zero or above is compared from 0 up; one below zero settles it. + const pos: BoolExpr = k >= 0 ? asU32 : { kind: "const", value: !less }; + return { kind: "or", items: [{ kind: "and", items: [below, neg] }, { kind: "and", items: [above, pos] }] }; +} + +/** A variable the rewriter made (`@t…`): the emitter's own, declared by no statement. */ +const isTemp = (id: string) => id.startsWith("@t"); + +/** Whether a value is a test (true or false) and nothing a number could be. */ +const isTest = (e: NumExpr | BoolExpr): boolean => e.kind === "compare" || e.kind === "and" || e.kind === "or" || e.kind === "not" || e.kind === "test"; + +/** Whether a value calls a function anywhere in it. */ +function holdsCall(x: unknown): boolean { + if (!x || typeof x !== "object") return false; + if (Array.isArray(x)) return x.some(holdsCall); + const o = x as Record; + return o.kind === "call" || Object.values(o).some(holdsCall); +} + +/** + * A program's statements as the emitter takes them: a value that is `?:`, `Math.abs`, `Math.min` / `max` as `if` / + * `else` of plain assignments (`x = c ? a : b` is `if (c) x = a; else x = b`; `x = abs(e)` tests e's sign; a product or + * quotient's abs tests the result's), such a value inside a sum or a comparison's side computed first into a variable + * of the emitter's own (`@t…`), and a loop's test that needs that computed at the top of each turn. Every expression + * moved is pure, so computing it ahead of a test that would have skipped it changes nothing. + * + * A call inside an expression becomes a statement of its own ahead of it, the expression reading the call's result: + * a value to its left that the call may change is taken into a variable of the emitter's own first (JavaScript reads + * it before the call), and a call in a later part of `&&` / `||` runs only when the parts before it leave the answer + * open. + */ +class Rewriter { + n = 0; + readonly key: string; + readonly functions: Map; + /** The program's arrays (a map of our own): which of them are kept in regions, whose cells a condition reads. */ + arrays = new Map(); + /** What a `return` gives back where it stands: the kind of the function or inlined call around it, innermost last. */ + returning: string[] = []; + /** The program's `u8` and `u16` variables (a map of our own): each one's top, 255 or 65 535. */ + bits = new Map(); + constructor(key: string, functions = new Map(), result?: string) { + this.key = key; + this.functions = functions; + if (result) this.returning.push(result); + } + + /** + * A value worked out before the statements `pre` gained from `mark` on, and read after them: taken into a variable of + * the emitter's own at `mark` when a call among them may write what it reads. + */ + before(e: E, pre: Stmt[], mark: number, where: { at: At; label: string }, test = false): E { + if (mark === pre.length || e.kind === "const") return e; + const after: Stmt = { kind: "block", body: pre.slice(mark), at: where.at }; + const later = writes(after, this.functions); + // (A cell it reads, when what comes after may store into an array: JavaScript reads it first.) + if (![...readIn(e)].some((id) => later.has(id)) && !(readsCell(e) && stores(after, this.functions))) return e; + const t = this.temp(); + pre.splice(mark, 0, test ? { kind: "assignBool", target: t, value: e as BoolExpr, ...where } : { kind: "assign", target: t, value: e as NumExpr, ...where }); + return { kind: "var", id: t } as E; + } + + /** A call as a statement of its own, after what its arguments need (in `pre`); an inlined call's body rewritten. */ + call(c: Call, pre: Stmt[], where: { at: At; label: string }): void { + const marks: number[] = []; + const inits = c.params.map((p) => { + marks.push(pre.length); + if (p.decl.kind === "boolean") return this.cond(p.init as BoolExpr, pre, where); + if (p.decl.kind !== "number") return p.init; + // (More than a sum — a cell of an array — into a variable of the emitter's own first; into a `u8` or `u16`, + // stopped at its top.) + return this.sum(this.flat(this.top(p.init as NumExpr, p.decl.bits && 2 ** p.decl.bits - 1, where), pre, where), pre, where); + }); + marks.push(pre.length); + // Each argument read before what the ones after it run (from the last back: a value put in ahead of a later one's + // statements leaves the earlier marks where they were). + for (let i = inits.length - 2; i >= 0; i--) { + const kind = c.params[i].decl.kind; + if (kind === "number" || kind === "boolean") inits[i] = this.before(inits[i] as NumExpr | BoolExpr, pre, marks[i + 1], where, kind === "boolean"); + } + this.returning.push(c.result?.kind ?? "void"); + const body = c.fn === undefined ? this.stmts(c.body) : c.body; + this.returning.pop(); + pre.push({ kind: "call", call: { ...c, params: c.params.map((p, i) => ({ ...p, init: inits[i] })), body }, ...where }); + } + + temp(): string { + return `@t${this.key}.${this.n++}`; + } + + /** + * A value stored into a `u8` or `u16` (a map of our own): stopped at the top, read from 0 up, as the simulator keeps + * it and the Python side's `saturate` stores it — the front end has already stopped it at 0 (`max(…, 0)`). As it is + * when its value range is inside. + */ + top(e: NumExpr, top: number | undefined, where: { at: At; label: string }): NumExpr { + const r = (e as NumExpr & { range?: readonly [number, number] }).range; + if (!top || (r && r[0] >= 0 && r[1] <= top) || (e.kind === "const" && u(e.value) <= top)) return e; + if (e.kind === "const") return { kind: "const", value: top }; + return { kind: "intrinsic", name: "min", args: [e, { kind: "const", value: top }], unsigned: true, range: [0, top], ...where } as NumExpr; + } + + /** The top of an array's cells, when it is a `u8` or `u16` one. */ + arrayTop(id: string): number | undefined { + const bits = this.arrays.get(id)?.bits; + return bits ? 2 ** bits - 1 : undefined; + } + + at(s: { at?: At; label?: string }) { + return { at: s.at as At, label: s.label ?? "" }; + } + + /** + * The loop being rewritten, while its body is: what it may write (its own stores and declarations, those of the + * functions it calls), and the values computed once on the way in. + */ + hoist: { written: Set; arrays: Set; out: Stmt[] } | null = null; + + /** + * A value worth computing once, ahead of the loop, and that may be: a shift, a fixed-point product or quotient, a cell + * of a list known when the map is built — reading nothing the loop writes, and calling nothing. + */ + invariant(e: NumExpr): boolean { + const h = this.hoist; + if (!h) return false; + const costly = (e.kind === "binary" && (e.op === ">>" || e.op === ">>>") && e.right.kind === "const") || (e.kind === "intrinsic" && (e.name === "fmul" || e.name === "fdiv")) || e.kind === "element"; + if (!costly) return false; + let pure = true; + const walk = (x: unknown): void => { + if (!pure || !x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(walk); return; } + const o = x as Record; + if (o.kind === "var" && typeof o.id === "string" && (h.written.has(o.id) || isTemp(o.id))) pure = false; + if (o.kind === "element" && h.arrays.has(o.array as string)) pure = false; + if (o.kind === "call" || o.kind === "read" || o.kind === "randomInt" || o.kind === "input" || o.kind === "unitField" || o.kind === "tableRead" || o.kind === "pop") pure = false; + Object.values(o).forEach(walk); + }; + walk(e); + return pure; + } + + /** Inside a later part of `&&` or `||`, whose reads go ahead of the parts before it (`ahead`). */ + early = 0; + ahead(f: () => X): X { + this.early++; + try { + return f(); + } finally { + this.early--; + } + } + /** An index as it is, or — read ahead of what decides whether it is read — without its range, so that it is tested. */ + unsure(index: NumExpr): NumExpr { + if (!this.early || !("range" in index)) return index; + const { range: _, ...rest } = index as NumExpr & { range?: unknown }; + return rest as NumExpr; + } + + /** `e` with every `?:`, abs, min and max in it computed first into a variable of the emitter's own. */ + flat(e: NumExpr, pre: Stmt[], where: { at: At; label: string }): NumExpr { + if (this.invariant(e)) { + const t = this.temp(); + // (Computed once, ahead of the loop; the rest of this rewriting sees a variable.) + const h = this.hoist!; + this.hoist = null; + try { + // (Ahead of the loop, and of any test in it that a range of an index knows from.) + h.out.push(...this.ahead(() => this.set(t, e, where))); + } finally { + this.hoist = h; + } + return { kind: "var", id: t }; + } + switch (e.kind) { + case "binary": { + const left = this.flat(e.left, pre, where), mark = pre.length; + const right = this.flat(e.right, pre, where); + return { ...e, left: this.before(left, pre, mark, where), right }; + } + case "unary": case "cast": return { ...e, expr: this.flat(e.expr, pre, where) }; + case "randomInt": return { ...e, bound: this.flat(e.bound, pre, where) }; + case "tableRead": { + if (e.cell.special) return e; + const t = this.temp(); + pre.push(...this.set(t, e, where)); + return { kind: "var", id: t }; + } + // (A map of our own: a number of the game's into a variable of the emitter's own, read there.) + case "read": { + if (!(gameRead(e.read) || this.facts(e.read))) return e; + const t = this.temp(); + pre.push(...this.set(t, e, where)); + return { kind: "var", id: t }; + } + case "element": { + // (A map of our own: an index that is more than a sum — a product, a shift, a random number — worked out first + // into a variable of the emitter's own, which the read's table then tests.) + const index = this.flat(e.index, pre, where); + if (linear(index)) return { ...e, index: this.unsure(index) }; + const t = this.temp(); + pre.push(...this.set(t, index, where)); + return { ...e, index: { kind: "var", id: t } }; + } + case "call": { + if (!e.call.result) return e; + this.call(e.call, pre, where); + return { kind: "var", id: e.call.result.decl.id }; + } + case "ternary": case "intrinsic": { + if (e.kind === "intrinsic" && e.name !== "abs" && e.name !== "min" && e.name !== "max" && !this.tabled(e)) return { ...e, args: this.each(e.args, pre, where) }; + const t = this.temp(); + pre.push(...this.set(t, e, where)); + // (What the value ranges know of it, known of the variable it is read from.) + const range = (e as NumExpr & { range?: readonly [number, number] }).range; + return { kind: "var", id: t, ...(range ? { range } : {}) } as NumExpr; + } + default: return e; + } + } + + /** Several values in order, each flattened: an earlier one read before a later one's calls run. */ + each(list: NumExpr[], pre: Stmt[], where: { at: At; label: string }, as = (e: NumExpr) => e): NumExpr[] { + const marks: number[] = []; + const out = list.map((x) => { + marks.push(pre.length); + return as(this.flat(x, pre, where)); + }); + marks.push(pre.length); + for (let i = out.length - 2; i >= 0; i--) out[i] = this.before(out[i], pre, marks[i + 1], where); + return out; + } + + /** + * An action of the game's with fields from values (a map of our own), as `Emitter.action` takes it: each value, in + * JavaScript's order, one variable times a constant plus a constant — a field holds that — or into a variable of the + * emitter's own first. A unit type stopped at 228, as the Python side's: past the table an action reads what is not + * a unit. A unit count is that many units, 0 or below none: the action done 255 at a time while more are left, then + * once with the rest — each half only where the count's range reaches it. A unit type and a count both from values + * share their dword, through one variable of the emitter's own: the type plus the count times 2^24. + */ + action(s: Extract, where: { at: At; label: string }): Stmt[] { + const pre: Stmt[] = []; + // (A text made while the game runs: its values as a print's — in JavaScript's order, the text before the fields.) + if (s.text?.kind === "template") { + const parts = s.text.parts; + const valued = parts.filter((p): p is Extract => p.kind === "number" || p.kind === "char"); + const exprs = this.each(valued.map((p) => p.expr), pre, where, (x) => (linear(x) ? x : this.sum(x, pre, where))); + let i = 0; + s = { ...s, text: { ...s.text, parts: parts.map((p) => (p.kind === "number" || p.kind === "char" ? { ...p, expr: exprs[i++] } : p)) } }; + if (!s.variables?.length) return [...pre, s]; + } + const one = (x: NumExpr) => (linear(x)?.terms.length ?? 2) <= 1; + const vars = s.variables!; + const exprs = this.each(vars.map((v) => (v.field === "unitId" ? this.top(settled(v.expr), 228, where) : settled(v.expr))), pre, where, (x) => this.single(x, pre, where)); + let fields = vars.map((v, i) => ({ ...v, expr: exprs[i] })); + const k = fields.findIndex((v) => v.field === "modifier"); + if (k < 0) return [...pre, { ...s, variables: fields }]; + const range = (vars[k].expr as NumExpr & { range?: readonly [number, number] }).range; + const many = !range || range[1] > 255, none = !range || range[0] < 1; + let count = fields[k].expr; + if (many || none || !one(count)) { + const t = this.temp(); + pre.push({ kind: "assign", target: t, value: count, ...where }); + count = { kind: "var", id: t }; + } + fields = fields.filter((_, i) => i !== k); + const v = (id: string): NumExpr => ({ kind: "var", id }); + const made = (n: NumExpr | null): Stmt[] => { + const type = fields.find((f) => f.field === "unitId"); + if (!n) return [{ ...s, record: { ...s.record, modifier: 255 }, variables: fields }]; + if (!type) return [{ ...s, variables: [...fields, { ...vars[k], expr: n }] }]; + const w = this.temp(); + const word: NumExpr = { kind: "binary", op: "+", left: type.expr, right: { kind: "binary", op: "*", left: n, right: { kind: "const", value: 2 ** 24 }, at: where.at, label: where.label }, at: where.at, label: where.label } as NumExpr; + return [{ kind: "assign", target: w, value: word, ...where }, { ...s, record: { ...s.record, modifier: 0 }, variables: fields.map((f) => (f === type ? { ...f, bits: 32 as const, expr: v(w) } : f)) }]; + }; + const at = { at: where.at, label: where.label }; + const c = count.kind === "var" ? count.id : ""; + if (many) { + pre.push({ kind: "while", cond: { kind: "compare", op: ">", left: count, right: { kind: "const", value: 255 }, ...at }, body: [...made(null), { kind: "assign", target: c, value: { kind: "binary", op: "-", left: count, right: { kind: "const", value: 255 }, ...at } as NumExpr, ...where }], ...where } as Stmt); + } + if (none) return [...pre, { kind: "if", cond: { kind: "compare", op: ">=", left: count, right: { kind: "const", value: 1 }, ...at }, then: made(count), ...where } as Stmt]; + return [...pre, ...made(count)]; + } + + /** + * A number of the game's compared with a constant, or with one variable times a constant plus a constant that is + * never below zero: the condition itself, its amount the value (`cond.amount`, a place of it) — no read at all. + * `>` and `<` are at least one more, at most one less. Null where it is not that, or the value's range would take the + * amount past 0 or 2^32 − 1. + */ + asked(c: Extract): BoolExpr | null { + const k = [c.left, c.right].findIndex((x) => x.kind === "read" && gameRead(x.read)); + if (k < 0) return null; + const read = (k === 0 ? c.left : c.right) as Extract, other = k === 0 ? c.right : c.left; + const l = linear(other); + if (!l || l.terms.length > 1 || !gameRead(read.read)) return null; + const base = read.read.record; + const range: readonly [number, number] | undefined = l.terms.length ? (other as NumExpr & { range?: readonly [number, number] }).range : [l.c, l.c]; + const flip = { "<": ">", ">": "<", "<=": ">=", ">=": "<=", "==": "==", "!=": "!=" } as const; + const op = k === 0 ? c.op : flip[c.op]; + // A constant below zero, compared signed (`i32(deaths(…)) >= -10`): the numbers it holds for, from the lowest to the + // highest, as bits — those below zero are above 2 147 483 647 as bits — one or two runs of the condition's own + // "at least" and "at most" (as `cellCompare` reads a cell). + if (!l.terms.length && (l.c | 0) < 0 && c.unsigned !== true && op !== "==" && op !== "!=") { + const epd = read.read.epd ? { epd: read.read.epd } : {}; + const raw = (comparison: number, amount: number): BoolExpr => ({ kind: "cond", record: { ...base, comparison, amount: u(amount) }, ...epd }); + const all = (items: BoolExpr[]): BoolExpr => (items.length === 0 ? { kind: "const", value: true } : items.length === 1 ? items[0] : { kind: "and", items }); + const between = (lo: number, hi: number): BoolExpr => all([...(u(lo) > 0 ? [raw(0, lo)] : []), ...(u(hi) < U32 - 1 ? [raw(1, hi)] : [])]); + const K = l.c | 0, MIN_S = -2147483648, MAX_S = 2147483647; + const [lo, hi] = op === "<" ? [MIN_S, K - 1] : op === "<=" ? [MIN_S, K] : op === ">" ? [K + 1, MAX_S] : [K, MAX_S]; + if (lo > hi) return { kind: "const", value: false }; + return lo >= 0 || hi < 0 ? between(lo, hi) : { kind: "or", items: [between(lo, -1), between(0, hi)] }; + } + if (!range || range[0] < 0) return null; + const how: Record = { ">=": [0, 0], ">": [0, 1], "<=": [1, 0], "<": [1, -1], "==": [10, 0], "!=": [10, 0] }; + const [comparison, add] = how[op]; + if (range[0] + add < 0) return op === "<" && range[1] < 1 ? { kind: "const", value: false } : null; + if (range[1] + add > U32 - 1) return null; + const record = { ...read.read.record, comparison, amount: l.terms.length ? 0 : u(l.c + add) }; + const epd = read.read.epd ? { epd: read.read.epd } : {}; + const cond: BoolExpr = l.terms.length ? { kind: "cond", record, amount: { expr: other, add }, ...epd } : { kind: "cond", record, ...epd }; + return op === "!=" ? { kind: "not", expr: cond } : cond; + } + + /** + * A location centred on a point (a map of our own): its four sides written where the game keeps them (MRGN), each a + * Set Deaths whose amount is a place of the side — the point less half the size, the far sides the near ones plus + * the size — after which Bring counts again (the game keeps its counts for a location whose sides are written). The + * size kept when none is given: the sides read first, where they are. As the Python side's `center_location`. + */ + centerLocation(s: Extract, where: { at: At; label: string }): Stmt[] { + const pre: Stmt[] = []; + const at = { at: where.at, label: where.label }; + const base = MRGN + (s.location - 1) * 20; + const one = (x: NumExpr) => (linear(x) ? x : this.sum(x, pre, where)); + const [x, y] = this.each([s.x, s.y], pre, where, one); + const bin = (op: "+" | "-" | ">>>", left: NumExpr, right: NumExpr): NumExpr => ({ kind: "binary", op, left, right, ...at }) as NumExpr; + const k = (value: number): NumExpr => ({ kind: "const", value }); + let w: NumExpr, h: NumExpr; + if (s.size) [w, h] = this.each([s.size.width, s.size.height], pre, where, one); + else { + const side = (i: number) => this.sum(memoryRead(base + 4 * i, undefined, where), pre, where); + const [l, t, r, b] = [0, 1, 2, 3].map(side); + w = this.sum(bin("-", r, l), pre, where); + h = this.sum(bin("-", b, t), pre, where); + } + const half = (e: NumExpr) => (e.kind === "const" ? k(Math.floor(u(e.value) / 2)) : this.sum(bin(">>>", e, k(1)), pre, where)); + const single = (e: NumExpr) => this.single(e, pre, where); + const left = single(bin("-", x, half(w))), top = single(bin("-", y, half(h))); + const right = single(bin("+", left, w)), bottom = single(bin("+", top, h)); + const write = (i: number, value: NumExpr): Stmt => { + const record: ActionRecord = { location: 0, text: 0, wav: 0, time: 0, player: u((base + 4 * i - DEATHS_TABLE) / 4), target: 0, unitId: 0, type: SET_DEATHS_ACTION, modifier: 7, flags: 0, padding: 0, mask: 0 }; + const l = linear(value)!; + if (!l.terms.length) return { kind: "action", record: { ...record, target: u(l.c) }, ...where }; + return { kind: "action", record, variables: [{ field: "target", bits: 32, name: "side", expr: value }], ...where }; + }; + return [...pre, write(0, left), write(1, top), write(2, right), write(3, bottom), { kind: "action", record: FORGET_COUNTS, ...where }]; + } + + /** Where a cell of the game's tables is (`TableCell`): a player's table at the current player is the owner's row. */ + cellAddress(c: TableCell): number { + const index = c.player && c.index === CURRENT_PLAYER ? this.owner ?? 0 : c.index; + return c.base + (c.key ?? 0) + index * c.stride; + } + + /** + * A cell of the game's tables read into `target` (a map of our own; the Python side's `table_read`): its bits of the + * dword it is in — a byte, a word, a dword or one bit — moved down, read where they are; a scaled field divided by + * its scale. + */ + tableRead(target: string, c: TableCell, where: { at: At; label: string }): Stmt[] { + const addr = this.cellAddress(c), sh = 8 * (addr & 3); + const bits = c.width === "bit" ? u(2 ** (c.bit ?? 0)) : c.width === 4 ? U32 - 1 : 2 ** (8 * c.width) - 1; + const shift = sh + (c.width === "bit" ? c.bit ?? 0 : 0); + const at = { at: where.at, label: where.label }; + const read = memoryRead(addr & ~3, c.width === 4 ? undefined : u(bits * 2 ** sh), where); + let value: NumExpr = shift ? ({ kind: "binary", op: ">>>", left: read, right: { kind: "const", value: shift }, ...at } as NumExpr) : read; + if ((c.scale ?? 1) > 1) { + const t = this.temp(); + return [{ kind: "assign", target: t, value, ...where }, { kind: "assign", target, value: { kind: "binary", op: "/", left: { kind: "var", id: t }, right: { kind: "const", value: c.scale! }, ...at } as NumExpr, ...where }]; + } + return [{ kind: "assign", target, value, ...where }]; + } + + /** + * A cell of the game's tables written (a map of our own; the Python side's `table_write`): a Set Deaths into the + * dword it is in — masked for a byte, a word or a bit — its amount a place of the value, which is times the field's + * scale (unless written so) and stopped at the field's top. A player's colour is its byte and the minimap's. A name + * known when built is its text's number among the map's strings. A top speed is the unit type's flingy's (`speed`). + * Null for a name made while the game runs. + */ + tableWrite(s: Extract, where: { at: At; label: string }): Stmt[] | null { + const c = s.cell; + const at = { at: where.at, label: where.label }; + const pre: Stmt[] = []; + if (c.special === "name") { + const t = s.value as { kind: string; text?: string }; + if (t.kind !== "text" || t.text === undefined) return null; + const w = this.cellWrite(this.cellAddress(c), 2, { kind: "const", value: 0 }, where) as Extract; + return [{ ...w, text: { kind: "text", text: t.text } } as Stmt]; + } + let value: NumExpr; + if (s.boolean && (s.value as BoolExpr).kind === "const") value = { kind: "const", value: (s.value as Extract).value ? 1 : 0 }; + else if (s.boolean) { + const t = this.temp(); + pre.push({ kind: "assignBool", target: t, value: this.cond(s.value as BoolExpr, pre, where), ...where }); + value = { kind: "var", id: t }; + } else value = this.single(this.flat(s.value as NumExpr, pre, where), pre, where); + const scale = c.scale ?? 1; + if (scale > 1 && !s.scaled) value = this.single({ kind: "binary", op: "*", left: value, right: { kind: "const", value: scale }, ...at } as NumExpr, pre, where); + if (c.special === "speed") return this.speed(c.index, value, pre, where); + const width = c.width === "bit" ? "bit" : c.width; + if (width !== 4) value = this.single(this.top(value, width === "bit" ? 1 : 2 ** (8 * width) - 1, where), pre, where); + const addr = this.cellAddress(c); + const out = [...pre, this.cellWrite(addr, width, value, where, c.bit)]; + if (c.special === "color") out.push(this.cellWrite(addr + MINIMAP_COLOR_OFFSET, 1, value, where)); + return out; + } + + /** + * A unit type's top speed (256 to a pixel a frame), as the Python side's `write_speed` and Magenta write it: the + * flingy the type moves as switched to table control and given the speed, an acceleration of about a seventeenth of + * it and the halting distance v² / 2a to match (each at least 1). The flingy is read where the game keeps it, so the + * writes into its tables name their dwords by a place of it: a dword's EPD the table's plus the flingy (plus half or a + * quarter of it for a word or a byte, whose place in its dword is the rest, chosen by a test). + */ + speed(unit: number, value: NumExpr, pre: Stmt[], where: { at: At; label: string }): Stmt[] { + const at = { at: where.at, label: where.label }; + const k = (n: number): NumExpr => ({ kind: "const", value: n }); + const v = (id: string): NumExpr => ({ kind: "var", id }); + const bin = (op: "+" | "*" | "/" | ">>>" | "&", left: NumExpr, right: NumExpr): NumExpr => ({ kind: "binary", op, left, right, ...at }) as NumExpr; + const f = this.temp(); + const out: Stmt[] = [...pre, ...this.set(f, memoryRead((UNITS_FLINGY + unit) & ~3, u(0xff * 2 ** (8 * ((UNITS_FLINGY + unit) & 3))), where), where)]; + // (The byte moved down where it is not the dword's first.) + const sh = 8 * ((UNITS_FLINGY + unit) & 3); + if (sh) out.splice(out.length - 1, 1, { kind: "assign", target: f, value: bin(">>>", memoryRead((UNITS_FLINGY + unit) & ~3, u(0xff * 2 ** sh), where), k(sh)), ...where }); + let acceleration: NumExpr, halt: NumExpr; + if (value.kind === "const") { + // (Rounded as the Python side's `round` rounds: a half to the even one.) + const even = (x: number) => (Math.abs(x % 1) === 0.5 ? 2 * Math.round(x / 2) : Math.round(x)); + const speed = value.value, a = Math.max(1, even(speed / 17)); + acceleration = k(Math.min(a, 0xffff)); + halt = k(Math.max(1, even((speed * speed) / (2 * a)))); + } else { + const a = this.temp(), h = this.temp(); + out.push({ kind: "assign", target: a, value: bin("/", value, k(17)), ...where }); + out.push({ kind: "if", cond: { kind: "compare", op: "==", left: v(a), right: k(0), ...at }, then: [{ kind: "assign", target: a, value: k(1), ...where }], ...where } as Stmt); + out.push({ kind: "assign", target: h, value: bin("/", bin("*", value, value), bin("*", v(a), k(2))), ...where }); + out.push({ kind: "if", cond: { kind: "compare", op: "==", left: v(h), right: k(0), ...at }, then: [{ kind: "assign", target: h, value: k(1), ...where }], ...where } as Stmt); + out.push({ kind: "assign", target: a, value: this.top(v(a), 0xffff, where), ...where }); + acceleration = v(a); + halt = v(h); + } + // A Set Deaths of `amount` (masked by `mask`) into the dword whose EPD is `epd` plus the flingy's `part`. + const write = (base: number, part: NumExpr, amount: NumExpr, mask = 0): Stmt => { + const record: ActionRecord = { location: mask, text: 0, wav: 0, time: 0, player: u((base - DEATHS_TABLE) / 4), target: 0, unitId: 0, type: SET_DEATHS_ACTION, modifier: 7, flags: 0, padding: 0, mask: mask ? MASKED_MARK : 0 }; + const variables: Extract["variables"] = [{ field: "player", bits: 32, name: "flingy", expr: bin("+", part, k(u((base - DEATHS_TABLE) / 4))) }]; + const l = linear(amount)!; + if (l.terms.length) variables.push({ field: "target", bits: 32, name: "flingy", expr: amount }); + return { kind: "action", record: { ...record, player: 0, target: l.terms.length ? 0 : u(l.c) }, variables, ...where }; + }; + const quarter = this.temp(), half = this.temp(), byte = this.temp(), odd = this.temp(); + out.push({ kind: "assign", target: quarter, value: bin(">>>", v(f), k(2)), ...where }, { kind: "assign", target: byte, value: bin("&", v(f), k(3)), ...where }); + out.push({ kind: "assign", target: half, value: bin(">>>", v(f), k(1)), ...where }, { kind: "assign", target: odd, value: bin("&", v(f), k(1)), ...where }); + // The flingy's control byte 0 (table control): its byte of the dword, as the rest of the flingy says. + for (let b = 0; b < 4; b++) out.push({ kind: "if", cond: { kind: "compare", op: "==", left: v(byte), right: k(b), ...at }, then: [write(FLINGY_CONTROL, v(quarter), k(0), u(0xff * 2 ** (8 * b)))], ...where } as Stmt); + out.push(write(FLINGY_SPEED, v(f), value), write(FLINGY_HALT, v(f), halt)); + // The acceleration, a word: its half of the dword. + const accel = this.single(acceleration, out, where); + for (const h of [0, 1]) out.push({ kind: "if", cond: { kind: "compare", op: "==", left: v(odd), right: k(h), ...at }, then: [write(FLINGY_ACCELERATION, v(half), h ? this.single(bin("*", accel, k(65536)), out, where) : accel, h ? 0xffff0000 : 0xffff)], ...where } as Stmt); + return out; + } + + /** A Set Deaths of `value` into the cell at `addr`: the dword, or (masked) its byte, word or bit. */ + cellWrite(addr: number, width: 1 | 2 | 4 | "bit", value: NumExpr, where: { at: At; label: string }, bit = 0): Stmt { + const sh = 8 * (addr & 3) + (width === "bit" ? bit : 0); + const mask = width === 4 ? 0 : u((width === "bit" ? 1 : 2 ** (8 * width) - 1) * 2 ** sh); + const record: ActionRecord = { location: mask, text: 0, wav: 0, time: 0, player: u(((addr & ~3) - DEATHS_TABLE) / 4), target: 0, unitId: 0, type: SET_DEATHS_ACTION, modifier: 7, flags: 0, padding: 0, mask: mask ? MASKED_MARK : 0 }; + const l = linear(value)!; + if (!l.terms.length) return { kind: "action", record: { ...record, target: u(u(l.c) * 2 ** sh) }, ...where }; + const shifted: NumExpr = sh ? ({ kind: "binary", op: "*", left: value, right: { kind: "const", value: 2 ** sh }, at: where.at, label: where.label } as NumExpr) : value; + return { kind: "action", record, variables: [{ field: "target", bits: 32, name: "cell", expr: shifted }], ...where }; + } + + /** A value as one variable times a constant plus a constant — what a field holds — or into a variable of the emitter's own first. */ + single(e: NumExpr, pre: Stmt[], where: { at: At; label: string }): NumExpr { + if ((linear(e)?.terms.length ?? 2) <= 1) return e; + const t = this.temp(); + pre.push(...this.set(t, e, where)); + return { kind: "var", id: t }; + } + + /** A player's race or slot, or supply, which a map of our own reads from memory (`fact`): one player, known. */ + facts(r: ReadSource): r is Extract { + const one = r.source !== "condition" && (r.player <= 11 || (r.player === CURRENT_PLAYER && this.owner !== undefined)); + return one && (r.source === "supply" || r.source === "player"); + } + + /** The program's owner, the current player while it runs (a map of our own). */ + owner?: number; + /** The map's player settings (a map of our own). */ + players: MapPlayers = ANY_PLAYERS; + + /** Each `rose()` and `once()`'s latch: a variable of the emitter's own, by the place in the script. */ + latches = new Map(); + + /** + * A player's race or slot (a byte of the player table: its bits of the dword, moved down) or supply (the race's + * table, the player's own race read first when none is given — a slot with none has no supply — then halves, as the + * top bar shows it, half a supply in use counting as one), as the Python side reads them. A computer's slot reads 5 + * in a Use Map Settings game and 1 in melee: both 1. (The current player is the owner.) + */ + fact(target: string, r: Extract, where: { at: At; label: string }): Stmt[] { + const p = r.player === CURRENT_PLAYER ? this.owner! : r.player; + const at = { at: where.at, label: where.label }; + const k = (value: number): NumExpr => ({ kind: "const", value }); + const v = (id: string): NumExpr => ({ kind: "var", id }); + const byte = (addr: number): NumExpr => { + const sh = 8 * (addr & 3); + return { kind: "binary", op: ">>>", left: memoryRead(addr & ~3, u(0xff * 2 ** sh), where), right: k(sh), ...at } as NumExpr; + }; + const eq = (x: NumExpr, n: number): BoolExpr => ({ kind: "compare", op: "==", left: x, right: k(n), ...at }); + if (r.source === "player" && r.fact === "left") { + // Whether a player who was in the map's settings as a human or a computer is gone, as eudplib's f_playerexist + // asks: their trigger list empty (the game empties it when they leave, and it is empty for a slot nobody took). + // A slot the map gives nobody: 0. + // (One condition: the list's first node, read where it is, exactly the list's end.) + const out: Stmt[] = [{ kind: "assign", target, value: k(0), ...where }]; + if (p >= 8 || !this.players.slots.includes(p)) return out; + const record: ConditionRecord = { location: 0, player: u((TRIGGER_LISTS + 12 * p + 8 - DEATHS_TABLE) / 4), amount: u(~(TRIGGER_LISTS + 12 * p + 4)), unitId: 0, comparison: 10, type: DEATHS_TYPE, resource: 0, flags: 0, mask: 0 }; + out.push({ kind: "if", cond: { kind: "cond", record }, then: [{ kind: "assign", target, value: k(1), ...where }], ...where } as Stmt); + return out; + } + if (r.source === "player") { + const out: Stmt[] = [{ kind: "assign", target, value: byte(PLAYER_BYTES[r.fact as "race" | "slot"] + p), ...where }]; + if (r.fact === "slot") out.push({ kind: "if", cond: eq(v(target), 5), then: [{ kind: "assign", target, value: k(1), ...where }], ...where } as Stmt); + return out; + } + const table = (race: number): NumExpr => memoryRead(SUPPLY_TABLES[r.of] + 4 * (36 * race + p), undefined, where); + const half = this.temp(), out: Stmt[] = []; + if (r.race !== null) out.push({ kind: "assign", target: half, value: table(r.race), ...where }); + else { + const race = this.temp(); + out.push({ kind: "assign", target: race, value: byte(PLAYER_BYTES.race + p), ...where }, { kind: "assign", target: half, value: k(0), ...where }); + for (const n of [0, 1, 2]) out.push({ kind: "if", cond: eq(v(race), n), then: [{ kind: "assign", target: half, value: table(n), ...where }], ...where } as Stmt); + } + const sum: NumExpr = r.of === "used" ? { kind: "binary", op: "+", left: v(half), right: k(1), ...at } as NumExpr : v(half); + out.push({ kind: "assign", target, value: { kind: "binary", op: ">>>", left: sum, right: k(1), ...at } as NumExpr, ...where }); + return out; + } + + /** The statements that set `target` to `e`. */ + set(target: string, e: NumExpr, where: { at: At; label: string }): Stmt[] { + const assign = (value: NumExpr): Stmt => ({ kind: "assign", target, value, ...where }); + const v = (id: string): NumExpr => ({ kind: "var", id }); + const zero: NumExpr = { kind: "const", value: 0 }; + const pre: Stmt[] = []; + if (e.kind === "read" && gameRead(e.read)) return [assign(e)]; + // (A byte of memory: the read moves its bits down itself, `Emitter.readGame`; or up, at a value's address.) + if (e.kind === "binary" && e.op === ">>>" && e.left.kind === "read" && gameRead(e.left.read) && e.right.kind === "const") return [assign(e)]; + if (e.kind === "binary" && e.op === "*" && e.left.kind === "read" && gameRead(e.left.read) && e.left.read.epd && e.right.kind === "const") return [assign(e)]; + if (e.kind === "read" && this.facts(e.read)) return this.fact(target, e.read, where); + if (e.kind === "tableRead" && !e.cell.special) return this.tableRead(target, e.cell, where); + if (e.kind === "ternary") { + const cond = this.cond(e.cond, pre, where); + return [...pre, { kind: "if", cond, then: this.set(target, e.whenTrue, where), else: this.set(target, e.whenFalse, where), ...where }]; + } + if (e.kind === "intrinsic" && e.name === "abs") { + let a = this.flat(e.args[0], pre, where); + // (A map of our own: anything more than a variable plus a constant into a variable of the emitter's own first, so + // that its sign is one condition, and the target is never set from itself — a variable of the emitter's own + // cannot be copied, which that would need.) + a = this.side(a, pre, where); + if (linear(a)) return [...pre, { kind: "if", cond: { kind: "compare", op: "<", left: a, right: zero, ...where }, then: [assign({ kind: "unary", op: "-", expr: a, at: where.at })], else: [assign(a)], ...where }]; + // (A product or a quotient: into the target first, its sign tested there.) + return [...pre, assign(a), { kind: "if", cond: { kind: "compare", op: "<", left: v(target), right: zero, ...where }, then: [assign({ kind: "unary", op: "-", expr: v(target), at: where.at })], ...where }]; + } + if (e.kind === "intrinsic" && this.tabled(e)) return e.name === "atan2" ? this.arcTangent(target, e, where) : this.sine(target, e, where); + if (e.kind === "intrinsic" && (e.name === "min" || e.name === "max") && e.args.length === 2) { + // (Compared with each other: each one variable plus a constant — bits under a mask computed first.) + const [a, b] = this.each(e.args, pre, where, (x) => { + const y = this.side(x, pre, where); + return y.kind === "binary" && y.op === "&" ? this.sum(y, pre, where) : y; + }); + const cond: BoolExpr = { kind: "compare", op: e.name === "min" ? "<" : ">", left: a, right: b, ...(e.unsigned ? { unsigned: true } : {}), ...where }; + return [...pre, { kind: "if", cond, then: [assign(a)], else: [assign(b)], ...where }]; + } + const value = this.flat(e, pre, where); + return [...pre, assign(value)]; + } + + /** A sine, a cosine or an arc tangent of a table the program has (a map of our own): written out as statements. */ + tabled(e: Extract): boolean { + return (e.name === "sin" || e.name === "cos" || e.name === "atan2") && e.args.length === 2 && !!e.table && !!this.arrays.get(e.table)?.values; + } + + /** + * `target` = table[i] and the way to table[i + 1] that f of 2^bits is, towards zero — the simulator's `between`: the + * difference of the two cells read once, its sign tested, and its magnitude times f (a product the emitter makes + * cheaply: the difference is small, f below 2^bits) shifted down. Cell i + 1 past the end reads 0, as fixedMath has + * it for the arc tangents' table and as the sine's first cell is (sin 0), the cell after its last. + */ + between(target: string, table: string, i: string, last: number, f: string, bits: number, where: { at: At; label: string }): Stmt[] { + const values = this.arrays.get(table)!.values!, n = values.length; + const cell = (k: number) => (k < n ? values[k] : 0); + let most = 0; + for (let k = 0; k <= last; k++) most = Math.max(most, Math.abs(cell(k + 1) - values[k])); + const low = Math.min(0, ...values), high = Math.max(0, ...values); + const [a, d] = [this.temp(), this.temp()]; + const v = (id: string, range: [number, number]): NumExpr => ({ kind: "var", id, range } as NumExpr); + const read = (index: NumExpr): NumExpr => ({ kind: "element", array: table, index, at: where.at, range: [low, high] } as NumExpr); + const part = (by: NumExpr): NumExpr => ({ kind: "binary", op: ">>>", right: { kind: "const", value: bits }, ...where, range: [0, most], + left: { kind: "binary", op: "*", left: by, right: v(f, [0, 2 ** bits - 1]), ...where, range: [0, most * (2 ** bits - 1)] } } as NumExpr); + return [ + { kind: "assign", target: a, value: read(v(i, [0, last])), ...where }, + { kind: "assign", target: d, value: { kind: "binary", op: "-", left: read({ kind: "binary", op: "+", left: v(i, [0, last]), right: { kind: "const", value: 1 }, ...where, range: [1, last + 1] } as NumExpr), right: v(a, [low, high]), ...where, range: [-most, most] } as NumExpr, ...where }, + { kind: "if", cond: { kind: "compare", op: "<", left: v(d, [-most, most]), right: { kind: "const", value: 0 }, ...where }, + then: [{ kind: "assign", target, value: { kind: "binary", op: "-", left: v(a, [low, high]), right: part({ kind: "unary", op: "-", expr: v(d, [-most, -1]), at: where.at, range: [1, most] } as NumExpr), ...where }, ...where }], + else: [{ kind: "assign", target, value: { kind: "binary", op: "+", left: v(a, [low, high]), right: part(v(d, [0, most])), ...where }, ...where }], ...where }, + ]; + } + + /** + * sin(x) or cos(x) of a fixed number (a map of our own), as the simulator's `fixedSin`: x times the scale (a product by + * a constant) is the place in the table — a quarter turn on for cos — its bits 16 to 27 the cell, the sixteen below + * the way to the next; then `between`. Statements of what the emitter makes already. + */ + sine(target: string, e: Extract, where: { at: At; label: string }): Stmt[] { + const pre: Stmt[] = []; + const [x, scale] = this.each(e.args, pre, where); + const [at, f, i] = [this.temp(), this.temp(), this.temp()]; + const v = (id: string): NumExpr => ({ kind: "var", id }); + const k = (value: number): NumExpr => ({ kind: "const", value }); + const bin = (op: "&" | ">>>" | "+", left: NumExpr, right: NumExpr, range?: [number, number]): NumExpr => ({ kind: "binary", op, left, right, ...where, ...(range ? { range } : {}) } as NumExpr); + const place: NumExpr = e.name === "cos" ? bin("+", v(at), k(1024 * 65536)) : v(at); + return [ + ...pre, + ...this.set(at, { kind: "intrinsic", name: "fmul", args: [x, scale], ...where }, where), + { kind: "assign", target: f, value: bin("&", v(at), k(0xffff), [0, 0xffff]), ...where }, + { kind: "assign", target: i, value: bin(">>>", bin("&", place, k(0x0fff0000), [0, 0x0fff0000]), k(16), [0, 4095]), ...where }, + ...this.between(target, e.table!, i, 4095, f, 16, where), + ]; + } + + /** + * atan2(y, x) of fixed numbers (a map of our own), as the simulator's `fixedAtan2`: the smaller magnitude over the + * larger (a quotient of fixed numbers; over 2^31, the magnitude of the least number, a shift), its bits from 6 up the + * cell of the table and the six below the way to the next (`between`), then put in its octant. Of (0, 0), 0: the + * quotient by 0 is 0, and so is the table's first cell. + */ + arcTangent(target: string, e: Extract, where: { at: At; label: string }): Stmt[] { + const pre: Stmt[] = []; + const [y0, x0] = this.each(e.args, pre, where); + const [Y, X, ay, ax, small, large, r, i, f, t] = Array.from({ length: 10 }, () => this.temp()); + const v = (id: string, range?: [number, number]): NumExpr => ({ kind: "var", id, ...(range ? { range } : {}) } as NumExpr); + const k = (value: number): NumExpr => ({ kind: "const", value }); + const assign = (target: string, value: NumExpr): Stmt => ({ kind: "assign", target, value, ...where }); + const cmp = (op: CompareOp, left: NumExpr, right: NumExpr, unsigned = false): BoolExpr => ({ kind: "compare", op, left, right, ...(unsigned ? { unsigned: true } : {}), ...where }); + const range = (x: NumExpr) => (x as NumExpr & { range?: Range }).range; + // (A magnitude of 2^31 — the least number's — only when a side's range has it.) + const least = [x0, y0].some((x) => { const rx = range(x); return !rx || rx[0] <= -2147483648; }); + const ratio = (over: string, under: string): Stmt[] => { + // (Both magnitudes below 2^31 here, the one over the other at most 1: a quotient of 17 steps, `divide`'s `ratio`.) + const q = assign(r, { kind: "intrinsic", name: "fdiv", args: [v(over, [0, 2147483647]), v(under, [0, 2147483647])], ratio: true, ...where } as NumExpr); + if (!least) return [q]; + return [{ kind: "if", cond: cmp("==", v(under), k(-2147483648)), then: [assign(r, { kind: "binary", op: ">>>", left: v(over), right: k(15), ...where })], else: [q], ...where }]; + }; + const HALF_PI = Math.round((Math.PI / 2) * 65536), PI = Math.round(Math.PI * 65536); + return [ + ...pre, + assign(Y, y0), assign(X, x0), + ...this.set(ay, { kind: "intrinsic", name: "abs", args: [v(Y)], ...where }, where), + ...this.set(ax, { kind: "intrinsic", name: "abs", args: [v(X)], ...where }, where), + { kind: "if", cond: cmp("<=", v(ay), v(ax), true), then: [assign(small, v(ay)), assign(large, v(ax))], else: [assign(small, v(ax)), assign(large, v(ay))], ...where }, + ...ratio(small, large), + assign(i, { kind: "binary", op: ">>>", left: v(r, [0, 65536]), right: k(6), ...where, range: [0, 1024] } as NumExpr), + assign(f, { kind: "binary", op: "&", left: v(r, [0, 65536]), right: k(63), ...where, range: [0, 63] } as NumExpr), + ...this.between(t, e.table!, i, 1024, f, 6, where), + { kind: "if", cond: cmp(">", v(ay), v(ax), true), then: [assign(t, { kind: "binary", op: "-", left: k(HALF_PI), right: v(t), ...where })], ...where }, + { kind: "if", cond: cmp("<", v(X), k(0)), then: [assign(t, { kind: "binary", op: "-", left: k(PI), right: v(t), ...where })], ...where }, + { kind: "if", cond: cmp("<", v(Y), k(0)), then: [assign(t, { kind: "unary", op: "-", expr: v(t), at: where.at } as NumExpr)], ...where }, + assign(target, v(t)), + ]; + } + + /** A value the emitter reads cells for as it is (a cell of a list known when the map is built, a shift): left as it is. */ + table(e: NumExpr): boolean { + return e.kind === "element" || (e.kind === "binary" && (e.op === ">>" || e.op === ">>>") && e.right.kind === "const" && !!linear(e.left)); + } + + /** A comparison's side as a variable plus a constant: a longer sum computed first. */ + side(e: NumExpr, pre: Stmt[], where: { at: At; label: string }): NumExpr { + if (side(e)) return e; + const t = this.temp(); + pre.push(...this.set(t, e, where)); + return { kind: "var", id: t }; + } + + /** + * A value as a sum the emitter writes: anything more computed first into a variable of its own — `x & k` too, which + * only a comparison with a constant reads where it is (a masked condition). + */ + sum(e: NumExpr, pre: Stmt[], where: { at: At; label: string }): NumExpr { + if (linear(e)) return e; + const t = this.temp(); + pre.push(...this.set(t, e, where)); + return { kind: "var", id: t }; + } + + /** + * A cell of an array a map of our own keeps, compared with a constant, left in the test, where a condition reads it + * at its index (`Emitter.cellConds`): as 32 bits — a signed comparison as the ranges of bits it holds for, one, or + * two either side of the sign — after a test that the index is inside the array, unless the value ranges know it is + * (outside, the cell reads 0). The test stays where it was written, so a range of its index known from the parts of + * a `&&` before it holds where it is read. Null when it is not that. + */ + cellCompare(c: Extract, pre: Stmt[], where: { at: At; label: string }): BoolExpr | null { + if (c.unsigned === "left" || c.unsigned === "right") return null; + const [el, k, op] = c.left.kind === "element" && c.right.kind === "const" ? [c.left, c.right.value, c.op] + : c.right.kind === "element" && c.left.kind === "const" ? [c.right, c.left.value, FLIP[c.op] as CompareOp] : [null, 0, c.op]; + if (!el || !inRegion(this.arrays, el.array)) return null; + const a = this.arrays.get(el.array)!; + const index = this.flat(el.index, pre, where), r = (index as NumExpr & { range?: readonly [number, number] }).range; + // (A cell is read at one variable plus a constant: an index under a mask — `xs[i & 3]`, which `side` would keep as a + // masked side — and one of more terms — `board[y * W + x]` — are computed first.) + const shape = side(index); + const at = shape && "mask" in shape && shape.mask !== undefined ? this.sum(index, pre, where) : this.side(index, pre, where), cell: NumExpr = { ...el, index: at }; + const raw = (o: CompareOp, v: number): BoolExpr => ({ kind: "compare", op: o, left: cell, right: { kind: "const", value: u(v) }, unsigned: true, ...where }); + const all = (items: BoolExpr[]): BoolExpr => (items.length === 0 ? { kind: "const", value: true } : items.length === 1 ? items[0] : { kind: "and", items }); + // The bits between two numbers, ends included, read as they are. + const between = (lo: number, hi: number): BoolExpr => all([...(u(lo) > 0 ? [raw(">=", lo)] : []), ...(u(hi) < MAX ? [raw("<=", hi)] : [])]); + let test: BoolExpr; + if (c.unsigned === true || op === "==" || op === "!=" || a.kind === "boolean") test = raw(op, k); + else { + // (Signed: the numbers it holds for, from the lowest to the highest, as bits — those below zero are above + // 2147483647 as bits.) + const K = k | 0, MIN_S = -2147483648, MAX_S = 2147483647; + const [lo, hi] = op === "<" ? [MIN_S, K - 1] : op === "<=" ? [MIN_S, K] : op === ">" ? [K + 1, MAX_S] : [K, MAX_S]; + test = lo > hi ? { kind: "const", value: false } : lo >= 0 || hi < 0 ? between(lo, hi) : { kind: "or", items: [between(lo, -1), between(0, hi)] }; + } + if (r && r[0] >= 0 && r[1] < a.length) return test; + // (What a cell outside reads, 0, against the constant: the index outside is then enough.) + const holds = (e: BoolExpr): boolean => e.kind === "const" ? e.value : e.kind === "and" ? e.items.every(holds) : e.kind === "or" ? e.items.some(holds) + : e.kind === "compare" ? compareU32(e.op, 0, (e.right as { value: number }).value) : false; + const inside: BoolExpr = { kind: "compare", op: "<", left: at, right: { kind: "const", value: a.length }, unsigned: true, ...where }; + const guarded: BoolExpr = { kind: "and", items: [inside, test] }; + return holds(test) ? { kind: "or", items: [{ ...inside, op: ">=" }, guarded] } : guarded; + } + + cond(c: BoolExpr, pre: Stmt[], where: { at: At; label: string }): BoolExpr { + switch (c.kind) { + // (A map of our own: random() a bit drawn into a variable of its own first, and tested.) + case "random": { + const t = this.temp(); + pre.push({ kind: "assign", target: t, value: { kind: "randomInt", bound: { kind: "const", value: 2 }, at: where.at, label: where.label }, ...where }); + return { kind: "compare", op: "==", left: { kind: "var", id: t }, right: { kind: "const", value: 1 }, unsigned: true, ...where }; + } + case "compare": { + const asked = this.asked(c); + if (asked) return asked; + const cell = this.cellCompare(c, pre, where); + if (cell) return cell; + let [left, right] = this.each([c.left, c.right], pre, where, (x) => this.side(x, pre, where)); + // (Bits under a mask are read where they are against a constant only.) + const masked = (x: NumExpr) => x.kind === "binary" && x.op === "&"; + if (masked(left) && right.kind !== "const") left = this.sum(left, pre, where); + if (masked(right) && left.kind !== "const") right = this.sum(right, pre, where); + return { ...c, left, right }; + } + case "test": return { ...c, expr: this.side(this.flat(c.expr, pre, where), pre, where) }; + case "and": case "or": { + if (!c.items.slice(1).some(holdsCall)) { + // (A later part's cells are read ahead of the whole, before the parts that decide whether it is read: what a + // range of its index knows from them does not hold there.) + return { ...c, items: c.items.map((x, i) => (i === 0 ? this.cond(x, pre, where) : this.ahead(() => this.cond(x, pre, where)))) }; + } + // A later part calls: it runs only when the parts before it leave the answer open. The answer so far is kept + // in a variable of the emitter's own, each later part worked out under an `if` on it. + const t = this.temp(), so: BoolExpr = { kind: "var", id: t }; + const first = this.cond(c.items[0], pre, where); + pre.push({ kind: "assignBool", target: t, value: first, ...where }); + for (const x of c.items.slice(1)) { + const inner: Stmt[] = []; + const value = this.cond(x, inner, where); + pre.push({ kind: "if", cond: c.kind === "and" ? so : { kind: "not", expr: so }, then: [...inner, { kind: "assignBool", target: t, value, ...where }], ...where }); + } + return so; + } + case "not": return { ...c, expr: this.cond(c.expr, pre, where) }; + // (A map of our own: `rose()` and `once()` against a latch of their own — a variable of the emitter's own for each + // place in the script, 0 when the map starts, kept from one time to the next: so when it was so, it says no; so + // now, the latch set; not so, a rise's latch cleared.) + case "edge": { + let latch = this.latches.get(c); + if (!latch) this.latches.set(c, (latch = this.temp())); + const held = this.temp(), t = this.temp(); + const v = (id: string): BoolExpr => ({ kind: "var", id }); + pre.push({ kind: "assignBool", target: held, value: this.cond(c.cond, pre, where), ...where }); + pre.push({ kind: "assignBool", target: t, value: { kind: "and", items: [v(held), { kind: "not", expr: v(latch) }] }, ...where } as Stmt); + pre.push({ kind: "assignBool", target: latch, value: c.edge === "rose" ? v(held) : { kind: "or", items: [v(latch), v(held)] }, ...where } as Stmt); + return v(t); + } + // (A ?: of booleans, on a map of our own: the side its test picks worked out under an `if` into a variable of the + // emitter's own — the other side is never read, as in the script.) + case "ternary": { + const t = this.temp(); + const test = this.cond(c.cond, pre, where); + const side = (x: BoolExpr): Stmt[] => { + const inner: Stmt[] = []; + const value = this.cond(x, inner, where); + return [...inner, { kind: "assignBool", target: t, value, ...where }]; + }; + pre.push({ kind: "if", cond: test, then: side(c.whenTrue), else: side(c.whenFalse), ...where }); + return { kind: "var", id: t }; + } + case "call": { + if (!c.call.result) return c; + this.call(c.call, pre, where); + return { kind: "var", id: c.call.result.decl.id }; + } + // (A cell of an array of booleans, as a test: read by the condition, or into a variable of the emitter's own first.) + case "element": { + const cell = this.cellCompare({ kind: "compare", op: "!=", left: c as NumExpr, right: { kind: "const", value: 0 }, ...where }, pre, where); + if (cell) return cell; + const t = this.temp(); + pre.push(...this.set(t, { ...c, index: this.unsure(c.index) } as NumExpr, where)); + return { kind: "var", id: t }; + } + default: return c; + } + } + + /** The statements as the emitter takes them; each one whose rewriting it would not take whole, as it was. */ + stmts(list: Stmt[]): Stmt[] { + return list.flatMap((s) => this.stmt(s)); + } + + stmt(s: Stmt): Stmt[] { + const where = this.at(s); + // (A store into a `u8` or `u16`: the value stopped at its top first.) + { + if (s.kind === "assign" && this.bits.has(s.target)) s = { ...s, value: this.top(s.value, this.bits.get(s.target), where) }; + else if (s.kind === "declare" && s.decl.bits && s.init) s = { ...s, init: this.top(s.init as NumExpr, 2 ** s.decl.bits - 1, where) }; + else if (s.kind === "store" && !isTest(s.value) && this.arrayTop(s.array)) s = { ...s, value: this.top(s.value as NumExpr, this.arrayTop(s.array), where) }; + else if (s.kind === "declareArray" && this.arrayTop(s.array)) { + const top = this.arrayTop(s.array); + s = { ...s, ...(s.init ? { init: s.init.map((v) => (isTest(v) ? v : this.top(v as NumExpr, top, where))) } : {}), ...(s.fill !== undefined && !isTest(s.fill) ? { fill: this.top(s.fill as NumExpr, top, where) } : {}) } as Stmt; + } + } + const plain = (e: NumExpr) => !!linear(e); + switch (s.kind) { + case "assign": return plain(s.value) ? [s] : this.set(s.target, s.value, where); + case "declare": { + if (s.decl.kind === "boolean") { + const pre: Stmt[] = []; + const init = this.cond(s.init as BoolExpr, pre, where); + return [...pre, { ...s, init }]; + } + const init = s.init as NumExpr; + if (s.decl.kind !== "number" || plain(init)) return [s]; + return [{ ...s, init: { kind: "const", value: 0 } }, ...this.set(s.decl.id, init, where)]; + } + case "block": return [{ ...s, body: this.stmts(s.body) }]; + case "store": { + // A value the emitter does not take as it is into a variable of its own first; the index likewise. (A map of + // our own: a test stored into an array of booleans, into a variable of its own as 1 or 0.) + const pre: Stmt[] = []; + if (isTest(s.value)) { + // (The index first, as JavaScript reads it: the test may call what changes it.) + let index = plain(s.index) ? s.index : this.sum(this.flat(s.index, pre, where), pre, where); + const mark = pre.length, t = this.temp(); + const value = this.cond(s.value as BoolExpr, pre, where); + pre.push({ kind: "assignBool", target: t, value, ...where }); + index = this.before(index, pre, mark, where); + return [...pre, { ...s, index, value: { kind: "var", id: t } }]; + } + const value = s.value as NumExpr; + let index = plain(s.index) ? s.index : this.sum(this.flat(s.index, pre, where), pre, where); + const mark = pre.length; + let stored: NumExpr = value; + // (A map of our own: a sum of cells, each at an index that is a sum, stored as it is — the store reads its cells + // first — so that a cell read goes straight into the store's number, not into a variable of its own copied there.) + const ofCells = !!linear(value, (x) => (x.kind === "element" && !!linear(x.index) ? "@cell" : null)); + if (!plain(value) && !this.table(value) && !ofCells) { + const t = this.temp(); + pre.push(...this.set(t, value, where)); + stored = { kind: "var", id: t }; + } + // (A cell at an index that is more than a sum — a `?:`, a product — read where it is, its index worked out first.) + else if (value.kind === "element" && !linear(value.index)) stored = this.flat(value, pre, where); + index = this.before(index, pre, mark, where); + return pre.length ? [...pre, { ...s, index, value: stored }] : [s]; + } + case "call": { + const pre: Stmt[] = []; + this.call(s.call, pre, where); + return pre; + } + case "declareArray": { + // The cells' values, in JavaScript's order; a test as 1 or 0, anything more than a sum, into variables of the + // emitter's own first. + const values = [...(s.init ?? []), ...(s.fill !== undefined ? [s.fill] : [])]; + if (values.every((v) => !isTest(v) && plain(v as NumExpr))) return [s]; + const pre: Stmt[] = []; + const nums = values.map((v) => { + if (!isTest(v)) return v as NumExpr; + const t = this.temp(); + pre.push({ kind: "assignBool", target: t, value: this.cond(v as BoolExpr, pre, where), ...where }); + return { kind: "var", id: t } as NumExpr; + }); + const flat = this.each(nums, pre, where, (x) => (plain(x) ? x : this.sum(x, pre, where))); + return [...pre, { ...s, ...(s.init ? { init: flat.slice(0, s.init.length) } : {}), ...(s.fill !== undefined ? { fill: flat[flat.length - 1] } : {}) }]; + } + case "print": { + // The values of a printed text, as the emitter takes them and in JavaScript's order: each worked out before a + // later one's call, a value more than a sum into a variable of the emitter's own first. + if (s.parts.every((p) => (p.kind !== "number" && p.kind !== "char") || plain(p.expr))) return [s]; + const pre: Stmt[] = []; + const valued = s.parts.filter((p): p is Extract => p.kind === "number" || p.kind === "char"); + const exprs = this.each(valued.map((p) => p.expr), pre, where, (x) => (plain(x) ? x : this.sum(x, pre, where))); + let i = 0; + return [...pre, { ...s, parts: s.parts.map((p) => (p.kind === "number" || p.kind === "char" ? { ...p, expr: exprs[i++] } : p)) }]; + } + case "return": { + // The value given back, as the emitter takes it: a boolean's test flattened, a number into a variable of the + // emitter's own first when it is more than a sum. + const kind = this.returning[this.returning.length - 1]; + if (s.value === undefined || (kind !== "number" && kind !== "boolean")) return [s]; + const pre: Stmt[] = []; + if (kind === "boolean") { + const value = this.cond(s.value as BoolExpr, pre, where); + return [...pre, { ...s, value }]; + } + if (plain(s.value as NumExpr)) return [s]; + const t = this.temp(); + return [...this.set(t, s.value as NumExpr, where), { ...s, value: { kind: "var", id: t } }]; + } + case "if": { + const pre: Stmt[] = []; + const cond = this.cond(s.cond, pre, where); + const head = pre.length ? pre : null; + // (The test as rewritten, whether or not anything comes before it: a cell compared with a constant is read by + // the condition itself.) + const out: Stmt = { ...s, cond, then: this.stmts(s.then), ...(s.else ? { else: this.stmts(s.else) } : {}) }; + return head ? [...head, out] : [out]; + } + case "while": case "for": { + const pre: Stmt[] = []; + const cond = s.cond ? this.cond(s.cond, pre, where) : undefined; + const body = this.stmts(s.body); + const update = s.kind === "for" ? this.stmts(s.update) : []; + if (!pre.length) return [s.kind === "for" ? { ...s, cond, body, update } : { ...s, cond, body }]; + // The test needs values computed first: at the top of each turn, before it. + const leave: Stmt = { kind: "if", cond: { kind: "not", expr: cond! }, then: [{ kind: "break", ...where }], ...where }; + return [s.kind === "for" ? { ...s, cond: undefined, body: [...pre, leave, ...body], update } : { ...s, cond: undefined, body: [...pre, leave, ...body] }]; + } + case "do": { + // (A map of our own: the test as rewritten; what it needs computed first runs before it at every turn's end, + // `before`, where a `continue` lands too — the emitter lays it out at the loop's continue label.) + const pre: Stmt[] = []; + const cond = this.cond(s.cond, pre, where); + // (What the texts pass put before the test stays, rewritten as statements, and the test's own needs follow it.) + const before = [...this.stmts(s.before ?? []), ...pre]; + return [{ ...s, body: this.stmts(s.body), cond, ...(before.length ? { before } : {}) }]; + } + case "unrolled": return [{ ...s, iterations: s.iterations.map((it) => this.stmts(it)) }]; + case "try": return [{ ...s, body: this.stmts(s.body), catch: this.stmts(s.catch) }]; + // (A task's arguments as a call's, worked out where it is started; its body rewritten as an inlined call's.) + case "start": { + const pre: Stmt[] = []; + this.call(s.call, pre, where); + const made = pre.pop() as Extract; + return [...pre, { ...s, call: made.call }]; + } + case "parallel": return [{ ...s, branches: s.branches.map((b) => this.stmts(b)) }]; + case "watch": return [{ ...s, handler: this.stmts(s.handler) }]; + case "switch": { + const pre: Stmt[] = []; + // (Tested against each case: one variable plus a constant — bits under a mask computed first.) + let value = side(s.value) ? s.value : this.side(this.flat(s.value, pre, where), pre, where); + if (value.kind === "binary" && value.op === "&") value = this.sum(value, pre, where); + const out: Stmt = { ...s, value, cases: s.cases.map((c) => ({ ...c, body: this.stmts(c.body) })) }; + return pre.length ? [...pre, out] : [out]; + } + case "assignBool": { + const pre: Stmt[] = []; + const value = this.cond(s.value, pre, where); + return [...pre, { ...s, value }]; + } + case "action": return s.variables?.length || s.text?.kind === "template" ? this.action(s, where) : [s]; + case "centerLocation": return this.stmts(this.centerLocation(s, where)); + case "tableWrite": { + const out = this.tableWrite(s, where); + return out ? this.stmts(out) : [s]; + } + default: return [s]; + } + } +} + +/** + * A statement with the calls of functions that are one `return` of a number put in their place, the parameters + * replaced by the arguments: `cellAt(mapX, mapY)` is `CELLS[mapY * SIZE + mapX]`. Only where every argument is a sum + * the emitter lowers (reading it twice is reading it once); the function itself stays, for its other calls. + */ +function inlined(x: unknown, functions: Map, depth = 0): unknown { + if (Array.isArray(x)) return x.map((y) => inlined(y, functions, depth)); + if (!x || typeof x !== "object") return x; + const o = x as Record; + if (o.kind === "call" && !("label" in o) && depth < 4) { + const c = (o as Extract).call; + // A called function's body, or (a function called once) the body the call carries. + const f = c.fn ? functions.get(c.fn) : undefined; + const body = (f ? f.body : c.fn ? undefined : c.body)?.filter((st) => st.kind !== "remark"); + const params = f ? f.params : c.params.map((p) => p.decl); + const ret = body?.length === 1 && body[0].kind === "return" ? body[0].value : undefined; + if (ret && c.result?.kind === "number" && params.length === c.params.length && params.every((p) => p.kind === "number" && !p.bits) && c.params.every((p) => linear(p.init as NumExpr))) { + const by = new Map(c.params.map((p) => [p.decl.id, p.init as NumExpr])); + const put = (e: unknown): unknown => { + if (Array.isArray(e)) return e.map(put); + if (!e || typeof e !== "object") return e; + const v = e as Record; + if (v.kind === "var" && by.has(v.id as string)) return by.get(v.id as string); + return Object.fromEntries(Object.entries(v).map(([k, w]) => [k, put(w)])); + }; + return inlined(put(ret), functions, depth + 1); + } + } + return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, inlined(v, functions, depth)])); +} + +/** + * A function's number kept as a value (`fn` on a constant) as a plain number: in a map of our own nothing else keeps + * the function in the map — the dispatcher's call of it lays it out. + */ +function plainFns(x: unknown): unknown { + if (Array.isArray(x)) return x.map(plainFns); + if (!x || typeof x !== "object") return x; + const o = x as Record; + if (o.kind === "const" && "fn" in o) { + const plain = { ...o }; + delete plain.fn; + return plain; + } + return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, plainFns(v)])); +} + +/** How often the emitter did what is worth counting, for its tests. */ +export const emitterCounts = { products: 0, quotients: 0, bits: 0, shifts: 0, fixed: 0, random: 0, reads: 0 }; + +/** Where a refusal points, at its end: ", line N", and the file when it is not the script's entry file. */ +export const refusedAt = (at?: At): string => (at ? `, line ${at.line}${at.file && at.file !== "main.ts" && at.file !== "main.tsx" ? ` of ${at.file}` : ""}` : ""); + +/** + * A whole body of statements lowered to one graph: every statement must be the emitter's — a print of a known text is + * the game's Display Text — and nothing read that the body did not write first (there is no one to copy it in; a copy + * within the body is made of triggers by the layout). Returns the graph, or why it cannot be. + */ +export function emitBody(body: Stmt[], decls: Map, key: string, functions = new Map(), arrays = new Map(), stackDepth = STACK_DEPTH, owner?: number, players: MapPlayers = ANY_PLAYERS, errors?: ProgramErrors, pass?: Stmt[], after?: Stmt[]): Graph | string { + const known = new Map(decls); + for (const f of functions.values()) { + for (const p of f.params) known.set(p.id, p); + if (f.result) known.set(f.result.decl.id, f.result.decl); + } + const tops = new Map([...known.values()].filter((d) => d.bits).map((d) => [d.id, 2 ** d.bits! - 1])); + // A called function's body, rewritten once, the first time a call reaches it (a call of itself meanwhile sees it as + // it was written, and is refused). + const bodies = new Map(); + const bodyOf = (id: string): Stmt[] | undefined => { + const f = functions.get(id); + if (!f) return undefined; + let b = bodies.get(id); + if (!b) { + bodies.set(id, f.body); + const rw = new Rewriter(`${key}.f${bodies.size}`, functions, f.result?.kind); + rw.arrays = arrays; + rw.owner = owner; + rw.players = players; + rw.bits = tops; + b = rw.stmts(plainFns(inlined(f.body, functions)) as Stmt[]); + bodies.set(id, b); + } + return b; + }; + const rules = (scope: Scope) => { + scope.owner = owner; + scope.players = players; + scope.functions = functions; + scope.arrays = arrays; + scope.bodyOf = bodyOf; + scope.errors = errors; + }; + const rw = new Rewriter(key, functions); + rw.arrays = arrays; + rw.owner = owner; + rw.players = players; + rw.bits = tops; + const block = rw.stmt(plainFns(inlined({ kind: "block", body, at: body[0]?.at } as Stmt, functions)) as Stmt); + // The tasks' slots, each start's planned (and the variables of a slot after the first declared). + const planned = planTasks(block.length === 1 ? block[0] : ({ kind: "block", body: block, at: body[0]?.at } as Stmt), key, known, arrays); + if (typeof planned === "string") return planned; + const s = planned.root; + // (The map's events pass, its statements as the program's: laid out apart, before the run's `@wake`.) + const passed = pass?.length ? rw.stmt({ kind: "block", body: pass, at: pass[0].at } as Stmt) : []; + const passStmt: Stmt | undefined = passed.length ? { kind: "block", body: passed, at: pass![0].at } : undefined; + // (The map's send pass, the input's: laid out apart too, after the last run's end.) + const aftered = after?.length ? rw.stmt({ kind: "block", body: after, at: after[0].at } as Stmt) : []; + const afterStmt: Stmt | undefined = aftered.length ? { kind: "block", body: aftered, at: after![0].at } : undefined; + const scope = new Scope(known); + rules(scope); + scope.slots = planned.slots; + scope.once = planned.once; + if (passStmt && !scope.stmt(passStmt, false)) return scope.why || "the events pass"; + if (afterStmt && !scope.stmt(afterStmt, false)) return scope.why || "the send pass"; + // (The map's own variables, which no program declares and no run's start sets: 0 as the map starts, one cell each.) + for (const d of known.values()) if (d.global) scope.declared.add(d.id); + if (!scope.stmt(s, false)) return scope.why || "?"; + if (scope.refused.size) { + // (The first that holds a part the emitter never takes, before one refused for how it is put together — a print to + // all players the front end adds, say, after a closure it cannot keep.) + const refused = [...scope.refused]; + const first = refused.find((s) => scope.whyOf.get(s)?.never) ?? refused[0]; + return `${scope.whyOf.get(first)?.why || `a ${first.kind} it does not lower`}${refusedAt(first.at)}`; + } + const make = (forced: Set, unguarded = new Set()): { e: Emitter; g: Graph | string } => { + const e = new Emitter(key, scope); + e.forced = forced; + e.unguarded = unguarded; + e.stackDepth = stackDepth; + // Stacks: each `sp` and `depth` set at the start, which sets every place of each `sp` — the fields of its stack it + // addresses. (Which stacks there are is known once the functions are laid out: the writes go into this trigger then.) + const start = scope.recursion ? e.push({ conds: [], items: [] }) : null; + // The program a coroutine whose wake is its run's `@wake`; when it sleeps or ends, the tasks have their turn. + e.co = { + tick: { data: WAKE_TICK }, + at: { data: WAKE_AT }, + reset: { dword: { data: WAKE_NEXT }, modifier: "set", value: { label: e.tasksLabel } }, + yieldTo: e.tasksLabel, + // At its end the program wakes no more — unless it started tasks, which run on after it (the simulator's rule, + // and the Python side's): its wake then comes every frame and goes straight to the tasks' turn. + end: () => { + if (scope.slots.size) e.open().items.push({ act: { dword: { data: WAKE_TICK }, modifier: "set", value: 0 } }, { act: { dword: { data: WAKE_AT }, modifier: "set", value: { label: e.tasksLabel } } }); + else e.open().items.push({ act: { dword: { data: WAKE_TICK }, modifier: "set", value: U32 - 1 } }); + e.goto(e.tasksLabel); + }, + }; + e.stmt(s); + e.co.end(); + // The functions the program calls and the tasks it starts, each of which may call and start more. + while (true) { + e.functions(); + e.tasks(); + if (!e.pending.length && !e.pendingTasks.length) break; + } + const tasks = e.turnOfTasks(); + // The events pass: in a trigger of its own, kept, which the layout sends the walk to before the run's `@wake`. + const passAt = `${key}:pass`; + if (passStmt) { + e.push({ label: passAt, conds: [], items: [], keep: true }); + e.stmt(passStmt); + e.goto(PASS_END); + } + const afterAt = `${key}:after`; + if (afterStmt) { + e.push({ label: afterAt, conds: [], items: [], keep: true }); + e.stmt(afterStmt); + e.goto(AFTER_END); + } + e.settleThrown(); + e.emitDigits(); + if (start) { + const writes: Write[] = [...[...e.stacks.values()].map((st) => st.sp), ...(e.stacks.size ? [e.depth] : [])].map((target) => ({ target, value: { c: 0, terms: [] } })); + start.items.unshift(...writes.map((write) => ({ write }))); + for (const w of writes) e.writes.set(w.target, [...(e.writes.get(w.target) ?? []), w]); + } + const g = e.graph([...scope.read].filter((id) => !scope.declared.has(id) && !isTemp(id))); + if (typeof g !== "string" && e.stacks.size) { + const most = Math.max(...[...e.stacks.values()].map((st) => st.frame)), cells = [...e.stacks.values()].reduce((n, st) => n + st.frame * e.stackDepth, 0); + if (most > MOST_ACTS) return { e, g: `a function that calls itself and keeps ${most - 1} variables around a call (${MOST_ACTS - 1} at the most)` }; + if (cells > STACK_CELLS_MOST) return { e, g: `stacks of ${cells} cells (${e.stackDepth} calls deep, ${cells / e.stackDepth} cells for a call of each function that calls itself), past the ${STACK_CELLS_MOST} a map of our own lays out: lower the recursion depth in the script's settings` }; + g.cells = [...(g.cells ?? []), ...[...e.stacks.values()].map((st) => ({ id: st.id, cells: st.frame * e.stackDepth }))]; + } + if (typeof g !== "string" && e.templates.length) g.templates = e.templates; + if (typeof g !== "string" && tasks) g.tasks = tasks; + if (typeof g !== "string" && passStmt) g.pass = passAt; + if (typeof g !== "string" && afterStmt) g.after = afterAt; + if (typeof g !== "string" && e.regions.size) g.cells = [...e.regions.values(), ...(g.cells ?? [])]; + const cells = typeof g === "string" ? 0 : (g.cells ?? []).reduce((n, r) => n + r.cells, 0); + if (cells > STACK_CELLS_MOST) return { e, g: `arrays and stacks of ${cells} cells, past the ${STACK_CELLS_MOST} a map of our own lays out (72 bytes a cell)` }; + return { e, g }; + }; + // Built again while it is crowded — a write with more places than a trigger's actions, which the next build copies + // after its reads instead, or an `if` in one trigger that did not fit, laid out as a branch — + // until a build finds nothing more to change. + const forced = new Set(), unguarded = new Set(); + let built = make(forced, unguarded); + for (let tries = 0; built.g === "crowded" && tries < 16; tries++) { + const before = forced.size + unguarded.size; + for (const id of built.e.crowded) { + if (!built.e.own.has(id) && !isTemp(id)) forced.add(id); + else for (const [target, ws] of built.e.writes) if (ws.some((w) => w.value.terms.some((t) => t.id === id))) forced.add(target); + } + for (const s of built.e.misfits) unguarded.add(s); + if (forced.size + unguarded.size === before) break; + built = make(forced, unguarded); + } + const g = built.g === "crowded" ? "a branch with too many actions" : built.g; + if (typeof g === "string") return g; + if (g.entries.length) return `${g.entries[0].from} is read before the body writes it`; + return g; +} diff --git a/compiler/backend/errors.ts b/compiler/backend/errors.ts new file mode 100644 index 0000000..e47bb55 --- /dev/null +++ b/compiler/backend/errors.ts @@ -0,0 +1,155 @@ +/** + * Errors on a map of our own (TrigScript 4, step 11 of the way off eudplib). The front end has done most of it (B4): + * before a `throw` it sets the error in flight — its class's code into `Program.errors.thrown`, its message into + * `message` — `finally` is copied before every way out, and a program's body is a `try` whose `catch` is `uncaught`. + * What is left is where the walk goes, which is the emitter's (`Emitter.handler`): a `throw` goes to the `catch` of the + * nearest `try` around it, or out of the function it is in, and the call goes on from there. + * + * `ownErrors` rewrites what the emitter would need texts for, before the texts are lowered (`texts.ts`): the line each + * error is thrown at kept in a variable of the program's, `uncaught` as the print the Python side makes of it, and — + * when a function calls itself — the RangeError a stack that runs out throws, a function the emitter calls there. + */ +import { ERROR_CODES, STACK_OVERFLOW_MESSAGE, type At, type FuncDecl, type Program, type ProgramErrors, type Stmt, type VarDecl } from "../ir"; + +/** The variable the line an error was thrown at is kept in: what an uncaught one is said with. */ +export const errorLine = (errors: ProgramErrors): string => `${errors.thrown}@line`; +/** The function that sets the error in flight to the RangeError of a stack that ran out (`Emitter.keep`). */ +export const OVERFLOW_FN = "@error.overflow"; +/** The function that says an error nothing caught, where more than one place may: each a call of it. */ +const UNCAUGHT_FN = "@error.uncaught"; +/** + * The functions a task's error goes through: the front end's `catch` around a task's body — the error noted in the + * task's slot for an `await` of it, or said — which reads the task's number (`taskHandle`) and nothing of the task's + * own, laid out once for every task whose `catch` is the same, a call each (a start of sixteen slots had sixteen). + */ +const TASK_FAILED_FN = "@error.task"; + +export function ownErrors(program: Program): Program { + const errors = program.errors; + if (!errors) return program; + const line = errorLine(errors), at0 = program.at; + const set = (target: string, value: number, at: At): Stmt => ({ kind: "assign", target, value: { kind: "const", value }, at, label: "" }); + // (`throw` and `uncaught` are statements only: wherever a list holds one, it is a list of statements.) + const walk = (list: Stmt[]): Stmt[] => deep(list) as Stmt[]; + const deep = (x: unknown): unknown => { + if (Array.isArray(x)) return x.flatMap((y) => (y && typeof y === "object" && (y.kind === "throw" || y.kind === "uncaught") ? statement(y as Stmt) : [deep(y)])); + if (!x || typeof x !== "object") return x; + const o = (x as { kind?: string }).kind === "start" ? outline(x as Extract) : (x as object); + const out: Record = {}; + for (const [k, v] of Object.entries(o)) out[k] = v && typeof v === "object" ? deep(v) : v; + return out; + }; + // (Said where it is when only one place says it; else once, in a function each place calls — a task's body has one + // in every slot.) + let sites = 0; + const count = (x: unknown): void => { + if (Array.isArray(x)) x.forEach(count); + else if (x && typeof x === "object") { + if ((x as { kind?: string }).kind === "uncaught") sites++; + Object.values(x).forEach(count); + } + }; + count([program.body, program.functions ?? []]); + // (A task's `catch` first: it says an uncaught error too.) + const failed: { shape: string; f: FuncDecl }[] = []; + const outline = (st: Extract): Extract => { + const top = st.call.body.length === 1 ? st.call.body[0] : undefined; + if (top?.kind !== "try" || !taskCatch(top.catch, st.task)) return st; + const shape = canonical(top.catch); + let f = failed.find((x) => x.shape === shape)?.f; + if (!f) { + const h: VarDecl = { id: `${TASK_FAILED_FN}${failed.length}.h`, name: "(task)", kind: "number", shared: false, at: st.at }; + const body = replaced(top.catch, (o) => (o.kind === "taskHandle" ? { kind: "var", id: h.id } : undefined)) as Stmt[]; + f = { id: `${TASK_FAILED_FN}${failed.length}`, name: "(a task's error)", params: [h], at: st.at, body }; + failed.push({ shape, f }); + } + const call: Stmt = { kind: "call", call: { fn: f.id, name: f.name, params: [{ decl: f.params[0], init: { kind: "taskHandle", at: top.at }, label: "" }], body: [], at: top.at, label: "" }, at: top.at, label: "" }; + return { ...st, call: { ...st.call, body: [{ ...top, catch: [call] }] } }; + }; + const said = (at: At, label: string): Stmt[] => [ + { kind: "print", to: CURRENT_PLAYER, position: "chat", at, label, parts: [ + { kind: "text", text: "\x06TrigScript: uncaught " }, + { kind: "value", text: { kind: "textOf", array: errors.names, index: { kind: "var", id: errors.thrown }, at } }, + { kind: "text", text: ": " }, + { kind: "value", text: { kind: "textVar", id: errors.message } }, + { kind: "text", text: ", line " }, + { kind: "number", expr: { kind: "var", id: line } }, + { kind: "text", text: "." }, + ] }, + set(errors.thrown, 0, at), + ]; + const statement = (s: Stmt): Stmt[] => { + switch (s.kind) { + // Where it was thrown: a caught error thrown again keeps where it came from. + case "throw": return s.rethrow ? [s] : [set(line, s.at.line, s.at), s]; + // Said in red to the player the program runs as, as a fault of the game's is, and given up. + case "uncaught": return sites > 1 ? [{ kind: "call", call: { fn: UNCAUGHT_FN, name: "(uncaught)", params: [], body: [], at: s.at, label: s.label }, at: s.at, label: s.label }] : said(s.at, s.label); + default: return [s]; + } + }; + const decl: VarDecl = { id: line, name: "(error line)", kind: "number", shared: true, at: at0 }; + const functions: FuncDecl[] = (program.functions ?? []).map((f) => ({ ...f, body: walk(f.body) })); + if (sites > 1) functions.push({ id: UNCAUGHT_FN, name: "(uncaught)", params: [], at: at0, body: said(at0, "") }); + const body = walk(program.body); + for (const { f } of failed) functions.push({ ...f, body: walk(f.body) }); + if (functions.some((f) => f.recursive)) { + functions.push({ id: OVERFLOW_FN, name: "(stack overflow)", params: [], at: at0, body: [ + { kind: "assignText", target: errors.message, value: { kind: "text", text: STACK_OVERFLOW_MESSAGE }, at: at0, label: "" }, + set(errors.thrown, ERROR_CODES.RangeError, at0), + ] }); + } + return { + ...program, + body: [{ kind: "declare", decl, init: { kind: "const", value: 0 }, at: at0, label: "" }, ...body], + ...(functions.length ? { functions } : {}), + }; +} + +/** Player 13 in a print: the current player. */ +const CURRENT_PLAYER = 13; + +/** + * Whether a `catch` is the front end's around a task's body: it starts by taking the task's number, reads it nowhere + * else, and reads no variable of the task's — what lets it be laid out once for every slot and every task. + */ +function taskCatch(list: Stmt[], task: number): boolean { + const first = list[0]; + if (first?.kind !== "declare" || (first.init as { kind?: string }).kind !== "taskHandle") return false; + let handles = 0, own = false; + const look = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(look); return; } + if (!x || typeof x !== "object") return; + const o = x as Record; + if (o.kind === "taskHandle") handles++; + if (o.decl && typeof o.decl === "object" && (o.decl as VarDecl).task === task) own = true; + if (o.kind === "start" || o.kind === "sleep" || o.kind === "parallel" || o.kind === "return" || o.kind === "break" || o.kind === "continue") own = true; + Object.values(o).forEach(look); + }; + look(list); + // (What it reads of the task's would be declared in the task: a variable it reads and does not declare is the program's.) + return handles === 1 && !own; +} + +/** A piece of the IR as text, the variables it declares numbered in the order they come, `at` and labels left out. */ +function canonical(x: unknown): string { + const names = new Map(); + const look = (y: unknown): void => { + if (Array.isArray(y)) { y.forEach(look); return; } + if (!y || typeof y !== "object") return; + const o = y as Record; + if (o.decl && typeof o.decl === "object" && !names.has((o.decl as VarDecl).id)) names.set((o.decl as VarDecl).id, `#${names.size}`); + Object.values(o).forEach(look); + }; + look(x); + return JSON.stringify(x, (k, v) => (k === "at" || k === "label" || k === "range" ? undefined : typeof v === "string" && names.has(v) ? names.get(v) : v)); +} + +/** A copy of a piece of the IR with each object `f` gives something for replaced by it. */ +function replaced(x: unknown, f: (o: Record) => unknown): unknown { + if (Array.isArray(x)) return x.map((y) => replaced(y, f)); + if (!x || typeof x !== "object") return x; + const o = x as Record; + const r = f(o); + if (r !== undefined) return r; + return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, replaced(v, f)])); +} diff --git a/compiler/backend/fixes.ts b/compiler/backend/fixes.ts new file mode 100644 index 0000000..7d25f2f --- /dev/null +++ b/compiler/backend/fixes.ts @@ -0,0 +1,104 @@ +/** + * What eudplib does to a map's sections before it writes them (`eudplib/core/mapdata/fixmapdata.py`), done here to the + * CHK a map of our own is built from, so that a map built either way is the same map (TrigScript 4, step 14): + * + * - UNIT: a placed unit's flags for a figure left at its default are cleared, and a figure whose flag is clear is + * zeroed, so that the record says one thing. + * - UNIx, UPGx, TECx: a row that says "use the defaults" has its settings zeroed. + * - VER: 206, a Remastered file (the programs' payload is laid out where Remastered keeps the strings). + * - MTXM: a tile of 0 (null terrain) is made tile 1 — by eudplib's account, null tiles desync a game. + * + * Each says in a line what it changed, when it changed anything. + */ +import type { Sections } from "./chk"; + +export const REMASTERED_VERSION = 206; + +export function fixMap(s: Sections): string[] { + return [...fixUnits(s), ...fixSettings(s), ...makeRemastered(s), ...fixNullTiles(s)]; +} + +function fixUnits(s: Sections): string[] { + const had = s.get("UNIT"); + if (!had) return []; + const unit = had.slice(); + let changed = 0; + for (let i = 0; i + 36 <= unit.length; i += 36) { + const before = unit.slice(i, i + 36); + // A figure at its default needs no flag: 100 % hit points or shields, no hangar units, a state not valid. + if (unit[i + 17] === 100) unit[i + 14] &= ~2; + if (unit[i + 18] === 100) unit[i + 14] &= ~4; + if (unit[i + 24] === 0 && unit[i + 25] === 0) unit[i + 14] &= ~32; + for (let k = 0; k < 5; k++) if ((unit[i + 26] & (1 << k)) === 0) unit[i + 12] &= ~(1 << k); + // A figure whose flag is clear is nothing. + if ((unit[i + 14] & 2) === 0) unit[i + 17] = 0; + if ((unit[i + 14] & 4) === 0) unit[i + 18] = 0; + if ((unit[i + 14] & 8) === 0) unit[i + 19] = 0; + if ((unit[i + 14] & 16) === 0) unit.fill(0, i + 20, i + 24); + if ((unit[i + 14] & 32) === 0) unit.fill(0, i + 24, i + 26); + for (let k = 0; k < 5; k++) if ((unit[i + 12] & (1 << k)) === 0) unit[i + 26] &= ~(1 << k); + if (before.some((b, k) => b !== unit[i + k])) changed++; + } + if (!changed) return []; + s.set("UNIT", unit); + return [`${changed} placed unit${changed === 1 ? "" : "s"} with a figure's flag at odds with the figure: made to agree (UNIT)`]; +} + +const TABLES: [name: string, count: number, settings: number[], pad: number][] = [ + ["UNIx", 228, [4, 2, 1, 2, 2, 2, 2], 0], + ["UPGx", 61, [2, 2, 2, 2, 2, 2], 1], + ["TECx", 44, [2, 2, 2, 2], 0], +]; + +function fixSettings(s: Sections): string[] { + const lines: string[] = []; + for (const [name, count, settings, pad] of TABLES) { + const had = s.get(name); + if (!had) continue; + const data = had.slice(); + let changed = 0; + for (let i = 0; i < count && i < data.length; i++) { + if (data[i] === 0) continue; + let offset = count + pad; + let touched = false; + for (const width of settings) { + for (let k = 0; k < width; k++) { + const at = offset + width * i + k; + if (at < data.length && data[at] !== 0) { data[at] = 0; touched = true; } + } + offset += width * count; + } + if (touched) changed++; + } + if (!changed) continue; + s.set(name, data); + lines.push(`${changed} row${changed === 1 ? "" : "s"} of ${name.trim()} at the defaults with settings left in: zeroed`); + } + return lines; +} + +function makeRemastered(s: Sections): string[] { + const ver = s.get("VER "); + const was = ver && ver.length >= 2 ? ver[0] | (ver[1] << 8) : 0; + if (was === REMASTERED_VERSION) return []; + s.set("VER ", new Uint8Array([REMASTERED_VERSION & 0xff, REMASTERED_VERSION >> 8])); + return [`a Remastered file (VER ${REMASTERED_VERSION}, was ${was}): a map with a program plays on StarCraft: Remastered`]; +} + +function fixNullTiles(s: Sections): string[] { + const had = s.get("MTXM"); + if (!had) return []; + const mtxm = had.slice(); + const dim = s.get("DIM "); + const width = dim && dim.length >= 2 ? dim[0] | (dim[1] << 8) : 0; + const at: string[] = []; + for (let i = 0; i + 2 <= mtxm.length; i += 2) { + if (mtxm[i] !== 0 || mtxm[i + 1] !== 0) continue; + mtxm[i] = 1; + const tile = i / 2; + at.push(width ? `(${tile % width}, ${Math.floor(tile / width)})` : String(tile)); + } + if (!at.length) return []; + s.set("MTXM", mtxm); + return [`${at.length} null tile${at.length === 1 ? "" : "s"} (0000.00) made 0000.01, as eudplib does — they desync a game: at ${at.slice(0, 12).join(", ")}${at.length > 12 ? ", …" : ""}`]; +} diff --git a/compiler/backend/graph.ts b/compiler/backend/graph.ts new file mode 100644 index 0000000..71191af --- /dev/null +++ b/compiler/backend/graph.ts @@ -0,0 +1,242 @@ +/** + * The emitter's triggers (TrigScript 4, the backend: the emitter, E1). A statement the emitter takes is lowered to a + * graph of these, which `python/trigscript.py` makes into the map's triggers where the statement was (`Lowering.graph`) + * while the rest of the lowering stays its own; the simulator runs the statements the graph replaced. + * + * A trigger compares dwords with numbers and writes numbers into dwords — the game's Deaths condition and Set Deaths + * action reaching any address, as an EUD map has them — and says where the walk goes after it. A dword is one of the + * game's, a variable's cell (the layout's), a dword of data the graph keeps, or a field of one of its own triggers: + * a condition's number, an action's number, destination or modifier, the trigger's next pointer. The emitter decides + * where every value lives by writing such fields, which is the point of having one. + */ +import type { At } from "../ir"; +import type { ScriptString } from "../runtime"; +import type { ActionRecord, ConditionRecord } from "../../vendor/triggers"; + +/** A dword the graph reads or writes. */ +export type Dword = + /** An address in the game's memory. */ + | { game: number } + /** The cell of a variable of the program (the layout's, by the variable's id): its value. */ + | { var: string } + /** A dword of the graph's own data, by name: made once in the map, 0 when the map starts. */ + | { data: string } + /** A field of one of the graph's triggers, by its label. */ + | { at: string; field: Field } + /** + * The value of the first cell of one of the program's arrays (a list known when the map is built, which nothing + * writes). A condition on it whose player field is a place reads the cell that field says: its EPD. + */ + | { element: string } + /** A scratch cell of the graph's: where a cell's value comes out. */ + | { temp: string } + /** The `word`-th dword of a printed text's template (`Graph.templates`), which the layout puts on a dword's start. */ + | { template: string; word: number } + /** A cell of one of a map of our own's regions of cell triggers (`Graph.cells`), by its index: its number. */ + | { cell: string; index: number }; + +/** + * A field of a trigger: a condition's number (or its player, the EPD of the dword it reads), an action's number, + * destination or modifier, the mask of a masked one (a condition's or an action's, which it reads as it runs: what the + * mask is can be written like any number), the next pointer. + */ +/** + * (An action of the game's, `Act.record`, names its other fields by these too: its location is where the mask is, its + * first player the destination, its second player or amount the number, its unit type, kind and count the modifier's + * dword; `text`, `wav` and `time` are its own.) + */ +/** + * `current`: the dword whose top byte is the trigger's current action (where a trigger held by a Wait or a + * Transmission goes on from); an action's `flags` dword has its flags in the low byte (bit 0: a Wait or a Transmission + * started, which the game steps over the next time it is reached). `flags`: the trigger's own (4, preserved; 8 more, + * done: the walk passes it by before its conditions, at about 40% of what an empty trigger costs it). + */ +export type Field = { cond: number; part?: "value" | "player" | "mask" } | { act: number; part: ActPart } | "next" | "current" | "flags"; +/** A trigger's flags: preserved, and preserved and done (passed by: `Trigger.off`). */ +export const TRIGGER_ON = 4, TRIGGER_OFF = 12; +export type ActPart = "value" | "dest" | "modifier" | "mask" | "text" | "wav" | "time" | "flags"; + +export type Compare = "atLeast" | "atMost" | "exactly"; +export type Modifier = "set" | "add" | "subtract"; + +/** + * A number known when the map is made: a constant, or the EPD of an array's first cell's value plus a constant, or the + * address of an EUDVArray's first cell (a trigger: what a jump into a cell goes to) plus a constant. + */ +export type Num = number | { epdOf: string; plus: number } | { addrOf: string; plus: number }; + +/** + * A number an action writes: one known when the map is made, where a label is (a trigger's address), or the EPD of a + * dword (what an action's destination holds: a cell of the graph's own routed to write somewhere else). + */ +export type Value = Num | { label: string } | { epd: Dword }; + +/** + * The dword compared with `value` (read from 0 up); with `mask`, the dword's bits of the mask (`value` as it is). + * `game`: instead, a condition of the game's as the script wrote it (a map of our own: TrigScript 4, step 5 of the way + * off eudplib) — its record laid out as it is, the dword not read. + */ +export interface Cond { dword: Dword; compare: Compare; value: number; mask?: number; game?: ConditionRecord } + +/** + * `value` written into the dword, added to it or taken from it; with `mask`, only the mask's bits change. An add wraps; + * the game's subtract stops at 0, so it is no way to take a variable away (add its negation). + */ +/** + * `at`: the statement the action is for, when a trigger holds the work of several (the cost of a line, action by + * action). `display`: instead of a write, the game's own Display Text of that text (to the current player, as a + * trigger of theirs shows it); only a graph made for a map of our own has one (`emitBody`): its text is put in the + * map's strings when the map is made. + */ +/** `show`: instead of a write, Display Text of a printed text's template (`Graph.templates`), as it is when shown. */ +/** + * `randomize`: instead of a write, the game's Set Switch of that switch (0-based) to a random state — one bit of the + * game's own random numbers, the same on every player's machine. A map of our own reads the switch where the game keeps + * it (`SWITCHES`). + */ +/** + * `record`: instead of a write, an action of the game's as the script wrote it (a map of our own), laid out as it is but + * for its text and its sound file, which are the script's strings (`strings`: a record's `text` / `wav` of `k > 0` is the + * script's `k`-th) and are put in the map's strings when the map is made. + */ +/** + * `textOf`: an action of the game's whose text is a printed text's template (`Graph.templates`), filled before it. + * `amountText`: a Set Deaths whose amount is a text's number among the map's strings, times 2^`shift` (a unit type's + * name written). + */ +export interface Act { dword: Dword; modifier: Modifier; value: Value; mask?: number; at?: At; display?: string; show?: string; randomize?: number; record?: ActionRecord; strings?: { text?: ScriptString; wav?: ScriptString }; textOf?: string; amountText?: { text: string; shift: number } } + +/** Where the game keeps its 256 switches: a bit each, 32 to a dword. */ +export const SWITCHES = 0x58dc40; + +/** + * A trigger: its conditions all hold, its actions run, in order. Then the walk goes to `next` — a label of the graph's, + * or `END` for what follows the graph — or, unset, to the trigger after it in the list. + */ +/** `off`: laid out done (`TRIGGER_OFF`), passed by until an action of the graph's switches it on: a task's slot with nothing in it. */ +export interface Trigger { label: string; conds: Cond[]; acts: Act[]; next?: string; at?: At; copy?: Copy; off?: boolean } + +/** + * A copy in the middle of the graph, in place of a trigger (`conds` and `acts` empty, no `next`: the walk goes on to the + * trigger after it): the variable's cell, as it is now, written into each dword — times its `scale`, plus `add`. + * What a variable whose writes cannot be laid out into all its places (one set + * from itself, two that read each other) does after each write. + */ +/** + * `from`: a variable's id, or (`@…`) a scratch cell of the graph's (`{ temp }`). `top`: the most the copied value can + * be, when value ranges know it never below zero (a multiple of it is then worked out over only the bits it can have). + */ +export interface Copy { from: string; into: { dword: Dword; add: Num; scale?: number }[]; top?: number } + +/** Where the walk goes when it leaves the graph: the statement after it. */ +export const END = "@end"; + +/** + * A map of our own's count of frames (TrigScript 4, step 11): a variable of the map's, one more each pass before the + * runs (`layout.ts`). A coroutine's wake — the program's, a task's, a branch of a `Promise.all`'s — asks whether it has + * reached the frame its sleep wrote: one copy of it plus the frames, where the game's own tick was read a bit at a time. + */ +export const NOW = "@now"; + +/** + * A copy into the graph on the way in: the variable's value times `scale` (1 when unset; wrapping) plus `add`, written + * into the dword. A whole program's graph has none: it reads nothing it did not write first (`emitBody`), and one + * that would is a refusal that names the variable. + */ +export interface Entry { from: string; into: Dword; add: Num; scale?: number } + +/** + * `arrays`: the arrays whose cells the graph reads where they are, each with the EPD between one cell's value and the + * next's as the emitter laid the reads out (1: a dword each): the layout puts the list so. + * + * `cells`: a map of our own keeps its arrays — and the frames of each function that calls itself, a stack each (an id + * starting `STACK`) — in regions of cell triggers as a routed variable's (`layout.ts`), this many cells each: a cell + * holds its value in the number of its one action, which adds it where its destination says and goes where its next + * pointer says. `values`: what the cells hold when the map starts (a list the script made when it was built). + * + * `templates`: the texts a map of our own prints with values in them, each its bytes as built — the text, and a field + * of 12 bytes on a dword's start for each number, which the program fills before it shows the text (0x0D, which draws + * nothing, wherever nothing goes) — ending in a 0. + * + * `rows`: the runs of triggers a doubling trigger walks (`TRIGGER_BYTES`), each laid out one after another in its + * order, `TRIGGER_BYTES` apart. Every other trigger the layout puts where it fits (`layout.ts`). + */ +/** (A template with `at` is the game's own memory there — the line a print at the centre of the screen is written into — and its bytes are written by the program, not laid out.) */ +/** + * `tasks`: where a run's tasks have their turn (a map of our own): the walk goes there after the program's turn, and when + * the program's wake does not hold; it ends at `END`. + */ +/** + * `pass`: where the map's events pass starts (a map of our own), which the walk goes through before the run's `@wake`; + * it ends at `PASS_END`. `after`: where the map's send pass starts (the input's, step 12), which the walk goes through + * after the last run's end; it ends at `AFTER_END`. + */ +export interface Graph { entries: Entry[]; triggers: Trigger[]; arrays?: { id: string; stride: number }[]; cells?: Region[]; templates?: { id: string; bytes: number[]; at?: number; data?: boolean }[]; rows?: string[][]; tasks?: string; pass?: string; after?: string } +/** Where the events pass goes when it is done: its run's `@wake`. */ +export const PASS_END = "@passed"; +/** Where the send pass goes when it is done: the walk's end. */ +export const AFTER_END = "@aftered"; +export interface Region { id: string; cells: number; values?: number[] } + +/** + * A region of cell triggers, as an array's id: `{ element }` is its first cell's number, `{ epdOf }` that number's EPD + * (a cell's destination one less, its next pointer 86 less: +344 and +4 of the trigger where the number is +348), + * `{ addrOf }` where the first cell's trigger is; a cell every 72 bytes. A stack's id starts with this. + */ +export const STACK = "@stack"; + +/** + * A trigger's bytes in memory: in a row of the graph's (`Graph.rows`, laid out one after another), a next pointer + * moved on by this much goes to the trigger after the one it named — how a trigger walks a run of triggers it is run + * between (`Emitter.product`, `Emitter.divide`). + */ +export const TRIGGER_BYTES = 2408; + +/** The most conditions and actions a trigger of the game holds. */ +export const MOST_CONDS = 16; +export const MOST_ACTS = 64; + +/** The trigger's own next pointer, as a dword. */ +export const nextOf = (label: string): Dword => ({ at: label, field: "next" }); +/** The dword holding a trigger's k-th condition's number. */ +export const condValue = (label: string, k: number): Dword => ({ at: label, field: { cond: k } }); +/** The dword holding a trigger's k-th action's number. */ +export const actValue = (label: string, k: number): Dword => ({ at: label, field: { act: k, part: "value" } }); + +/** The checks a graph must pass before the layout makes it: labels known, sizes within the game's. */ +export function checkGraph(g: Graph): string[] { + const labels = new Set(g.triggers.map((t) => t.label)); + const out: string[] = []; + const known = (l: string) => l === END || l === PASS_END || l === AFTER_END || labels.has(l); + const dword = (d: Dword, where: string) => { + if ("at" in d && !labels.has(d.at)) out.push(`${where}: no trigger ${d.at}`); + }; + if (labels.size !== g.triggers.length) out.push("a label is used twice"); + for (const t of g.triggers) { + if (t.copy && (t.conds.length || t.acts.length || t.next !== undefined)) out.push(`${t.label}: a copy with conditions, actions or a next of its own`); + t.copy?.into.forEach((c, k) => dword(c.dword, `${t.label} copy ${k}`)); + if (t.conds.length > MOST_CONDS) out.push(`${t.label}: ${t.conds.length} conditions`); + if (t.acts.length > MOST_ACTS) out.push(`${t.label}: ${t.acts.length} actions`); + if (t.next !== undefined && !known(t.next)) out.push(`${t.label}: goes to ${t.next}, which is not there`); + t.conds.forEach((c, k) => dword(c.dword, `${t.label} condition ${k}`)); + t.acts.forEach((a, k) => { + dword(a.dword, `${t.label} action ${k}`); + if (typeof a.value === "object" && "label" in a.value && !known(a.value.label)) out.push(`${t.label} action ${k}: no label ${a.value.label}`); + }); + } + for (const e of g.entries) dword(e.into, `entry of ${e.from}`); + // (A trigger whose next pointer is moved on by a trigger's bytes walks a row: wherever it is pointed is in one — + // but where the trigger pointing it also moves where it adds, to take its numbers out, and goes on from there.) + const rowed = new Set((g.rows ?? []).flat()); + for (const l of rowed) if (!labels.has(l)) out.push(`a row holds ${l}, which is not there`); + const walkers = new Set(g.triggers.flatMap((t) => t.acts.filter((a) => "at" in a.dword && a.dword.field === "next" && a.modifier === "add" && typeof a.value === "number" && a.value % TRIGGER_BYTES === 0).map((a) => (a.dword as { at: string }).at))); + for (const t of g.triggers) { + t.acts.forEach((a, k) => { + if (!("at" in a.dword) || a.dword.field !== "next" || !walkers.has(a.dword.at) || a.modifier !== "set" || typeof a.value !== "object" || !("label" in a.value)) return; + const W = a.dword.at; + if (t.acts.some((b) => "at" in b.dword && b.dword.at === W && typeof b.dword.field === "object" && "act" in b.dword.field && b.dword.field.part === "dest")) return; + if (!rowed.has(a.value.label)) out.push(`${t.label} action ${k}: points ${a.dword.at}, which walks a row, at ${a.value.label}, which is in none`); + }); + } + return out; +} diff --git a/compiler/backend/heap.ts b/compiler/backend/heap.ts new file mode 100644 index 0000000..b51b6d8 --- /dev/null +++ b/compiler/backend/heap.ts @@ -0,0 +1,930 @@ +/** + * Arrays that grow, on the heap (TrigScript 4, the backend: step 9 of the way off eudplib). A map of our own keeps the + * cells of every growing array in one array of a fixed length that the emitter already lays out — a region of cell + * triggers, `@heap`, as long as the heap the script's settings give — and each growing array as four numbers of the + * program's: where its block starts among the heap's cells (0: it has none), how many cells are in use, how many the + * block has room for, and the block's size class (room 4 is class 0, 8 class 1, …). `growingArrays` rewrites a + * program's IR so, before the emitter sees it: a read is a cell of the heap under a test of the index against the + * length, a push a store at the end and an addition, and what needs more room calls the run time's `grow`. + * + * Blocks are handed out as the simulator counts them (`simulateIr.ts`, `heap`): a power of two of cells, four at the + * least; one given back waits in its class's list for the next array that wants that size — the list threaded through + * the first cell of each block in it, its head a cell of `@heap.free` — and new ground is taken from the bottom up, + * from cell 1, to the heap's end. So an array runs out at the same push in both, and says so once, as the Python side + * does. (A made text of a map of our own has a block of its own, `texts.ts`, and takes nothing from the heap.) + * + * What it does not take — an array that grows inside another, one of texts, a task's, one a function that calls itself + * keeps — it leaves as it was, for the emitter to refuse. + */ +import { HEAP_CELLS, HEAP_SMALLEST, heapCells, type ArrayDecl, type At, type BoolExpr, type Call, type CompareOp, type FuncDecl, type NumExpr, type Program, type Stmt, type VarDecl } from "../ir"; + +/** + * The numbers a growing array is kept as: where its block is, its length, its room, its block's class — and where its + * next cell goes (`e`, the block's start plus the length), which a push stores at and a pop reads at, so that neither + * works an index out. + */ +interface Handle { p: string; n: string; r: string; k: string; e: string; decl: ArrayDecl; through?: NonNullable } + +const k = (value: number): NumExpr => ({ kind: "const", value }); +const V = (id: string): NumExpr => ({ kind: "var", id }); + +/** Whether an expression may do something, or give another value, each time it is worked out: a call, a draw, a pop. */ +function impure(x: unknown): boolean { + if (Array.isArray(x)) return x.some(impure); + if (!x || typeof x !== "object") return false; + const o = x as { kind?: string }; + // (A cell read by the heap's own functions does nothing: it is a value as a variable is.) + if (o.kind === "call" && PURE.test((o as { call?: { fn?: string } }).call?.fn ?? "")) return impure((o as { call: Call }).call.params.map((p) => p.init)); + if (o.kind === "call" || o.kind === "pop" || o.kind === "randomInt" || o.kind === "random" || o.kind === "input" || o.kind === "read" || o.kind === "cond") return true; + return Object.values(o).some((v) => v && typeof v === "object" && impure(v)); +} +/** Whether a variable is read or written anywhere in `x`. */ +function mentions(x: unknown, id: string): boolean { + if (Array.isArray(x)) return x.some((y) => mentions(y, id)); + if (!x || typeof x !== "object") return false; + const o = x as Record; + if ((o.kind === "var" && o.id === id) || o.target === id) return true; + return Object.values(o).some((v) => v && typeof v === "object" && mentions(v, id)); +} +/** A statement with every read and write of a variable one of another's. */ +function renamed(x: unknown, from: string, to: string): unknown { + if (Array.isArray(x)) return x.map((y) => renamed(y, from, to)); + if (!x || typeof x !== "object") return x; + const o = x as Record; + const out = Object.fromEntries(Object.entries(o).map(([key, v]) => [key, v && typeof v === "object" ? renamed(v, from, to) : v])); + if (o.kind === "var" && o.id === from) out.id = to; + if ((o.kind === "assign" || o.kind === "assignBool") && o.target === from) out.target = to; + return out; +} +/** The heap's functions that only read a cell. */ +const PURE = /^@heap\.(get|read|rowRead)\./; + +/** The heap's cells and its list of blocks given back: one each for the whole map. */ +export const HEAP = "@heap", FREE = "@heap.free"; +const HEAP_TOP = "@heap.top", HEAP_SAID = "@heap.said"; +/** + * The map's own variables of the heap (`HEAP_TOP`, `HEAP_SAID`): no program declares them, so no run's start sets them + * again; each is kept in one cell (`routed`) and read where it is used, so that what one run writes the next reads. + */ +export const HEAP_GLOBALS: readonly VarDecl[] = [HEAP_TOP, HEAP_SAID].map((id) => ({ id, name: "(heap)", kind: "number", shared: true, routed: true, global: true, at: { file: "", line: 0, column: 0 } })); +/** Whether an id is the map's own, the same in every run (the heap's). */ +export const isHeapGlobal = (id: string): boolean => id === HEAP || id === FREE || id === HEAP_TOP || id === HEAP_SAID; + +/** What the growing arrays take, or why a program is left as it was. */ +export function growingArrays(program: Program, cells = HEAP_CELLS): Program { + const handles = new Map(); + // (An array that grows inside another — `rows[i]` of `number[][]` — is kept as one that grows alone, its numbers taken + // from the outer arrays' cells at the row's index before what is done to it, and put back after.) + for (const a of program.arrays ?? []) { + if (!a.dynamic || a.slice || a.texts || a.task !== undefined) continue; + if (a.kind !== "number" && a.kind !== "boolean") continue; + handles.set(a.id, { p: `${a.id}@p`, n: `${a.id}@n`, r: `${a.id}@r`, k: `${a.id}@k`, e: `${a.id}@e`, decl: a, ...(a.through ? { through: a.through } : {}) }); + } + if (!handles.size) return program; + + const H = heapCells(cells); + // The classes of block there can be: 4 · 2^c cells, up to the heap's length. + let classes = 0; + while (HEAP_SMALLEST * 2 ** classes <= H) classes++; + // (The heap is the map's, one for every program and every player's run of one: where new ground starts — less 1, so + // that the map's 0 is the first ground, cell 1 — and whether running out was said are cells no run declares.) + const TOP = HEAP_TOP, SAID = HEAP_SAID; + // The run time's operands: the array that grows as `grow` takes it, and how many cells it needs; what it gives back + // (the new block, its room and its class, and whether there was one). An array's own numbers are written only by a + // function of its own (`growOf`), from what `grow` gave back: never `grow`'s from the array's and the array's from + // `grow`'s, which the emitter would keep by copying both into every place at every read. + const G = { p: "@heap.p", n: "@heap.n", r: "@heap.r", k: "@heap.k", need: "@heap.need" }; + const S = { blk: "@heap.blk", nr: "@heap.nr", nk: "@heap.nk", j: "@heap.j", to: "@heap.to", end: "@heap.end", ok: "@heap.ok" }; + + let here: At = program.at; + let made = 0; + const fresh = (what: string) => `@heap.${what}${made++}`; + const bin = (op: "+" | "-", a: NumExpr, b: NumExpr): NumExpr => ({ kind: "binary", op, left: a, right: b, at: here, label: "" }); + const cmp = (op: CompareOp, a: NumExpr, b: NumExpr): BoolExpr => ({ kind: "compare", op, left: a, right: b, unsigned: true, at: here, label: "" }); + const set = (target: string, value: NumExpr): Stmt => ({ kind: "assign", target, value, at: here, label: "" }); + const add = (target: string, n: number): Stmt => set(target, bin("+", V(target), k(n))); + const when = (cond: BoolExpr, then: Stmt[], otherwise?: Stmt[]): Stmt => ({ kind: "if", cond, then, ...(otherwise?.length ? { else: otherwise } : {}), at: here, label: "" }); + const loop = (cond: BoolExpr, body: Stmt[]): Stmt => ({ kind: "while", cond, body, at: here, label: "" }); + /** + * `routed`: what `grow` gives back, which every array's `grow` reads — kept in one cell and read where it is used, so + * that a write of it in `grow` is one action, not one for each place every array keeps of where its block is. + */ + const ROUTED = new Set([S.blk, S.nr, S.nk, S.ok, G.p, G.n, G.r, G.k, G.need]); + /** Where each array's block starts: written only when the block moves, read wherever a cell is (`everywhere`). */ + const EVERYWHERE = new Set([...handles.values()].map((h) => h.p)); + const num = (id: string, top?: number): VarDecl => ({ id, name: "(heap)", kind: "number", shared: false, at: here, temp: true, ...(top !== undefined ? { top } : {}), ...(ROUTED.has(id) ? { routed: true } : {}), ...(EVERYWHERE.has(id) ? { everywhere: true } : {}) } as VarDecl); + const dec = (id: string, init: NumExpr, top?: number): Stmt => ({ kind: "declare", decl: num(id, top), init, at: here, label: "" }); + /** An expression with what the value ranges know of it. */ + const ranged = (e: NumExpr, lo: number, hi: number): NumExpr => ({ ...e, range: [lo, hi] }) as unknown as NumExpr; + /** A cell of the heap (inside it: what tests it is around it). */ + const cell = (at: NumExpr): NumExpr => ({ kind: "element", array: HEAP, index: ranged(at, 0, H - 1), at: here }) as NumExpr; + const store = (at: NumExpr, value: NumExpr): Stmt => ({ kind: "store", array: HEAP, index: ranged(at, 0, H - 1), value, at: here, label: "" }) as Stmt; + const freeAt = (c: NumExpr): NumExpr => ({ kind: "element", array: FREE, index: ranged(c, 0, classes - 1), at: here }) as NumExpr; + const setFree = (c: NumExpr, value: NumExpr): Stmt => ({ kind: "store", array: FREE, index: ranged(c, 0, classes - 1), value, at: here, label: "" }) as Stmt; + + // ── the run time ── + const functions: FuncDecl[] = []; + const call = (fn: FuncDecl, args: NumExpr[] = []): Call => ({ fn: fn.id, name: fn.name, at: here, label: "", params: fn.params.map((p, i) => ({ decl: p, init: args[i], label: "" })), body: [], ...(fn.result ? { result: { decl: num(fresh("v")), kind: fn.result.kind } } : {}) }); + const callStmt = (fn: FuncDecl, args: NumExpr[] = []): Stmt => ({ kind: "call", call: call(fn, args), at: here, label: "" }) as Stmt; + const callExpr = (fn: FuncDecl, args: NumExpr[] = []): NumExpr => ({ kind: "call", call: call(fn, args) }) as NumExpr; + /** + * `grow`: room for `need` cells in the array its operands hold — a block of the smallest class that has it, from + * its class's list or new ground, the cells in use copied over and the old block given back. When the heap has none, + * nothing changes (`ok` 0), and the game says so once. + */ + const grow: FuncDecl = { + id: "@heap.grow", name: "@heap.grow", params: [], at: here, + body: [ + set(S.ok, k(0)), + dec(S.nr, k(HEAP_SMALLEST), H), + dec(S.nk, k(0), classes - 1), + when(cmp("!=", V(G.r), k(0)), [set(S.nr, V(G.r)), set(S.nk, V(G.k))]), + loop(cmp("<", V(S.nr), V(G.need)), [set(S.nr, bin("+", V(S.nr), V(S.nr))), add(S.nk, 1)]), + dec(S.blk, freeAt(V(S.nk)), H - 1), + when(cmp("!=", V(S.blk), k(0)), [setFree(V(S.nk), cell(V(S.blk)))], [ + // (New ground: below the heap's end, as the simulator counts it.) + when(cmp("<=", bin("+", V(TOP), V(S.nr)), k(H - 1)), [set(S.blk, bin("+", V(TOP), k(1))), set(TOP, bin("+", V(TOP), V(S.nr)))], [ + when(cmp("==", V(SAID), k(0)), [ + set(SAID, k(1)), + { kind: "print", parts: [{ kind: "text", text: `\x06TrigScript: out of memory - an array could not grow (the programs' arrays share ${H} cells).` }], to: 13, position: "chat", at: here, label: "" } as Stmt, + ]), + { kind: "return", at: here, label: "" } as Stmt, + ]), + ]), + // (Both blocks walked a cell at a time, where the next is in each: no index worked out.) + dec(S.j, V(G.p), H), dec(S.to, V(S.blk), H), dec(S.end, bin("+", V(G.p), V(G.n)), H), + loop(cmp("<", V(S.j), V(S.end)), [store(V(S.to), cell(V(S.j))), add(S.j, 1), add(S.to, 1)]), + // The old block to the head of its class's list. + when(cmp("!=", V(G.r), k(0)), [store(V(G.p), freeAt(V(G.k))), setFree(V(G.k), V(G.p))]), + set(S.ok, k(1)), + ], + }; + /** + * The array's block given back to its class's list, and the array empty with none. (Where the block was is left as it + * was: with no room and no cells, nothing reads it, and it is written in one place, `growOf`.) + */ + const give = (h: Handle): Stmt[] => [ + when(cmp("!=", V(h.r), k(0)), [set(G.p, V(h.p)), set(G.k, V(h.k)), callStmt(release)]), + // (Where its next cell goes is 0 with no block: a loop over it tests that against where its first cell is, which + // is never below. With one, it is the block's start plus the length, everywhere.) + set(h.n, k(0)), set(h.r, k(0)), set(h.k, k(0)), set(h.e, k(0)), + ]; + /** `release`: the block its operands name (`G.p`, of class `G.k`) to the head of its class's list. */ + const release: FuncDecl = { id: "@heap.release", name: "@heap.release", params: [], at: here, body: [store(V(G.p), freeAt(V(G.k))), setFree(V(G.k), V(G.p))] }; + functions.push(grow, release); + /** + * An array's own `grow`: its numbers handed to the run time's, and the block it gave taken. (Its length is in `G.n` + * already: read once where the array's length is read for a push that has no room, not in each place of its own.) + */ + const growers = new Map(); + const growOf = (h: Handle): FuncDecl => { + let f = growers.get(h.decl.id); + if (!f) { + const need = num(`${h.decl.id}@need`); + f = { + id: `@heap.grow.${h.decl.id}`, name: `@heap.grow.${h.decl.id}`, params: [need], at: here, + body: [ + set(G.p, V(h.p)), set(G.r, V(h.r)), set(G.k, V(h.k)), set(G.need, V(need.id)), + callStmt(grow), + when(cmp("!=", V(S.ok), k(0)), [set(h.p, V(S.blk)), set(h.r, V(S.nr)), set(h.k, V(S.nk)), set(h.e, bin("+", V(S.blk), V(G.n)))]), + ], + }; + growers.set(h.decl.id, f); + functions.push(f); + } + return f; + }; + /** Room for `need` cells (a variable or a constant): the array's `grow` when the block has less. */ + const room = (h: Handle, need: NumExpr): Stmt => when(cmp("<", V(h.r), need), [set(G.n, V(h.n)), callStmt(growOf(h), [need])]); + /** + * A push with no room: the array grown by one, and the cell at the end when it was. (Its length read once, into + * `G.n`, which is read where it is used: a push that has room pays for none of this.) + */ + const more = (h: Handle): FuncDecl => ownFn(h, "more", () => { + const v = num(`${h.decl.id}@mv`); + return { params: [v], body: [ + set(G.n, V(h.n)), + when(cmp("<", V(h.r), bin("+", V(G.n), k(1))), [callStmt(growOf(h), [bin("+", V(G.n), k(1))])]), + when(cmp("<", V(G.n), V(h.r)), atEnd(h, V(v.id))), + ] }; + }); + + /** A number as a cell keeps it: a boolean 1 or 0, a `u8` / `u16` stopped at its top (the simulator's `kept`). */ + const kept = (h: Handle, value: NumExpr | BoolExpr): NumExpr => { + if (h.decl.kind === "boolean") { + const b = rewriteBool(value as BoolExpr); + return { kind: "ternary", cond: b, whenTrue: k(1), whenFalse: k(0), at: here, label: "" } as NumExpr; + } + const v = rewriteNum(value as NumExpr); + const bits = h.decl.bits; + if (!bits) return v; + const top = 2 ** bits - 1; + return { kind: "intrinsic", name: "min", args: [v, k(top)], unsigned: true, range: [0, top], at: here, label: "" } as NumExpr; + }; + /** A value worked out once, ahead of what follows: into a variable of its own when working it out may do something. */ + const once = (value: NumExpr, pre: Stmt[]): NumExpr => { + if (value.kind === "const" || value.kind === "var" || !impure(value)) return value; + const t = fresh("t"); + pre.push(dec(t, value)); + return V(t); + }; + /** + * A value worked out already, for one more place it is used: a copy, each call in it with a result of its own (a + * value `once` left as it was — a variable, a constant, or a cell read, which does nothing — may be used in several). + */ + const again = (e: NumExpr): NumExpr => { + const copy = (x: unknown): unknown => { + if (Array.isArray(x)) return x.map(copy); + if (!x || typeof x !== "object") return x; + const o = x as Record; + const out = Object.fromEntries(Object.entries(o).map(([key, v]) => [key, v && typeof v === "object" ? copy(v) : v])); + if (o.kind === "call" && !("label" in o) && (o.call as Call).result) { + const c = out.call as Call; + out.call = { ...c, result: { ...c.result!, decl: num(fresh("v")) } }; + } + return out; + }; + return copy(e) as NumExpr; + }; + /** The cell at the end: the value stored where the next cell goes, the length and the end one on. */ + const atEnd = (h: Handle, v: NumExpr): Stmt[] => [store(V(h.e), again(v)), add(h.n, 1), add(h.e, 1)]; + /** + * A push of a value worked out already: with room, the cell at the end; without, the array's `more` — room for one + * more, then the cell — a function of its own, so that the cell is stored at the end in one place here. + */ + const pushed = (h: Handle, v: NumExpr): Stmt[] => [when(cmp("<", V(h.n), V(h.r)), atEnd(h, v), [callStmt(more(h), [again(v)])])]; + /** + * A store of a value worked out already at an index worked out already: inside, the cell; at the length a push, past + * it nothing. (Inside asked first: one condition, where whether it is at the length is two.) + */ + const stored = (h: Handle, j: NumExpr, v: NumExpr): Stmt[] => [when(cmp("<", j, V(h.n)), [store(bin("+", V(h.p), j), again(v))], [when(cmp("==", j, V(h.n)), pushed(h, v))])]; + /** + * A cell at an index, 0 past the length: the heap's first cell, which nothing writes, when the index is past it — so + * the test chooses where the read goes, and what it reads goes straight to where it is used. + */ + const read = (h: Handle, i: NumExpr): NumExpr => cell({ kind: "ternary", cond: cmp("<", i, V(h.n)), whenTrue: bin("+", V(h.p), i), whenFalse: k(0), at: here, label: "" } as NumExpr); + /** + * The array's functions, one of each, for what is done to it outside every loop: a read, a store and a push there is a + * call, so that where its block is has a place only in them — each place of it a field every move of the block + * writes, and every read inlined three of them (`hot`). In a loop, or in a function of the script's (laid out once), + * they are written where they are used; the read there a function that is inlined. + */ + const own = new Map(); + const ownFn = (h: Handle, what: string, make: () => Omit): FuncDecl => { + const id = `@heap.${what}.${h.decl.id}`; + let f = own.get(id); + if (!f) { + f = { id, name: id, at: here, ...make() }; + own.set(id, f); + functions.push(f); + } + return f; + }; + const getter = (h: Handle): FuncDecl => ownFn(h, "get", () => { + const i = num(`${h.decl.id}@i`); + return { params: [i], result: { decl: num(`${h.decl.id}@got`), kind: "number" }, body: [{ kind: "return", value: read(h, V(i.id)), at: here, label: "" } as Stmt] }; + }); + const reader = (h: Handle): FuncDecl => ownFn(h, "read", () => { + const i = num(`${h.decl.id}@ri`); + // (Two statements: called, not inlined.) + return { params: [i], result: { decl: num(`${h.decl.id}@read`), kind: "number" }, body: [ + when(cmp("<", V(i.id), V(h.n)), [{ kind: "return", value: cell(bin("+", V(h.p), V(i.id))), at: here, label: "" } as Stmt]), + { kind: "return", value: k(0), at: here, label: "" } as Stmt, + ] }; + }); + const pusher = (h: Handle): FuncDecl => ownFn(h, "push", () => { + const v = num(`${h.decl.id}@pv`); + return { params: [v], body: pushed(h, V(v.id)) }; + }); + const setter = (h: Handle): FuncDecl => ownFn(h, "set", () => { + // (The value first, then the index: as the simulator works them out, and a call its arguments.) + const v = num(`${h.decl.id}@sv`), i = num(`${h.decl.id}@si`); + return { params: [v, i], body: stored(h, V(i.id), V(v.id)) }; + }); + /** `xs.pop()` as a value: the last cell, the array a cell shorter; 0 when it is empty. */ + const poppers = new Map(); + const popper = (h: Handle): FuncDecl => { + let f = poppers.get(h.decl.id); + if (!f) { + const result = num(`${h.decl.id}@popped`), out = `${h.decl.id}@last`; + f = { + id: `@heap.pop.${h.decl.id}`, name: `@heap.pop.${h.decl.id}`, params: [], at: here, + result: { decl: result, kind: "number" }, + body: [ + dec(out, k(0)), + when(cmp("!=", V(h.n), k(0)), [add(h.n, -1), add(h.e, -1), set(out, cell(V(h.e)))]), + { kind: "return", value: V(out), at: here, label: "" } as Stmt, + ], + }; + poppers.set(h.decl.id, f); + functions.push(f); + } + return f; + }; + + // ── a function that calls itself ── + /** + * A call that may come back into the function it is in, which has arrays that grow of its own (`Call.saves.arrays`): + * each one's numbers into variables the call keeps on the stack with the function's others, the array empty for the + * call; after it, the block the inner run left given back, and the numbers taken back. + */ + const aroundCall = (s: Extract): Stmt[] => { + const was = here; + here = s.at ?? here; + try { + const saves = s.call.saves!; + const mine = saves.arrays.map((id) => handles.get(id)).filter((h): h is Handle => !!h && !h.through); + const outer = (h: Handle) => ({ p: `${h.p}@outer`, n: `${h.n}@outer`, r: `${h.r}@outer`, k: `${h.k}@outer`, e: `${h.e}@outer` }); + const before: Stmt[] = [], after: Stmt[] = []; + for (const h of mine) { + const o = outer(h); + for (const [id, top] of [[o.p, H - 1], [o.n, H], [o.r, H], [o.k, classes - 1], [o.e, H]] as const) extra.set(id, top); + before.push(set(o.p, V(h.p)), set(o.n, V(h.n)), set(o.r, V(h.r)), set(o.k, V(h.k)), set(o.e, V(h.e)), set(h.n, k(0)), set(h.r, k(0)), set(h.k, k(0)), set(h.e, k(0))); + after.push(...give(h), set(h.p, V(o.p)), set(h.n, V(o.n)), set(h.r, V(o.r)), set(h.k, V(o.k)), set(h.e, V(o.e))); + } + const vars = [...saves.vars, ...mine.flatMap((h) => Object.values(outer(h)))]; + const inner = walk(s.call) as Call; + const call: Stmt = { ...s, call: { ...inner, saves: { ...saves, vars, arrays: saves.arrays.filter((id) => !mine.some((h) => h.decl.id === id)) } } }; + return [...before, call, ...after]; + } finally { + here = was; + } + }; + /** Variables of the rewriting's own, declared at the program's start, with their tops. */ + const extra = new Map(); + + // ── rows that grow ── + /** A cell of one of the outer arrays a row's numbers are kept in, at the row's index. */ + const outer = (array: string, index: string): NumExpr => ({ kind: "element", array, index: V(index), at: here }) as NumExpr; + /** A row's numbers from the outer arrays' cells into its own (`e` too, for a push or a pop). */ + const load = (h: Handle): Stmt[] => { + const t = h.through!; + return walk([set(h.p, outer(t.ptr, t.index)), set(h.n, outer(t.len, t.index)), set(h.r, outer(t.room, t.index)), set(h.k, outer(t.k, t.index)), set(h.e, bin("+", V(h.p), V(h.n)))]) as Stmt[]; + }; + /** A row's numbers back into the outer arrays' cells (only the length, when that is all that changed). */ + const putBack = (h: Handle, all: boolean): Stmt[] => { + const t = h.through!; + const put = (array: string, from: string): Stmt => ({ kind: "store", array, index: V(t.index), value: V(from), at: here, label: "" }) as Stmt; + return walk(all ? [put(t.ptr, h.p), put(t.len, h.n), put(t.room, h.r), put(t.k, h.k)] : [put(t.len, h.n)]) as Stmt[]; + }; + /** A row's cell, read through the outer arrays: the heap's first cell (0) past the row's length. */ + const rowRead = (h: Handle, i: NumExpr): NumExpr => { + const t = h.through!; + return cell({ kind: "ternary", cond: cmp("<", i, rewriteNum(outer(t.len, t.index))), whenTrue: bin("+", rewriteNum(outer(t.ptr, t.index)), i), whenFalse: k(0), at: here, label: "" } as NumExpr); + }; + const rowReader = (h: Handle): FuncDecl => ownFn(h, "rowRead", () => { + const i = num(`${h.decl.id}@ri`); + return { params: [i], result: { decl: num(`${h.decl.id}@read`), kind: "number" }, body: [{ kind: "return", value: rowRead(h, V(i.id)), at: here, label: "" } as Stmt] }; + }); + const rowPopper = (h: Handle): FuncDecl => ownFn(h, "rowPop", () => { + const result = num(`${h.decl.id}@popped`), out = `${h.decl.id}@last`; + return { params: [], result: { decl: result, kind: "number" }, body: [ + ...load(h), + dec(out, k(0)), + when(cmp("!=", V(h.n), k(0)), [add(h.n, -1), add(h.e, -1), set(out, cell(V(h.e))), ...putBack(h, false)]), + { kind: "return", value: V(out), at: here, label: "" } as Stmt, + ] }; + }); + /** + * A statement on a row: what it is worked out from first (it may change the row itself), then the row's numbers taken, + * the statement done as to an array that grows alone, and the numbers put back. + */ + const row = (h: Handle, s: Stmt): Stmt[] => { + const was = here; + here = s.at ?? here; + try { + const pre: Stmt[] = []; + const plain = { ...h, through: undefined }; + const hold = (e: NumExpr | BoolExpr | undefined) => (e === undefined ? undefined : once(kept(h, e), pre)); + switch (s.kind) { + case "push": { + const v = hold(s.value)!; + return [...pre, ...load(h), ...pushed(plain, v), ...putBack(h, true)]; + } + case "pop": return [...load(h), when(cmp("!=", V(h.n), k(0)), [add(h.n, -1), ...putBack(h, false)])]; + case "setLength": { + const n = once(rewriteNum(s.value), pre); + return [...pre, ...load(h), when(cmp("<", n, V(h.n)), [set(h.n, n), ...putBack(h, false)])]; + } + case "store": { + const v = hold(s.value)!; + const i = once(rewriteNum(s.index), pre); + const j = i.kind === "const" || i.kind === "var" ? i : V(fresh("i")); + if (j !== i) pre.push(dec((j as { id: string }).id, i)); + return [...pre, ...load(h), ...stored(plain, j, v), ...putBack(h, true)]; + } + case "declareArray": { + // Given back through the outer arrays' cells, which are 0 again; what it is declared with makes a new one. + const filled = s.fill ? hold(s.fill)! : undefined; + const values = filled ? Array.from({ length: h.decl.length }, () => filled) : (s.init ?? []).map((e) => hold(e)!); + const out: Stmt[] = [...pre, ...load(h), ...give(plain), set(h.e, V(h.p))]; + if (values.length) out.push(room(plain, k(values.length)), when(cmp("<=", k(values.length), V(h.r)), [ + ...values.map((v, i) => store(bin("+", V(h.p), k(i)), v)), + set(h.n, k(values.length)), + ])); + out.push(...putBack(h, true)); + return out; + } + default: return [s]; + } + } finally { + here = was; + } + }; + + // ── the rewriting ── + const rewriteNum = (e: NumExpr): NumExpr => walk(e) as NumExpr; + const rewriteBool = (e: BoolExpr): BoolExpr => walk(e) as BoolExpr; + /** An expression with every read of a growing array a read of the heap. */ + const expr = (o: Record): unknown => { + const h = typeof o.array === "string" ? handles.get(o.array) : undefined; + if (!h) return undefined; + const at = (o.at as At | undefined) ?? here; + const was = here; + here = at; + try { + const asBool = (n: NumExpr): BoolExpr => ({ kind: "test", expr: n, at: here, label: "" }) as BoolExpr; + if (h.through) { + // A row's: read through the outer arrays' cells at the row's index, as they are. + const t = h.through; + switch (o.kind) { + case "length": return rewriteNum(outer(t.len, t.index)); + case "element": { + const i = once(rewriteNum(o.index as NumExpr), []); + const got = impure(i) ? callExpr(rowReader(h), [i]) : rowRead(h, i); + return h.decl.kind === "boolean" ? asBool(got) : got; + } + case "pop": { + const got = callExpr(rowPopper(h)); + return h.decl.kind === "boolean" ? asBool(got) : got; + } + default: return undefined; + } + } + switch (o.kind) { + case "length": return V(h.n); + case "element": { + // (At a loop's index, inside the array or tested on the way in: `loops`.) + const index = o.index as NumExpr; + const sure = index.kind === "var" ? inside(h, index.id) : undefined; + const at = sure ? cellAt(h, (index as { id: string }).id) : undefined; + const got = sure === "inside" ? cell(at!) + : sure ? cell({ kind: "ternary", cond: { kind: "var", id: sure } as BoolExpr, whenTrue: at!, whenFalse: { kind: "ternary", cond: cmp("<", index, V(h.n)), whenTrue: at!, whenFalse: k(0), at: here, label: "" } as NumExpr, at: here, label: "" } as NumExpr) + : callExpr(hot && !cold ? getter(h) : reader(h), [rewriteNum(index)]); + return h.decl.kind === "boolean" ? asBool(got) : got; + } + case "pop": { + const got = callExpr(popper(h)); + return h.decl.kind === "boolean" ? asBool(got) : got; + } + default: return undefined; + } + } finally { + here = was; + } + }; + /** A statement on a growing array as statements on the heap; undefined when it is not one. */ + const statement = (s: Stmt): Stmt[] | undefined => { + if (s.kind === "call" && s.call.saves?.arrays.some((id) => handles.has(id))) return aroundCall(s); + if (s.kind === "for") return overArray(s); + if (s.kind === "while") return moved(s); + const h = "array" in s && typeof s.array === "string" ? handles.get(s.array) : undefined; + if (!h) return undefined; + if (h.through) return row(h, s); + const was = here; + here = s.at ?? here; + try { + const pre: Stmt[] = []; + switch (s.kind) { + case "push": { + // The value first (it may push to the array itself), then room for one more, then the cell. + if (!hot) return [callStmt(pusher(h), [kept(h, s.value)])]; + const v = once(kept(h, s.value), pre); + return [...pre, ...pushed(h, v)]; + } + case "pop": return [when(cmp("!=", V(h.n), k(0)), [add(h.n, -1), add(h.e, -1)])]; + case "setLength": { + const n = once(rewriteNum(s.value), pre); + return [...pre, when(cmp("<", n, V(h.n)), [set(h.n, n), set(h.e, bin("+", V(h.p), n))])]; + } + case "store": { + // The value, then the index, as the simulator works them out; at the length it is a push. (At a loop's index, + // inside the array or tested on the way in: `loops`.) + const sure = s.index.kind === "var" ? inside(h, s.index.id) : undefined; + if (sure === "inside") return [store(cellAt(h, (s.index as { id: string }).id), kept(h, s.value))]; + if (sure) { + const v = once(kept(h, s.value), pre); + return [...pre, when({ kind: "var", id: sure } as BoolExpr, [store(cellAt(h, (s.index as { id: string }).id), again(v))], stored(h, s.index, v))]; + } + if (!hot || cold) return [callStmt(setter(h), [kept(h, s.value), rewriteNum(s.index)])]; + const v = once(kept(h, s.value), pre); + const i = rewriteNum(s.index); + const j = i.kind === "const" || i.kind === "var" ? i : V(fresh("i")); + if (j !== i) pre.push(dec((j as { id: string }).id, i)); + return [...pre, ...stored(h, j, v)]; + } + case "declareArray": { + // Declared again, it gives back the block it held; what it is declared with is worked out after. + const out = give(h); + const values: NumExpr[] = []; + if (s.fill) { + const v = once(kept(h, s.fill), out); + if (h.decl.length) { + const j = fresh("j"); + out.push(room(h, k(h.decl.length)), when(cmp("<=", k(h.decl.length), V(h.r)), [ + dec(j, k(0)), + loop(cmp("<", V(j), k(h.decl.length)), [store(bin("+", V(h.p), V(j)), v), add(j, 1)]), + set(h.n, k(h.decl.length)), + set(h.e, bin("+", V(h.p), k(h.decl.length))), + ])); + } + return out; + } + for (const e of s.init ?? []) values.push(once(kept(h, e), out)); + // (Outside a loop, a push each: where the block is, read in one place.) + if (values.length && !hot) { + out.push(room(h, k(values.length)), ...values.map((v) => callStmt(pusher(h), [v]))); + return out; + } + if (values.length) { + out.push(room(h, k(values.length)), when(cmp("<=", k(values.length), V(h.r)), [ + ...values.map((v, i) => store(bin("+", V(h.p), k(i)), v)), + set(h.n, k(values.length)), + set(h.e, bin("+", V(h.p), k(values.length))), + ])); + } + return out; + } + default: return undefined; + } + } finally { + here = was; + } + }; + // ── loops over an array that grows ── + /** + * `for (…; i < xs.length; …)` — and `for (const x of xs)`, which is that — whose body neither changes `xs`'s length + * nor writes `i`, and calls nothing, sleeps nowhere: in the body `i` is inside `xs`, and a read or a store of `xs[i]` + * tests nothing (a store there is never a push). Every other array that grows the body reads or writes at `i`, its + * length not changed in the body either, is weighed against `xs`'s once on the way in (as the Python side does), into a + * flag of its own that each of its reads and stores at `i` tests instead of the length: each field of a record. + */ + /** + * Each array's cell at the loop's index is kept beside the counter when the loop only adds a constant to it (`at`: + * the block's start plus the index, moved by the same additions), so that a read or a store there works no index out: + * eudplib's offset beside a counter. + */ + const loops: { index: string; array: string; others: Map; at: Map }[] = []; + /** The front end's own variables (`temp`): a `for…of`'s counter among them. */ + const temps = new Set(); + const findTemps = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(findTemps); return; } + if (!x || typeof x !== "object") return; + const o = x as { kind?: string; decl?: VarDecl }; + if (o.kind === "declare" && o.decl?.temp) temps.add(o.decl.id); + for (const v of Object.values(o)) if (v && typeof v === "object") findTemps(v); + }; + findTemps([program.body, program.functions ?? []]); + const inside = (h: Handle, index: string): string | undefined => { + for (let j = loops.length - 1; j >= 0; j--) { + const l = loops[j]; + if (l.index !== index) continue; + if (l.array === h.decl.id) return "inside"; + return l.others.get(h.decl.id); + } + return undefined; + }; + /** Where the cell at a loop's index is, kept beside its counter, or the block's start plus the index. */ + const cellAt = (h: Handle, index: string): NumExpr => { + for (let j = loops.length - 1; j >= 0; j--) { + const l = loops[j]; + if (l.index !== index) continue; + const q = l.at.get(h.decl.id); + if (q) return V(q); + break; + } + return bin("+", V(h.p), V(index)); + }; + /** Whether statements leave arrays' lengths and a variable alone: no length changed, no write of it, no call, no wait. */ + const leaves = (body: unknown, arrays: Set, index: string): boolean => { + let ok = true; + const look = (x: unknown): void => { + if (!ok || !x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(look); return; } + const o = x as Record; + const kind = o.kind as string; + if (kind === "call" || kind === "sleep" || kind === "start" || kind === "parallel" || kind === "pop" || kind === "return" || kind === "throw") { ok = false; return; } + if ((kind === "push" || kind === "setLength" || kind === "declareArray") && arrays.has(o.array as string)) { ok = false; return; } + // (A store at another index may be at the length, a push; at the loop's, never.) + if (kind === "store" && arrays.has(o.array as string) && !((o.index as NumExpr).kind === "var" && (o.index as { id: string }).id === index)) { ok = false; return; } + if ((kind === "assign" && o.target === index) || (kind === "declare" && (o.decl as VarDecl).id === index)) { ok = false; return; } + for (const v of Object.values(o)) if (v && typeof v === "object") look(v); + }; + look(body); + return ok; + }; + /** The arrays that grow (on their own) a body reads or writes at a variable. */ + const atIndex = (body: unknown, index: string): Set => { + const out = new Set(); + const look = (x: unknown): void => { + if (!x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(look); return; } + const o = x as Record; + if ((o.kind === "element" || o.kind === "store") && typeof o.array === "string" && handles.has(o.array) && !handles.get(o.array)!.through) { + const i = o.index as NumExpr; + if (i.kind === "var" && i.id === index) out.add(o.array); + } + for (const v of Object.values(o)) if (v && typeof v === "object") look(v); + }; + look(body); + return out; + }; + /** + * A `for` over an array that grows, as `loops` says; undefined otherwise. With other arrays read or written at its + * index — a record's fields — their lengths weighed against its own once on the way in: when none is shorter, the loop + * where each of them is as sure as its own (every cell at its place kept beside the counter, nothing tested); when one + * is, the loop with theirs tested at each read and store, as anywhere. + */ + const overArray = (s: Extract): Stmt[] | undefined => { + const c = s.cond; + if (!c || c.kind !== "compare" || c.op !== "<" || c.unsigned !== true || c.left.kind !== "var" || c.right.kind !== "length") return undefined; + const h = handles.get(c.right.array); + if (!h || h.through) return undefined; + const index = c.left.id; + const used = atIndex(s.body, index); + used.delete(h.decl.id); + if (!leaves(s.body, new Set([h.decl.id, ...used]), index)) return undefined; + const was = here; + here = s.at ?? here; + try { + // (The counter's step, when it only adds a constant: each array's cell at it kept beside it.) + const u = s.update.length === 1 && s.update[0].kind === "assign" && s.update[0].target === index ? s.update[0].value : undefined; + const step = u?.kind === "binary" && u.op === "+" && u.left.kind === "var" && u.left.id === index && u.right.kind === "const" ? u.right.value : undefined; + /** + * The loop, the arrays `sure` read and written at the counter untested. `own`: with a counter of its own, set from + * the loop's on the way in and given back on the way out, so that its steps write only its own places (the other + * version's are the loop's counter's). + */ + const version = (sure: string[], own = false): Stmt[] => { + const i = own ? `${index}@fast${made++}` : index; + const loop = own ? (renamed(s, index, i) as Extract) : s; + const at = new Map(), beside: Stmt[] = own ? [dec(i, V(index))] : [], moves: Stmt[] = []; + if (step !== undefined) for (const id of sure) { + const q = `${id}@at${made++}`; + at.set(id, q); + extra.set(q, H); + // (From the loop's own counter, which the loop does not write: so its steps write no place here.) + beside.push(set(q, bin("+", V(handles.get(id)!.p), V(index)))); + moves.push(add(q, step)); + } + loops.push({ index: i, array: h.decl.id, others: new Map(sure.filter((id) => id !== h.decl.id).map((id) => [id, "inside"])), at }); + const wasHot = hot; + hot = true; + try { + const body = walk(loop.body) as Stmt[]; + // (A body that reads its counter nowhere once its cells are read at their places — `xs[i] = xs[i] + 1`: the + // loop tests where its own array's cell is against where the array ends — where its next cell would go — and + // steps only the places; the counter, given back after, is where the place is less where the block starts.) + const q = at.get(h.decl.id); + if (own && q && !mentions(body, i)) { + const back = temps.has(index) ? [] : [set(index, bin("-", V(q), V(h.p)))]; + return [...beside, { ...loop, cond: cmp("<", V(q), V(h.e)), update: moves, body }, ...back]; + } + // (The loop's counter given back after it, but a counter of the front end's own — `for…of`'s — which nothing + // reads after.) + const back = own && !temps.has(index) ? [set(index, V(i))] : []; + return [...beside, { ...loop, cond: walk(loop.cond) as BoolExpr, update: [...(walk(loop.update) as Stmt[]), ...moves], body }, ...back]; + } finally { + hot = wasHot; + loops.pop(); + } + }; + if (!used.size) return version([h.decl.id], step !== undefined); + const longEnough: BoolExpr = { kind: "and", items: [...used].map((id) => cmp(">=", V(handles.get(id)!.n), V(h.n))) }; + return [when(longEnough, version([h.decl.id, ...used], true), version([h.decl.id]))]; + } finally { + here = was; + } + }; + + // ── cells moved along an array ── + /** + * The library's loops that move cells along arrays that grow by a gap — `splice`, `shift`, `unshift`, a row of an + * array of texts' three arrays at once: `while (row + gap < n) { X[row] = X[row + gap]; …; row++ }` forward, `while + * (row >= b) { X[row] = X[row - gap]; …; row-- }` back. When every cell they touch is inside each array (asked once, + * on the way in) they are the run time's `move`, which walks the heap's cells by where they are — three triggers a + * cell, one function for the whole map — and the counter is left where the loop leaves it; when not, the loop as it is. + */ + const mover = (back: boolean): FuncDecl => { + const id = back ? "@heap.moveBack" : "@heap.move"; + let f = own.get(id); + if (!f) { + const from = num(`${id}@from`, H), to = num(`${id}@to`, H), count = num(`${id}@count`, H); + const step = back ? -1 : 1; + f = { id, name: id, at: here, params: [from, to, count], body: [ + loop(cmp("!=", V(count.id), k(0)), [store(V(to.id), cell(V(from.id))), add(from.id, step), add(to.id, step), add(count.id, -1)]), + ] }; + own.set(id, f); + functions.push(f); + } + return f; + }; + const sameExpr = (a: unknown, b: unknown) => JSON.stringify(a, (key, v) => (key === "at" || key === "label" || key === "range" ? undefined : v)) === JSON.stringify(b, (key, v) => (key === "at" || key === "label" || key === "range" ? undefined : v)); + const moved = (s: Extract): Stmt[] | undefined => { + const body = s.body, last = body[body.length - 1]; + if (body.length < 2 || last.kind !== "assign" || last.value.kind !== "binary" || last.value.left.kind !== "var" || last.value.left.id !== last.target || last.value.right.kind !== "const" || last.value.right.value !== 1) return undefined; + const row = last.target, back = last.value.op === "-"; + if (!back && last.value.op !== "+") return undefined; + const stores = body.slice(0, -1); + let gap: NumExpr | undefined; + const arrays: Handle[] = []; + for (const st of stores) { + if (st.kind !== "store" || st.index.kind !== "var" || st.index.id !== row) return undefined; + const h = handles.get(st.array); + const v = st.value as NumExpr; + if (!h || h.through || v.kind !== "element" || v.array !== st.array || v.index.kind !== "binary" || v.index.op !== (back ? "-" : "+") || v.index.left.kind !== "var" || v.index.left.id !== row) return undefined; + const g = v.index.right; + if (g.kind !== "var" && g.kind !== "const") return undefined; + if (gap && !sameExpr(gap, g)) return undefined; + gap = g; + arrays.push(h); + } + if (!gap || (gap.kind === "var" && gap.id === row)) return undefined; + const c = s.cond; + if (!c || c.kind !== "compare" || impure(c)) return undefined; + if (!back && !(c.op === "<" && c.left.kind === "binary" && c.left.op === "+" && c.left.left.kind === "var" && c.left.left.id === row && sameExpr(c.left.right, gap))) return undefined; + if (back && !(c.op === ">=" && c.left.kind === "var" && c.left.id === row)) return undefined; + const was = here; + here = s.at ?? here; + try { + const cnt = fresh("count"); + // How many turns the loop makes: forward, the length less where it stops; back, down to its bottom. + const count = back ? bin("+", bin("-", V(row), c.right as NumExpr), k(1)) : bin("-", c.right as NumExpr, bin("+", V(row), gap)); + // Inside each array: the cells written and read, the first and the last of each. + const lo = back ? bin("-", bin("+", bin("-", V(row), V(cnt)), k(1)), gap) : V(row); + const hi = back ? V(row) : bin("+", bin("+", V(row), V(cnt)), gap); + const inside: BoolExpr = { kind: "and", items: arrays.flatMap((h) => [{ kind: "compare", op: ">=", left: lo, right: k(0), at: here, label: "" } as BoolExpr, back ? cmp("<", hi, V(h.n)) : cmp("<=", hi, V(h.n))]) }; + const fast: Stmt[] = [ + // (From where each cell is read, to where it is written: back, from the last cells down.) + ...arrays.map((h) => callStmt(mover(back), back ? [bin("+", V(h.p), bin("-", V(row), gap)), bin("+", V(h.p), V(row)), V(cnt)] : [bin("+", V(h.p), bin("+", V(row), gap)), bin("+", V(h.p), V(row)), V(cnt)])), + set(row, back ? bin("-", V(row), V(cnt)) : bin("+", V(row), V(cnt))), + ]; + // (Otherwise the loop as written, its reads and stores the arrays' own functions: past an end a read is 0 and a + // store at the length a push, as anywhere — and the loop a few triggers, where it is never expected to run.) + const wasCold = cold; + cold = true; + let slow: Stmt; + try { slow = plainWalk(s as unknown as Record) as Stmt; } finally { cold = wasCold; } + return [when(c, [dec(cnt, count), when(inside, fast, [slow])])]; + } finally { + here = was; + } + }; + + /** Inside a loop, or a function of the script's: what is done to an array is written where it is done. */ + let hot = false; + /** Where nothing is expected to run (`moved`'s other way): reads and stores the arrays' own functions, all the same. */ + let cold = false; + const isStmt = (o: unknown): o is Stmt => !!o && typeof o === "object" && "label" in o && STMT_KINDS.has((o as { kind?: string }).kind ?? ""); + /** A statement's insides rewritten, the statement itself as it is (a loop's body hot). */ + const plainWalk = (o: Record): unknown => { + const was = hot; + if (LOOPS.has(o.kind as string)) hot = true; + try { + return Object.fromEntries(Object.entries(o).map(([key, v]) => [key, v && typeof v === "object" ? walk(v) : v])); + } finally { + hot = was; + } + }; + const walk = (x: unknown): unknown => { + if (Array.isArray(x)) { + if (x.length && isStmt(x[0])) return x.flatMap((s) => (isStmt(s) ? (statement(s) ?? [walk(s)]) : [walk(s)])); + return x.map(walk); + } + if (!x || typeof x !== "object") return x; + const o = x as Record; + if (!isStmt(o)) { + const e = expr(o); + if (e !== undefined) return e; + } + else { + const out = statement(o); + if (out !== undefined) return { kind: "block", body: out, at: o.at, label: "" }; + } + const was = hot; + if (isStmt(o) && LOOPS.has(o.kind as string)) hot = true; + try { + return Object.fromEntries(Object.entries(o).map(([key, v]) => [key, v && typeof v === "object" ? walk(v) : v])); + } finally { + hot = was; + } + }; + + const body = walk(program.body) as Stmt[]; + hot = true; + const fns = (program.functions ?? []).map((f) => ({ ...f, body: walk(f.body) as Stmt[] })); + here = program.at; + const start: Stmt[] = [ + dec(G.p, k(0), H - 1), dec(G.n, k(0), H), dec(G.r, k(0), H), dec(G.k, k(0), classes - 1), dec(G.need, k(0)), dec(S.ok, k(0)), + ...[...handles.values()].flatMap((h) => [dec(h.p, k(0), H - 1), dec(h.n, k(0), H), dec(h.r, k(0), H), dec(h.k, k(0), classes - 1), dec(h.e, k(0), H)]), + ...[...extra].map(([id, top]) => dec(id, k(0), top)), + ]; + const arrays: ArrayDecl[] = [ + ...(program.arrays ?? []).filter((a) => !handles.has(a.id)), + { id: HEAP, name: "(heap)", kind: "number", length: H, shared: true, at: program.at }, + { id: FREE, name: "(heap's free blocks)", kind: "number", length: classes, shared: true, at: program.at }, + ]; + return { ...program, arrays, body: [...start, ...body], functions: [...fns, ...functions] }; +} + +/** The statements whose bodies run again and again. */ +const LOOPS = new Set(["while", "do", "for", "unitLoop", "textLoop", "unrolled"]); +/** The kinds of statement (a `call` and a `pop` with a label: an expression of either has none). */ +const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "if", "parallel", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "watch", "while"]); + +/** + * The variables the closures count (`VarDecl.held`: a reference to an environment, a row of the front end's arrays — the + * pool of 1,024): each is read by its own declare, which lets go of what it held, and so is 0 before it is ever declared. + * In a map of our own it is declared once, 0, as the program starts, and each of its declares is an assignment. + */ +export function heldVariables(program: Program): Program { + const held = new Map(); + const find = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(find); return; } + if (!x || typeof x !== "object") return; + const o = x as { kind?: string; decl?: VarDecl }; + if (o.kind === "declare" && o.decl?.held && o.decl.kind === "number" && o.decl.task === undefined) held.set(o.decl.id, o.decl); + for (const v of Object.values(o)) if (v && typeof v === "object") find(v); + }; + find([program.body, program.functions ?? []]); + if (!held.size) return program; + const walk = (x: unknown): unknown => { + if (Array.isArray(x)) return x.map(walk); + if (!x || typeof x !== "object") return x; + const o = x as Record; + if (o.kind === "declare" && held.has((o.decl as VarDecl).id)) { + const s = o as Extract; + return { kind: "assign", target: s.decl.id, value: walk(s.init) as NumExpr, at: s.at, label: s.label }; + } + return Object.fromEntries(Object.entries(o).map(([key, v]) => [key, v && typeof v === "object" ? walk(v) : v])); + }; + const start: Stmt[] = [...held.values()].map((d) => { + const { held: _, ...plain } = d; + void _; + return { kind: "declare", decl: plain, init: k(0), at: program.at, label: "" } as Stmt; + }); + return { ...program, body: [...start, ...(walk(program.body) as Stmt[])], ...(program.functions ? { functions: walk(program.functions) as FuncDecl[] } : {}) }; +} + +/** + * The variables a loop's body declares that nothing outside the loop reads — `for (const v of xs)`'s `v` — marked + * `scoped`: dead once the turn is over, so what a cell read puts into one may go straight to the one field that reads + * it, its own cell left as it was (a map of our own's emitter, as for a variable of its own). A debugger reading cells + * would see such a variable's cell stale. + */ +export function loopScoped(program: Program): Program { + const scoped = new Set(); + const declaredIn = (x: unknown, out: Set): void => { + if (Array.isArray(x)) { x.forEach((y) => declaredIn(y, out)); return; } + if (!x || typeof x !== "object") return; + const o = x as { kind?: string; decl?: VarDecl }; + if (o.kind === "declare" && o.decl && o.decl.kind === "number" && !o.decl.shared && !o.decl.held && o.decl.task === undefined) out.add(o.decl.id); + for (const v of Object.values(o)) if (v && typeof v === "object") declaredIn(v, out); + }; + /** Every variable read or written in `x`, but inside `skip`. */ + const named = (x: unknown, skip: unknown, out: Set): void => { + if (x === skip || !x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach((y) => named(y, skip, out)); return; } + const o = x as { kind?: string; id?: string; target?: string }; + if (o.kind === "var" && typeof o.id === "string") out.add(o.id); + if (typeof o.target === "string") out.add(o.target); + for (const v of Object.values(o)) if (v && typeof v === "object") named(v, skip, out); + }; + const whole = [program.body, program.functions ?? []]; + const loops = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(loops); return; } + if (!x || typeof x !== "object") return; + const o = x as Record; + if (o.kind === "while" || o.kind === "for" || o.kind === "do") { + const inside = new Set(); + declaredIn(o.body, inside); + if (inside.size) { + const outside = new Set(); + named(whole, o, outside); + for (const id of inside) if (!outside.has(id)) scoped.add(id); + } + } + for (const v of Object.values(o)) if (v && typeof v === "object") loops(v); + }; + loops(whole); + if (!scoped.size) return program; + const mark = (x: unknown): unknown => { + if (Array.isArray(x)) return x.map(mark); + if (!x || typeof x !== "object") return x; + const o = x as Record; + const out = Object.fromEntries(Object.entries(o).map(([key, v]) => [key, v && typeof v === "object" ? mark(v) : v])); + if (o.kind === "declare" && scoped.has((o.decl as VarDecl).id)) out.decl = { ...(o.decl as VarDecl), scoped: true }; + return out; + }; + return { ...program, body: mark(program.body) as Stmt[], ...(program.functions ? { functions: mark(program.functions) as FuncDecl[] } : {}) }; +} diff --git a/compiler/backend/input.ts b/compiler/backend/input.ts new file mode 100644 index 0000000..38a6ab8 --- /dev/null +++ b/compiler/backend/input.ts @@ -0,0 +1,791 @@ +/** + * The players' input in a map of our own (TrigScript 4, step 12 of the way off eudplib): keys, clicks, the mouse and + * `sync()`, carried between the players' computers by the map itself, in place of chatEvent and MSQC. + * + * A key is pressed on one computer. What a map can carry to every computer is a game command: the game sends each + * player's commands to all of them in turns and runs them in step, so what a command leaves in the game's memory is + * the same everywhere. The carrier is a unit, as MSQC's: for each human player a ring of hidden units of theirs, and + * a send is a Select of the next one and a targeted Move to a point — the point is the value, in the carrier's order + * target (`+0x58`) that same frame. Every computer, before the programs' turns (the receive pass, `receive`), reads + * each human's ring from its read cursor, takes what arrived in order, writes 0 back and moves the cursor on. After the + * programs' turns (the send pass, `send`), the computer the player sits at reads what they did and queues what changed, + * never more in flight than the ring holds, and then a Select of the player's own selection, so their orders go on + * reaching their units. A turn that brings several frames' sends keeps every one. + * + * Two rings a human: **points** (the mouse, raw, one a frame when it moved) and **messages**, each a header and then + * the raw values the header counts: a *state* (a bit a key the programs read, then the three mouse buttons; sent when + * it changed, paid out one a frame on arrival, so a key is held for as many frames as it was down, and a press is the + * frame its bit came on), a *sync* (the slot, then the value). The value of a point is `x − 1 + (y − 1) << XB` with + * `2^XB` the largest power of two at most the map's width in pixels less 2, which the game keeps a Move's target + * within (x in `[1, W − 2]`, y in `[1, H − 34]`): `bits` of a value, 20 on a 64×64 map. + * + * The carriers are made at the first frame, on every computer alike (`startUp`): the chosen type's dat entry patched + * as MSQC's (the Command Center's flingy so it cannot move, 1×1, no weapon, no sight, no right-click), the units + * created in transit — hidden, and selectable — through a unit-properties slot the build writes, given to Player 12 + * so they are in no human's list, and their owner byte put back to the player's so a Select and an order of that + * player reach them. The records' EPDs and tags are kept in cells of the map's. + * + * A typed line (part 2) is read where it was typed: the game shows each line as `name:\x07 text` in its thirteen text + * lines (0x640B60, 218 bytes each) and moves the byte at 0x640B58 on with every line. When that byte has moved, the + * send pass looks through the lines for one that begins with this computer's player's name (read at the first frame + * from the player table, 0x57EEEB + 36p), copies the text into cells, swaps the two bytes after the colon so the line is + * not found again (chatEvent's mark: the line reads the same), and matches the text against the patterns in order — + * text byte for byte, `{n}` digits, `{k:ore|gas}` and `{u:unit}` by a hash of the word or of the rest of the line + * (`hashOf`), a unit's name found among the names' hashes in order by halves. The first pattern that fits the whole + * line goes as a *chat* message: the pattern's number, then its values. On arrival the pattern's flag is up for a + * frame and its values are in cells. + */ +import { STATEMENT_KINDS } from "./emit"; +import type { ArrayDecl, At, BoolExpr, NumExpr, Program, Stmt, VarDecl } from "../ir"; +import type { ActionRecord, TriggerRecord } from "../../vendor/triggers"; +import { MAX_CHAT_BYTES, MAX_CHAT_NUMBER, MOUSE_BUTTONS, inputsOf, parseChatPattern, virtualKey, type ChatPattern, type InputSource, type MouseButton } from "../input"; + +/** + * What the build says of the map: the human players' slots, the map in pixels, the unit-properties slot (1-based) it + * wrote as "in transit", and the carrier type. `triggers`: the map's own triggers as its TRIG section holds them before + * the script's records (TrigScript 4, step 13) — laid out with the records, before them; their texts and sound files + * are the map's strings already. + */ +export interface OwnMap { humans: number[]; width: number; height: number; cuwp: number; carrier?: number; triggers?: TriggerRecord[] } + +export interface OwnInputPlan { + humans: number[]; + /** Bit `i` of a state is key `i`'s; the buttons' bits follow. */ + keys: string[]; + buttons: MouseButton[]; + mouse: boolean; + /** How many `sync()` slots the programs use, numbered across the build. */ + syncs: number; + /** The typed-line patterns the programs read, numbered from 1 in this order; the most values one carries; what a number in a line is at most (what a point carries). */ + chats: ChatPattern[]; + captures: number; + numberMost: number; + /** The names a `{…:unit}` capture knows: `hashOf` each name in rising order, and the type each means. */ + units: { hashes: number[]; ids: number[] }; + carrier: number; + cuwp: number; + /** Carriers a ring, and rings a human (the mouse's second). */ + ring: number; + rings: number; + /** The bits of x in a point's value, and the bits a value has. */ + xb: number; + bits: number; + /** The Move's target is kept within these (W − 2, H − 34). */ + box: { w: number; h: number }; +} + +/** The Valkyrie: what MSQC makes its carriers of too. */ +export const CARRIER = 58; +/** Carriers a ring. */ +export const RING_SIZE = 6; +/** Where the carriers stand, in pixels: the map's top left corner. */ +export const CARRIER_X = 64, CARRIER_Y = 64; +/** The most a `sync()` value is: what a point carries on the smallest map the contract holds for (64×64), less the 1 that tells a value from nothing. */ +export const SYNC_MOST = 2 ** 20 - 2; + +const DEATHS = 0x58a364; +const epd = (address: number) => (address - DEATHS) / 4; +const UNIT_TABLE = 0x59cca8, STRIDE = 336, UNIT_TABLE_EPD = epd(UNIT_TABLE); +const LOCAL = epd(0x512684), CURRENT = epd(0x6509b0), SELECTION = epd(0x6284b8), QUEUE = epd(0x654880), QUEUED = epd(0x654aa0), QUEUE_MOST = epd(0x57f0d8); +const FIRST_UNIT = epd(0x6283f8), MRGN = epd(0x58dc60), SCRATCH_LOCATION = 255; +const KEYS = 0x596a18, TYPING = epd(0x68c144), BUTTONS = epd(0x6cddc0), MOUSE_X = epd(0x6cddc4), MOUSE_Y = epd(0x6cddc8), SCREEN_X = epd(0x62848c), SCREEN_Y = epd(0x6284a8); +/** The text lines: the byte that moves with every line shown, the lines, a line's bytes, how many; the player table and where a name is in a record. */ +const TEXT_RING = epd(0x640b58), TEXT_LINES = epd(0x640b60), TEXT_LINE = 218, TEXT_LINE_COUNT = 13, PLAYERS = epd(0x57eee0), PLAYER_STRIDE = 36, NAME_OFFSET = 11, NAME_MOST = 24; +const BUTTON_BITS: Record = { left: 2, right: 8, middle: 32 }; +const NEXT_UNIT = 0x6c >> 2, ORDER_TARGET = 0x58 >> 2, OWNER_DWORD = 0x4c >> 2, STATUS = 0xdc >> 2, UID_DWORD = 0xa4 >> 2; +const SET_DEATHS = 45, SET_TO = 7, MASKED = 0x4353; +const CREATE_WITH_PROPERTIES = 11, GIVE_UNITS = 48, NEUTRAL = 11; +const AT0: At = { file: "", line: 0, column: 0 }; + +/* ── the cells ── */ +const CARRIERS = "@in.carrier", TAGS = "@in.tag", WRITE = "@in.wr", READ = "@in.rd"; +const STATE = "@in.state", PRESSED = "@in.pressed", SENT = "@in.sent", READ_1 = "@in.raw1", READ_2 = "@in.raw2"; +const MX = "@in.mx", MY = "@in.my", SENT_MX = "@in.smx", SENT_MY = "@in.smy"; +const EXPECT = "@in.expect", SLOT = "@in.slot"; +const SYNC = "@in.sync", PENDING = "@in.pend"; +/** A typed line, on arrival: the pattern that came (0 none), a flag a pattern, the values a pattern, the pattern whose values are coming and how many came. */ +const CHAT = "@in.chat", CHAT_FLAG = "@in.chatf", CAPTURES = "@in.cap", CHAT_PENDING = "@in.cpend", CAPTURE_AT = "@in.ci"; +/** A typed line, where it was typed: the text ring's byte as last seen, the name prefix and its length, the line's text and its length, the pattern matched and its values, the unit names' hashes and types. */ +const RING = "@in.ring", NAME = "@in.name", NAME_LENGTH = "@in.nlen", LINE = "@in.line", LINE_LENGTH = "@in.llen", TYPED = "@in.typed", TYPED_VALUES = "@in.tval", UNIT_HASHES = "@in.uh", UNIT_IDS = "@in.uu"; +const MADE = "@in.made"; +const SLOTS = 8; +const MESSAGES = 0, POINTS = 1; +const KIND_STATE = 0, KIND_SYNC = 1, KIND_CHAT = 2; + +const global = (id: string): VarDecl => ({ id, name: "(input)", kind: "number", shared: true, routed: true, global: true, at: AT0 }); +export const INPUT_GLOBALS: readonly VarDecl[] = [global(MADE), global(RING), global(NAME_LENGTH), global(LINE_LENGTH), global(TYPED)]; +/** An id of the input's: its arrays, globals and temps are the map's, one of each however many programs and runs read them (as the events' `@ev.`). */ +export const isInputGlobal = (id: string) => id.startsWith("@in."); +/** The mouse of a player on the map, as the receive pass keeps it: what `underMouse()`'s pick measures from. */ +export const MOUSE_CELLS = { x: MX, y: MY }; + +/** The arrays of the map's the passes and the programs' reads share, which no program declares. */ +export function inputArrays(plan: OwnInputPlan): ArrayDecl[] { + const a = (id: string, length: number): ArrayDecl => ({ id, name: "(input)", kind: "number", length, shared: true, at: AT0 }); + const chats = plan.chats.length, captures = plan.captures, units = plan.units.hashes.length; + return [ + a(CARRIERS, SLOTS * plan.rings * plan.ring), a(TAGS, SLOTS * plan.rings * plan.ring), a(WRITE, SLOTS * plan.rings), a(READ, SLOTS * plan.rings), + a(STATE, SLOTS), a(PRESSED, SLOTS), a(SENT, SLOTS), a(READ_1, SLOTS), a(READ_2, SLOTS), a(EXPECT, SLOTS), a(SLOT, SLOTS), + ...(plan.mouse ? [a(MX, SLOTS), a(MY, SLOTS), a(SENT_MX, SLOTS), a(SENT_MY, SLOTS)] : []), + ...(plan.syncs ? [a(SYNC, SLOTS * plan.syncs), a(PENDING, plan.syncs)] : []), + ...(chats ? [a(CHAT, SLOTS), a(CHAT_FLAG, SLOTS * chats), a(CHAT_PENDING, SLOTS), a(CAPTURE_AT, SLOTS), a(NAME, NAME_MOST + 3), a(LINE, MAX_CHAT_BYTES + 1)] : []), + ...(chats && captures ? [a(CAPTURES, SLOTS * chats * captures), a(TYPED_VALUES, captures)] : []), + ...(units ? [{ ...a(UNIT_HASHES, units), unsigned: true, values: plan.units.hashes }, { ...a(UNIT_IDS, units), values: plan.units.ids }] : []), + ]; +} + +/** What a word or a unit's name hashes to, in a typed line: capitals A to Z as small letters, h = h × 31 + byte, in 32 bits. */ +export function hashOf(data: Uint8Array): number { + let h = 0; + for (let b of data) { + if (b >= 65 && b <= 90) b += 32; + h = (Math.imul(h, 31) + b) >>> 0; + } + return h; +} +const bytesOf = (s: string) => new TextEncoder().encode(s); + +/** Whether a program reads the players, or sends to them. */ +export function hasInput(x: unknown): boolean { + if (Array.isArray(x)) return x.some(hasInput); + if (!x || typeof x !== "object") return false; + const o = x as { kind?: string; mouse?: unknown }; + if (o.kind === "input" || o.kind === "syncSend") return true; + // (A pick near a player's mouse reads the mouse the receive pass keeps.) + if (o.kind === "pick" && o.mouse !== undefined) return true; + return Object.values(o).some((v) => v && typeof v === "object" && hasInput(v)); +} + +/** + * The plan for a build's programs: null when none reads the players; a string when the map cannot carry what they + * read, worded for the script's author. + */ +export function ownInputPlan(programs: Program[], map: OwnMap, unitNames: readonly (readonly [string, number])[] = []): OwnInputPlan | string | null { + const { sources, mouse } = inputsOf(programs); + if (!sources.length && !mouse) return null; + if (!map.humans.length) return "a program reads keys, clicks, the mouse, typed lines or sync(): this map has no human player to read them from"; + const keys: string[] = [], buttons: MouseButton[] = [], chats: ChatPattern[] = []; + for (const s of sources) { + if (s.source === "key" && !keys.includes(s.key)) keys.push(s.key); + else if (s.source === "click" && !buttons.includes(s.button)) buttons.push(s.button); + else if (s.source === "chat" && !chats.some((c) => c.pattern === s.pattern)) { + try { chats.push(parseChatPattern(s.pattern)); } catch (err) { return err instanceof Error ? err.message : String(err); } + } + } + for (const b of MOUSE_BUTTONS) if (!buttons.includes(b)) buttons.push(b); + const syncs = Math.max(0, ...sources.map((s) => (s.source === "sync" ? s.slot + 1 : 0))); + const box = { w: map.width - 2, h: map.height - 34 }; + const xb = Math.floor(Math.log2(box.w)); + const bits = Math.floor(Math.log2(2 ** xb * box.h)); + // (A state is a header: its kind takes two bits.) + if (keys.length + buttons.length > bits - 2) return `a script reads ${keys.length} keys: on a map this size it can read ${bits - 2 - buttons.length} at the most`; + if (bits < 16) return "the map is too small to carry the players' input: 32 by 32 tiles at the least"; + const captures = Math.max(0, ...chats.map((c) => c.captures.length)); + // (A unit's name by its hash, the first of a hash's names: `{…:unit}` gives the type of the name typed.) + const byHash = new Map(); + if (chats.some((c) => c.captures.some((x) => x.kind === "unit"))) for (const [name, id] of unitNames) { const h = hashOf(bytesOf(name)); if (!byHash.has(h)) byHash.set(h, id); } + const sorted = [...byHash].sort((a, b) => a[0] - b[0]); + const units = { hashes: sorted.map((x) => x[0]), ids: sorted.map((x) => x[1]) }; + return { humans: map.humans, keys, buttons, mouse, syncs, chats, captures, numberMost: Math.min(MAX_CHAT_NUMBER, 2 ** bits - 1), units, carrier: map.carrier ?? CARRIER, cuwp: map.cuwp, ring: RING_SIZE, rings: mouse ? 2 : 1, xb, bits, box }; +} + +/** What MSQC's `Respawn` writes into the dat tables for its carrier type: each a byte, word or dword at an address. */ +export function carrierDat(type: number): { address: number; bytes: 1 | 2 | 4; value: number }[] { + return [ + { address: 0x6644f8 + type, bytes: 1, value: 94 }, // flingy: the Command Center's, top speed 0 + { address: 0x6617c8 + type * 8, bytes: 4, value: 0x10001 }, { address: 0x6617cc + type * 8, bytes: 4, value: 0x10001 }, // dimensions 1×1 + { address: 0x662860 + type * 4, bytes: 4, value: 0 }, // building dimensions + { address: 0x6636b8 + type, bytes: 1, value: 130 }, { address: 0x6616e0 + type, bytes: 1, value: 130 }, // no ground or air weapon + { address: 0x662db8 + type, bytes: 1, value: 0 }, { address: 0x663238 + type, bytes: 1, value: 0 }, // seek range, sight range + { address: 0x662098 + type, bytes: 1, value: 6 }, // right-click action: none + { address: 0x664080 + type * 4, bytes: 4, value: 0x38000025 }, // special ability flags + { address: 0x6637a0 + type, bytes: 1, value: 0 }, // group flags + { address: 0x660fc8 + type, bytes: 1, value: 0xc5 }, { address: 0x663150 + type, bytes: 1, value: 0x13 }, // movement flags, elevation + ]; +} + +/** + * The program with its input as reads of the map's cells, and — when `passes` — the map's receive pass (before the + * events pass, in `Program.pass`) and send pass (`Program.after`). + */ +export function inputValues(program: Program, plan: OwnInputPlan, passes: boolean): Program { + let here: At = program.at; + let made = 0; + const fresh = (what: string) => `@in.${what}${made++}`; + const owner = !program.perPlayer && program.owners.length <= 1 && program.owner >= 0 && program.owner < 8 ? program.owner : undefined; + + // ── the IR's pieces ── + const at = () => ({ at: here, label: "" }); + const k = (value: number): NumExpr => ({ kind: "const", value }); + const V = (id: string): NumExpr => ({ kind: "var", id }); + const bin = (op: "+" | "-" | "*" | "&" | "^" | ">>>", a: NumExpr, b: NumExpr): NumExpr => { + if (a.kind === "const" && b.kind === "const") { + const [x, y] = [a.value >>> 0, b.value >>> 0]; + return k((op === "+" ? x + y : op === "-" ? x - y : op === "*" ? Number((BigInt(x) * BigInt(y)) & 0xffffffffn) : op === "&" ? x & y : op === "^" ? x ^ y : x >>> y) >>> 0); + } + return { kind: "binary", op, left: a, right: b, ...at() } as NumExpr; + }; + const div = (a: NumExpr, by: number): NumExpr => ({ kind: "binary", op: "/", left: a, right: k(by), unsigned: true, ...at() } as NumExpr); + const cmp = (op: "==" | "!=" | "<" | "<=" | ">" | ">=", a: NumExpr, b: NumExpr): BoolExpr => ({ kind: "compare", op, left: a, right: b, unsigned: true, ...at() }); + const ranged = (e: NumExpr, lo: number, hi: number): NumExpr => ({ ...e, range: [lo, hi] }) as unknown as NumExpr; + const num = (id: string, top?: number, keep?: "routed" | "everywhere"): VarDecl => ({ id, name: "(input)", kind: "number", shared: false, at: here, temp: true, ...(top !== undefined ? { top } : {}), ...(keep ? { [keep]: true } : {}) } as VarDecl); + const dec = (id: string, init: NumExpr, top?: number, keep?: "routed" | "everywhere"): Stmt => ({ kind: "declare", decl: num(id, top, keep), init, ...at() }); + const set = (target: string, value: NumExpr): Stmt => ({ kind: "assign", target, value, ...at() }); + const when = (cond: BoolExpr, then: Stmt[], otherwise: Stmt[] = []): Stmt[] => [{ kind: "if", cond, then, ...(otherwise.length ? { else: otherwise } : {}), ...at() }]; + const loop = (body: Stmt[]): Stmt => ({ kind: "while", cond: undefined, body, ...at() } as Stmt); + const brk = (): Stmt => ({ kind: "break", ...at() }); + const element = (array: string, index: NumExpr): NumExpr => ({ kind: "element", array, index, at: here }); + const store = (array: string, index: NumExpr, value: NumExpr): Stmt => ({ kind: "store", array, index, value, ...at() }); + /** The dword at an EPD, or its bits of the mask, moved down `shift`. */ + const peek = (at_: NumExpr, mask = 0xffffffff, shift = 0): NumExpr => { + const whole = mask >>> 0 === 0xffffffff; + const record = { location: whole ? 0 : mask >>> 0, player: 0, amount: 0, unitId: 0, comparison: 0, type: 15, resource: 0, flags: 0, mask: whole ? 0 : MASKED }; + const r: NumExpr = { kind: "read", read: { source: "condition", record, epd: at_ }, ...at() } as NumExpr; + return shift ? bin(">>>", r, k(shift)) : r; + }; + /** A Set Deaths of the dword at an EPD: the value written, or its bits of the mask. */ + const poke = (at_: NumExpr, value: NumExpr, mask = 0xffffffff): Stmt => { + const whole = mask >>> 0 === 0xffffffff; + const record: ActionRecord = { location: whole ? 0 : mask >>> 0, text: 0, wav: 0, time: 0, player: 0, target: value.kind === "const" ? value.value >>> 0 : 0, unitId: 0, type: SET_DEATHS, modifier: SET_TO, flags: 0, padding: 0, mask: whole ? 0 : MASKED }; + const variables = [{ field: "player" as const, bits: 32 as const, name: "at", expr: at_ }, ...(value.kind === "const" ? [] : [{ field: "target" as const, bits: 32 as const, name: "n", expr: value }])]; + return { kind: "action", record, variables, ...at() } as Stmt; + }; + /** A byte, word or dword at an address, as a Set Deaths of its dword's bits. */ + const pokeAt = (address: number, bytes: 1 | 2 | 4, value: number): Stmt => { + const shift = 8 * (address & 3), mask = bytes === 4 ? 0xffffffff : ((2 ** (8 * bytes) - 1) * 2 ** shift) >>> 0; + return poke(k(epd(address & ~3)), k((value * 2 ** shift) >>> 0), mask); + }; + /** The byte at an address, 0 to 255. */ + const byteAt = (address: number): NumExpr => ranged(peek(k(epd(address & ~3)), (0xff * 2 ** (8 * (address & 3))) >>> 0, 8 * (address & 3)), 0, 255); + const action = (record: ActionRecord): Stmt => ({ kind: "action", record, ...at() } as Stmt); + const local = (): NumExpr => ranged(peek(k(LOCAL)), 0, 255); + + /** The player a source names: a slot, or the current player — this run's when known when built, else read. */ + const playerOf = (p: number): NumExpr => { + if (p !== 13) return k(p); + if (owner !== undefined) return k(owner); + return ranged(peek(k(CURRENT), 0xff), 0, 255); + }; + const cell = (array: string, p: NumExpr, base = 0): NumExpr => element(array, base ? bin("+", k(base), p) : p); + const bitOf = (s: InputSource & { source: "key" | "click" }): number => (s.source === "key" ? plan.keys.indexOf(s.key) : plan.keys.length + plan.buttons.indexOf(s.button)); + + // ── the reads ── + const read = (s: InputSource): NumExpr => { + const p = playerOf(s.player); + switch (s.source) { + case "key": case "click": { + const bit = bitOf(s); + return ranged(bin(">>>", bin("&", cell(s.source === "key" && s.held ? STATE : PRESSED, p), k(2 ** bit)), k(bit)), 0, 1); + } + case "mouse": return ranged(cell(s.axis === "x" ? MX : MY, p), 0, 0xffff); + case "sync": return cell(SYNC, p, s.slot * SLOTS); + case "chat": { + const index = plan.chats.findIndex((c) => c.pattern === s.pattern); + if (index < 0) throw new Error(`the input plan has no pattern ${JSON.stringify(s.pattern)}`); + if (s.capture === null) return ranged(cell(CHAT_FLAG, p, index * SLOTS), 0, 1); + return cell(CAPTURES, p, captureAt(index, s.capture)); + } + } + }; + /** Where a pattern's capture is kept, by player: a cell a player after the base. */ + const captureAt = (pattern: number, capture: number, player = 0) => (pattern * plan.captures + capture) * SLOTS + player; + /** The byte `offset` bytes after an aligned EPD, read from its dword's quarter: the statements, and the variable the byte is in. */ + const byteFrom = (base: number, offset: NumExpr): { get: Stmt[]; b: string } => { + const o = fresh("o"), e = fresh("e"), r = fresh("r"), b = fresh("b"); + return { b, get: [ + dec(o, offset, 0xffff), dec(e, bin("+", k(base), bin(">>>", V(o), k(2))), 0x3fffffff, "routed"), dec(r, bin("&", V(o), k(3)), 3), dec(b, k(0), 255), + ...[0, 1, 2, 3].flatMap((q) => when(cmp("==", V(r), k(q)), [set(b, ranged(peek(V(e), (0xff * 2 ** (8 * q)) >>> 0, 8 * q), 0, 255))])), + ] }; + }; + /** A byte written `offset` bytes after an aligned EPD. */ + const pokeByte = (base: number, offset: NumExpr, value: number): Stmt[] => { + const o = fresh("o"), e = fresh("e"), r = fresh("r"); + return [ + dec(o, offset, 0xffff), dec(e, bin("+", k(base), bin(">>>", V(o), k(2))), 0x3fffffff, "routed"), dec(r, bin("&", V(o), k(3)), 3), + ...[0, 1, 2, 3].flatMap((q) => when(cmp("==", V(r), k(q)), [poke(V(e), k((value * 2 ** (8 * q)) >>> 0), (0xff * 2 ** (8 * q)) >>> 0)])), + ]; + }; + /** `syncSend`: on the player's own computer, the value kept to what a point carries, plus 1, left for the send pass. */ + const syncSend = (slot: number, player: number, value: NumExpr): Stmt[] => { + const v = fresh("v"); + return when(cmp("==", local(), playerOf(player)), [ + dec(v, value), + ...when(cmp(">=", V(v), k(0x80000000)), [set(v, k(0))]), + ...when(cmp(">=", V(v), k(SYNC_MOST + 1)), [set(v, k(SYNC_MOST))]), + store(PENDING, k(slot), bin("+", V(v), k(1))), + ]); + }; + + const fix = (x: unknown): unknown => { + // (A list of statements inside a statement — an if's, a loop's, a try's — as statements: a sync() in it is lowered too.) + if (Array.isArray(x)) return x.length && x[0] && typeof x[0] === "object" && STATEMENT_KINDS.has((x[0] as { kind?: string }).kind ?? "") ? stmts(x as Stmt[]) : x.map(fix); + if (!x || typeof x !== "object") return x; + const o = x as Record & { kind?: string }; + if (o.kind === "input") return read(o.input as InputSource); + return Object.fromEntries(Object.entries(o).map(([key, v]) => [key, key === "at" || !v || typeof v !== "object" ? v : fix(v)])); + }; + const stmts = (list: Stmt[]): Stmt[] => list.flatMap(stmt); + const stmt = (s: Stmt): Stmt[] => { + if (!hasInput(s)) return [s]; + const was = here; + here = s.at ?? here; + try { + if (s.kind === "syncSend") return syncSend(s.slot, s.player, fix(s.value) as NumExpr); + return [fix(s) as Stmt]; + } finally { here = was; } + }; + + // ── the carriers ── + const ringBase = (h: number, ring: number) => (ring * SLOTS + h) * plan.ring; + const cursor = (h: number, ring: number) => k(ring * SLOTS + h); + /** A value into its point: x − 1 + (y − 1) << XB. */ + const xOf = (v: NumExpr): NumExpr => ranged(bin("+", bin("&", v, k(2 ** plan.xb - 1)), k(1)), 1, 2 ** plan.xb); + const yOf = (v: NumExpr): NumExpr => ranged(bin("+", bin(">>>", v, k(plan.xb)), k(1)), 1, plan.box.h); + + /** + * The first frame: the carriers made, on every computer alike. The dat entry patched; for each human, the scratch + * location a point at the corner, the ring's units created there in transit and given to Player 12, then found at the + * head of Player 12's list — the EPD and the tag of each kept, the owner byte put back to the player's, the unit made + * to stack and never collide. + */ + const startUp = (): Stmt[] => { + const out: Stmt[] = []; + for (const d of carrierDat(plan.carrier)) out.push(pokeAt(d.address, d.bytes, d.value)); + out.push(pokeAt(0x661518 + plan.carrier * 2, 2, 0x1cf)); // (StarEdit's availability, while they are made) + const side = (i: number, v: number) => poke(k(MRGN + 5 * (SCRATCH_LOCATION - 1) + i), k(v)); + out.push(side(0, CARRIER_X), side(1, CARRIER_Y), side(2, CARRIER_X), side(3, CARRIER_Y), poke(k(MRGN + 5 * (SCRATCH_LOCATION - 1) + 4), k(0), 0xffff0000)); + const n = plan.rings * plan.ring; + for (const h of plan.humans) { + for (let i = 0; i < n; i++) out.push(action({ location: SCRATCH_LOCATION, text: 0, wav: 0, time: 0, player: h, target: plan.cuwp, unitId: plan.carrier, type: CREATE_WITH_PROPERTIES, modifier: 1, flags: 0x1c, padding: 0, mask: 0 })); + out.push(action({ location: SCRATCH_LOCATION, text: 0, wav: 0, time: 0, player: h, target: NEUTRAL, unitId: plan.carrier, type: GIVE_UNITS, modifier: n, flags: 0x14, padding: 0, mask: 0 })); + // (The given units head Player 12's list, the last made first: a pointer's EPD is its address over 4, less the deaths table's.) + const p = fresh("p"); + out.push(dec(p, bin("-", peek(k(FIRST_UNIT + NEUTRAL), 0xfffffffc, 2), k(DEATHS / 4)), 0x3fffffff, "routed")); + for (let i = 0; i < n; i++) { + const slot = ringBase(h, Math.floor(i / plan.ring)) + (i % plan.ring); + const index = fresh("ix"), uid = fresh("uid"); + out.push( + store(CARRIERS, k(slot), V(p)), + dec(index, div(bin("-", V(p), k(UNIT_TABLE_EPD)), STRIDE / 4), 1699, "routed"), + dec(uid, peek(bin("+", V(p), k(UID_DWORD)), 0xff00, 8), 255, "routed"), + store(TAGS, k(slot), bin("+", bin("+", V(index), k(1)), bin("*", V(uid), k(2048)))), + poke(bin("+", V(p), k(OWNER_DWORD)), k(h), 0xff), + poke(bin("+", V(p), k(STATUS)), k(0xa00000), 0xa00000), + poke(bin("+", V(p), k(ORDER_TARGET)), k(0)), + ); + if (i + 1 < n) out.push(set(p, bin("-", peek(bin("+", V(p), k(NEXT_UNIT)), 0xfffffffc, 2), k(DEATHS / 4)))); + } + } + out.push(pokeAt(0x661518 + plan.carrier * 2, 2, 0)); + // (The mouse as last sent: where a point starts, so the first frame sends nothing.) + if (plan.mouse) for (const h of plan.humans) out.push(store(SENT_MX, k(h), k(1)), store(SENT_MY, k(h), k(1))); + // This computer's player's name, with the ":\x07 " the game shows after it: what a line of theirs begins with. + if (plan.chats.length) { + const lp = fresh("lp"), n = fresh("nl"); + const byte = byteFrom(PLAYERS, bin("+", k(NAME_OFFSET), bin("+", bin("*", V(lp), k(PLAYER_STRIDE)), V(n)))); + out.push(dec(lp, local(), 255), ...when(cmp("<", V(lp), k(SLOTS)), [ + dec(n, k(0), NAME_MOST), + loop([ + ...when(cmp(">=", V(n), k(NAME_MOST)), [brk()]), + ...byte.get, + ...when(cmp("==", V(byte.b), k(0)), [brk()]), + store(NAME, V(n), V(byte.b)), + set(n, bin("+", V(n), k(1))), + ]), + store(NAME, V(n), k(0x3a)), store(NAME, bin("+", V(n), k(1)), k(7)), store(NAME, bin("+", V(n), k(2)), k(0x20)), + set(NAME_LENGTH, bin("+", V(n), k(3))), + set(RING, ranged(peek(k(TEXT_RING), 0xff), 0, 255)), + ])); + } + out.push(set(MADE, k(1))); + return out; + }; + + // ── a typed line, where it was typed ── + /** + * The line's text (`LINE`, `LINE_LENGTH`) against each pattern in turn: the first that fits the whole line is `TYPED`, + * its values `TYPED_VALUES`. A pattern's block is a loop left at the first mismatch. + */ + const matchLine = (): Stmt[] => { + const out: Stmt[] = []; + const matched = fresh("m"); + out.push(dec(matched, k(0), 1)); + const lineByte = (at: NumExpr) => ranged(element(LINE, at), 0, 255); + plan.chats.forEach((c, index) => { + const body: Stmt[] = []; + let pos: number | string = 0; + const posVar = (): string => { + if (typeof pos === "string") return pos; + const p = fresh("pos"); + body.push(dec(p, k(pos), MAX_CHAT_BYTES + 1)); + pos = p; + return p; + }; + const values: string[] = []; + for (const seg of c.segments) { + if (typeof seg === "string") { + const data = bytesOf(seg); + data.forEach((byte, j) => { + const b = fresh("sb"); + body.push(dec(b, lineByte(typeof pos === "number" ? k(pos + j) : bin("+", V(pos), k(j))), 255), ...when(cmp("!=", V(b), k(byte)), [brk()])); + }); + if (typeof pos === "number") pos += data.length; + else body.push(set(pos, bin("+", V(pos), k(data.length)))); + continue; + } + const cap = c.captures[seg]; + const p = posVar(); + const value = fresh("cv"), ch = fresh("ch"); + values.push(value); + body.push(dec(value, k(0))); + if (cap.kind === "number") { + // Digits, the value kept to what a point carries. + const digits = fresh("dg"); + body.push(dec(digits, k(0), MAX_CHAT_BYTES), loop([ + ...when(cmp(">=", V(p), V(LINE_LENGTH)), [brk()]), + dec(ch, lineByte(V(p)), 255), + ...when(cmp("<", V(ch), k(48)), [brk()]), ...when(cmp(">", V(ch), k(57)), [brk()]), + set(value, bin("*", V(value), k(10))), set(value, bin("+", V(value), V(ch))), set(value, bin("-", V(value), k(48))), + ...when(cmp(">", V(value), k(plan.numberMost)), [set(value, k(plan.numberMost))]), + set(p, bin("+", V(p), k(1))), set(digits, bin("+", V(digits), k(1))), + ]), ...when(cmp("==", V(digits), k(0)), [brk()])); + continue; + } + // A word (to the next space) or the rest of the line, hashed as `hashOf`. + const h = fresh("h"), found = fresh("fd"); + body.push(dec(h, k(0)), loop([ + ...when(cmp(">=", V(p), V(LINE_LENGTH)), [brk()]), + dec(ch, lineByte(V(p)), 255), + ...(cap.kind === "word" ? when(cmp("==", V(ch), k(32)), [brk()]) : []), + ...when({ kind: "and", items: [cmp(">=", V(ch), k(65)), cmp("<=", V(ch), k(90))] }, [set(ch, bin("+", V(ch), k(32)))]), + set(h, bin("*", V(h), k(31))), set(h, bin("+", V(h), V(ch))), + set(p, bin("+", V(p), k(1))), + ]), dec(found, k(0), 1)); + if (cap.kind === "word") { + cap.words.forEach((w, i) => body.push(...when(cmp("==", V(h), k(hashOf(bytesOf(w)))), [set(value, k(i)), set(found, k(1))]))); + } else { + // The names' hashes in rising order, searched by halves. + const count = plan.units.hashes.length; + const lo = fresh("lo"), hi = fresh("hi"), mid = fresh("mid"), hm = fresh("hm"), hl = fresh("hl"); + body.push(dec(lo, k(0), count), dec(hi, k(count), count), loop([ + ...when(cmp(">=", V(lo), V(hi)), [brk()]), + dec(mid, bin(">>>", bin("+", V(lo), V(hi)), k(1)), count), + dec(hm, ranged(element(UNIT_HASHES, V(mid)), 0, 0xffffffff)), + ...when(cmp("<", V(hm), V(h)), [set(lo, bin("+", V(mid), k(1)))], [set(hi, V(mid))]), + ])); + if (count) body.push(...when(cmp("<", V(lo), k(count)), [dec(hl, ranged(element(UNIT_HASHES, V(lo)), 0, 0xffffffff)), ...when(cmp("==", V(hl), V(h)), [set(value, ranged(element(UNIT_IDS, V(lo)), 0, 0xffff)), set(found, k(1))])])); + } + body.push(...when(cmp("==", V(found), k(0)), [brk()])); + } + body.push(...when(cmp("!=", typeof pos === "number" ? k(pos) : V(pos), V(LINE_LENGTH)), [brk()])); + body.push(set(TYPED, k(index + 1))); + values.forEach((v, i) => body.push(store(TYPED_VALUES, k(i), V(v)))); + body.push(set(matched, k(1)), brk()); + out.push(...when(cmp("==", V(matched), k(0)), [loop(body)])); + }); + return out; + }; + + /** + * When the text ring's byte has moved: each of the thirteen lines that begins with this player's name prefix is + * theirs — its text copied into `LINE`, the two bytes after the colon swapped so it is not found again, and matched. + */ + const typedLines = (): Stmt[] => { + const cur = fresh("cur"), o = fresh("lo"), slot = fresh("ls"), j = fresh("j"), nb = fresh("nb"), i = fresh("i"); + const pre = byteFrom(TEXT_LINES, bin("+", V(o), V(j))); + const text = byteFrom(TEXT_LINES, bin("+", bin("+", V(o), V(NAME_LENGTH)), V(i))); + const mine: Stmt[] = [ + dec(i, k(0), MAX_CHAT_BYTES), + loop([ + ...when(cmp(">=", V(i), k(MAX_CHAT_BYTES)), [brk()]), + ...text.get, + ...when(cmp("==", V(text.b), k(0)), [brk()]), + store(LINE, V(i), V(text.b)), + set(i, bin("+", V(i), k(1))), + ]), + set(LINE_LENGTH, V(i)), + ...pokeByte(TEXT_LINES, bin("-", bin("+", V(o), V(NAME_LENGTH)), k(2)), 0x20), + ...pokeByte(TEXT_LINES, bin("-", bin("+", V(o), V(NAME_LENGTH)), k(1)), 0x07), + ...matchLine(), + ]; + return [ + dec(cur, ranged(peek(k(TEXT_RING), 0xff), 0, 255), 255), + ...when(cmp("!=", V(cur), V(RING)), [ + set(RING, V(cur)), + dec(o, k(0), TEXT_LINE * TEXT_LINE_COUNT), dec(slot, k(0), TEXT_LINE_COUNT), + loop([ + ...when(cmp(">=", V(slot), k(TEXT_LINE_COUNT)), [brk()]), + dec(j, k(0), NAME_MOST + 3), + loop([ + ...when(cmp(">=", V(j), V(NAME_LENGTH)), [brk()]), + ...pre.get, + dec(nb, ranged(element(NAME, V(j)), 0, 255), 255), + ...when(cmp("!=", V(pre.b), V(nb)), [brk()]), + set(j, bin("+", V(j), k(1))), + ]), + ...when(cmp("==", V(j), V(NAME_LENGTH)), mine), + set(o, bin("+", V(o), k(TEXT_LINE))), set(slot, bin("+", V(slot), k(1))), + ]), + ]), + ]; + }; + + // ── the receive pass ── + /** + * Each human's rings read from the read cursor: a carrier whose order target holds a point has a packet, taken and + * the target written back to 0. The points ring: the mouse, one a frame. The messages ring: a header (a state, paid out + * one a frame; a sync's slot) or the raw value a header said would follow. + */ + const receive = (): Stmt[] => { + const out: Stmt[] = []; + for (let h = 0; h < SLOTS; h++) { + if (!plan.humans.includes(h)) continue; + out.push(store(PRESSED, k(h), k(0))); + for (let s = 0; s < plan.syncs; s++) out.push(store(SYNC, k(s * SLOTS + h), k(0))); + // (A typed line that arrived the frame before: its flag down and its values cleared.) + if (plan.chats.length) { + const came = fresh("came"); + out.push(...when(cmp(">=", element(CHAT, k(h)), k(1)), [ + dec(came, element(CHAT, k(h)), plan.chats.length), + ...plan.chats.flatMap((c, i) => when(cmp("==", V(came), k(i + 1)), [store(CHAT_FLAG, k(i * SLOTS + h), k(0)), ...c.captures.map((_, ci) => store(CAPTURES, k(captureAt(i, ci, h)), k(0)))])), + store(CHAT, k(h), k(0)), + ])); + } + } + for (const h of plan.humans) { + for (let ring = 0; ring < plan.rings; ring++) { + const rd = fresh("rd"), c = fresh("c"), v = fresh("v"), value = fresh("val"), n = fresh("n"); + const body: Stmt[] = [ + dec(c, element(CARRIERS, bin("+", k(ringBase(h, ring)), V(rd))), 0x3fffffff, "routed"), + dec(v, peek(bin("+", V(c), k(ORDER_TARGET)))), + ...when(cmp("==", V(v), k(0)), [brk()]), + poke(bin("+", V(c), k(ORDER_TARGET)), k(0)), + set(rd, bin("+", V(rd), k(1))), + ...when(cmp(">=", V(rd), k(plan.ring)), [set(rd, k(0))]), + store(READ, cursor(h, ring), V(rd)), + ]; + if (ring === POINTS) { + body.push(store(MX, k(h), bin("&", V(v), k(0xffff))), store(MY, k(h), bin(">>>", V(v), k(16))), brk()); + } else { + const header: Stmt[] = []; + const kind = fresh("kind"), payload = fresh("pay"); + header.push(dec(kind, bin("&", V(value), k(3)), 3), dec(payload, bin(">>>", V(value), k(2)), 2 ** (plan.bits - 2) - 1)); + const old = fresh("old"); + header.push(...when(cmp("==", V(kind), k(KIND_STATE)), [ + dec(old, element(STATE, k(h)), 2 ** (plan.bits - 2) - 1), + store(STATE, k(h), V(payload)), + store(PRESSED, k(h), bin("&", V(payload), bin("^", V(old), k(2 ** (plan.bits - 2) - 1)))), + brk(), + ])); + if (plan.syncs) header.push(...when(cmp("==", V(kind), k(KIND_SYNC)), [store(SLOT, k(h), V(payload)), store(EXPECT, k(h), k(1))])); + // (A typed line: the pattern's number, then as many values as it has; one with none has arrived at once.) + if (plan.chats.length) { + header.push(...when(cmp("==", V(kind), k(KIND_CHAT)), [ + store(CHAT_PENDING, k(h), V(payload)), store(CAPTURE_AT, k(h), k(0)), + ...plan.chats.flatMap((c, i) => when(cmp("==", V(payload), k(i + 1)), c.captures.length + ? [store(EXPECT, k(h), k(c.captures.length))] + : [store(CHAT, k(h), k(i + 1)), store(CHAT_FLAG, k(i * SLOTS + h), k(1)), store(CHAT_PENDING, k(h), k(0))])), + ])); + } + const sync: Stmt[] = []; + if (plan.syncs) sync.push(store(SYNC, bin("+", bin("*", element(SLOT, k(h)), k(SLOTS)), k(h)), bin("+", V(value), k(1)))); + sync.push(store(EXPECT, k(h), bin("-", element(EXPECT, k(h)), k(1)))); + let raw = sync; + if (plan.chats.length) { + const came = fresh("cp"), ci = fresh("ci"); + raw = when(cmp(">=", element(CHAT_PENDING, k(h)), k(1)), [ + dec(came, element(CHAT_PENDING, k(h)), plan.chats.length), dec(ci, element(CAPTURE_AT, k(h)), plan.captures), + ...plan.chats.flatMap((c, i) => c.captures.flatMap((_, cj) => when({ kind: "and", items: [cmp("==", V(came), k(i + 1)), cmp("==", V(ci), k(cj))] }, [store(CAPTURES, k(captureAt(i, cj, h)), V(value))]))), + store(CAPTURE_AT, k(h), bin("+", V(ci), k(1))), + store(EXPECT, k(h), bin("-", element(EXPECT, k(h)), k(1))), + ...when(cmp("==", element(EXPECT, k(h)), k(0)), [ + store(CHAT, k(h), V(came)), + ...plan.chats.flatMap((_, i) => when(cmp("==", V(came), k(i + 1)), [store(CHAT_FLAG, k(i * SLOTS + h), k(1))])), + store(CHAT_PENDING, k(h), k(0)), + ]), + ], sync); + } + // (A point the send pass made: x and y are 1 at the least, which the subtractions lean on. Each a step of its + // own: folded into one sum, the emitter's bit-sliced copy of v got the constant wrong — 2026-10-23.) + const x = fresh("x"), y = fresh("y"); + body.push(dec(x, bin("&", V(v), k(0xffff)), 0xffff), set(x, bin("-", V(x), k(1))), dec(y, bin(">>>", V(v), k(16)), 0xffff), set(y, bin("-", V(y), k(1)))); + body.push(dec(value, bin("+", V(x), bin("*", V(y), k(2 ** plan.xb))), 2 ** plan.bits - 1)); + body.push(...when(cmp("==", element(EXPECT, k(h)), k(0)), header, raw)); + } + out.push(dec(rd, element(READ, cursor(h, ring)), plan.ring - 1), dec(n, k(0), plan.ring), loop([...when(cmp(">=", V(n), k(plan.ring)), [brk()]), set(n, bin("+", V(n), k(1))), ...body])); + } + } + return [...when(cmp("==", V(MADE), k(0)), startUp()), ...out]; + }; + + // ── the send pass ── + /** + * On this computer's player's own: the keys and buttons read into a state and sent when it changed; the mouse on the + * map sent when it moved; a pending sync sent as its header and value; then the player's own selection put back. + * A send is a Select of the ring's next carrier and a Move to the point, 16 bytes into the game's queue as four + * dwords — the queue brought to a dword first with KeepAlive bytes (0x05, which the game passes over). + */ + const send = (): Stmt[] => { + const out: Stmt[] = []; + for (const h of plan.humans) { + const body: Stmt[] = []; + const sent = fresh("sent"); + body.push(dec(sent, k(0), 1)); + /** `at`: the queue's used length, kept in a variable, brought to a multiple of 4 with KeepAlives. */ + const q = fresh("q"); + body.push(dec(q, peek(k(QUEUED)), 0x7fffffff, "routed")); + const align = (): Stmt[] => { + const r = fresh("r"); + return [ + dec(r, bin("&", V(q), k(3)), 3), + ...[1, 2, 3].flatMap((rr) => when(cmp("==", V(r), k(rr)), [poke(bin("+", k(QUEUE), bin(">>>", V(q), k(2))), k((0x05050505 * 2 ** (8 * rr)) >>> 0), (0xffffffff * 2 ** (8 * rr)) >>> 0), set(q, bin("+", V(q), k(4 - rr)))])), + ]; + }; + /** + * A packet to the ring's next carrier when the ring has room for `need` more and the queue for this one: 16 + * bytes; `flag` set to 1 when it went. + */ + const packet = (ring: number, x: NumExpr, y: NumExpr, flag: string, need = 1): Stmt[] => { + const wr = fresh("wr"), ahead = fresh("ahead"), tag = fresh("tag"), w = fresh("w"); + return [ + dec(wr, element(WRITE, cursor(h, ring)), plan.ring - 1), + // (In flight: what the write cursor is ahead of the read one by; a ring with no room waits.) + dec(ahead, bin("+", V(wr), k(plan.ring)), 2 * plan.ring), + set(ahead, bin("-", V(ahead), element(READ, cursor(h, ring)))), + ...when(cmp(">=", V(ahead), k(plan.ring)), [set(ahead, bin("-", V(ahead), k(plan.ring)))]), + ...when(cmp("<=", V(ahead), k(plan.ring - 1 - need)), [ + ...align(), + // (Room in the queue for every packet of the message and the restoring Select, so that none goes without the rest.) + ...when(cmp(">=", peek(k(QUEUE_MOST)), ranged(bin("+", V(q), k(16 * need + 28)), 0, 0x7fffffff)), [ + dec(tag, element(TAGS, bin("+", k(ringBase(h, ring)), V(wr))), 0xffff), + dec(w, bin("+", k(QUEUE), bin(">>>", V(q), k(2))), 0x3fffffff), + poke(V(w), bin("+", k(0x09 | (1 << 8)), bin("*", V(tag), k(0x10000)))), + poke(bin("+", V(w), k(1)), bin("+", bin("+", k(0x15), bin("*", x, k(0x100))), bin("*", bin("&", y, k(0xff)), k(0x1000000)))), + poke(bin("+", V(w), k(2)), bin("+", bin(">>>", y, k(8)), k(0xe4000000))), + poke(bin("+", V(w), k(3)), k(0x05000600)), + set(q, bin("+", V(q), k(16))), + poke(k(QUEUED), V(q)), + set(wr, bin("+", V(wr), k(1))), + ...when(cmp(">=", V(wr), k(plan.ring)), [set(wr, k(0))]), + store(WRITE, cursor(h, ring), V(wr)), + set(sent, k(1)), + set(flag, k(1)), + ]), + ]), + ]; + }; + const message = (value: NumExpr, flag: string, need = 1): Stmt[] => packet(MESSAGES, xOf(value), yOf(value), flag, need); + // The state: a bit a key, unless a line is being typed; then the buttons. + const now = fresh("now"); + body.push(dec(now, k(0), 2 ** (plan.bits - 2) - 1)); + if (plan.keys.length) { + body.push(...when(cmp("==", peek(k(TYPING)), k(0)), plan.keys.flatMap((key, i) => when(cmp(">=", byteAt(KEYS + virtualKey(key)!), k(1)), [set(now, bin("+", V(now), k(2 ** i)))])))); + } + plan.buttons.forEach((b, i) => body.push(...when(cmp(">=", peek(k(BUTTONS), BUTTON_BITS[b]), k(1)), [set(now, bin("+", V(now), k(2 ** (plan.keys.length + i))))]))); + // A bit that was up the two frames before is kept up for one frame it reads down: in Remastered the key table + // drops a held key for a frame when it begins to repeat (played 2026-10-23: a hold of F7 gave a second press + // after 17 frames), so a release goes one frame late and a one-frame gap goes unseen. + const raw = fresh("raw"), kept = fresh("kept"); + body.push( + dec(raw, V(now), 2 ** (plan.bits - 2) - 1), + dec(kept, bin("&", element(READ_1, k(h)), element(READ_2, k(h))), 2 ** (plan.bits - 2) - 1), + set(now, bin("+", V(now), bin("&", V(kept), bin("^", V(now), k(2 ** (plan.bits - 2) - 1))))), + store(READ_2, k(h), element(READ_1, k(h))), store(READ_1, k(h), V(raw)), + ); + const st = fresh("st"); + body.push(...when(cmp("!=", V(now), element(SENT, k(h))), [ + dec(st, k(0), 1), + ...message(bin("+", k(KIND_STATE), bin("*", V(now), k(4))), st), + // (Not sent for want of room: what was sent last is what the receivers hold, and the state goes when there is.) + ...when(cmp("==", V(st), k(1)), [store(SENT, k(h), V(now))]), + ])); + // The mouse on the map, kept within what a Move's target may be. + if (plan.mouse) { + const mx = fresh("mx"), my = fresh("my"); + body.push( + dec(mx, bin("+", peek(k(SCREEN_X)), peek(k(MOUSE_X))), 0xffff), dec(my, bin("+", peek(k(SCREEN_Y)), peek(k(MOUSE_Y))), 0xffff), + ...when(cmp("<", V(mx), k(1)), [set(mx, k(1))]), ...when(cmp(">", V(mx), k(plan.box.w)), [set(mx, k(plan.box.w))]), + ...when(cmp("<", V(my), k(1)), [set(my, k(1))]), ...when(cmp(">", V(my), k(plan.box.h)), [set(my, k(plan.box.h))]), + ); + const mv = fresh("mv"); + body.push(...when({ kind: "or", items: [cmp("!=", V(mx), element(SENT_MX, k(h))), cmp("!=", V(my), element(SENT_MY, k(h)))] }, [ + dec(mv, k(0), 1), + ...packet(POINTS, V(mx), V(my), mv), + ...when(cmp("==", V(mv), k(1)), [store(SENT_MX, k(h), V(mx)), store(SENT_MY, k(h), V(my))]), + ])); + } + // A typed line, matched: the header (the pattern's number), then its values — all or none. + if (plan.chats.length) { + body.push(...typedLines()); + const sh = fresh("sh"), t = fresh("t"); + body.push(...when(cmp(">=", V(TYPED), k(1)), [ + dec(sh, k(0), 1), dec(t, V(TYPED), plan.chats.length), + ...message(bin("+", k(KIND_CHAT), bin("*", V(t), k(4))), sh, 1 + plan.captures), + ...when(cmp("==", V(sh), k(1)), [ + ...plan.chats.flatMap((c, i) => c.captures.length ? when(cmp("==", V(t), k(i + 1)), c.captures.flatMap((_, ci) => { + const f = fresh("cf"), tv = fresh("tv"); + return [dec(f, k(0), 1), dec(tv, element(TYPED_VALUES, k(ci)), 2 ** plan.bits - 1), ...message(V(tv), f)]; + })) : []), + set(TYPED, k(0)), + ]), + ])); + } + // Pending syncs: the header (the slot), then the value — both or neither, so the ring needs room for two. + for (let s = 0; s < plan.syncs; s++) { + const pending = fresh("pend"), sh = fresh("sh"), sv = fresh("sv"); + body.push(dec(pending, element(PENDING, k(s)), SYNC_MOST + 1, "routed")); + body.push(...when(cmp(">=", V(pending), k(1)), [ + dec(sh, k(0), 1), dec(sv, k(0), 1), + ...message(bin("+", k(KIND_SYNC), k(s * 4)), sh, 2), + ...when(cmp("==", V(sh), k(1)), [...message(bin("-", V(pending), k(1)), sv), store(PENDING, k(s), k(0))]), + ])); + } + // The player's own selection put back: a Select of the units in this computer's twelve slots. + const restore: Stmt[] = []; + { + const n = fresh("n"), w = fresh("w"); + restore.push(dec(n, k(0), 12)); + const tags: string[] = []; + for (let i = 0; i < 12; i++) { + const ptr = fresh("sp"), tag = fresh("stag"), index = fresh("six"), uid = fresh("suid"); + tags.push(tag); + restore.push(dec(tag, k(0), 0xffff, "routed")); + // (The slot's pointer over 4: a unit's is at least the table's; its EPD is that less the deaths table's.) + restore.push(...when(cmp("==", V(n), k(i)), [ + dec(ptr, peek(k(SELECTION + i), 0xfffffffc, 2), 0x3fffffff, "routed"), + ...when(cmp(">=", V(ptr), k(UNIT_TABLE / 4)), [ + set(ptr, bin("-", V(ptr), k(DEATHS / 4))), + dec(index, div(bin("-", V(ptr), k(UNIT_TABLE_EPD)), STRIDE / 4), 1699, "routed"), + dec(uid, peek(bin("+", V(ptr), k(UID_DWORD)), 0xff00, 8), 255, "routed"), + set(tag, bin("+", bin("+", V(index), k(1)), bin("*", V(uid), k(2048)))), + set(n, k(i + 1)), + ]), + ])); + } + restore.push(...align()); + restore.push(...when(cmp(">=", peek(k(QUEUE_MOST)), ranged(bin("+", V(q), k(28)), 0, 0x7fffffff)), [ + dec(w, bin("+", k(QUEUE), bin(">>>", V(q), k(2))), 0x3fffffff), + // (The first dword: the command, the count, the first tag; then two tags a dword; KeepAlives after the last.) + poke(V(w), bin("+", bin("+", k(0x09), bin("*", V(n), k(0x100))), bin("*", V(tags[0]), k(0x10000)))), + ...[1, 2, 3, 4, 5, 6].map((d) => poke(bin("+", V(w), k(d)), d < 6 ? bin("+", V(tags[2 * d - 1]), bin("*", V(tags[2 * d]), k(0x10000))) : V(tags[11]))), + // (Where the count leaves a dword's bytes unused, KeepAlives: the Select is 2 + 2n bytes, brought to a multiple of 4.) + ...Array.from({ length: 13 }, (_, c) => c).flatMap((c) => { + const bytes = 2 + 2 * c, dwords = Math.ceil(bytes / 4), pad = dwords * 4 - bytes; + const fixes: Stmt[] = pad ? [poke(bin("+", V(w), k(dwords - 1)), k((0x05050505 * 2 ** (8 * (4 - pad))) >>> 0), (0xffffffff * 2 ** (8 * (4 - pad))) >>> 0)] : []; + return when(cmp("==", V(n), k(c)), [...fixes, set(q, bin("+", V(q), k(dwords * 4)))]); + }), + poke(k(QUEUED), V(q)), + ])); + } + body.push(...when(cmp("==", V(sent), k(1)), restore)); + out.push(...when(cmp("==", local(), k(h)), body)); + } + return out; + }; + + here = program.at; + const body = stmts(program.body); + const functions = program.functions?.map((f) => ({ ...f, body: stmts(f.body) })); + const passed = passes ? { pass: [...receive(), ...(program.pass ?? [])], after: send() } : {}; + return { ...program, body, ...(functions ? { functions } : {}), ...passed, arrays: [...(program.arrays ?? []), ...inputArrays(plan)] }; +} diff --git a/compiler/backend/layout.ts b/compiler/backend/layout.ts new file mode 100644 index 0000000..666e90d --- /dev/null +++ b/compiler/backend/layout.ts @@ -0,0 +1,643 @@ +/** + * A map of our own (TrigScript 4, the backend: our own layout and start-up). A graph (`graph.ts`) laid out as the + * game's triggers at fixed addresses, and the few triggers of the map's own TRIG section that hand the game's trigger + * walk to them — no eudplib, no Python. + * + * Remastered keeps a map's string section at 0x191943C8 (eudplib counts on it: `payload_offset` in its + * `maprw/injector/payload_init.py`), so what is put after the strings is at an address known when the map is built: + * the payload needs no relocation in the game. The start-up is eudplib's stage 1, as small as it goes, in the + * triggers of every player the map gives a human or a computer: the player's last trigger node is found by reading + * their trigger list (0x51A284 + 12 × player, the last node) a bit at a time, and that node's next pointer is set to + * the player's entry in the payload. The start-up's triggers are not preserved: each runs once. + * + * Every player's list is then their entry alone, and the game walks each list once a frame, P1's first. The entry + * sends the walk on through a gate, which lets it into the programs only when no player before this one is in the + * game — so they run once a frame, from the list of the first player still there, and go on when a player leaves + * (TrigScript 4, step 7). The game empties a leaver's list by following its next pointers, zeroing each node's two + * pointers and freeing it (measured in the lifted game): so only the entry is ever reachable from a list, its next + * pointer back at the list's end whenever the walk is not in it, as eudplib's runner keeps its own. Each program's + * run then wakes or not, and the walk ends at a next pointer of 0x80000000 (it stops at a pointer with its top bit + * set, as a list's own end is). + * + * In memory a trigger is a node: the previous and the next pointer, 16 conditions of 20 bytes, 64 actions of 32, the + * flags (4: preserved) and 28 bytes of players. A condition or an action names a dword by its EPD (its distance from + * the deaths table in dwords) in its player field; a mask goes in its first field, with 'SC' in its last. + * + * The game reads no more of a node than its pointers, its conditions up to the first of kind 0, its actions up to the + * first of kind 0, its flags and its current action (the last byte of the players, which it writes each time the + * trigger runs): the rest is another trigger's, or data's (`holds`). So the triggers are stacked as eudplib stacks its own + * (`stack_objects` in its allocator): each where none of the dwords it holds is another's, from where the last one + * went on — a trigger of a few conditions and actions in a little over 200 bytes, not 2,408. A row a doubling trigger + * walks (`Graph.rows`) is stacked as one, its triggers 2,408 bytes apart. + */ +import { AFTER_END, END, NOW, PASS_END, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, type Act, type Cond, type Dword, type Graph, type Value } from "./graph"; +import { TRIGGER_LISTS, WAKE_AT, WAKE_NEXT, WAKE_TICK } from "./emit"; +import type { ScriptString } from "../runtime"; +import type { TriggerRecord } from "../../vendor/triggers"; + +export const STRINGS = 0x191943c8; +const DEATHS = 0x58a364; +const CURRENT_PLAYER = 0x6509b0; +const TRIGGER_WAIT = 0x6509a0; +const MASKED = 0x4353; // 'SC' +const NODE = TRIGGER_BYTES; +const COMPARE = { atLeast: 0, atMost: 1, exactly: 10 } as const; +const MODIFIER = { set: 7, add: 8, subtract: 9 } as const; + +const u = (n: number) => ((n % 2 ** 32) + 2 ** 32) % 2 ** 32; +/** An address as the player field of a Deaths / Set Deaths names it. */ +export const epd = (addr: number) => u((addr - DEATHS) / 4); + +export interface OwnMap { + /** What goes after the string section (its padding to four bytes first). */ + payload: Uint8Array; + /** The TRIG section: the start-up's triggers. */ + trig: Uint8Array; + /** Where the payload's first trigger is in the game. */ + address: number; + /** Where each variable's cell is, by id. */ + vars: Map; + /** Where each printed text's template is: the string the map gives it is to start there. */ + templates: Map; + /** Where each region of cell triggers (an array, a stack) starts: its first cell's trigger. */ + regions: Map; + /** Where each list the graph reads where it is (an array known when built) starts, and its cells' stride in dwords. */ + lists: Map; + /** Where each trigger is, by label. */ + triggers: Map; + /** How many cell triggers hold routed variables, before the regions: the regions' cells follow them. */ + routeCells: number; + /** The payload's cell triggers in all (routed variables, then the regions' cells), 72 bytes apart from `regions`' first or `vars`' first route. */ + cellCount: number; + /** Where the cell triggers start. */ + cellBase: number; +} + +/** + * The dwords of a node a trigger of so many conditions and actions holds, from its start (above): its previous pointer + * when it is in a list of the game's, its next; its conditions, and of the one after them the dwords of its kind and its + * flags (the game asks whether a condition is switched off, then whether it is the last: measured in the lifted game); + * its actions, and the same two of the one after them; its flags and its current action. eudplib holds the whole of the + * condition and the action after; a cell trigger here has held only those two dwords of each since arrays were laid + * out so, played in Remastered. + */ +function holds(conds: number, acts: number, listed: boolean): number[] { + const out = listed ? [0, 1] : [1]; + for (let k = 0; k < 5 * conds; k++) out.push(2 + k); + if (conds < 16) out.push(2 + 5 * conds + 3, 2 + 5 * conds + 4); + for (let k = 0; k < 8 * acts; k++) out.push(82 + k); + if (acts < 64) out.push(82 + 8 * acts + 6, 82 + 8 * acts + 7); + out.push(594, 601); + return out; +} + +/** + * The dwords a cell trigger holds (below): its next pointer, the kind and the flags of its first condition, its action, + * the kind and the flags of the next (the game asks whether a condition or an action is switched off before whether it + * is the last: measured in the lifted game), its flags and current action. A cell's first condition's flags are the + * dword of the next action's kind of the cell five before, and its next action's flags the current action's dword of the + * cell 28 before: 0 for both, and the current action's byte one neither reads. + */ +const CELL_HOLDS = [1, 5, 6, 82, 83, 84, 85, 86, 87, 88, 89, 96, 97, 594, 601]; +/** A cell trigger every 72 bytes: what one holds the others do not. */ +const CELL_DWORDS = 18; + +/** How far back from where the last object went the next is looked for, in dwords. */ +const WINDOW = 4096; + +/** + * The payload's dwords, and which are taken. An object goes at the first dword from where the last one went on where + * none of those it holds is taken; a dword of data, in the first gap; a run of them, at the end. + */ +class Space { + private used = new Uint8Array(1 << 16); + /** One past the last dword taken. */ + top = 0; + /** Where the last object went. */ + private from = 0; + /** No free dword below this. */ + private gap = 0; + taken(i: number): boolean { return i < this.used.length && this.used[i] === 1; } + private take(i: number): void { + if (i >= this.used.length) { + const more = new Uint8Array(Math.max(2 * this.used.length, i + 1)); + more.set(this.used); + this.used = more; + } + this.used[i] = 1; + if (i >= this.top) this.top = i + 1; + } + /** + * An object holding these dwords (from its start; one may name a dword twice), at or past `from`: where it goes. Unset, + * from the first free dword a little before where the last one went: eudplib looks from where its last went on, which + * left a third of the made-texts probe's payload empty; from the first free dword of all, a tenth less again but four + * times as long to lay out. + */ + stack(dwords: number[], from?: number): number { + while (this.taken(this.gap)) this.gap++; + let at = from ?? Math.max(this.gap, this.from - WINDOW); + for (let k = 0; k < dwords.length;) { + if (this.taken(at + dwords[k])) { at++; k = 0; } else k++; + } + for (const d of dwords) this.take(at + d); + this.from = at; + return at; + } + /** A dword of data. */ + dword(): number { + while (this.taken(this.gap)) this.gap++; + this.take(this.gap); + return this.gap; + } + /** `n` dwords one after another, after everything. */ + run(n: number): number { + const at = this.top; + for (let k = 0; k < n; k++) this.take(at + k); + this.top = at + n; + return at; + } +} + +export interface LayoutInput { + /** The one run, when there is one (`runs` unset). */ + graph?: Graph; + /** The programs' runs, in the order they run each frame (`emitOwn`), each with the player it runs as (0-based). */ + runs?: { graph: Graph; owner: number }[]; + /** The byte length of the string section the payload follows. */ + strings: number; + /** The one run's player, 0-based. */ + owner?: number; + /** The players whose lists the payload is linked into: those the map gives a human or a computer (all eight unset). */ + players?: number[]; + /** A text's index in the map's string table (1-based), for Display Text and an action's text or sound file. */ + text: (t: string) => number; + /** The cells of the lists the graph reads where they are, by array id. */ + arrays: Map; + /** + * A printed text's template's index in the map's string table (1-based), for Display Text: the map maker points + * that string at the template, which the layout puts after the triggers (`OwnMap.templates`). + */ + template?: (id: string) => number; + /** + * Where each template already is, when the map maker has put it among the map's strings (the game's own table of + * strings names only its first 64 KB in 1.16.1, which the lifted game is): the layout then leaves them out of the + * payload. Unset, the payload holds them. + */ + templateAt?: Map; + /** + * What each dword no trigger or data holds is, 0 unset: a test fills them so, to show nothing reads them (the + * game, among the triggers stacked in each other's room). + */ + free?: number; + /** + * The map's own triggers (TrigScript 4, step 13), in the order the map's TRIG section would hold them, each with the + * players (0-based slots among `players`) whose lists the game would copy it into. Laid out as the game's nodes in + * the payload — a copy a player, chained after the player's entry and before their gate — in place of the TRIG + * section, which holds the start-up alone. + */ + triggers?: OwnTriggerInput[]; +} + +/** A trigger of the map's own for the layout: its record, with each condition and action as the graph has them (`game`, `record`). */ +export interface OwnTriggerInput { record: TriggerRecord; conds: Cond[]; acts: Act[]; slots: number[] } + +/** + * The dwords of a variable's cell trigger (below) a routing writes — its action's destination, where it goes after — + * and the label the walk is sent to it by. + */ +const ROUTE_DEST = "@route.dest:", ROUTE_NEXT = "@route.next:", ROUTE_CELL = "@route:"; + +/** + * A graph's copies made of triggers: the layout routes a variable's cell as eudplib's build routed every read. A + * variable that is copied keeps its value in the number of the one action of a trigger of its own — + * Set Deaths, add — and a copy into a place is a trigger that sets the place to its constant, points that action at + * the place and its trigger back here, and goes to it: the value added in, about 50 ns a place. A place that holds a + * multiple of the variable other than 1 is filled a bit at a time instead: set to its constant, then one trigger a bit + * of the cell adding that bit times the multiple (about half a microsecond a copy). Returns the variables routed. + */ +function expandCopies(graph: Graph): { graph: Graph; routed: Set } { + const out: Graph["triggers"] = []; + const routed = new Set(); + type T = Graph["triggers"][number]; + const chunks = (xs: X[]) => Array.from({ length: Math.ceil(xs.length / 64) || 1 }, (_, k) => xs.slice(64 * k, 64 * k + 64)); + /** A copy as triggers from `label` on, the last going on to `after` (`falls`: by falling through to what follows). */ + const expand = (copy: NonNullable, label: string, after: string): T[] => { + const made: T[] = []; + const id = copy.from; + const from: Dword = { var: id }; + const plain = copy.into.filter((p) => (p.scale ?? 1) === 1), scaled = copy.into.filter((p) => (p.scale ?? 1) !== 1); + const route = (j: number) => (j === 0 ? label : `${label}.route${j}`); + const bits = (k: number) => (k === 0 && !plain.length ? label : `${label}.clear${k}`); + if (!plain.length && !scaled.length) made.push({ label, conds: [], acts: [] }); + if (plain.length) routed.add(id); + plain.forEach((p, j) => made.push({ + label: route(j), + conds: [], + acts: [ + { dword: p.dword, modifier: "set", value: p.add }, + { dword: { data: ROUTE_DEST + id }, modifier: "set", value: { epd: p.dword } }, + { dword: { data: ROUTE_NEXT + id }, modifier: "set", value: { label: j + 1 < plain.length ? route(j + 1) : scaled.length ? bits(0) : after } }, + ], + next: ROUTE_CELL + id, + })); + if (!scaled.length) return made; + chunks(scaled).forEach((part, k) => made.push({ label: bits(k), conds: [], acts: part.map((p) => ({ dword: p.dword, modifier: "set", value: p.add })) })); + // (Only the bits the value can have, when its range is known never below zero.) + const bits32 = copy.top === undefined ? 32 : Math.max(1, 32 - Math.clz32(copy.top)); + for (let bit = 0; bit < bits32; bit++) chunks(scaled).forEach((part, k) => made.push({ + label: `${label}.bit${bit}.${k}`, + conds: [{ dword: from, compare: "atLeast", value: 1, mask: 2 ** bit }], + acts: part.map((p) => ({ dword: p.dword, modifier: "add", value: u((p.scale ?? 1) * 2 ** bit) })), + })); + // (The last bit's trigger falls through to what follows the copy.) + return made; + }; + /** How many triggers a copy is. */ + const size = (copy: NonNullable) => { + const plain = copy.into.filter((p) => (p.scale ?? 1) === 1).length, scaled = copy.into.filter((p) => (p.scale ?? 1) !== 1); + const bits32 = copy.top === undefined ? 32 : Math.max(1, 32 - Math.clz32(copy.top)); + return plain + (scaled.length ? Math.ceil(scaled.length / 64) * (1 + bits32) : 0); + }; + // The same copy written after several writes of its variable — the same places, from the same cell — is laid out + // once, after the graph, and entered from each: the place it goes back to set first, as a function's return. Each + // copy two triggers more, every copy past the first a trigger instead of its routings and bits. + const keyOf = (copy: NonNullable) => JSON.stringify(copy); + const sites = new Map(); + for (const t of graph.triggers) if (t.copy && size(t.copy) > 4) sites.set(keyOf(t.copy), (sites.get(keyOf(t.copy)) ?? 0) + 1); + const shared = new Map }>(); + graph.triggers.forEach((t, i) => { + if (!t.copy) { out.push(t); return; } + const after = graph.triggers[i + 1]?.label ?? END; + const key = keyOf(t.copy); + if ((sites.get(key) ?? 0) < 2) { out.push(...expand(t.copy, t.label, after)); return; } + let s = shared.get(key); + if (!s) { + const start = `${t.label}.shared`; + s = { start, back: `${start}.back`, copy: t.copy }; + shared.set(key, s); + } + out.push({ label: t.label, conds: [], acts: [{ dword: { at: s.back, field: "next" }, modifier: "set", value: { label: after } }], next: s.start }); + }); + if (shared.size) { + // (After the graph, which goes nowhere by falling: its last trigger ends it.) + const last = out[out.length - 1]; + if (last && last.next === undefined && !last.copy) last.next = END; + for (const s of shared.values()) out.push(...expand(s.copy, s.start, s.back), { label: s.back, conds: [], acts: [], next: END }); + } + return { graph: { ...graph, triggers: out }, routed }; +} + +export function layout(input: LayoutInput): OwnMap { + const runs = (input.runs ?? [{ graph: input.graph!, owner: input.owner ?? 0 }]).map((r) => ({ owner: r.owner, ...expandCopies(r.graph) })); + const players = input.players ?? [0, 1, 2, 3, 4, 5, 6, 7]; + const routed = new Set(runs.flatMap((r) => [...r.routed])); + const pad = -input.strings & 3; + const base = STRINGS + input.strings + pad; + // The payload's triggers. Each run's `@wake` first: when the map's count of frames (`NOW`) has reached the one the run + // sleeps until and its player is in the game (their list not empty), it sends the walk where the run goes on (its + // `@init`, the first time) with the current player theirs; otherwise on to the run's tasks, or the next run. A sleep + // writes `@wake`'s frame and where to go on; the run's end, a frame never reached. `@init`: `@wake` back to going on + // past the program once woken. + const wake = (i: number) => (i ? `@wake#${i}` : "@wake"), init = (i: number) => (i ? `@init#${i}` : "@init"); + const stop = "@stop", run = "@run", entry = (p: number) => `@entry${p}`, gate = (p: number) => `@gate${p}`; + // A run with tasks: the current player set to its owner before its wake, every frame. The game set it to the player + // whose list carries the walk (the last one's), and the wake sets it to the owner only when the program runs; the + // tasks' turn comes while the program sleeps too, and what a task showed then, the owner did not see. (The first + // play of the events probe, 2026-10-24: "back", printed by a task two seconds after a death, never showed.) + const cur = (i: number) => (i ? `@cur#${i}` : "@cur"); + const before = (i: number) => (runs[i].graph.tasks ? cur(i) : wake(i)); + // (A run whose graph holds the map's events pass goes through it first.) + const first = (i: number) => runs[i].graph.pass ?? before(i); + // (A run whose graph holds the map's send pass: the walk goes through it after the last run, before it stops.) + const sendPass = runs.find((r) => r.graph.after)?.graph.after; + const after = (i: number) => (i + 1 < runs.length ? first(i + 1) : sendPass ?? stop); + const list = (p: number) => TRIGGER_LISTS + 12 * p; + const listEnd = (p: number) => u(~(list(p) + 4)); + // (Where each run's graph ends: the next run.) + const ends = new Map(runs.flatMap((_, i): [string, string][] => [[i ? `${END}#${i}` : END, after(i)], [i ? `${PASS_END}#${i}` : PASS_END, before(i)], [i ? `${AFTER_END}#${i}` : AFTER_END, stop]])); + const endOf = (l: string) => ends.get(l) ?? l; + // (`flags`: a node's execution flags as written, a trigger of the map's own; else preserved, or `off`.) + type T = { label: string; conds: Cond[]; acts: Act[]; next?: string | number; prev?: number; off?: boolean; flags?: number }; + // The map's own triggers (step 13): a player's copies chained in order after their entry, the last going on to their + // gate. They are the game's nodes, so the game runs them as it runs a TRIG section's — a trigger that is not + // preserved is switched off once it has run, a Wait holds it in the player's wait slot — and they are reachable only + // from the entry while the walk is in the list, so a leaver's list empties as before. + const own = (p: number, k: number) => `@trig${k}.p${p}`; + const chains = new Map(players.map((p) => [p, (input.triggers ?? []).flatMap((t, k) => (t.slots.includes(p) ? [own(p, k)] : []))])); + const chainTriggers: T[] = (input.triggers ?? []).flatMap((t, k) => t.slots.filter((p) => players.includes(p)).map((p): T => { + const chain = chains.get(p)!, i = chain.indexOf(own(p, k)); + return { label: own(p, k), prev: 0, conds: t.conds, acts: t.acts, next: chain[i + 1] ?? gate(p), flags: t.record.flags }; + })); + const triggers: T[] = runs.flatMap(({ graph, owner }, i) => [ + ...(graph.tasks ? [{ label: cur(i), conds: [], acts: [{ dword: { game: CURRENT_PLAYER }, modifier: "set", value: owner }], next: wake(i) } satisfies T] : []), + { + label: wake(i), + conds: [{ dword: { var: NOW }, compare: "atLeast", value: 0 }, { dword: { game: list(owner) + 8 }, compare: "atMost", value: 0x7fffffff }], + acts: [{ dword: { at: wake(i), field: "next" }, modifier: "set", value: { label: init(i) } }, { dword: { game: CURRENT_PLAYER }, modifier: "set", value: owner }], + next: graph.tasks ?? after(i), + } satisfies T, + { label: init(i), conds: [], acts: [{ dword: { at: wake(i), field: "next" }, modifier: "set", value: { label: graph.tasks ?? after(i) } }], next: graph.triggers[0]?.label ?? after(i) } satisfies T, + ...graph.triggers.map((t, k) => ({ label: t.label, conds: t.conds, acts: t.acts, next: t.next !== undefined ? endOf(t.next) : graph.triggers[k + 1]?.label ?? after(i), ...(t.off ? { off: true } : {}) })), + ]); + triggers.push( + // (Every pass ends here: the game's wait before the next pass set to none, as eudplib's eudTurbo does, so the + // triggers run every frame and not every few seconds.) + { label: stop, conds: [], acts: [{ dword: { game: TRIGGER_WAIT }, modifier: "set", value: 0 }], next: 0x80000000 }, + // Each player's entry, their list's one node: the list made it alone (the start-up's triggers dropped), and the + // walk sent on to the player's own triggers and then the gate — the next pointer goes back to the list's end at + // `@run`, or the next frame's. + ...players.map((p): T => ({ + label: entry(p), + prev: list(p) + 4, + conds: [], + acts: [ + { dword: { game: list(p) + 8 }, modifier: "set", value: { label: entry(p) } }, + { dword: { game: list(p) + 4 }, modifier: "set", value: { label: entry(p) } }, + { dword: { at: entry(p), field: "next" }, modifier: "set", value: { label: chains.get(p)![0] ?? gate(p) } }, + ], + next: listEnd(p), + })), + ...chainTriggers, + // The gate: into the programs when each player after this one has an empty list — so the programs run from the + // last player's list still there, after every player's own triggers, as eudplib's build runs them (its + // RunTrigTrigger before the programs); otherwise the walk ends. + ...players.map((p): T => ({ + label: gate(p), + conds: players.filter((q) => q > p).map((q): Cond => ({ dword: { game: list(q) + 8 }, compare: "atLeast", value: 0x80000000 })), + acts: [{ dword: { at: gate(p), field: "next" }, modifier: "set", value: { label: run } }], + next: listEnd(p), + })), + // Every entry and gate back to ending at its list's end, the count of frames one more, then the first run. + { label: run, conds: [], acts: [...players.flatMap((p): Act[] => [{ dword: { at: entry(p), field: "next" }, modifier: "set", value: listEnd(p) }, { dword: { at: gate(p), field: "next" }, modifier: "set", value: listEnd(p) }]), { dword: { var: NOW }, modifier: "add", value: 1 }], next: first(0) }, + ); + for (const t of runs.flatMap((r) => r.graph.triggers)) if (t.copy) throw new Error(`${t.label}: a copy was not made into triggers`); + // The triggers stacked, a row as one. + const space = new Space(); + const at = new Map(); + const rowOf = new Map(); + for (const row of runs.flatMap((r) => r.graph.rows ?? [])) for (const l of row) rowOf.set(l, row); + const byLabel = new Map(triggers.map((t) => [t.label, t])); + for (const t of triggers) { + if (at.has(t.label)) continue; + const row = rowOf.get(t.label) ?? [t.label]; + const n = space.stack(row.flatMap((l, i) => holds(byLabel.get(l)!.conds.length, byLabel.get(l)!.acts.length, byLabel.get(l)!.prev !== undefined).map((d) => d + (NODE / 4) * i))); + row.forEach((l, i) => at.set(l, base + 4 * n + NODE * i)); + } + // The cell triggers of the variables routed, after the triggers, and then the stack's: eudplib's EUDVariable, 72 + // bytes each — a trigger whose conditions end at once, its action at +328 (destination +344, number +348), the next + // action's kind 0. They overlap: what one trigger reads of its node is bytes the others never read (eudplib lays its + // variables out so). + const routes = [...routed]; + const graphs = runs.map((r) => r.graph); + // (A region every run names — the heap's — is laid out once.) + const regionList = [...new Map(graphs.flatMap((g) => g.cells ?? []).map((r) => [r.id, r])).values()]; + const cellCount = routes.length + regionList.reduce((n, r) => n + r.cells, 0); + const cellBase = cellCount ? base + 4 * space.stack(Array.from({ length: cellCount }, (_, k) => CELL_HOLDS.map((d) => d + CELL_DWORDS * k)).flat(), space.top) : base; + routes.forEach((id, k) => at.set(ROUTE_CELL + id, cellBase + 72 * k)); + const routeOf = (id: string) => at.get(ROUTE_CELL + id); + const regions = new Map(); + let cellTriggers = routes.length; + for (const r of regionList) { + regions.set(r.id, cellBase + 72 * cellTriggers); + cellTriggers += r.cells; + } + const regionOf = (id: string) => regions.get(id); + // The data in the gaps: the variables' cells, the graph's dwords; after everything, the lists it reads (each cell + // `stride` EPD apart) and the templates. + const cells = new Map(); + const cell = (name: string) => { + if (!cells.has(name)) cells.set(name, base + 4 * space.dword()); + return cells.get(name)!; + }; + const lists = new Map(); + const listData: { at: number; values: number[]; stride: number }[] = []; + for (const a of graphs.flatMap((g) => g.arrays ?? [])) { + const values = input.arrays.get(a.id); + if (!values) throw new Error(`the graph reads ${a.id}, a list it was not given`); + const l = base + 4 * space.run(a.stride * Math.max(1, values.length) + 1); + lists.set(a.id, l); + listData.push({ at: l, values, stride: a.stride }); + } + // The templates of texts printed with values, each on a dword's start (where the map maker put them, or here). + const templates = new Map(input.templateAt ?? []); + for (const t of graphs.flatMap((g) => g.templates ?? [])) { + if (t.at !== undefined) templates.set(t.id, t.at); + if (templates.has(t.id)) continue; + templates.set(t.id, base + 4 * space.run(Math.ceil(t.bytes.length / 4))); + } + const field = (d: Extract): number => { + const t = at.get(d.at); + if (t === undefined) throw new Error(`no trigger ${d.at}`); + const f = d.field; + if (f === "next") return t + 4; + if (f === "current") return t + 2404; + if (f === "flags") return t + 2376; + if ("cond" in f) return t + 8 + 20 * f.cond + (f.part === "player" ? 4 : f.part === "mask" ? 0 : 8); + return t + 328 + 32 * f.act + { value: 20, dest: 16, modifier: 24, mask: 0, text: 4, wav: 8, time: 12, flags: 28 }[f.part]; + }; + const address = (d: Dword): number => { + if ("game" in d) return d.game; + if ("var" in d) return routeOf(d.var) !== undefined ? routeOf(d.var)! + 348 : cell(`var:${d.var}`); + if ("data" in d && d.data.startsWith(ROUTE_DEST)) return routeOf(d.data.slice(ROUTE_DEST.length))! + 344; + if ("data" in d && d.data.startsWith(ROUTE_NEXT)) return routeOf(d.data.slice(ROUTE_NEXT.length))! + 4; + // (The fields of its run's `@wake` a sleep writes: the tick its condition compares the game's with, where it goes, + // its next.) + if ("data" in d) { + const [name, i] = d.data.split("#"); + const w = name === WAKE_TICK ? 8 + 8 : name === WAKE_AT ? 328 + 20 : name === WAKE_NEXT ? 4 : undefined; + if (w !== undefined) return at.get(wake(Number(i ?? 0)))! + w; + } + if ("data" in d) return cell(`data:${d.data}`); + // (A scratch cell of the graph's is the cell of a variable named after it, as the copy from it says.) + if ("temp" in d) return address({ var: `@${d.temp}` }); + if ("element" in d && regionOf(d.element) !== undefined) return regionOf(d.element)! + 348; + if ("cell" in d) { + const r = regionOf(d.cell); + if (r === undefined) throw new Error(`no region ${d.cell}`); + return r + 72 * d.index + 348; + } + if ("template" in d) { + const t = templates.get(d.template); + if (t === undefined) throw new Error(`no template ${d.template}`); + return t + 4 * d.word; + } + if ("element" in d) { + const l = lists.get(d.element); + if (l === undefined) throw new Error(`the graph reads ${d.element} without saying so`); + return l; + } + if ("at" in d) return field(d); + throw new Error("a parameter of a function of the Python side's has no place in a map of our own"); + }; + const value = (v: Value): number => { + if (typeof v === "number") return u(v); + if ("epd" in v) return epd(address(v.epd)); + if ("label" in v) { + const t = at.get(endOf(v.label)); + if (t === undefined) throw new Error(`no trigger ${v.label}`); + return t; + } + if ("addrOf" in v && regionOf(v.addrOf) !== undefined) return u(regionOf(v.addrOf)! + v.plus); + if ("addrOf" in v) throw new Error(`a jump into ${v.addrOf}'s cells, which a map of our own does not lay out`); + if (regionOf(v.epdOf) !== undefined) return u(epd(regionOf(v.epdOf)! + 348) + v.plus); + const l = lists.get(v.epdOf); + if (l === undefined) throw new Error(`no list ${v.epdOf}`); + return u(epd(l) + v.plus); + }; + // (Every address first: the cells are allocated as the triggers name them, so the size is known after.) + const encoded = triggers.map((t) => ({ + t, + conds: t.conds.map((c) => ({ c, player: c.game ? 0 : epd(address(c.dword)) })), + acts: t.acts.map((a) => { + const none = a.display !== undefined || a.show !== undefined || a.randomize !== undefined || a.record !== undefined; + return { a, player: none ? 0 : epd(address(a.dword)), number: none ? 0 : value(a.value) }; + }), + })); + const size = 4 * space.top; + const out = new Uint8Array(pad + size); + const dv = new DataView(out.buffer); + const w32 = (addr: number, v: number) => dv.setUint32(pad + addr - base, u(v), true); + if (input.free) for (let i = 0; i < space.top; i++) if (!space.taken(i)) w32(base + 4 * i, input.free); + encoded.forEach(({ t, conds, acts }) => { + const n = at.get(t.label)!; + if (t.prev !== undefined) w32(n, t.prev); + w32(n + 4, typeof t.next === "number" ? t.next : at.get(t.next!)!); + if (conds.length > 16 || acts.length > 64) throw new Error(`${t.label}: too many conditions or actions`); + conds.forEach(({ c, player }, k) => { + const o = n + 8 + 20 * k; + if (c.game) { + // A condition of the game's, as the script wrote it. + const r = c.game; + w32(o, r.location); + w32(o + 4, r.player); + w32(o + 8, r.amount); + dv.setUint16(pad + o + 12 - base, r.unitId, true); + for (const [at, b] of [[14, r.comparison], [15, r.type], [16, r.resource], [17, r.flags]]) dv.setUint8(pad + o + at - base, b); + dv.setUint16(pad + o + 18 - base, r.mask, true); + return; + } + w32(o, c.mask ?? 0); + w32(o + 4, player); + w32(o + 8, c.value); + dv.setUint8(pad + o + 14 - base, COMPARE[c.compare]); + dv.setUint8(pad + o + 15 - base, 15); + if (c.mask !== undefined) dv.setUint16(pad + o + 18 - base, MASKED, true); + }); + acts.forEach(({ a, player, number }, k) => { + const o = n + 328 + 32 * k; + if (a.record) { + // An action of the game's, as the script wrote it: its text and its sound file put among the map's strings. + const r = a.record; + const str = (s: ScriptString | undefined, k: number) => { + if (!k) return 0; + if (!s) throw new Error(`${t.label}: an action's string ${k} with no text given for it`); + return "index" in s ? s.index : input.text(s.text); + }; + w32(o, r.location); + if (a.textOf !== undefined && !input.template) throw new Error(`an action's text (${a.textOf}) with no string to show it by`); + w32(o + 4, a.textOf !== undefined ? input.template!(a.textOf) : str(a.strings?.text, r.text)); + w32(o + 8, str(a.strings?.wav, r.wav)); + w32(o + 12, r.time); + w32(o + 16, r.player); + w32(o + 20, a.amountText ? u(input.text(a.amountText.text) * 2 ** a.amountText.shift) : r.target); + dv.setUint16(pad + o + 24 - base, r.unitId, true); + for (const [at, b] of [[26, r.type], [27, r.modifier], [28, r.flags], [29, r.padding]]) dv.setUint8(pad + o + at - base, b); + dv.setUint16(pad + o + 30 - base, r.mask, true); + return; + } + if (a.randomize !== undefined) { + // Set Switch (13), its switch in the record's number, Randomize (11). + w32(o + 20, a.randomize); + dv.setUint8(pad + o + 26 - base, 13); + dv.setUint8(pad + o + 27 - base, 11); + dv.setUint8(pad + o + 28 - base, 4); + return; + } + if (a.display !== undefined || a.show !== undefined) { + if (a.show !== undefined && !input.template) throw new Error(`a printed text (${a.show}) with no string to show it by`); + w32(o + 4, a.show !== undefined ? input.template!(a.show) : input.text(a.display!)); + dv.setUint8(pad + o + 26 - base, 9); + dv.setUint8(pad + o + 28 - base, 4); + return; + } + w32(o, a.mask ?? 0); + w32(o + 16, player); + w32(o + 20, number); + dv.setUint8(pad + o + 26 - base, 45); + dv.setUint8(pad + o + 27 - base, MODIFIER[a.modifier]); + dv.setUint8(pad + o + 28 - base, 20); + if (a.mask !== undefined) dv.setUint16(pad + o + 30 - base, MASKED, true); + }); + // (Preserved — and done, one passed by; a trigger of the map's own with its flags as written.) + w32(n + 2376, t.flags ?? (t.off ? TRIGGER_OFF : TRIGGER_ON)); + }); + for (const l of listData) l.values.forEach((v, i) => w32(l.at + 4 * l.stride * i, v)); + for (const t of graphs.flatMap((g) => g.templates ?? [])) if (!input.templateAt?.has(t.id) && t.at === undefined) t.bytes.forEach((b, i) => dv.setUint8(pad + templates.get(t.id)! + i - base, b)); + // What a region's cells hold when the map starts. + for (const r of regionList) r.values?.forEach((v, i) => w32(regions.get(r.id)! + 72 * i + 348, v)); + // Each cell trigger: Set Deaths, add (its destination and next pointer written by every routing), preserved. + for (let k = 0; k < cellTriggers; k++) { + const c = cellBase + 72 * k; + dv.setUint8(pad + c + 328 + 26 - base, 45); + dv.setUint8(pad + c + 328 + 27 - base, MODIFIER.add); + dv.setUint8(pad + c + 328 + 28 - base, 20); + w32(c + 2376, 4); + } + const vars = new Map([...cells].filter(([k]) => k.startsWith("var:")).map(([k, a]) => [k.slice(4), a])); + routes.forEach((id, k) => vars.set(id, cellBase + 72 * k + 348)); + return { + payload: out, trig: startUp(players, (p) => at.get(entry(p))!), address: base, vars, templates, regions, + lists: new Map([...lists].map(([id, l]) => [id, { at: l, stride: listData.find((d) => d.at === l)!.stride }])), + triggers: new Map(triggers.map((t) => [t.label, at.get(t.label)!])), + routeCells: routes.length, cellCount, cellBase, + }; +} + +/** + * The start-up, in the map's TRIG section (the game's own triggers): in each player's, their list's last node read a + * bit at a time into the deaths of Player 12's unit 0 (a scratch dword), made the current player's EPD less the one of + * its next pointer; then that next pointer set to the player's entry. Player 12's deaths go back to 0. The first and + * the last trigger are each player's own; those between, every player's (the game copies a trigger into the list of + * each player it is for, and walks one list after another). + */ +function startUp(players: number[], entry: (p: number) => number): Uint8Array { + const records: Uint8Array[] = []; + const SCRATCH = 11; // Player 12's deaths of unit 0 + const record = (owners: number[], conds: { player: number; compare: number; value: number; mask?: number }[], acts: { player: number; modifier: number; value: number }[]) => { + const r = new Uint8Array(2400); + const dv = new DataView(r.buffer); + conds.forEach((c, k) => { + const o = 20 * k; + dv.setUint32(o, c.mask ?? 0, true); + dv.setUint32(o + 4, c.player, true); + dv.setUint32(o + 8, u(c.value), true); + dv.setUint8(o + 14, c.compare); + dv.setUint8(o + 15, 15); + if (c.mask !== undefined) dv.setUint16(o + 18, MASKED, true); + }); + acts.forEach((a, k) => { + const o = 320 + 32 * k; + dv.setUint32(o + 16, a.player, true); + dv.setUint32(o + 20, u(a.value), true); + dv.setUint8(o + 26, 45); + dv.setUint8(o + 27, a.modifier); + dv.setUint8(o + 28, 20); + }); + for (const p of owners) r[2372 + p] = 1; + records.push(r); + }; + const CURRENT = 13; // the player field "current player" + // Each player's list's last node, into the scratch dword. + for (const p of players) record([p], [], [{ player: epd(CURRENT_PLAYER), modifier: 7, value: epd(TRIGGER_LISTS + 4 + 12 * p) }, { player: SCRATCH, modifier: 7, value: 0 }]); + for (let e = 2; e < 32; e++) record(players, [{ player: CURRENT, compare: 0, value: 1, mask: 2 ** e }], [{ player: SCRATCH, modifier: 8, value: 2 ** e }]); + // The current player: the EPD of that node's next pointer. + record(players, [], [{ player: epd(CURRENT_PLAYER), modifier: 7, value: epd(4) }]); + for (let e = 2; e < 32; e++) record(players, [{ player: SCRATCH, compare: 0, value: 1, mask: 2 ** e }], [{ player: epd(CURRENT_PLAYER), modifier: 8, value: 2 ** (e - 2) }]); + // The player's entry after it; the scratch dword back to 0; the current player the player again — the game set it + // when it started on the list, and the map's own triggers after the start-up (step 13) read it (a Wait takes the + // current player's wait slot; Display Text shows to them). + for (const p of players) record([p], [], [{ player: CURRENT, modifier: 7, value: entry(p) }, { player: SCRATCH, modifier: 7, value: 0 }, { player: epd(CURRENT_PLAYER), modifier: 7, value: p }]); + const out = new Uint8Array(2400 * records.length); + records.forEach((r, i) => out.set(r, 2400 * i)); + return out; +} diff --git a/compiler/backend/maps.ts b/compiler/backend/maps.ts new file mode 100644 index 0000000..3003621 --- /dev/null +++ b/compiler/backend/maps.ts @@ -0,0 +1,136 @@ +/** + * The tools' maps of a map of our own (TrigScript 4, step 15): where each trigger of the payload is and the line it + * came from (`TriggerMap`), and where each program's variables and arrays are (`VariableMap`) — the rows the Python + * build wrote into its log for the lifted game, Find an EUD Error, the cost by line, the desync detector, the debugger + * and `sim.program()` to read (`compiler/gameVariables.ts`), made here from the layout instead, and said in the same + * lines (`TRIGSCRIPT-MAP`, `-ROWS`, `-VARS`, `-PROGRAM`) so that every reader of them is unchanged. + * + * - A trigger's origin is the statement its graph trigger was made for ("file:line:column"); a trigger holding several + * statements' work says each action's. A cell trigger (a routed variable, an array's or a stack's cell, the heap's) + * and what no statement made (a run's wake, the gate, a route) have no origin. + * - A unit is two numbers of the program's: the EPD of its record and its uniqueness byte (`units.ts`); a made text is + * its block's EPD and its length (`texts.ts`): said with `epd` set, and the reader makes the address. + * - A per-player program runs once a player, each run with variables of its own (`own.ts`): one row of the map a run, + * its owner that player, its ids the program's own (the run's renaming undone). + */ +import { programDeclarations, type ArrayDecl, type At, type Program, type VarDecl } from "../ir"; +import type { GameArray, GameProgramPlaces, GameVariable, TriggerMap, VariableMap } from "../gameVariables"; +import { STRINGS, type OwnMap } from "./layout"; +import { HEAP } from "./heap"; +import type { Own } from "./own"; + +/** A cell trigger's value: its action's number field. */ +const CELL_VALUE = 348; +const CELL_BYTES = 72; + +export interface OwnMaps { + triggers: TriggerMap; + variables: VariableMap; + /** The maps as lines of a build's log, for the readers of the Python build's. */ + lines: string[]; +} + +/** + * `stringsBefore`: the string section's length before the payload (the payload's offsets count from its end); + * `textBase`: the map's string the pool's first text is (a text variable holds its number in the pool). + */ +export function ownMaps(own: Own, laid: OwnMap, ir: readonly Program[], stringsBefore: number, textBase: number): OwnMaps { + const payload = laid.payload; + const dv = new DataView(payload.buffer, payload.byteOffset, payload.byteLength); + const payloadStart = STRINGS + stringsBefore; + const offsetOf = (address: number) => address - payloadStart; + const kindByte = (address: number, at: number) => { const o = offsetOf(address) + at; return o >= 0 && o < payload.length ? dv.getUint8(o) : 0; }; + /** How many conditions and actions the game reads of a node: up to the first of kind 0. */ + const counts = (address: number): [number, number] => { + let conds = 0; + while (conds < 16 && kindByte(address, 8 + 20 * conds + 15) !== 0) conds++; + let acts = 0; + while (acts < 64 && kindByte(address, 328 + 32 * acts + 26) !== 0) acts++; + return [conds, acts]; + }; + const origin = (at?: At): string => (at ? `${at.file}:${at.line}:${at.column}` : ""); + const rows: TriggerMap["triggers"] = []; + const seen = new Set(); + for (const run of own.runs) { + for (const t of run.graph.triggers) { + const address = laid.triggers.get(t.label); + if (address === undefined || seen.has(address)) continue; + seen.add(address); + const [conds, acts] = counts(address); + const row: TriggerMap["triggers"][number] = [offsetOf(address), conds, acts, origin(t.at)]; + // (A trigger of the emitter's holding several lines' work: each action's line, where any differs from the trigger's.) + if (t.acts.some((a) => a.at && origin(a.at) !== origin(t.at))) row.push(t.acts.slice(0, acts).map((a) => origin(a.at ?? t.at))); + rows.push(row); + } + } + for (const t of own.triggers) for (const slot of t.slots) { + const address = laid.triggers.get(`@trig${own.triggers.indexOf(t)}.p${slot}`); + if (address === undefined || seen.has(address)) continue; + seen.add(address); + const [conds, acts] = counts(address); + rows.push([offsetOf(address), conds, acts, ""]); + } + for (let k = 0; k < laid.cellCount; k++) { + const address = laid.cellBase + CELL_BYTES * k; + if (seen.has(address)) continue; + seen.add(address); + const [conds, acts] = counts(address); + rows.push([offsetOf(address), conds, acts, ""]); + } + rows.sort((a, b) => a[0] - b[0]); + const triggers: TriggerMap = { payload: payload.length, triggers: rows }; + + const heapRegion = laid.regions.get(HEAP); + const programs: GameProgramPlaces[] = own.runs.map((run) => { + const program = ir[run.program]; + const cellOf = (id: string): number | null => laid.vars.get(run.rename(id)) ?? null; + const vars: GameVariable[] = []; + const seenIds = new Set(); + for (const d of programDeclarations(program)) { + if (seenIds.has(d.id) || d.temp || d.task !== undefined) continue; + seenIds.add(d.id); + vars.push(variableRow(d, cellOf, textBase)); + } + const arrays: GameArray[] = []; + for (const a of program.arrays ?? []) { + if (a.task !== undefined) continue; + arrays.push(arrayRow(a, run.rename, laid, cellOf)); + } + return { index: run.program, name: program.name ?? null, perPlayer: !!program.perPlayer, owners: [run.player], vars, arrays }; + }); + const variables: VariableMap = { heap: null, cells: heapRegion === undefined ? null : heapRegion + CELL_VALUE, strings: STRINGS, programs }; + + const lines = [ + "TRIGSCRIPT-MAP " + JSON.stringify({ payload: triggers.payload, count: rows.length }), + ...chunks(rows, 400).map((part) => "TRIGSCRIPT-ROWS " + JSON.stringify(part)), + "TRIGSCRIPT-VARS " + JSON.stringify({ programs: programs.length, heap: variables.heap, cells: variables.cells, strings: variables.strings }), + ...programs.map((p) => "TRIGSCRIPT-PROGRAM " + JSON.stringify(p)), + ]; + return { triggers, variables, lines }; +} + +function variableRow(d: VarDecl, cellOf: (id: string) => number | null, textBase: number): GameVariable { + const base = { id: d.id, kind: d.kind, text: d.kind === "text" ? d.text ?? "id" : null, bits: d.bits ?? null, unsigned: !!d.unsigned, rowed: false, stride: 4 }; + if (d.kind === "unit") return { ...base, cells: { ptr: cellOf(`${d.id}@e`), uid: cellOf(`${d.id}@k`) }, epd: true }; + if (d.kind === "text" && d.text === "made") return { ...base, cells: { addr: cellOf(`${d.id}@e`), len: cellOf(`${d.id}@n`) }, epd: true }; + if (d.kind === "text") return { ...base, cells: { value: cellOf(d.id) }, textBase }; + return { ...base, cells: { value: cellOf(d.id) } }; +} + +function arrayRow(a: ArrayDecl, rename: (id: string) => string, laid: OwnMap, cellOf: (id: string) => number | null): GameArray { + const base = { id: a.id, kind: a.kind, bits: a.bits ?? null, unsigned: !!a.unsigned, length: a.length, rowed: false }; + // One that grows: its handle's cells, its block among the heap's (`heap.ts`). + const ptr = cellOf(`${a.id}@p`), len = cellOf(`${a.id}@n`); + if (ptr !== null || len !== null) return { ...base, dynamic: true, ptr, len, stride: 4 }; + const region = laid.regions.get(rename(a.id)); + if (region !== undefined) return { ...base, at: region + CELL_VALUE, stride: CELL_BYTES }; + const list = laid.lists.get(rename(a.id)); + if (list) return { ...base, at: list.at, stride: 4 * list.stride }; + return { ...base, at: null, stride: 4 }; +} + +function chunks(xs: T[], n: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < xs.length; i += n) out.push(xs.slice(i, i + n)); + return out.length ? out : [[]]; +} diff --git a/compiler/backend/own.ts b/compiler/backend/own.ts new file mode 100644 index 0000000..f933e48 --- /dev/null +++ b/compiler/backend/own.ts @@ -0,0 +1,230 @@ +/** + * A map of our own, as far as the emitter goes: each of a compiled script's programs lowered whole by `emitBody` — + * once for each player it runs for — or why it is not. `scripts/build-own.mts` makes the map from what this gives; the + * ledger (`tests/ledger.test.ts`) asks it of every feature, so that the two refuse the same. + * + * A run is a program as one player runs it (TrigScript 4, step 7): a program for one player has one run, as the first + * of its owners; a per-player program one for each of its owners the map gives a human or a computer, each with + * variables and arrays of its own but for the `shared` ones, which are the program's. The layout runs them one after + * another every frame, in the order of the programs and then of the players, as the Python side's lowering does — and + * a run whose player is not in the game is passed over. + */ +import type { CompileResult } from "../compiler"; +import type { ArrayDecl, FuncDecl, Program, VarDecl } from "../ir"; +import type { ScriptString } from "../runtime"; +import { ActionType, PlayerGroup, TriggerFlag, type TriggerRecord } from "../../vendor/triggers"; +import { ANY_PLAYERS, emitBody, groupSlots, type MapPlayers } from "./emit"; +import { NOW, type Act, type Cond, type Dword, type Graph, type Num, type Trigger, type Value } from "./graph"; +import { TextPool, knownTexts } from "./texts"; +import { HEAP_GLOBALS, growingArrays, heldVariables, isHeapGlobal, loopScoped } from "./heap"; +import { EVENT_GLOBALS, UNIT_GLOBALS, eventSources, isEventGlobal, unitValues } from "./units"; +import { INPUT_GLOBALS, hasInput, inputValues, isInputGlobal, ownInputPlan, type OwnInputPlan, type OwnMap } from "./input"; +import { ownErrors } from "./errors"; + +export interface OwnRun { + /** The program's index among the script's. */ + program: number; + /** The player it runs as, 0-based: the current player while it runs. */ + player: number; + graph: Graph; + /** A variable's or an array's id as this run's graph names it (the first run keeps the program's; each other has its own, but for what is shared). */ + rename: (id: string) => string; +} + +export interface Own { + programs: Program[]; + runs: OwnRun[]; + /** The lists the runs read where they are (an array the script made when it was built), by the id a graph names. */ + values: Map; + /** + * The texts known when built that the programs choose between while the game runs (`texts.ts`), in the order of + * their numbers: the map's strings from `textBase` on are to be these, one after another. + */ + texts: string[]; + /** The players' input the map carries itself (`input.ts`): what the build makes the carriers from; absent when no program reads the players. */ + input?: OwnInputPlan; + /** + * The map's own triggers (TrigScript 4, step 13): the map's before the script's `trigger()` and `hyperTriggers()` + * records, in order, each with the players whose lists it runs in. The layout lays them out as the game's nodes, a + * copy a player, before the player's gate — so that each player's run them in turn, before the programs, as + * eudplib's build runs them. + */ + triggers: OwnTrigger[]; + /** How many hyper triggers (`hyperTriggers()`, or the map's own) were left out of them. */ + hyperTriggersLeftOut: number; +} + +/** A hyper trigger: preserved, most of its actions Wait(0) (`compiler/lower.ts`'s, or a map maker's own). */ +export function isHyperTrigger(t: TriggerRecord): boolean { + return (t.flags & TriggerFlag.Preserve) !== 0 && t.actions.filter((a) => a.type === ActionType.Wait && a.time === 0).length >= 32; +} + +/** + * A trigger of the map's own as a map of our own lays it out: its record as written, its conditions and actions as + * the graph's (`game` / `record`, with the strings a text or a sound file is), and the players whose lists hold a copy — + * the slots this map gives a human or a computer among those the record names, All Players and the forces resolved as + * the game resolves them when it loads the map. A record naming no such player is in no list, as in the game. + */ +export interface OwnTrigger { + record: TriggerRecord; + conds: Cond[]; + acts: Act[]; + slots: number[]; +} + +/** A trigger record as the layout takes it; `string` names a text or a sound file by the record's index. */ +function ownTrigger(record: TriggerRecord, players: MapPlayers, string: (i: number) => ScriptString | undefined): OwnTrigger { + const slots: number[] = []; + for (const [group, on] of record.players.entries()) { + if (!on || group > PlayerGroup.Force4) continue; + // (A player not in the game has no list: a trigger for them alone never runs, as in the game.) + for (const p of groupSlots(group, players) ?? []) if (players.slots.includes(p) && !slots.includes(p)) slots.push(p); + } + slots.sort((a, b) => a - b); + return { + record, + conds: record.conditions.map((c): Cond => ({ dword: { game: 0 }, compare: "atLeast", value: 0, game: c })), + acts: record.actions.map((a): Act => ({ dword: { game: 0 }, modifier: "set", value: 0, record: a, ...(a.text || a.wav ? { strings: { text: string(a.text), wav: string(a.wav) } } : {}) })), + slots, + }; +} + +/** The script's settings a map of our own is built with (`script.ts`'s `ScriptSettings`): the defaults when unset. */ +export interface OwnOptions { + /** The cells of the heap the programs' growing arrays share (`ir.ts`'s `HEAP_CELLS`). */ + heapCells?: number; + /** How many calls deep a function that calls itself may go (`ir.ts`'s `STACK_DEPTH`). */ + stackDepth?: number; +} + +/** What `emitOwn` takes the map for when the build gave none: the players' slots all human, a 128 by 128 map, the last unit-properties slot. */ +const ANY_MAP: OwnMap = { humans: ANY_PLAYERS.slots, width: 128 * 32, height: 128 * 32, cuwp: 64 }; + +/** The players a program runs for, as the Python side's `owner_slots`: each owner's slots, in the order given. */ +function ownerSlots(program: Program, players: MapPlayers): number[] | string { + const slots: number[] = []; + for (const o of program.owners?.length ? program.owners : [program.owner]) { + const of = groupSlots(o, players); + // (Players 9 to 12 run no triggers of their own; the Python side refuses them too.) + if (!of) return `a program owned by ${o < 12 ? `P${o + 1}` : `player group ${o}`}: a program runs for players 1 to 8, All Players or a force`; + for (const p of of) if (!slots.includes(p)) slots.push(p); + } + return slots.length ? slots : "no human or computer player of this map is among the program's owners"; +} + +/** + * `textBase`: the string the first of `Own.texts` is to be among the map's (the next one the map adds; 1 when there is + * no map). + */ +export function emitOwn(r: Pick & Partial>, players: MapPlayers = ANY_PLAYERS, textBase = 1, map: OwnMap = { ...ANY_MAP, humans: players === ANY_PLAYERS ? ANY_MAP.humans : players.slots }, options: OwnOptions = {}): Own | string { + // The map's own triggers, then the script's records (step 13): a record's text or sound file is the script's string + // it names, the map's the map's own string already. Hyper triggers are left out: a map of our own goes through its + // triggers every frame as it is, and under that pass the game holds a list with hyper triggers in it to every second + // frame (measured in the lifted game; eudplib's build is held so too). + const kept = [...(map.triggers ?? []).map((t) => [t, true] as const), ...r.triggers.map((t) => [t, false] as const)].filter(([t]) => !isHyperTrigger(t)); + const hyperTriggersLeftOut = (map.triggers?.length ?? 0) + r.triggers.length - kept.length; + const triggers = kept.map(([t, ofMap]) => ownTrigger(t, players, ofMap ? (i) => (i ? { index: i } : undefined) : (i) => (i ? r.strings[i - 1] : undefined))); + const runs: OwnRun[] = []; + const values = new Map(); + // Every program's texts known when built first: the pool is numbered once they are all in, before any is lowered. + // (What an error needs texts for — an uncaught one said, a stack that ran out — written out before: `errors.ts`.) + const pool = new TextPool(); + const known = r.ir.map((p) => knownTexts(ownErrors(p), pool, textBase)); + const texts = pool.finish(); + // Units as two numbers each (`units.ts`), then arrays that grow on the heap (`heap.ts`), once the texts' numbers are + // in: those passes copy what they rewrite. + // (The map's events pass is one, laid out by the first program that has events, for what all of them loop over.) + const sources = eventSources(known); + const passer = known.findIndex((p) => eventSources(p).size); + // (The players' input likewise: its passes laid out by the first program that reads them.) + // (The names a `{…:unit}` capture of a typed line knows: the ones the compile listed for eudplib's build.) + const planned = ownInputPlan(known, map, r.input?.unitNames); + if (typeof planned === "string") return planned; + const input = planned; + const reader = known.findIndex(hasInput); + const programs = known.map((p, k) => loopScoped(growingArrays(heldVariables(withInput(unitValues(p, { sources, pass: k === passer }, input?.carrier), k)), options.heapCells))); + function withInput(p: Program, k: number): Program { return input && (hasInput(p) || k === reader) ? inputValues(p, input, k === reader) : p; } + for (const [k, program] of programs.entries()) { + const slots = ownerSlots(program, players); + if (typeof slots === "string") return slots; + const decls = new Map(); + const collect = (x: unknown): void => { + if (Array.isArray(x)) x.forEach(collect); + else if (x && typeof x === "object") { + const o = x as Record; + if (o.decl && typeof o.decl === "object" && "id" in (o.decl as object)) decls.set((o.decl as VarDecl).id, o.decl as VarDecl); + for (const v of Object.values(o)) if (v && typeof v === "object") collect(v); + } + }; + collect(program); + // (The heap's own variables, which no program declares, and the count of frames: one cell each for the map.) + for (const d of [...HEAP_GLOBALS, ...UNIT_GLOBALS, ...EVENT_GLOBALS, ...INPUT_GLOBALS, NOW_DECL]) decls.set(d.id, d); + const functions = new Map((program.functions ?? []).map((f) => [f.id, f])); + const arrays = new Map((program.arrays ?? []).map((a) => [a.id, a])); + // The variables and arrays every run of the program has in common: one for every player of a per-player program. + const shared = new Set(program.perPlayer ? [...[...decls.values()].filter((d) => d.shared).map((d) => d.id), ...[...arrays.values()].filter((a) => a.shared).map((a) => a.id)] : []); + // A program for one player runs as the first of its owners (the Python side's `slots[0]`), one for each player + // as each of them, in the order of the slots. + const firstRun = runs.length; + for (const player of program.perPlayer ? [...slots].sort((a, b) => a - b) : slots.slice(0, 1)) { + const n = runs.length; + // (The first run keeps the emitter's names; each other one's are its own, but for what its program shares.) + const graph = emitBody(program.body, decls, n ? `own${n}` : "own", functions, arrays, options.stackDepth, player, players, program.errors, runs.length === firstRun ? program.pass : undefined, runs.length === firstRun ? program.after : undefined); + if (typeof graph === "string") return graph; + const name = (id: string) => (!n || isHeapGlobal(id) || id === NOW || isEventGlobal(id) || isInputGlobal(id) || UNIT_GLOBALS.some((d) => d.id === id) ? id : shared.has(id) ? (k ? `${id}#p${k}` : id) : `${id}#${n}`); + const g = n ? renamed(graph, `#${n}`, name) : graph; + // An action of the game's names its text and its sound file by the script's strings: given here, for the layout. + const of = (i: number) => (i ? r.strings[i - 1] : undefined); + for (const t of g.triggers) for (const a of t.acts) if (a.record && (a.record.text || a.record.wav)) a.strings = { text: of(a.record.text), wav: of(a.record.wav) }; + for (const a of program.arrays ?? []) if (a.values) values.set(name(a.id), a.values); + runs.push({ program: k, player, graph: g, rename: name }); + } + } + if (!runs.length) return "a script with no program"; + return { programs, runs, values, texts, triggers, hyperTriggersLeftOut, ...(input ? { input } : {}) }; +} + +/** + * A run's graph with names of its own: `suffix` added to every label (the graph's end too: the layout sends each run's + * to the run after it), dword of data, scratch cell and template, and every variable and array named as `name` says. + */ +function renamed(g: Graph, suffix: string, name: (id: string) => string): Graph { + const label = (l: string) => l + suffix; + const dword = (d: Dword): Dword => { + if ("var" in d) return { var: name(d.var) }; + if ("data" in d) return { data: d.data + suffix }; + if ("at" in d) return { at: label(d.at), field: d.field }; + // (A scratch cell is the cell of the variable `@` + its name, as a copy from it names it.) + if ("temp" in d) return { temp: d.temp + suffix }; + if ("element" in d) return { element: name(d.element) }; + if ("cell" in d) return { cell: name(d.cell), index: d.index }; + if ("template" in d) return { template: d.template + suffix, word: d.word }; + return d; + }; + const num = (v: Num): Num => (typeof v === "number" ? v : "epdOf" in v ? { epdOf: name(v.epdOf), plus: v.plus } : { addrOf: name(v.addrOf), plus: v.plus }); + const value = (v: Value): Value => (typeof v === "object" && "label" in v ? { label: label(v.label) } : typeof v === "object" && "epd" in v ? { epd: dword(v.epd) } : num(v)); + const cond = (c: Cond): Cond => ({ ...c, dword: dword(c.dword) }); + const act = (a: Act): Act => ({ ...a, dword: dword(a.dword), value: value(a.value), ...(a.show !== undefined ? { show: a.show + suffix } : {}), ...(a.textOf !== undefined ? { textOf: a.textOf + suffix } : {}) }); + const trigger = (t: Trigger): Trigger => ({ + ...t, + label: label(t.label), + conds: t.conds.map(cond), + acts: t.acts.map(act), + ...(t.next !== undefined ? { next: label(t.next) } : {}), + ...(t.copy ? { copy: { ...t.copy, from: name(t.copy.from), into: t.copy.into.map((p) => ({ ...p, dword: dword(p.dword), add: num(p.add) })) } } : {}), + }); + return { + entries: g.entries.map((e) => ({ ...e, from: name(e.from), into: dword(e.into), add: num(e.add) })), + triggers: g.triggers.map(trigger), + ...(g.arrays ? { arrays: g.arrays.map((a) => ({ ...a, id: name(a.id) })) } : {}), + ...(g.cells ? { cells: g.cells.map((c) => ({ ...c, id: name(c.id) })) } : {}), + ...(g.templates ? { templates: g.templates.map((t) => ({ ...t, id: t.id + suffix })) } : {}), + ...(g.rows ? { rows: g.rows.map((r) => r.map(label)) } : {}), + ...(g.tasks ? { tasks: label(g.tasks) } : {}), + ...(g.pass ? { pass: label(g.pass) } : {}), + ...(g.after ? { after: label(g.after) } : {}), + }; +} + +/** The map's count of frames (`NOW`): a variable of every run's, one more each pass. */ +const NOW_DECL: VarDecl = { id: NOW, name: "(frames)", kind: "number", shared: true, routed: true, global: true, at: { file: "", line: 0, column: 0 } }; diff --git a/compiler/backend/texts.ts b/compiler/backend/texts.ts new file mode 100644 index 0000000..7be5366 --- /dev/null +++ b/compiler/backend/texts.ts @@ -0,0 +1,1549 @@ +/** + * Texts known when the map is built, chosen while the game runs (TrigScript 4, the backend: step 8, part 1). A text + * whatever it is in the game is one of a few the script wrote — a `?:` of texts, a list of them read at a value, a + * variable only ever given such texts (`VarDecl.text` "id") — is a number in a map of our own: its place in the + * script's pool of texts, which the map's strings hold one after another from `base` on, sorted as texts compare. So + * nothing of it is ever copied: a print of it alone is Display Text of the string its number names; two of them + * compare as their numbers; what a function of one gives — its length, a search in it, a character, a slice — is + * worked out for each text it can be when the map is built, and looked up. A printed text with such a text in it is + * printed once for each text it can be, under a test of which it is. A text made while the game runs is part 2's. + * + * `knownTexts` rewrites a program's IR so; what it does not take it leaves as it was, for the emitter to refuse. + */ +import { HEAP_CELLS, isTextExpr, programDeclarations, type ArrayDecl, type Call, type FuncDecl, type At, type BoolExpr, type CompareOp, type NumExpr, type Program, type Stmt, type TextExpr, type TextPart, type VarDecl } from "../ir"; + +/** The most texts one choice may be: past it, part 2's way. */ +const MOST_CHOICES = 16; +/** The most times a printed text is printed, once for each text it can be; past it, its choices are written byte by byte. */ +const MOST_PRINTS = 4; +/** The most cells a table of what a function of a text gives at each value of a number may have. */ +const MOST_CELLS = 512; + +/** Texts compared as the simulator compares them: character by character, by their numbers. */ +export function compareTexts(a: string, b: string): number { + const x = [...a], y = [...b]; + for (let i = 0; i < x.length && i < y.length; i++) { + const d = x[i].codePointAt(0)! - y[i].codePointAt(0)!; + if (d) return d; + } + return x.length - y.length; +} + +/** + * The script's texts that a program chooses between while the game runs, numbered once all are known: sorted as texts + * compare, so that a number's order is its text's. The constants and the cells of tables that hold a text's number are + * filled in then. + */ +export class TextPool { + private readonly refs: { node: { value: number }; text: string }[] = []; + private readonly tables: { values: number[]; texts: (string | null)[] }[] = []; + private done: string[] | null = null; + + /** A text's number, as a constant of the IR filled in by `finish`. */ + id(text: string): NumExpr { + if (this.done) throw new Error("the pool of texts is finished"); + const node = { kind: "const" as const, value: 0 }; + this.refs.push({ node, text }); + return node; + } + + /** Cells holding texts' numbers (a cell of `null` keeps its value), filled in by `finish`. */ + table(values: number[], texts: (string | null)[]): void { + this.tables.push({ values, texts }); + } + + get size(): number { + return new Set([...this.refs.map((r) => r.text), ...this.tables.flatMap((t) => t.texts.filter((x): x is string => x !== null))]).size; + } + + /** The texts in the order of their numbers. */ + finish(): string[] { + if (this.done) return this.done; + const all = [...new Set([...this.refs.map((r) => r.text), ...this.tables.flatMap((t) => t.texts.filter((x): x is string => x !== null))])].sort(compareTexts); + const index = new Map(all.map((t, i) => [t, i])); + for (const r of this.refs) r.node.value = index.get(r.text)!; + for (const t of this.tables) t.texts.forEach((x, i) => { if (x !== null) t.values[i] = index.get(x)!; }); + return (this.done = all); + } +} + +/** + * A text that is one of `texts` in the game: `expr` is its number in the pool. `over`: it is the `index`-th of + * `results`, a table's, read at a value (what a function of a text known when built gives at a variable). + */ +interface Choice { expr: NumExpr; texts: string[]; over?: { index: NumExpr; results: string[] } } + +const k = (value: number): NumExpr => ({ kind: "const", value }); +const chars = (s: string) => [...s]; + +/** A number worked out from the values of the variables it reads, when it is of the few kinds this knows. */ +function evaluate(e: NumExpr, env: Map): number | undefined { + switch (e.kind) { + case "const": return e.fixed ? undefined : e.value; + case "var": return env.get(e.id); + case "unary": { const x = evaluate(e.expr, env); return x === undefined ? undefined : -x | 0; } + case "binary": { + const a = evaluate(e.left, env), b = evaluate(e.right, env); + if (a === undefined || b === undefined || e.unsigned) return undefined; + switch (e.op) { + case "+": return (a + b) | 0; + case "-": return (a - b) | 0; + case "*": return Math.imul(a, b); + default: return undefined; + } + } + case "intrinsic": { + if (e.unsigned) return undefined; + const xs = e.args.map((x) => evaluate(x, env)); + if (xs.some((x) => x === undefined)) return undefined; + const v = xs as number[]; + return e.name === "min" ? Math.min(...v) : e.name === "max" ? Math.max(...v) : e.name === "abs" ? Math.abs(v[0]) | 0 : undefined; + } + case "ternary": { + const c = test(e.cond, env); + return c === undefined ? undefined : evaluate(c ? e.whenTrue : e.whenFalse, env); + } + default: return undefined; + } +} + +function test(e: BoolExpr, env: Map): boolean | undefined { + switch (e.kind) { + case "const": return e.value; + case "not": { const x = test(e.expr, env); return x === undefined ? undefined : !x; } + case "compare": { + if (e.unsigned) return undefined; + const a = evaluate(e.left, env), b = evaluate(e.right, env); + if (a === undefined || b === undefined) return undefined; + return compare(e.op, a, b); + } + default: return undefined; + } +} + +const compare = (op: CompareOp, a: number, b: number) => (op === "==" ? a === b : op === "!=" ? a !== b : op === "<" ? a < b : op === "<=" ? a <= b : op === ">" ? a > b : a >= b); + +/** The variables a number reads, or null when it reads anything else of the game's or the program's. */ +function readsOf(e: NumExpr, out = new Set()): Set | null { + switch (e.kind) { + case "const": return out; + case "var": out.add(e.id); return out; + case "unary": return readsOf(e.expr, out); + case "binary": return readsOf(e.left, out) && readsOf(e.right, out); + case "intrinsic": return e.args.every((x) => readsOf(x, out)) ? out : null; + case "ternary": return boolReads(e.cond, out) && readsOf(e.whenTrue, out) && readsOf(e.whenFalse, out); + default: return null; + } +} +function boolReads(e: BoolExpr, out: Set): Set | null { + switch (e.kind) { + case "const": return out; + case "not": return boolReads(e.expr, out); + case "compare": return readsOf(e.left, out) && readsOf(e.right, out); + default: return null; + } +} + +/** + * The values of a variable where numbers of it can be inside `-span … span`: for every part of them that is a line of + * the variable (worked out at a few values), the values that put it there, all of them together, a value either side. + * Null when no part is a line of it, or one is too steep to say. + */ +function windowOf(nums: (NumExpr | undefined)[], v: string, span: number): [number, number] | null { + let lo = Infinity, hi = -Infinity; + const look = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(look); return; } + if (!x || typeof x !== "object") return; + const e = x as NumExpr; + if (typeof e.kind === "string" && e.kind !== "const") { + const f = (t: number) => evaluate(e, new Map([[v, t]])); + const b = f(0), a = f(1) === undefined || b === undefined ? undefined : f(1)! - b; + if (a !== undefined && a !== 0 && [-1000, -7, 3, 1000].every((t) => f(t) === ((b! + a * t) | 0))) { + const ends = [(-span - b!) / a, (span - b!) / a]; + lo = Math.min(lo, Math.floor(Math.min(...ends)) - 1); + hi = Math.max(hi, Math.ceil(Math.max(...ends)) + 1); + } + } + for (const y of Object.values(x)) if (y && typeof y === "object") look(y); + }; + look(nums); + return lo <= hi && hi - lo < 4096 ? [lo, hi] : null; +} + +/** The values a variable can have, as the front end's ranges say where the numbers read it (`[lowest, highest]`). */ +function rangeOf(nums: (NumExpr | undefined)[], id: string): [number, number] | undefined { + let out: [number, number] | undefined; + const look = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(look); return; } + if (!x || typeof x !== "object") return; + const o = x as { kind?: string; id?: string; range?: [number, number] }; + if (o.kind === "var" && o.id === id && o.range) out = out ? [Math.max(out[0], o.range[0]), Math.min(out[1], o.range[1])] : [o.range[0], o.range[1]]; + for (const v of Object.values(o)) if (v && typeof v === "object") look(v); + }; + look(nums); + return out && out[0] <= out[1] ? out : undefined; +} + +/** A number with each scratch variable the front end declared just before it (`decl.temp`) seen through. */ +function through(e: NumExpr, temps: Map): NumExpr { + const map = (x: unknown): unknown => { + if (Array.isArray(x)) return x.map(map); + if (!x || typeof x !== "object") return x; + const o = x as { kind?: string; id?: string }; + if (o.kind === "var" && temps.has(o.id!)) return map(temps.get(o.id!)); + return Object.fromEntries(Object.entries(o).map(([key, v]) => [key, map(v)])); + }; + return map(e) as NumExpr; +} + +/** + * Rewrites a program's texts known when built as numbers of `pool`; `base` is the string the pool's first text is + * among the map's. Gives back the program as rewritten (its arrays, the tables' too). + */ +export function knownTexts(program: Program, pool: TextPool, base: number): Program { + // (Every declaration, a call's parameters and result among them, wherever the call is.) + const decls = new Map(programDeclarations(program).map((d) => [d.id, d])); + const deep = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(deep); return; } + if (!x || typeof x !== "object") return; + const o = x as { decl?: VarDecl }; + if (o.decl && typeof o.decl === "object" && typeof o.decl.id === "string" && !decls.has(o.decl.id)) decls.set(o.decl.id, o.decl); + for (const v of Object.values(o)) if (v && typeof v === "object") deep(v); + }; + deep(program); + const arrays: ArrayDecl[] = [...(program.arrays ?? [])]; + const arrayById = new Map(arrays.map((a) => [a.id, a])); + let n = 0; + const fresh = (what: string) => `@text.${what}${n++}`; + // (A scratch variable the front end declares just before the text that reads it, `(index)` = i + 4: what it was + // set to, so that a table can be of the one variable both read.) + const temps = new Map(); + + // The texts each variable of known texts is ever given: what its declaration and its assignments are, until no + // more come in (one may be given another's). + const idVars = new Map>(); + for (const d of decls.values()) if (d.kind === "text" && d.text === "id") idVars.set(d.id, new Set()); + const writes: { target: string; value: TextExpr }[] = []; + const collect = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(collect); return; } + if (!x || typeof x !== "object") return; + const o = x as Record; + if (o.kind === "declare" && idVars.has((o.decl as VarDecl).id)) writes.push({ target: (o.decl as VarDecl).id, value: o.init as TextExpr }); + if (o.kind === "assignText" && idVars.has(o.target as string)) writes.push({ target: o.target as string, value: o.value as TextExpr }); + for (const v of Object.values(o)) if (v && typeof v === "object") collect(v); + }; + collect([program.body, program.functions ?? []]); + const possible = (t: TextExpr): string[] | null => { + switch (t.kind) { + case "text": return [t.text]; + case "textVar": return idVars.has(t.id) ? [...idVars.get(t.id)!] : null; + case "textTernary": { const a = possible(t.whenTrue), b = possible(t.whenFalse); return a && b ? [...a, ...b] : null; } + case "textOf": { const a = arrayById.get(t.array); return a?.texts ? [...a.texts] : null; } + default: return null; + } + }; + for (let grew = true; grew;) { + grew = false; + for (const w of writes) { + const s = idVars.get(w.target)!; + for (const t of possible(w.value) ?? []) if (!s.has(t)) { s.add(t); grew = true; } + } + } + // (A variable given a text it cannot know is the emitter's to refuse: its texts stay unknown.) + for (const w of writes) if (!possible(w.value)) idVars.delete(w.target); + + /** A text's choice, its numbers and conditions rewritten; null when it is not one of texts known when built. */ + const choice = (t: TextExpr): Choice | null => { + switch (t.kind) { + case "text": return { expr: pool.id(t.text), texts: [t.text] }; + case "textVar": { + const s = idVars.get(t.id); + return s ? { expr: { kind: "var", id: t.id }, texts: [...s] } : null; + } + case "textTernary": { + const a = choice(t.whenTrue), b = choice(t.whenFalse); + if (!a || !b) return null; + const texts = [...new Set([...a.texts, ...b.texts])]; + if (texts.length > MOST_CHOICES) return null; + return { expr: { kind: "ternary", cond: bool(t.cond), whenTrue: a.expr, whenFalse: b.expr, at: t.at, label: t.label }, texts }; + } + case "textOf": { + const a = arrayById.get(t.array); + if (!a?.texts || !a.values) return null; + // (A list of the texts' numbers in the pool, read at the index.) + const id = `${a.id}@texts`; + if (!arrayById.has(id)) { + const values = a.values.map(() => 0); + pool.table(values, a.values.map((v) => a.texts![v] ?? "")); + const decl: ArrayDecl = { id, name: a.name, kind: "number", length: values.length, shared: true, values, at: a.at }; + arrays.push(decl); + arrayById.set(id, decl); + } + return { expr: { kind: "element", array: id, index: num(t.index), at: t.at }, texts: [...new Set(a.texts)] }; + } + case "textSlice": { + const of = choice(t.of); + if (!of) return null; + return made(of, [t.start, t.end], ([s], [start, end]) => { + const c = chars(s); + const a = start === undefined ? 0 : Math.min(c.length, Math.max(0, start)), b = end === undefined ? c.length : Math.min(c.length, Math.max(0, end)); + return c.slice(a, Math.max(a, b)).join(""); + }, t.at); + } + default: return null; + } + }; + + /** + * What `f` gives — a number, or a text — of the texts `of` and the numbers `nums` (absent: undefined), worked out + * when built: for each text the one choice can be, the numbers known; or, the texts one each, for each value of + * the one variable the numbers read. Null when there is more to it than that. + */ + const table = (of: Choice[], nums: (NumExpr | undefined)[], f: (texts: string[], nums: (number | undefined)[]) => R, at: At): { expr: NumExpr; results: R[]; index?: NumExpr } | null => { + const many = of.filter((c) => c.texts.length > 1); + const known = nums.map((x) => (x === undefined ? undefined : evaluate(x, new Map()))); + const fixed = nums.every((x, i) => x === undefined || known[i] !== undefined); + const value = (r: R): NumExpr => (typeof r === "string" ? pool.id(r) : k(r)); + if (!many.length && fixed) { + const r = f(of.map((c) => c.texts[0]), known); + return { expr: value(r), results: [r] }; + } + if (many.length === 1 && fixed) { + // One choice: a test of which text it is, for each but the last. + const c = many[0]; + if (c.texts.length > MOST_CHOICES) return null; + const results = c.texts.map((s) => f(of.map((x) => (x === c ? s : x.texts[0])), known)); + let expr = value(results[results.length - 1]); + for (let i = results.length - 2; i >= 0; i--) expr = { kind: "ternary", cond: { kind: "compare", op: "==", left: c.expr, right: pool.id(c.texts[i]), at, label: "" }, whenTrue: value(results[i]), whenFalse: expr, at, label: "" }; + return { expr, results }; + } + if (many.length === 1 && many[0].texts.length <= MOST_CHOICES) { + // One choice, and numbers that read a variable: for each text it can be, a table of the variable (below), the one + // its text is picked by a test of which it is. + const c = many[0]; + const each = c.texts.map((s) => table(of.map((x) => (x === c ? { expr: pool.id(s), texts: [s] } : x)), nums, f, at)); + if (each.some((t) => !t)) return null; + let expr = each[each.length - 1]!.expr; + for (let i = each.length - 2; i >= 0; i--) expr = { kind: "ternary", cond: { kind: "compare", op: "==", left: c.expr, right: pool.id(c.texts[i]), at, label: "" }, whenTrue: each[i]!.expr, whenFalse: expr, at, label: "" }; + return { expr, results: each.flatMap((t) => t!.results) }; + } + if (many.length) return null; + // The texts known; the numbers read one variable: a table of what f gives at each of its values, as far as it + // changes, read at the variable held between the table's ends. + nums = nums.map((x) => (x ? through(x, temps) : x)); + const reads = new Set(); + for (const x of nums) if (x && !readsOf(x, reads)) return null; + if (reads.size !== 1) return null; + const [v] = reads; + const texts = of.map((c) => c.texts[0]); + const at_ = (i: number) => { const env = new Map([[v, i]]); return f(texts, nums.map((x) => (x === undefined ? undefined : evaluate(x, env)))); }; + // Where it can change: where each part of the numbers that moves with the variable as a line does (`c - 97`) is + // within the texts' lengths, and a little more; past those it is the same as at their ends. + const span = Math.max(...texts.map((s) => chars(s).length)) + 2; + const window = windowOf(nums, v, span); + if (!window) return null; + let [lo, hi] = window; + if (at_(lo) !== at_(lo - 1000) || at_(hi) !== at_(hi + 1000) || at_(lo) !== at_(lo - 1) || at_(hi) !== at_(hi + 1)) return null; + while (lo < hi && at_(lo + 1) === at_(lo)) lo++; + while (hi > lo && at_(hi - 1) === at_(hi)) hi--; + // (Within the values the variable can have, when the front end's ranges know them.) + const range = rangeOf(nums, v); + if (range) { lo = Math.max(lo, range[0]); hi = Math.max(lo, Math.min(hi, range[1])); } + if (hi - lo + 1 > MOST_CELLS) return null; + const results: R[] = []; + for (let i = lo; i <= hi; i++) results.push(at_(i)); + const id = fresh("table"); + const values = results.map((r) => (typeof r === "number" ? r : 0)); + if (results.some((r) => typeof r === "string")) pool.table(values, results.map((r) => (typeof r === "string" ? r : null))); + arrays.push({ id, name: id, kind: "number", length: values.length, shared: true, values, at }); + // (The variable held between lo and hi, as a value of the table's place: lo from it.) + const held: NumExpr = { kind: "intrinsic", name: "max", args: [k(lo), { kind: "intrinsic", name: "min", args: [{ kind: "var", id: v }, k(hi)], at, label: "" }], at, label: "" }; + const index: NumExpr = { kind: "binary", op: "-", left: held, right: k(lo), at, label: "" }; + return { expr: { kind: "element", array: id, index, at }, results, index }; + }; + const made = (of: Choice, nums: (NumExpr | undefined)[], f: (texts: string[], nums: (number | undefined)[]) => string, at: At): Choice | null => { + const t = table([of], nums.map((x) => (x ? num(x) : undefined)), f, at); + if (!t) return null; + return { expr: t.expr, texts: [...new Set(t.results)], ...(t.index ? { over: { index: t.index, results: t.results } } : {}) }; + }; + + const num = (e: NumExpr): NumExpr => { + switch (e.kind) { + case "textLength": { + if (madeOf(e.of)) return V(madeOf(e.of)!.c); + const of = choice(e.of); + const t = of && table([of], [], ([s]) => chars(s).length, e.at); + return t ? t.expr : e; + } + case "textIndexOf": { + const of = choice(e.of), find = choice(e.find); + if (!of || !find) return e; + const t = table([of, find], [e.from ? num(e.from) : undefined], ([s, f], [from]) => { + const c = chars(s), start = Math.max(0, from ?? 0); + const i = s.indexOf(f, c.slice(0, start).join("").length); + return i < 0 || start > c.length ? (f === "" && start <= c.length ? start : -1) : chars(s.slice(0, i)).length; + }, e.at); + return t ? t.expr : e; + } + case "textCode": { + const of = choice(e.of); + const t = of && table([of], [num(e.index)], ([s], [i]) => chars(s)[i!]?.codePointAt(0) ?? -1, e.at); + return t ? t.expr : e; + } + default: return copy(e) as NumExpr; + } + }; + const bool = (e: BoolExpr): BoolExpr => { + switch (e.kind) { + case "textCompare": { + const a = choice(e.left), b = choice(e.right); + // A made text and a known one, the same or not. + const mv = madeOf; + const [m, known] = mv(e.left) && b?.texts.length === 1 ? [mv(e.left), b.texts[0]] : mv(e.right) && a?.texts.length === 1 ? [mv(e.right), a.texts[0]] : [undefined, undefined]; + if (m && known !== undefined && (e.op === "==" || e.op === "!=")) return equalsKnown(m, known, e.op); + if (!a || !b) return e; + // (Numbered as their texts compare: the same text, the same number.) + return { kind: "compare", op: e.op, left: a.expr, right: b.expr, at: e.at, label: e.label }; + } + case "textTest": { + const of = choice(e.of), find = choice(e.find); + const t = of && find && table([of, find], [], ([s, f]) => (e.test === "startsWith" ? s.startsWith(f) : e.test === "endsWith" ? s.endsWith(f) : s.includes(f)) ? 1 : 0, e.at); + return t ? { kind: "compare", op: "==", left: t.expr, right: k(1), at: e.at, label: e.label } : e; + } + default: return copy(e) as BoolExpr; + } + }; + + /** + * Anything else with every number, condition and statement inside it rewritten: a list of statements as statements, + * a number or a condition of a text as one (`num`, `bool`), anything else field by field. + */ + const NUMS = new Set(["textLength", "textIndexOf", "textCode"]), BOOLS = new Set(["textCompare", "textTest"]); + const walk = (x: unknown): unknown => { + if (Array.isArray(x)) return x.length && isStmt(x[0]) ? stmts(x as Stmt[]) : x.map(walk); + if (!x || typeof x !== "object") return x; + const kind = (x as { kind?: unknown }).kind; + if (typeof kind === "string" && NUMS.has(kind)) return num(x as NumExpr); + if (typeof kind === "string" && BOOLS.has(kind)) return bool(x as BoolExpr); + return copy(x); + }; + const copy = (x: unknown): unknown => { + const out: Record = {}; + for (const [key, v] of Object.entries(x as Record)) out[key] = v && typeof v === "object" ? walk(v) : v; + return out; + }; + + + // ── Texts made while the game runs (part 2) ── + // + // A made text variable has a block of its own — a string of the map's, so that printing it alone is Display Text of + // it as it is — and two numbers beside it: its length in bytes and in characters. Its block's EPD is a variable set + // once where it is declared (`textBlock`). A text is made at the program's one cursor — a dword (an EPD), the byte in + // it, and the bytes and characters written — from a block's start: a known text's bytes as constant writes (one test + // of the byte the cursor is at), anything else by the run time's functions, laid out once for the program and called + // (`fn`): another text's dwords copied whole, a number's digits, one byte. What a text needs made first — a call's + // result, a text read from — is made before the text it is in is begun, so that one text is made at a time. + interface Made { e: string; n: string; c: string; block: string } + const madeVars = new Map(); + for (const d of decls.values()) if (d.kind === "text" && d.text === "made") madeVars.set(d.id, { e: `${d.id}@e`, n: `${d.id}@n`, c: `${d.id}@c`, block: `${d.id}@block` }); + const SCRATCH: Made = { e: "@text.scratch@e", n: "@text.scratch@n", c: "@text.scratch@c", block: "@text.scratch" }; + // (A buffer JSX is put together in — `buildText`, read as `built` — is a made text of its own.) + const buffers = new Map(); + const bufferOf = (id: string): Made => { + if (!buffers.has(id)) buffers.set(id, { e: `${id}@e`, n: `${id}@n`, c: `${id}@c`, block: `${id}@block` }); + return buffers.get(id)!; + }; + /** The made text a text is as it stands — a made variable, or a buffer as built — if it is one. */ + const madeOf = (t: TextExpr): Made | undefined => (t.kind === "textVar" ? madeVars.get(t.id) : t.kind === "built" ? bufferOf(t.buffer) : undefined); + let here: At = { file: "", line: 0, column: 0 }; + const V = (id: string): NumExpr => ({ kind: "var", id }); + const bin = (op: "+" | "-" | "*" | "&" | ">>>", a: NumExpr, b: NumExpr): NumExpr => ({ kind: "binary", op, left: a, right: b, at: here, label: "" }); + const cmp = (op: CompareOp, a: NumExpr, b: NumExpr, unsigned = false): BoolExpr => ({ kind: "compare", op, left: a, right: b, ...(unsigned ? { unsigned: true } : {}), at: here, label: "" }); + const set = (target: string, value: NumExpr): Stmt => ({ kind: "assign", target, value, at: here, label: "" }); + const add = (target: string, n: NumExpr | number): Stmt => set(target, bin("+", V(target), typeof n === "number" ? k(n) : n)); + const dec = (id: string, init: NumExpr, routed = false): Stmt => ({ kind: "declare", decl: { id, name: "(text)", kind: "number", shared: false, at: here, ...(routed ? { routed: true } : {}) }, init, at: here, label: "" }); + const when = (cond: BoolExpr, then: Stmt[], otherwise?: Stmt[]): Stmt => ({ kind: "if", cond, then, ...(otherwise?.length ? { else: otherwise } : {}), at: here, label: "" }); + const loop = (cond: BoolExpr, body: Stmt[]): Stmt => ({ kind: "while", cond, body, at: here, label: "" }); + const all = (...items: BoolExpr[]): BoolExpr => ({ kind: "and", items }); + const any = (...items: BoolExpr[]): BoolExpr => ({ kind: "or", items }); + const u32 = (n: number) => ((n % 2 ** 32) + 2 ** 32) % 2 ** 32; + const ranged = (e: NumExpr, lo: number, hi: number): NumExpr => ({ ...e, range: [lo, hi] }) as unknown as NumExpr; + /** The bytes `from` to `to` (0 to 3) of a dword, as a mask. */ + const bytesMask = (from: number, to: number) => { let m = 0; for (let j = from; j <= to; j++) m += 0xff * 2 ** (8 * j); return m; }; + /** A Set Deaths of the dword at an EPD: the value written, or its bits of the mask. */ + const poke = (epd: NumExpr, value: NumExpr, mask?: number): Stmt => { + const whole = mask === undefined || u32(mask) === 0xffffffff; + const record = { location: whole ? 0 : u32(mask!), text: 0, wav: 0, time: 0, player: 0, target: value.kind === "const" ? u32(value.value) : 0, unitId: 0, type: 45, modifier: 7, flags: 0, padding: 0, mask: whole ? 0 : 0x4353 }; + const variables = [{ field: "player" as const, bits: 32 as const, name: "at", expr: epd }, ...(value.kind === "const" ? [] : [{ field: "target" as const, bits: 32 as const, name: "n", expr: value }])]; + return { kind: "action", record, variables, at: here, label: "" }; + }; + /** The dword at an EPD, or its bits of the mask moved down `shift` (up, below 0). */ + const peek = (epd: NumExpr, mask?: number, shift = 0): NumExpr => { + const whole = mask === undefined; + const r: NumExpr = { kind: "read", read: { source: "condition", record: { location: whole ? 0 : u32(mask!), player: 0, amount: 0, unitId: 0, comparison: 0, type: 15, resource: 0, flags: 0, mask: whole ? 0 : 0x4353 }, epd }, at: here, label: "" } as NumExpr; + if (shift > 0) return { kind: "binary", op: ">>>", left: r, right: k(shift), at: here, label: "" }; + if (shift < 0) return { kind: "binary", op: "*", left: r, right: k(2 ** -shift), at: here, label: "" }; + return r; + }; + /** + * Through the current player, as eudplib reads and writes: a condition's or an action's player field 13 is the dword + * the current player is the EPD of, so one write of the current player points every read and write after it there + * (no place of the address in each), and moving it on is an addition. Every function that moves it gives it back to + * the program's own player (`ME`) before it ends. + */ + const AT_CURRENT = k(CURRENT_PLAYER); + const pointAt = (epd: NumExpr): Stmt => poke(k(CURRENT), epd); + const moveBy = (by: NumExpr): Stmt => ({ kind: "action", record: { location: 0, text: 0, wav: 0, time: 0, player: 0, target: by.kind === "const" ? u32(by.value) : 0, unitId: 0, type: 45, modifier: 8, flags: 0, padding: 0, mask: 0 }, variables: [{ field: "player", bits: 32, name: "at", expr: k(CURRENT) }, ...(by.kind === "const" ? [] : [{ field: "target" as const, bits: 32 as const, name: "n", expr: by }])], at: here, label: "" } as Stmt); + /** One of four statement lists by the byte a cursor is at (0 to 3). */ + const byByte = (at: string, each: (o: number) => Stmt[]): Stmt => when(cmp("==", V(at), k(0)), each(0), [when(cmp("==", V(at), k(1)), each(1), [when(cmp("==", V(at), k(2)), each(2), each(3))])]); + /** The byte at a dword and a byte of it, into a variable. */ + const readByte = (target: string, e: string, o: string): Stmt[] => [pointAt(V(e)), byByte(o, (b) => [set(target, peek(AT_CURRENT, 0xff * 2 ** (8 * b), 8 * b))]), pointAt(V(ME))]; + /** Whether a byte starts a character (UTF-8: not 10xxxxxx). */ + const starts = (b: string): BoolExpr => any(cmp("<", V(b), k(0x80), true), cmp(">=", V(b), k(0xc0), true)); + const enc = new TextEncoder(); + /** The most a known text written at once may run past a made text's most: the room a block has after it. */ + const SLACK = 256; + const BLOCK = TEXT_BYTES + 1 + SLACK; + + // The run time: its variables, one of each for the program (declared at its start), and its functions. + const globals = new Set(); + const G = (name: string) => { const id = `@t.${name}`; globals.add(id); return id; }; + const CE = G("ce"), CO = G("co"), CN = G("cn"), CC = G("cc"), B = G("b"), SE = G("se"), SN = G("sn"), SC = G("sc"), RV = G("rv"); + const runtime = new Map(); + /** A call of the run time's function `name`, its body made the first time it is called. */ + const fn = (name: string, body: () => Stmt[]): Stmt => { + const id = `@text.fn.${name}`; + if (!runtime.has(id)) { + runtime.set(id, null); + const at = here; + runtime.set(id, { id, name: id, params: [], body: body(), at }); + } + return { kind: "call", call: { fn: id, name: id, at: here, label: "", params: [], body: [] }, at: here, label: "" } as Stmt; + }; + /** The cursor at a block's start. */ + const begin = (e: NumExpr): Stmt[] => [set(CE, e), set(CO, k(0)), set(CN, k(0)), set(CC, k(0))]; + /** + * A known text's bytes at the cursor. Up to a block's room past its most: written by a function of the run time's of + * its own (one a text, called wherever it is written), in one trigger for each byte of a dword the cursor may be at — + * the current player pointed at the cursor and moved on a dword at each write, as eudplib writes a known text, then + * back to the program's own player (`ME`, read at the program's start). Nothing written once the text is at its most. + * A longer one: copied from a block of the program's that holds it, as a made text's are. + */ + const known = new Map(); + const ME = G("me"); + const CURRENT = (0x6509b0 - DEATHS) / 4; + const written = new Map(); + /** + * The current player pointed at the cursor: one function for every known text, so that the cursor is read in one + * action (an action's field from a value is a place its variable is copied into at every write). + */ + const point = (): Stmt => fn("point", () => [poke(k(CURRENT), V(CE))]); + const writeKnown = (text: string): Stmt => { + if (!written.has(text)) written.set(text, written.size); + return fn(`known${written.get(text)}`, () => { + const bytes = [...enc.encode(text)]; + const ADV = G(`adv${written.get(text)}`); + const variant = (o: number): Stmt[] => { + const out: Stmt[] = []; + for (let d = 0; 4 * d < o + bytes.length; d++) { + let value = 0, mask = 0; + for (let j = 0; j < 4; j++) { + const b = 4 * d + j - o; + if (b < 0 || b >= bytes.length) continue; + value += bytes[b] * 2 ** (8 * j); + mask += 0xff * 2 ** (8 * j); + } + if (d) out.push(moveBy(k(1))); + out.push(toCurrent(k(value), mask)); + } + // (How many dwords the cursor goes on, set here and added once after, with the current player given back: the + // cursor written, and the program's player read, in one place.) + const to = o + bytes.length; + return [...out, set(ADV, k(to >> 2)), set(CO, k(to & 3))]; + }; + return [when(cmp("<", V(CN), k(TEXT_BYTES), true), [point(), byByte(CO, variant), pointAt(V(ME)), add(CE, V(ADV))]), add(CN, bytes.length), add(CC, chars(text).length)]; + }); + }; + const writeConst = (text: string): Stmt[] | null => { + const b = enc.encode(text).length; + if (!b) return []; + if (b <= SLACK) return [writeKnown(text)]; + if (!known.has(text)) known.set(text, `@text.known${known.size}`); + const id = known.get(text)!; + return [set(SE, V(`${id}@e`)), set(SN, k(b)), set(SC, k(chars(text).length)), copyText()]; + }; + /** + * A dword where the current player points: a value, or its bytes of the mask. (The cursor is read through the + * current player, in one place a function: a variable read in an action's field is copied into each such place at + * every write, and the cursor is written at every text.) + */ + const toCurrent = (value: NumExpr, mask = 0xffffffff): Stmt => { + const whole = u32(mask) === 0xffffffff; + return { kind: "action", record: { location: whole ? 0 : u32(mask), text: 0, wav: 0, time: 0, player: CURRENT_PLAYER, target: value.kind === "const" ? u32(value.value) : 0, unitId: 0, type: 45, modifier: 7, flags: 0, padding: 0, mask: whole ? 0 : 0x4353 }, ...(value.kind === "const" ? {} : { variables: [{ field: "target", bits: 32, name: "n", expr: value }] }), at: here, label: "" } as Stmt; + }; + /** The byte in `B` at the cursor, the cursor on past it (a variable for each byte of a dword it may go in). */ + const put = (): Stmt => fn("put", () => [ + pointAt(V(CE)), + byByte(CO, (o) => { + if (!o) return [toCurrent(V(B), 0xff)]; + const b = G(`b${o}`); + return [set(b, ranged(V(B), 0, 255)), toCurrent(bin("*", V(b), k(2 ** (8 * o))), 0xff * 2 ** (8 * o))]; + }), + pointAt(V(ME)), + add(CO, 1), + when(cmp("==", V(CO), k(4)), [set(CO, k(0)), add(CE, 1)]), + add(CN, 1), + ]); + /** A character of one byte (a value) at the cursor. */ + const putChar = (byte: NumExpr): Stmt[] => [set(B, byte), put(), add(CC, 1)]; + /** + * The made text at `SE` (`SN` bytes, `SC` characters) at the cursor, no more than a made text holds: its dwords whole + * when the cursor is at a dword's start; else each dword read once, as two masked reads that put its bytes where they + * go in the two dwords it falls in. What runs past its end is written over, or is past the 0 the text ends with. Read + * and written through the current player (`pointAt`), pointed at the one and then the other a dword at a time. + */ + const copyText = (): Stmt => fn("copy", () => { + const n = G("n"), w = G("w"), i = G("i"), pos = G("pos"); + const write = toCurrent; + return [ + set(n, V(SN)), + when(cmp(">=", V(CN), k(TEXT_BYTES)), [set(n, k(0))], [when(cmp(">", V(n), bin("-", k(TEXT_BYTES), V(CN)), true), [set(n, bin("-", k(TEXT_BYTES), V(CN)))])]), + set(w, bin(">>>", bin("+", V(n), k(3)), k(2))), + set(i, k(0)), + // (Each byte of a dword the cursor may be at has the text's and the cursor's dwords of its own, each read in one + // place: the current player pointed at the one, then the other.) + byByte(CO, (o) => { + const p = G(`p${o}`), q = G(`q${o}`), step = [add(p, 1), add(q, 1), add(i, 1)]; + const from = [set(p, V(SE)), set(q, V(CE))]; + if (o === 0) { + const d = G("d0"); + return [...from, loop(cmp("<", V(i), V(w)), [pointAt(V(p)), set(d, peek(AT_CURRENT)), pointAt(V(q)), write(V(d)), ...step])]; + } + const lo = G(`lo${o}`), hi = G(`hi${o}`); + return [...from, loop(cmp("<", V(i), V(w)), [ + pointAt(V(p)), + set(lo, peek(AT_CURRENT, 2 ** (32 - 8 * o) - 1, -8 * o)), + set(hi, peek(AT_CURRENT, u32(-(2 ** (32 - 8 * o))), 32 - 8 * o)), + pointAt(V(q)), write(V(lo), bytesMask(o, 3)), moveBy(k(1)), write(V(hi), bytesMask(0, o - 1)), + ...step, + ])]; + }), + pointAt(V(ME)), + set(pos, bin("+", V(CO), V(n))), + add(CE, bin(">>>", V(pos), k(2))), + set(CO, bin("&", V(pos), k(3))), + add(CN, V(n)), + add(CC, V(SC)), + ]; + }); + /** A made text at the cursor. */ + const writeMade = (m: Made): Stmt[] => [set(SE, V(m.e)), set(SN, V(m.n)), set(SC, V(m.c)), copyText()]; + /** A number's digits at the cursor (`X`), with its minus sign unless `unsigned`: from 10^9 down, 8, 4, 2, 1 off. */ + const X = G("x"); + const number = (unsigned: boolean): Stmt => fn(unsigned ? "unumber" : "number", () => { + const r = G("r"), started = G("started"), d = G("d10"); + const out: Stmt[] = [set(r, V(X)), set(started, k(0))]; + if (!unsigned) out.push(when(cmp("<", V(r), k(0)), [...writeConst("-")!, set(r, bin("-", k(0), V(r)))])); + for (let p = 9; p >= 0; p--) { + out.push(set(d, k(48))); + for (const m of [8, 4, 2, 1]) { + const step = m * 10 ** p; + if (step >= 2 ** 32) continue; + out.push(when(cmp(">=", V(r), k(u32(step) | 0), true), [set(r, bin("-", V(r), k(step | 0))), add(d, m)])); + } + const digit = putChar(V(d)); + out.push(p ? when(any(cmp(">=", V(d), k(49)), cmp(">=", V(started), k(1))), [set(started, k(1)), ...digit]) : digit[0], ...(p ? [] : digit.slice(1))); + } + return out; + }); + /** The player whose name or colour is put at the cursor (0 to 7). */ + const PL = G("pl"); + const N: Reader = { se: G("ne"), so: G("no"), i: G("ni"), b: G("nb") }; + /** + * A player's name at the cursor (`PL`): its bytes from the game's table of players — 36 bytes a player at 0x57EEE0, + * the name 11 in — to its 0, 24 at the most (eudplib's `PName` prints from there). + */ + const name = (): Stmt => fn("name", () => [ + set(N.se, bin("+", k((0x57eee8 - DEATHS) / 4), bin("*", V(PL), k(9)))), set(N.so, k(3)), set(N.i, k(0)), set(N.b, k(1)), + loop(all(cmp("<", V(N.i), k(24)), cmp("!=", V(N.b), k(0))), [...next(N), when(cmp("!=", V(N.b), k(0)), [set(B, V(N.b)), put(), when(starts(N.b), [add(CC, 1)])])]), + ]); + /** + * The name of a player known when the map is built, at the cursor: where it is, so its length is a condition a byte + * (the first 0, no byte read) and whether a byte starts a character one more; its first byte put alone, the rest + * copied a dword at a time from the dword after. + */ + const nameOf = (player: number): Stmt => fn(`name${player}`, () => { + const L = G(`nl${player}`), C = G(`nc${player}`); + const at = (0x57eee8 + 36 * player - DEATHS) / 4; + const byteIs = (j: number, value: number, mask = 0xff): BoolExpr => { + const o = (j + 3) & 3; + const record = { location: u32(mask * 2 ** (8 * o)), player: 0, amount: u32(value * 2 ** (8 * o)), unitId: 0, comparison: 10, type: 15, resource: 0, flags: 0, mask: 0x4353 }; + return { kind: "cond", record, epd: k(at + ((j + 3) >> 2)) } as BoolExpr; + }; + let length: Stmt[] = [set(L, k(24))]; + for (let j = 23; j >= 0; j--) length = [when(byteIs(j, 0), [set(L, k(j))], length)]; + const high = any(...Array.from({ length: 7 }, (_, d) => cmp(">=", peek(k(at + d), d === 0 ? 0x80000000 : 0x80808080), k(1), true))); + return [ + ...length, + set(C, V(L)), + // (A byte that goes on a character, 10xxxxxx, is no character of its own.) + when(high, Array.from({ length: 24 }, (_, j) => when(all(cmp(">", V(L), k(j)), byteIs(j, 0x80, 0xc0)), [add(C, -1)]))), + when(cmp(">=", V(L), k(1)), [set(B, peek(k(at), 0xff000000, 24)), put()]), + when(cmp(">=", V(L), k(2)), [set(SE, k(at + 1)), set(SN, bin("-", V(L), k(1))), set(SC, k(0)), copyText()]), + add(CC, V(C)), + ]; + }); + /** + * A player's colour at the cursor (`PL`), as a code of the game's texts: the first shade of the player's colour (8 + * bytes a player at 0x581D76) and the code nearest it, eudplib's table of them (its `PColor`). + */ + const colourTable = (): string => { + const id = "@text.colours"; + if (!arrays.some((a) => a.id === id)) arrays.push({ id, name: id, kind: "number", length: 256, shared: true, values: COLOUR_CODES, at: here }); + return id; + }; + const colour = (): Stmt => fn("colour", () => [ + set(B, { kind: "element", array: colourTable(), index: ranged(peek(bin("+", k((0x581d74 - DEATHS) / 4), bin("*", V(PL), k(2))), 0xff0000, 16), 0, 255), at: here } as NumExpr), + when(cmp("!=", V(B), k(0)), [put(), add(CC, 1)]), + ]); + /** The 0 after a made text — at its most, which cuts it there, when it ran past — the rest of its dword 0s too. */ + const BLK = G("blk"); + const end = (): Stmt => fn("end", () => [ + when(cmp(">", V(CN), k(TEXT_BYTES), true), [set(CN, k(TEXT_BYTES)), set(CC, k(TEXT_BYTES)), poke(bin("+", V(BLK), k(TEXT_BYTES >> 2)), k(0), u32(-(2 ** (8 * (TEXT_BYTES & 3)))))], [ + pointAt(V(CE)), byByte(CO, (o) => [toCurrent(k(0), u32(-(2 ** (8 * o))))]), pointAt(V(ME)), + ]), + ]); + /** The text being made ended, its lengths into `into`. */ + const finish = (into: Made): Stmt[] => [set(BLK, V(into.e)), end(), set(into.n, V(CN)), set(into.c, V(CC))]; + /** A reader of a made text's bytes: the dword, the byte, how many read, the byte read. */ + interface Reader { se: string; so: string; i: string; b: string } + const reader = (names: Reader, m: { e: NumExpr }): Stmt[] => [set(names.se, m.e), set(names.so, k(0)), set(names.i, k(0)), set(names.b, k(0))]; + /** The reader's byte into its `b`, and the reader on past it. */ + const next = (r: Reader): Stmt[] => [...readByte(r.b, r.se, r.so), add(r.so, 1), when(cmp("==", V(r.so), k(4)), [set(r.so, k(0)), add(r.se, 1)]), add(r.i, 1)]; + const R: Reader = { se: G("re"), so: G("ro"), i: G("ri"), b: G("rb") }; + const LO = G("slo"), HI = G("shi"); + /** + * Characters `LO` up to `HI` of the made text at `SE` at the cursor. Of one byte a character, the bytes from `LO` + * read and put; any other walked, a character counted at each byte that starts one. + */ + const slice = (): Stmt => fn("slice", () => { + const ci = G("ci"), lo = G("lo2"), hi = G("hi2"); + return [ + set(lo, { kind: "intrinsic", name: "min", args: [{ kind: "intrinsic", name: "max", args: [V(LO), k(0)], at: here, label: "" }, V(SC)], at: here, label: "" } as NumExpr), + set(hi, { kind: "intrinsic", name: "min", args: [{ kind: "intrinsic", name: "max", args: [V(HI), V(lo)], at: here, label: "" }, V(SC)], at: here, label: "" } as NumExpr), + when(cmp("==", V(SC), V(SN)), [ + set(R.se, bin("+", V(SE), bin(">>>", V(lo), k(2)))), set(R.so, bin("&", V(lo), k(3))), set(R.i, V(lo)), + loop(all(cmp("<", V(R.i), V(hi)), cmp("<", V(CN), k(TEXT_BYTES), true)), [...next(R), set(B, V(R.b)), put(), add(CC, 1)]), + ], [ + ...reader(R, { e: V(SE) }), set(ci, k(-1)), + loop(all(cmp("<", V(R.i), V(SN), true), cmp("<", V(CN), k(TEXT_BYTES), true)), [ + ...next(R), + when(starts(R.b), [add(ci, 1)]), + when(all(cmp(">=", V(ci), V(lo)), cmp("<", V(ci), V(hi))), [set(B, V(R.b)), put(), when(starts(R.b), [add(CC, 1)])]), + ]), + ]), + ]; + }); + /** Characters `from` up to `to` (absent: to its end) of a made text at the cursor. */ + const writeSlice = (src: Made, from: NumExpr, to: NumExpr | undefined): Stmt[] => [set(SE, V(src.e)), set(SN, V(src.n)), set(SC, V(src.c)), set(LO, from), set(HI, to ?? V(src.c)), slice()]; + /** A known text's characters at the cursor until `count` are written (each whole), no more than a made text holds. */ + const fill = (text: string, count: NumExpr): Stmt[] | null => { + const cs = chars(text); + if (!cs.length) return []; + const j = fresh("j"), ph = fresh("ph"), times = fresh("times"); + const each = cs.map((ch) => writeConst(ch)); + if (each.some((x) => !x)) return null; + let one: Stmt[] = each[cs.length - 1]!; + for (let x = cs.length - 2; x >= 0; x--) one = [when(cmp("==", V(ph), k(x)), each[x]!, one)]; + return [ + dec(times, count), dec(j, k(0)), dec(ph, k(0)), + loop(all(cmp("<", V(j), V(times)), cmp("<", V(CN), k(TEXT_BYTES), true)), [ + ...one, + ...(cs.length > 1 ? [add(ph, 1), when(cmp("==", V(ph), k(cs.length)), [set(ph, k(0))])] : []), + add(j, 1), + ]), + ]; + }; + + /** What a text needs done before it is begun (`pre`), and what writes it at the cursor (`body`). */ + interface Writing { pre: Stmt[]; body: Stmt[] } + /** A fresh made text with a block of its own, for a text made only to be read. */ + const temp = (): Made => { + const id = fresh("made"); + return { e: `${id}@e`, n: `${id}@n`, c: `${id}@c`, block: `${id}@block` }; + }; + /** A text as a made one to read from: a made variable as it is, anything else made into a block of its own first. */ + const source = (t: TextExpr): { pre: Stmt[]; m: Made } | null => { + if (madeOf(t)) return { pre: [], m: madeOf(t)! }; + if (t.kind === "textAt") return cellOf(t); + const m = temp(); + const body = make(t, m); + return body ? { pre: [...blockOf(m), ...body], m } : null; + }; + /** A text written at the cursor; null when it is of a kind this does not make yet. */ + const write = (t: TextExpr): Writing | null => { + if (madeOf(t)) return { pre: [], body: writeMade(madeOf(t)!) }; + if (t.kind === "textAt") { const c = cellOf(t); return c ? { pre: c.pre, body: writeMade(c.m) } : null; } + if (t.kind === "textCall") { + const r = call(t.call); + return r?.result ? { pre: r.pre, body: writeMade(r.result) } : null; + } + const c = choice(t); + if (c) { + // (One text it can be: written as it is — its test still asked when a call in it does something, `f() ? t : t`.) + if (c.texts.length === 1) { const body = writeConst(c.texts[0]); return body ? { pre: holdsCall(c.expr) ? [dec(fresh("pick"), c.expr)] : [], body } : null; } + // One of the texts it can be, by a test of its number. + const pick = fresh("pick"); + const each = c.texts.map((text) => writeConst(text)); + if (each.some((x) => !x)) return null; + let rest: Stmt[] = each[each.length - 1]!; + for (let j = c.texts.length - 2; j >= 0; j--) rest = [when(cmp("==", V(pick), pool.id(c.texts[j])), each[j]!, rest)]; + return { pre: [dec(pick, c.expr)], body: rest }; + } + switch (t.kind) { + case "textTernary": { + // (Each side's own needs not done before: they would be done whichever side is taken. What only reads numbers — + // a cell of an array of texts, where it is and its lengths — is done in its side, as the side is written: it + // makes no text, so the cursor is left as it is.) + const a = write(t.whenTrue), b = write(t.whenFalse); + if (!a || !b) return null; + if (!a.pre.length && !b.pre.length && !holdsCall(t.cond)) return { pre: [], body: [when(bool(t.cond), a.body, b.body)] }; + // (The test is asked once, before the text is begun, its answer kept in a number of its own — it may call a + // function, which may make a text of its own at the cursor — and what the side it picks needs done first is done + // then, under it, as JavaScript does only the side taken. The sides' bodies write under the same answer.) + const c = fresh("cond"); + const test = cmp("!=", V(c), k(0)); + const pre: Stmt[] = [dec(c, { kind: "ternary", cond: bool(t.cond), whenTrue: k(1), whenFalse: k(0), at: here, label: "" })]; + if (a.pre.length || b.pre.length) pre.push(when(test, a.pre, b.pre)); + return { pre, body: [when(test, a.body, b.body)] }; + } + case "template": return writeParts(t.parts); + case "textSlice": { + const src = source(t.of); + return src ? { pre: src.pre, body: writeSlice(src.m, t.start ? num(t.start) : k(0), t.end ? num(t.end) : undefined) } : null; + } + case "textPad": { + // The text made first (its length is wanted), then the fill — a known text — before or after it. + const by = choice(t.with); + const src = by?.texts.length === 1 ? source(t.of) : null; + if (!src) return null; + const pad = fill(by!.texts[0], bin("-", num(t.width), V(src.m.c))); + if (!pad) return null; + const text = writeMade(src.m); + return { pre: src.pre, body: t.side === "start" ? [...pad, ...text] : [...text, ...pad] }; + } + case "textRepeat": { + // A known text written whole each time; any other made first and copied. + const one = choice(t.of); + const times = fresh("times"), j = fresh("j"); + const again = (body: Stmt[]): Stmt[] => [dec(times, num(t.count)), dec(j, k(0)), loop(all(cmp("<", V(j), V(times)), cmp("<", V(CN), k(TEXT_BYTES), true)), [...body, add(j, 1)])]; + if (one?.texts.length === 1) { + const w = writeConst(one.texts[0]); + return w ? { pre: [], body: again(w) } : null; + } + const src = source(t.of); + return src ? { pre: src.pre, body: again(writeMade(src.m)) } : null; + } + default: return null; + } + }; + /** Whether an expression calls a function of the script's: one that may make a text of its own, or change what a part reads. */ + const holdsCall = (x: unknown): boolean => { + if (Array.isArray(x)) return x.some(holdsCall); + if (!x || typeof x !== "object") return false; + const o = x as { kind?: string }; + return o.kind === "call" || o.kind === "textCall" || Object.values(o).some((v) => v && typeof v === "object" && holdsCall(v)); + }; + /** + * A call among a text's parts runs before the text is begun (it may make a text of its own at the cursor), so every + * value up to the last call is worked out first, in the parts' order, as JavaScript reads them: a number into a + * variable of its own, a text of the map's into a number of its own (its texts the variable's), a made text copied + * into a block of its own (`made`) — what the call may change is read before it runs. The parts come back rewritten. + */ + const ahead = (parts: TextPart[]): { pre: Stmt[]; parts: TextPart[]; made: Map } => { + const pre: Stmt[] = [], made = new Map(); + const lastCall = parts.reduce((last, p, i) => (((p.kind === "number" || p.kind === "char") && holdsCall(p.expr)) || (p.kind === "value" && holdsCall(p.text)) ? i : last), -1); + const out = parts.map((p, i): TextPart => { + if (lastCall < 0 || i > lastCall) return p; + if ((p.kind === "number" || p.kind === "char") && !(p.kind === "number" && p.fixed)) { + const t = fresh("part"); + pre.push(dec(t, num(p.expr))); + return { ...p, expr: V(t) }; + } + if (p.kind !== "value" || (i === lastCall && !holdsCall(p.text))) return p; + // (Texts known when built, chosen among: the choice's number kept, its texts the new variable's.) + const c = holdsCall(p.text) ? null : choice(p.text); + if (c) { + const t = fresh("pick"); + pre.push(dec(t, c.expr)); + idVars.set(t, new Set(c.texts)); + return { ...p, text: { kind: "textVar", id: t } }; + } + const m = temp(); + const body = make(p.text, m); + if (body) { pre.push(...blockOf(m), ...body); made.set(i, m); madeVars.set(m.block, m); } + return p; + }); + return { pre, parts: out, made }; + }; + const writeParts = (given: TextPart[]): Writing | null => { + const first = ahead(given); + const out: Writing = { pre: first.pre, body: [] }, parts = first.parts; + for (const [i, p] of parts.entries()) { + let part: Writing | null; + if (first.made.has(i)) part = { pre: [], body: writeMade(first.made.get(i)!) }; + else if (p.kind === "text") { const body = writeConst(p.text); part = body ? { pre: [], body } : null; } + else if (p.kind === "number") part = p.fixed ? null : { pre: [], body: [set(X, num(p.expr)), number(!!p.unsigned)] }; + else if (p.kind === "value") part = write(p.text); + else if (p.kind === "char") part = { pre: [], body: [set(B, bin("+", num(p.expr), k(0))), when(cmp("!=", V(B), k(0)), [put(), add(CC, 1)])] }; + else if (p.kind === "name" || p.kind === "color") { + // (The current player: the one the game says it is when the text is made, the program's own.) + const who = p.player < 8 ? k(p.player) : p.player === CURRENT_PLAYER ? peek(k((0x6509b0 - DEATHS) / 4)) : null; + part = !who ? null : p.kind === "name" && p.player < 8 ? { pre: [], body: [nameOf(p.player)] } : { pre: [], body: [set(PL, who), p.kind === "name" ? name() : colour()] }; + } + else part = null; + if (!part) return null; + out.pre.push(...part.pre); + out.body.push(...part.body); + } + return out; + }; + /** A text made into a made text's block from its start; null when it is of a kind this does not make yet. */ + const make = (t: TextExpr, into: Made): Stmt[] | null => { + const w = write(t); + return w ? [...w.pre, ...begin(V(into.e)), ...w.body, ...finish(into)] : null; + }; + /** + * The block of a made text (or the scratch one) and its numbers: laid out once, at the program's start (a block never + * moves), its address kept in a cell read where it is used. + */ + const blocks = new Map(); + const blockOf = (m: Made): Stmt[] => { + blocks.set(m.block, m); + return []; + }; + /** Whether a text reads a variable. */ + const mentions = (x: unknown, id: string): boolean => { + if (Array.isArray(x)) return x.some((y) => mentions(y, id)); + if (!x || typeof x !== "object") return false; + const o = x as { kind?: string; id?: string; buffer?: string }; + if ((o.kind === "textVar" && o.id === id) || (o.kind === "built" && o.buffer === id)) return true; + return Object.values(o).some((v) => v && typeof v === "object" && mentions(v, id)); + }; + /** + * An array of texts the program makes is three arrays of numbers (the front end's): where each cell's text is, the + * block it owns, its length in characters. Here each cell's text has a block of its own, laid out with the made + * variables', its EPD in the first array as soon as the array is declared; the second holds its length in bytes. + * An array that grows (`grows`, step 9: its three arrays the heap's, `heap.ts`) takes a block for a cell from a pool + * of them the first time a text is stored there — its EPD then in the first array, 0 before — and gives it back + * when the cell's text is let go of (popped, cut off, declared again). + */ + /** `length`: of one that does not grow but is too long for a block a cell (`MOST_TEXT_CELLS`), which takes its blocks as one that grows does. */ + interface TextArray { addr: string; block: string; chars: string; cells: Made[]; grows?: boolean; length?: number } + const textArrays = new Map(); + const findTextArrays = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(findTextArrays); return; } + if (!x || typeof x !== "object") return; + const o = x as { kind?: string; addr?: string; block?: string; chars?: string }; + if ((o.kind === "storeText" || o.kind === "textAt") && !textArrays.has(o.addr!)) { + const a = arrayById.get(o.addr!); + if (a && !a.dynamic && !a.slice && !a.through && a.length <= MOST_TEXT_CELLS) { + textArrays.set(o.addr!, { addr: o.addr!, block: o.block!, chars: o.chars!, cells: Array.from({ length: a.length }, (_, i) => ({ e: `${o.addr}.${i}@e`, n: `${o.addr}.${i}@n`, c: `${o.addr}.${i}@c`, block: `${o.addr}.${i}@block` })) }); + } + else if (a && a.dynamic && !a.slice && !a.through && a.task === undefined) textArrays.set(o.addr!, { addr: o.addr!, block: o.block!, chars: o.chars!, cells: [], grows: true }); + // (Longer: its cells' blocks from the pool, the first time a text is stored in each — the messages of the errors + // a program's tasks end on, a cell a slot, which few of them ever hold.) + else if (a && !a.dynamic && !a.slice && !a.through) textArrays.set(o.addr!, { addr: o.addr!, block: o.block!, chars: o.chars!, cells: [], grows: true, length: a.length }); + } + for (const v of Object.values(o)) if (v && typeof v === "object") findTextArrays(v); + }; + findTextArrays([program.body, program.functions ?? []]); + const element = (array: string, index: NumExpr): NumExpr => ({ kind: "element", array, index, at: here }); + const store = (array: string, index: NumExpr, value: NumExpr): Stmt => ({ kind: "store", array, index, value, at: here, label: "" }); + /** + * A cell of an array of texts as a made text to read: where it is and its lengths, read from the arrays. (A cell of + * one that grows with no block yet, or past its end, is the pool's empty block.) + */ + const cellOf = (t: Extract): { pre: Stmt[]; m: Made } | null => { + const a = textArrays.get(t.addr); + if (!a) return null; + const at = fresh("at"), m: Made = { e: fresh("e"), n: fresh("n"), c: fresh("c"), block: "" }; + const where: Stmt[] = a.grows + ? [dec(m.e, element(a.addr, V(at))), when(cmp("==", V(m.e), k(0)), [set(m.e, V(cellPool().empty))])] + : [dec(m.e, element(a.addr, V(at)))]; + return { pre: [dec(at, num(t.index)), ...where, dec(m.n, element(a.block, V(at))), dec(m.c, element(a.chars, V(at)))], m }; + }; + /** + * The pool the cells of arrays of texts that grow take their blocks from: `POOL_BLOCKS` blocks laid out once, one + * after another, handed out from the bottom up and, given back, from a list of them; and a block of nothing for a cell + * with no text. When none is left, the text stored is empty, and the game says so once (as the Python side does). + */ + let pooled: { e: string; empty: string; count: string; next: string; said: string; free: string } | null = null; + const cellPool = () => (pooled ??= { e: "@text.pool@e", empty: "@text.empty@e", count: G("pooln"), next: G("poolnext"), said: G("poolsaid"), free: "@text.free" }); + /** A block from the pool into `e` (0 when there is none, said once). */ + const takeBlock = (e: string): Stmt[] => { + const P = cellPool(); + return [when(cmp("!=", V(P.count), k(0)), [add(P.count, -1), set(e, element(P.free, V(P.count)))], [ + when(cmp("<", V(P.next), k(POOL_BLOCKS), true), [set(e, bin("+", V(P.e), bin("*", V(P.next), k(BLOCK / 4)))), add(P.next, 1)], [ + set(e, k(0)), + when(cmp("==", V(P.said), k(0)), [set(P.said, k(1)), { kind: "print", parts: [{ kind: "text", text: `\x06TrigScript: out of memory - a text could not be made (the programs' arrays and texts share ${HEAP_CELLS} cells).` }], to: CURRENT_PLAYER, position: "chat", at: here, label: "" } as Stmt]), + ]), + ])]; + }; + /** A block back to the pool's list. */ + const giveBack = (e: string): Stmt[] => { const P = cellPool(); return [store(P.free, V(P.count), V(e)), add(P.count, 1)]; }; + /** Whether a text reads a cell of an array of texts. */ + const readsArray = (x: unknown, addr: string): boolean => { + if (Array.isArray(x)) return x.some((y) => readsArray(y, addr)); + if (!x || typeof x !== "object") return false; + const o = x as { kind?: string; addr?: string }; + if (o.kind === "textAt" && o.addr === addr) return true; + return Object.values(o).some((v) => v && typeof v === "object" && readsArray(v, addr)); + }; + /** One made text copied into another's block. */ + const copyBlock = (from: Made, into: Made): Stmt[] => [...begin(V(into.e)), ...writeMade(from), ...finish(into)]; + /** A made text equal to a known one (or not): its length, and each dword of the known one's bytes and 0 where it is. */ + const equalsKnown = (m: Made, text: string, op: CompareOp): BoolExpr => { + // (After a made text's 0 its dword is 0s, as the known one's is here.) + const b = [...enc.encode(text), 0]; + const items: BoolExpr[] = [cmp("==", V(m.n), k(b.length - 1))]; + for (let w = 0; 4 * w < b.length; w++) { + const value = b.slice(4 * w, 4 * w + 4).reduce((n, x, j) => n + x * 2 ** (8 * j), 0); + items.push({ kind: "cond", record: { location: 0, player: 0, amount: value, unitId: 0, comparison: 10, type: 15, resource: 0, flags: 0, mask: 0 }, epd: w ? bin("+", V(m.e), k(w)) : V(m.e) }); + } + const both: BoolExpr = { kind: "and", items }; + return op === "==" ? both : { kind: "not", expr: both }; + }; + + /** + * The made text a `return` of the function or inlined call being rewritten makes its value into, and whether one + * could not be made (the call is then refused whole: its result would be left empty without a word). + */ + const results: { into?: Made; failed: boolean }[] = []; + const recursive = new Set((program.functions ?? []).filter((f) => f.recursive).map((f) => f.id)); + /** + * A call whose parameters or result are made texts: each such parameter made into its block before it (the call no + * longer sets it), the call a statement whose `return`s make the result into its block. Null when one of them is not + * a text this makes, or the function calls itself (its texts would need a block for each call). + */ + const call = (c: Call): { pre: Stmt[]; result?: Made } | null => { + if (c.fn && recursive.has(c.fn)) return null; + const pre: Stmt[] = []; + const params: Call["params"] = []; + for (const p of c.params) { + if (p.decl.kind !== "text") { params.push({ ...p, init: walk(p.init) as NumExpr }); continue; } + const m = madeVars.get(p.decl.id); + if (m) { + const body = make(p.init as TextExpr, m); + if (!body) return null; + pre.push(...blockOf(m), ...body); + continue; + } + const ch = idVars.has(p.decl.id) ? choice(p.init as TextExpr) : null; + if (!ch) return null; + pre.push({ kind: "declare", decl: { ...p.decl, kind: "number", text: undefined }, init: ch.expr, at: here, label: p.label }); + } + // (A called function makes its text into its own result's block — one for the function, whichever call — so a call + // of it reads that block; an inlined call has a result of its own, which its body's returns make.) + const fnResult = c.fn ? (program.functions ?? []).find((f) => f.id === c.fn)?.result : undefined; + const result = c.result?.kind === "text" ? madeVars.get(fnResult?.kind === "text" ? fnResult.decl.id : c.result.decl.id) : undefined; + if (c.result?.kind === "text" && !result) return null; + if (result) pre.push(...blockOf(result)); + results.push({ into: result, failed: false }); + const body = c.fn ? c.body : stmts(c.body); + if (results.pop()!.failed) return null; + const { result: _, ...rest } = c; + void _; + pre.push({ kind: "call", call: { ...rest, params, body, ...(result ? {} : c.result ? { result: c.result } : {}) }, at: c.at, label: c.label } as Stmt); + return { pre, ...(result ? { result } : {}) }; + }; + + // What is read of a made text — a character, a search, a test, an order — by the run time's functions, each called + // with its operands in the run time's variables, its answer in `RV`. + const IDX = G("idx"), FE = G("fe"), FN = G("fn"), FROM = G("from"), OFF = G("off"); + /** + * The number of character `IDX` of the made text at `SE`, or −1 past either end. Of one byte a character, its byte + * `IDX` read where it is; any other walked: each byte that starts a character counted, the one at `IDX` decoded + * from it and the bytes after it. + */ + const code = (): Stmt => fn("code", () => { + const ci = G("ci3"), more = G("more"), we = G("we"), wo = G("wo"); + const and = (n: number): NumExpr => bin("&", V(R.b), k(n)); + return [ + set(RV, k(-1)), + when(cmp("==", V(SC), V(SN)), [ + when(all(cmp(">=", V(IDX), k(0)), cmp("<", V(IDX), V(SN))), [set(we, bin("+", V(SE), bin(">>>", V(IDX), k(2)))), set(wo, bin("&", V(IDX), k(3))), ...readByte(RV, we, wo)]), + ], [ + ...reader(R, { e: V(SE) }), set(ci, k(-1)), set(more, k(-1)), + loop(all(cmp("<", V(R.i), V(SN), true), cmp("!=", V(more), k(0))), [ + ...next(R), + when(starts(R.b), [ + add(ci, 1), + when(cmp("==", V(ci), V(IDX)), [ + when(cmp("<", V(R.b), k(0x80), true), [set(RV, V(R.b)), set(more, k(0))], [ + when(cmp(">=", V(R.b), k(0xf0), true), [set(RV, and(7)), set(more, k(3))], [ + when(cmp(">=", V(R.b), k(0xe0), true), [set(RV, and(15)), set(more, k(2))], [set(RV, and(31)), set(more, k(1))]), + ]), + ]), + ]), + ], [when(cmp(">=", V(more), k(1)), [set(RV, bin("+", bin("*", V(RV), k(64)), and(63))), add(more, -1)])]), + ]), + ]), + ]; + }); + /** + * Whether the `FN` bytes of the made text at `FE` are at byte `OFF` of the one at `SE` (1 or 0): the two read side + * by side until it ends or a byte differs. + */ + const T: Reader = { se: G("te"), so: G("to"), i: G("ti"), b: G("tb") }, F: Reader = { se: G("fe2"), so: G("fo"), i: G("fi"), b: G("fb") }; + const prefix = (): Stmt => fn("prefix", () => [ + set(T.se, bin("+", V(SE), bin(">>>", V(OFF), k(2)))), set(T.so, bin("&", V(OFF), k(3))), set(T.i, k(0)), + ...reader(F, { e: V(FE) }), set(RV, k(1)), + loop(all(cmp("<", V(F.i), V(FN), true), cmp("==", V(RV), k(1))), [...next(F), ...next(T), when(cmp("!=", V(T.b), V(F.b)), [set(RV, k(0))])]), + ]); + /** + * The place in characters of the first match of the made text at `FE` in the one at `SE`, at or after character + * `FROM`, or −1: at each byte that starts a character from there, `prefix` asked. + */ + const find = (): Stmt => fn("find", () => { + const ci = G("ci4"), at = G("at4"), found = G("found"); + return [ + set(FROM, { kind: "intrinsic", name: "max", args: [V(FROM), k(0)], at: here, label: "" } as NumExpr), set(found, k(-1)), + when(cmp("==", V(FN), k(0)), [when(cmp("<=", V(FROM), V(SC)), [set(found, V(FROM))])], [ + ...reader(R, { e: V(SE) }), set(ci, k(0)), + loop(all(cmp("<", V(R.i), V(SN), true), cmp("<", V(found), k(0))), [ + set(at, V(R.i)), + ...readByte(R.b, R.se, R.so), + when(all(cmp(">=", V(ci), V(FROM)), starts(R.b)), [set(OFF, V(at)), prefix(), when(cmp("==", V(RV), k(1)), [set(found, V(ci))])]), + ...next(R), + when(starts(R.b), [add(ci, 1)]), + ]), + ]), + set(RV, V(found)), + ]; + }); + /** The made texts at `SE` and `FE` compared byte by byte (UTF-8's order of bytes is its characters'): below 0, 0, above. */ + const order = (): Stmt => fn("order", () => [ + ...reader(R, { e: V(SE) }), ...reader(F, { e: V(FE) }), set(RV, k(0)), + loop(cmp("==", V(RV), k(0)), [ + ...next(R), ...next(F), + when(cmp("!=", V(R.b), V(F.b)), [set(RV, bin("-", V(R.b), V(F.b)))], [when(cmp("==", V(R.b), k(0)), [{ kind: "break", at: here, label: "" } as Stmt])]), + ]), + ]); + const operands = (m: Made): Stmt[] => [set(SE, V(m.e)), set(SN, V(m.n)), set(SC, V(m.c))]; + const needle = (m: Made): Stmt[] => [set(FE, V(m.e)), set(FN, V(m.n))]; + /** + * A number or a test of texts that are not all known when built, worked out by statements before the statement it is + * in: its value then a variable. Null when it is not one of those, or one of its texts is not made by this. + */ + const measured = (o: { kind?: string }, pre: Stmt[]): NumExpr | BoolExpr | null => { + const out = fresh("v"); + const take = (t: TextExpr) => { + if (choice(t)) return null; + const src = source(t); + if (src) pre.push(...src.pre); + return src?.m ?? null; + }; + /** A text read from that may be known: made into a block when it is not a made variable. */ + const any = (t: TextExpr) => { const src = source(t); if (src) pre.push(...src.pre); return src?.m ?? null; }; + const answer = (stmts: Stmt[]): NumExpr => { pre.push(...stmts, dec(out, V(RV))); return V(out); }; + switch (o.kind) { + case "textLength": { const m = take((o as Extract).of); return m ? V(m.c) : null; } + case "textCode": { + const e = o as Extract, m = take(e.of); + return m ? answer([...operands(m), set(IDX, num(e.index)), code()]) : null; + } + case "textIndexOf": { + const e = o as Extract, m = take(e.of), f = m ? any(e.find) : null; + return m && f ? answer([...operands(m), ...needle(f), set(FROM, e.from ? num(e.from) : k(0)), find()]) : null; + } + case "textTest": { + const e = o as Extract, m = take(e.of), f = m ? any(e.find) : null; + if (!m || !f) return null; + if (e.test === "includes") return cmp(">=", answer([...operands(m), ...needle(f), set(FROM, k(0)), find()]), k(0)); + // (Where it would start: 0, or the text's length less the other's, in bytes; nothing when it is longer.) + const off = e.test === "startsWith" ? k(0) : bin("-", V(m.n), V(f.n)); + pre.push(dec(out, k(0)), when(cmp(">=", V(m.n), V(f.n), true), [...operands(m), ...needle(f), set(OFF, off), prefix(), set(out, V(RV))])); + return cmp("==", V(out), k(1)); + } + case "textCompare": { + const e = o as Extract; + if (choice(e.left) && choice(e.right)) return null; + // (A made variable the same as a known text, or not: one condition a dword, `equalsKnown`.) + const known = (t: TextExpr) => { const c = choice(t); return c?.texts.length === 1 ? c.texts[0] : undefined; }; + const mv = (t: TextExpr) => !!madeOf(t); + if ((e.op === "==" || e.op === "!=") && ((mv(e.left) && known(e.right) !== undefined) || (mv(e.right) && known(e.left) !== undefined))) return null; + const a = any(e.left), b = a ? any(e.right) : null; + if (!a || !b) return null; + return cmp(e.op, answer([set(SE, V(a.e)), set(FE, V(b.e)), order()]), k(0)); + } + default: return null; + } + }; + + /** + * A statement's texts that are only made to be looked at — a call's result, a text compared or measured — made first, + * each into a block of its own, and looked at there. + */ + const hoist = (x: unknown, pre: Stmt[]): unknown => { + if (Array.isArray(x)) return x.map((y) => hoist(y, pre)); + if (!x || typeof x !== "object") return x; + const o = x as { kind?: string }; + if (o.kind === "textLength" || o.kind === "textCode" || o.kind === "textIndexOf" || o.kind === "textTest" || o.kind === "textCompare") { + // (A made variable's length, and a made text against a known one, need no statements: `num`, `bool`.) + const easy = o.kind === "textLength" ? !!madeOf((o as { of: TextExpr }).of) : false; + const v = easy ? null : measured(o, pre); + if (v) return v; + } + if (o.kind === "textCall") { + const c = (o as Extract).call; + const r = call(c); + if (!r?.result) return o; + pre.push(...r.pre); + const id = [...madeVars].find(([, m]) => m === r.result)![0]; + // (A called function's result block is the function's, one for every call: what this call gave is copied into a + // block of its own before another call of it — `${f(a)} and ${f(b)}` — writes the block again.) + if (c.fn) { + const m = temp(); + const body = make({ kind: "textVar", id }, m); + if (body) { pre.push(...blockOf(m), ...body); madeVars.set(m.block, m); return { kind: "textVar", id: m.block }; } + } + return { kind: "textVar", id }; + } + // (A call of a number or a condition with texts among its parameters: a statement first, its result read after.) + const c = (o as { call?: Call }).call; + if (o.kind === "call" && c?.params.some((p) => p.decl.kind === "text") && c.result && c.result.kind !== "text") { + const r = call(c); + if (!r) return o; + pre.push(...r.pre); + return { kind: "var", id: c.result.decl.id }; + } + // (Statement lists — a call's body among them — are not looked into: their statements are rewritten as theirs.) + const out: Record = {}; + for (const [key, v] of Object.entries(o)) out[key] = v && typeof v === "object" && key !== "body" && key !== "then" && key !== "else" ? hoist(v, pre) : v; + return out; + }; + + const stmts = (body: Stmt[]): Stmt[] => body.flatMap((s) => { + // (A text call where a text is only looked at: made first. In a `while`'s test, which is asked again and again, the + // loop asks it at the top of its body: `while (true) { …; if (!test) break; … }`.) + if (s.kind === "while") { + if ("at" in s && s.at) here = s.at; + const pre: Stmt[] = []; + const cond = hoist(s.cond, pre) as BoolExpr; + if (!pre.length) return stmt(s); + return [{ ...s, cond: { kind: "const", value: true }, body: [...pre, when({ kind: "not", expr: bool(cond) }, [{ kind: "break", at: here, label: "" } as Stmt]), ...stmts(s.body)] }]; + } + // (A `do`'s test is asked at every turn's end: what it needs made first goes before it there, `before`, where a + // `continue` lands too. A `for`'s, at the top of each turn, as a `while`'s.) + if (s.kind === "do") { + if ("at" in s && s.at) here = s.at; + const pre: Stmt[] = []; + const cond = hoist(s.cond, pre) as BoolExpr; + if (!pre.length) return stmt(s); + return [{ ...s, cond: bool(cond), before: [...(s.before ?? []), ...pre], body: stmts(s.body) }]; + } + if (s.kind === "for") { + if (!s.cond) return stmt(s); + if ("at" in s && s.at) here = s.at; + const pre: Stmt[] = []; + const cond = hoist(s.cond, pre) as BoolExpr; + if (!pre.length) return stmt(s); + return [{ ...s, cond: undefined, body: [...pre, when({ kind: "not", expr: bool(cond) }, [{ kind: "break", at: here, label: "" } as Stmt]), ...stmts(s.body)], update: stmts(s.update) }]; + } + const pre: Stmt[] = []; + const t = { ...s, ...Object.fromEntries(Object.entries(s).filter(([key]) => key !== "then" && key !== "else" && key !== "body").map(([key, v]) => [key, v && typeof v === "object" ? hoist(v, pre) : v])) } as Stmt; + return [...pre, ...stmt(t)]; + }); + const stmt = (s: Stmt): Stmt[] => { + if ("at" in s && s.at) here = s.at; + switch (s.kind) { + case "return": { + const top = results[results.length - 1]; + if (!top?.into || !s.value || !isTextExpr(s.value as { kind: string })) break; + const body = make(s.value as TextExpr, top.into); + if (!body) { top.failed = true; return [walk(s) as Stmt]; } + return [...body, { kind: "return", at: s.at, label: s.label }]; + } + case "declare": { + if (s.decl.temp && s.decl.kind === "number" && readsOf(s.init as NumExpr)) temps.set(s.decl.id, s.init as NumExpr); + if (madeVars.has(s.decl.id)) { + const m = madeVars.get(s.decl.id)!; + const body = make(s.init as TextExpr, m); + return body ? [...blockOf(m), ...body] : [walk(s) as Stmt]; + } + if (s.decl.kind !== "text" || !idVars.has(s.decl.id)) break; + const c = choice(s.init as TextExpr); + if (!c) break; + return [{ ...s, decl: { ...s.decl, kind: "number", text: undefined }, init: c.expr }]; + } + case "assignText": { + if (madeVars.has(s.target)) { + // Into its own block, unless the text reads the variable: then made in the scratch block and copied over. + const m = madeVars.get(s.target)!; + if (!mentions(s.value, s.target)) return make(s.value, m) ?? [walk(s) as Stmt]; + const body = make(s.value, SCRATCH); + return body ? [...blockOf(SCRATCH), ...body, ...copyBlock(SCRATCH, m)] : [walk(s) as Stmt]; + } + if (!idVars.has(s.target)) break; + const c = choice(s.value); + if (!c) break; + return [{ kind: "assign", target: s.target, value: c.expr, at: s.at, label: s.label }]; + } + case "print": return print(s); + case "declareArray": { + // An array of texts' first array: each cell where its text's block is. + const a = textArrays.get(s.array); + if (!a) break; + return [walk(s) as Stmt, ...a.cells.flatMap((m, i) => [...blockOf(m), store(a.addr, k(i), V(m.e))])]; + } + case "storeText": { + // Made into the cell's block (through the scratch block when it reads the array), its lengths into the arrays; + // past the array's end, nothing, as a store of a number. + const a = textArrays.get(s.addr); + const w = a ? write(s.value) : null; + if (!a || !w) break; + if (a.grows) { + // (A cell of an array that grows: its block taken from the pool the first time; none left, an empty text.) + const at = fresh("at"), into: Made = { e: fresh("e"), n: fresh("n"), c: fresh("c"), block: "" }; + const reads = readsArray(s.value, a.addr); + const made = reads ? [...blockOf(SCRATCH), ...w.pre, ...begin(V(SCRATCH.e)), ...w.body, ...finish(SCRATCH)] : w.pre; + const fill = reads ? copyBlock(SCRATCH, into) : [...begin(V(into.e)), ...w.body, ...finish(into)]; + return [dec(at, num(s.index)), ...made, when(cmp("<", V(at), a.length !== undefined ? k(a.length) : ({ kind: "length", array: a.addr, at: here } as NumExpr), true), [ + dec(into.e, element(a.addr, V(at))), dec(into.n, k(0)), dec(into.c, k(0)), + when(cmp("==", V(into.e), k(0)), [...takeBlock(into.e), store(a.addr, V(at), V(into.e))]), + when(cmp("!=", V(into.e), k(0)), fill), + store(a.block, V(at), V(into.n)), store(a.chars, V(at), V(into.c)), + ])]; + } + const at = fresh("at"), into: Made = { e: fresh("e"), n: fresh("n"), c: fresh("c"), block: "" }; + const cell = [dec(into.e, element(a.addr, V(at))), dec(into.n, k(0)), dec(into.c, k(0))]; + const lengths = [store(a.block, V(at), V(into.n)), store(a.chars, V(at), V(into.c))]; + if (readsArray(s.value, a.addr)) { + return [dec(at, num(s.index)), ...blockOf(SCRATCH), ...w.pre, ...begin(V(SCRATCH.e)), ...w.body, ...finish(SCRATCH), when(cmp("<", V(at), k(a.cells.length), true), [...cell, ...copyBlock(SCRATCH, into), ...lengths])]; + } + return [dec(at, num(s.index)), ...w.pre, when(cmp("<", V(at), k(a.cells.length), true), [...cell, ...begin(V(into.e)), ...w.body, ...finish(into), ...lengths])]; + } + case "releaseText": { + // (A cell's block is its own for good: nothing to give back. A cell of an array that grows gives its pool's back.) + const a = [...textArrays.values()].find((x) => x.block === s.block); + if (a?.grows) { + const at = fresh("at"), e = fresh("e"); + return [dec(at, num(s.index)), when(cmp("<", V(at), { kind: "length", array: a.addr, at: here } as NumExpr, true), [ + dec(e, element(a.addr, V(at))), + when(cmp("!=", V(e), k(0)), [...giveBack(e), store(a.addr, V(at), k(0))]), + store(a.block, V(at), k(0)), store(a.chars, V(at), k(0)), + ])]; + } + if (a) return []; + break; + } + case "buildText": { + // Into its buffer from its start, or on from its end: the cursor where its bytes and characters end. + const m = bufferOf(s.buffer); + if (mentions(s.parts, s.buffer)) break; + const w = writeParts(s.parts); + if (!w) break; + const resume = [set(CE, bin("+", V(m.e), bin(">>>", V(m.n), k(2)))), set(CO, bin("&", V(m.n), k(3))), set(CN, V(m.n)), set(CC, V(m.c))]; + return [...blockOf(m), ...w.pre, ...(s.reset ? begin(V(m.e)) : resume), ...w.body, ...finish(m)]; + } + case "call": { + if (!s.call.params.some((p) => p.decl.kind === "text") && s.call.result?.kind !== "text") break; + const r = call(s.call); + return r ? r.pre : [walk(s) as Stmt]; + } + case "textLoop": { + // Each character made into the loop's variable — the byte that starts it and those after it — then the body. + // (The walk's own reader: the body may read texts with the run time's.) + const m = madeVars.get(s.decl.id), src = m ? source(s.of) : null; + if (!m || !src) break; + const r: Reader = { se: fresh("se"), so: fresh("so"), i: fresh("i"), b: fresh("b") }, look = fresh("look"); + return [ + ...src.pre, ...blockOf(m), dec(r.se, V(src.m.e)), dec(r.so, k(0)), dec(r.i, k(0)), dec(r.b, k(0)), dec(look, k(0)), + loop(cmp("<", V(r.i), V(src.m.n), true), [ + ...begin(V(m.e)), + ...next(r), set(B, V(r.b)), put(), add(CC, 1), + ...readByte(look, r.se, r.so), + loop(all(cmp("<", V(r.i), V(src.m.n), true), { kind: "not", expr: starts(look) }), [...next(r), set(B, V(r.b)), put(), ...readByte(look, r.se, r.so)]), + ...finish(m), + ...stmts(s.body), + ]), + ]; + } + case "action": { + if (!s.text) break; + const c = choice(s.text); + if (!c || c.texts.length === 1) break; + // The text field from the value: the string its number names. (The rest rewritten first: the choice's numbers + // are the pool's to fill in, and a copy of them would not be.) + const { text: _, ...rest } = s; + void _; + const done = walk(rest) as Extract; + return [{ ...done, variables: [...(done.variables ?? []), { field: "text", bits: 32, name: "text", expr: { kind: "binary", op: "+", left: c.expr, right: k(base), at: s.at, label: s.label } }] }]; + } + } + return [walk(s) as Stmt]; + }; + + /** + * A print with texts known when built in it. One of them alone: Display Text of the string its number names (to the + * current player). Otherwise printed once for each text each can be, under a test of which it is — each choice + * worked out once, into a variable of its own. + */ + const print = (given: Extract): Stmt[] => { + // (A call among the parts: what comes before it read first, `ahead`, as JavaScript reads it. A made text among the + // parts goes through `make`, which does the same once more and finds nothing left to do.) + const first = ahead(given.parts); + if (first.pre.length) return [...first.pre, ...print({ ...given, parts: first.parts.map((p, i) => (first.made.has(i) ? { kind: "value", text: { kind: "textVar", id: first.made.get(i)!.block } } : p)) })]; + const s = given; + const parts = s.parts.map((p) => (p.kind === "value" ? choice(p.text) : null)); + if (s.parts.some((p, i) => (p.kind === "value" && !parts[i]) || p.kind === "name" || p.kind === "color") && s.position === "chat") { + // A made text in it, or a player's name or colour. Alone: its block shown as it is. Else the printed text made + // in the scratch block, shown. + const [first] = s.parts; + if (s.parts.length === 1 && first.kind === "value" && madeOf(first.text)) return [...blockOf(madeOf(first.text)!), { kind: "showText", block: madeOf(first.text)!.block, bytes: BLOCK, to: s.to, at: s.at }]; + const body = make({ kind: "template", parts: s.parts, at: s.at, label: s.label }, SCRATCH); + if (!body) return [walk(s) as Stmt]; + return [...blockOf(SCRATCH), ...body, { kind: "showText", block: SCRATCH.block, bytes: BLOCK, to: s.to, at: s.at }]; + } + if (!parts.some((c) => c && c.texts.length > 1)) { + // (A choice that comes to one text — `f() ? t : t` — still asks its test: a call in it runs, for what it does.) + const asked = parts.flatMap((c) => (c && holdsCall(c.expr) ? [dec(fresh("pick"), c.expr)] : [])); + const plain = s.parts.map((p, i) => (parts[i] ? ({ kind: "text", text: parts[i]!.texts[0] } as TextPart) : p)); + return [...asked, walk({ ...s, parts: plain }) as Stmt]; + } + if (s.parts.some((p, i) => p.kind === "value" && !parts[i])) return [walk(s) as Stmt]; + const choices = parts.filter((c): c is Choice => !!c && c.texts.length > 1); + if (choices.reduce((m, c) => m * c.texts.length, 1) > MOST_PRINTS) return bytes(s, parts); + const one = s.parts.length === 1 && s.position === "chat" && s.to === CURRENT_PLAYER && !choices[0].texts.includes(""); + if (one) { + const record = { location: 0, text: 0, wav: 0, time: 0, player: 0, target: 0, unitId: 0, type: DISPLAY_TEXT, modifier: 0, flags: 4, padding: 0, mask: 0 }; + return [{ kind: "action", record, variables: [{ field: "text", bits: 32, name: "text", expr: { kind: "binary", op: "+", left: choices[0].expr, right: k(base), at: s.at, label: s.label } }], at: s.at, label: s.label }]; + } + const out: Stmt[] = []; + const picks = choices.map((c) => { + const decl: VarDecl = { id: fresh("pick"), name: "(text)", kind: "number", shared: false, at: s.at }; + out.push({ kind: "declare", decl, init: c.expr, at: s.at, label: s.label }); + return { c, id: decl.id }; + }); + // Each text of the first choice, under a test of it; inside, each of the next's; the print at the bottom. + const nest = (i: number, chosen: Map): Stmt[] => { + if (i === picks.length) { + const plain = s.parts.map((p, j) => (parts[j] ? ({ kind: "text", text: chosen.get(parts[j]!) ?? parts[j]!.texts[0] } as TextPart) : p)); + return [walk({ ...s, parts: plain }) as Stmt]; + } + const { c, id } = picks[i]; + let rest: Stmt[] = nest(i + 1, new Map([...chosen, [c, c.texts[c.texts.length - 1]]])); + for (let j = c.texts.length - 2; j >= 0; j--) { + rest = [{ kind: "if", cond: { kind: "compare", op: "==", left: { kind: "var", id }, right: pool.id(c.texts[j]), at: s.at, label: s.label }, then: nest(i + 1, new Map([...chosen, [c, c.texts[j]]])), else: rest, at: s.at, label: s.label }]; + } + return rest; + }; + out.push(...nest(0, new Map())); + return out; + }; + + /** + * A print with texts of many choices in it: each of them a field of the printed text, which the text it is is + * written into whole (a `pick`: a case for each text, by its number; or by its place in the table it is read from). + * What it is picked by is worked out once, into a variable of its own. + */ + const bytes = (s: Extract, parts: (Choice | null)[]): Stmt[] => { + const out: Stmt[] = []; + const once = (e: NumExpr): NumExpr => { + if (e.kind === "var" || e.kind === "const") return e; + const decl: VarDecl = { id: fresh("at"), name: "(text)", kind: "number", shared: false, at: s.at }; + out.push({ kind: "declare", decl, init: e, at: s.at, label: s.label }); + return { kind: "var", id: decl.id }; + }; + const printed: TextPart[] = []; + for (const [i, p] of s.parts.entries()) { + const c = parts[i]; + if (!c || c.texts.length === 1) { printed.push(c ? { kind: "text", text: c.texts[0] } : (walk(p) as TextPart)); continue; } + if (c.over) { + // (Places next to each other with the same text, one case from the first to the last.) + const cases: { when: NumExpr; upTo?: NumExpr; text: string }[] = []; + c.over.results.forEach((text, j) => { + const last = cases[cases.length - 1]; + if (last && last.text === text) last.upTo = k(j); + else cases.push({ when: k(j), text }); + }); + printed.push({ kind: "pick", by: once(c.over.index), cases }); + } + else printed.push({ kind: "pick", by: once(c.expr), cases: c.texts.map((text) => ({ when: pool.id(text), text })) }); + } + out.push({ ...(walk({ ...s, parts: [] }) as Extract), parts: printed }); + return out; + }; + + const body = stmts(program.body); + // (A called function that gives back a made text makes it into its result's block, and has no result of its own.) + const functions = program.functions?.map((f) => { + const result = f.result?.kind === "text" && !recursive.has(f.id) ? madeVars.get(f.result.decl.id) : undefined; + results.push({ into: result, failed: false }); + const fbody = stmts(f.body); + // (One of its texts not made: the function as it was, for the emitter to refuse.) + if (results.pop()!.failed) return f; + return { ...f, body: fbody, ...(result ? { result: undefined } : {}) }; + }); + // The run time's variables declared at the program's start, its functions beside the program's. + here = program.at; + // (The shared ones kept in one cell each — the cursor, the reader, the operands — and so each block's address.) + const ROUTED = new Set([CE, CO, CN, CC, B, SE, SN, SC, RV, X, BLK, IDX, FE, FN, FROM, OFF, LO, HI, R.se, R.so, R.i, R.b, T.se, T.so, T.i, T.b, F.se, F.so, F.i, F.b]); + // (Only those the program reads: a program with no made text pays nothing at its start.) + const fns = [...runtime.values()].filter((f): f is FuncDecl => !!f); + const read = new Set(); + const reads = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(reads); return; } + if (!x || typeof x !== "object") return; + const o = x as { kind?: string; id?: string }; + if (o.kind === "var" && typeof o.id === "string") read.add(o.id); + for (const v of Object.values(o)) if (v && typeof v === "object") reads(v); + }; + reads([body, fns, functions ?? []]); + // (The pool of blocks the cells of arrays of texts that grow take, when there are any: laid out once, and its list.) + const P = pooled as { e: string; empty: string; free: string } | null; + if (P) arrays.push({ id: P.free, name: "(text blocks given back)", kind: "number", length: POOL_BLOCKS, shared: true, at: here }); + const start: Stmt[] = [ + // (The program's own player, as the game has it now: the one a known text's writing gives it back to.) + ...[...globals].filter((id) => read.has(id)).map((id) => dec(id, id === ME ? peek(k(CURRENT)) : k(0), ROUTED.has(id))), + ...(P ? [ + { kind: "textBlock", decl: { id: P.e, name: "(text)", kind: "number", shared: false, at: here, routed: true }, block: "@text.pool", bytes: POOL_BLOCKS * BLOCK, data: true, at: here } as Stmt, + { kind: "textBlock", decl: { id: P.empty, name: "(text)", kind: "number", shared: false, at: here, routed: true }, block: "@text.empty", bytes: 4, at: here } as Stmt, + ] : []), + ...[...known].map(([text, id]) => ({ kind: "textBlock", decl: { id: `${id}@e`, name: "(text)", kind: "number", shared: false, at: here, routed: true }, block: id, bytes: enc.encode(text).length + 1, text, at: here }) as Stmt), + ...[...blocks.values()].flatMap((m) => [ + { kind: "textBlock", decl: { id: m.e, name: "(text)", kind: "number", shared: false, at: here, routed: true }, block: m.block, bytes: BLOCK, at: here } as Stmt, + dec(m.n, k(0)), + dec(m.c, k(0)), + ]), + ]; + return { ...program, body: [...start, ...body], ...(functions || fns.length ? { functions: [...(functions ?? []), ...fns] } : {}), arrays }; +} + +const CURRENT_PLAYER = 13; +/** The most cells an array of texts may have here: each a block of its own. */ +const MOST_TEXT_CELLS = 64; +/** The blocks the cells of arrays of texts that grow share (`pool`): as many texts at once, a made text's room each. */ +const POOL_BLOCKS = 64; +const DEATHS = 0x58a364; +/** The code of the game's texts nearest each colour of the palette (eudplib's `_pcolor`). */ +const COLOUR_CODES = [ + 21, 14, 16, 14, 14, 14, 14, 28, 16, 14, 28, 28, 28, 28, 16, 16, 21, 21, 21, 21, 21, 21, 21, 6, 21, 21, 21, 8, 17, 17, 8, 17, + 17, 3, 24, 24, 24, 24, 29, 29, 14, 14, 14, 14, 14, 14, 14, 14, 28, 28, 28, 28, 31, 14, 28, 31, 31, 31, 31, 31, 22, 23, 25, 25, + 21, 21, 21, 21, 21, 14, 21, 21, 21, 16, 30, 29, 29, 29, 30, 29, 29, 22, 22, 22, 22, 4, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, + 21, 24, 21, 21, 21, 24, 21, 6, 17, 21, 29, 17, 6, 17, 8, 8, 8, 8, 27, 17, 3, 7, 14, 16, 14, 14, 14, 14, 14, 28, 28, 2, + 31, 29, 29, 3, 3, 3, 27, 23, 25, 25, 21, 21, 21, 21, 21, 21, 21, 16, 16, 24, 21, 28, 30, 30, 27, 27, 30, 2, 17, 24, 15, 15, + 14, 16, 14, 16, 16, 14, 3, 3, 21, 21, 21, 21, 21, 21, 6, 8, 6, 8, 8, 17, 17, 21, 24, 24, 24, 24, 24, 24, 29, 29, 29, 29, + 14, 14, 14, 28, 28, 28, 30, 2, 21, 21, 21, 21, 21, 14, 14, 21, 21, 21, 21, 21, 21, 21, 21, 29, 21, 21, 21, 21, 21, 21, 21, 21, + 21, 21, 21, 21, 24, 24, 24, 16, 14, 14, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 14, 21, 21, 21, 14, 28, 28, 28, 4, 4, +]; +/** The most a made text holds, in bytes (`compiler/ir.ts` has the same). */ +const TEXT_BYTES = 1023; +const DISPLAY_TEXT = 9; +// (`call` and `pop` are numbers too: a list of numbers that starts with one is walked as statements, which does to +// each what walking it as a number would.) +const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "if", "parallel", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "watch", "while"]); +function isStmt(o: unknown): boolean { + return !!o && typeof o === "object" && STMT_KINDS.has((o as { kind?: string }).kind ?? ""); +} diff --git a/compiler/backend/units.ts b/compiler/backend/units.ts new file mode 100644 index 0000000..bb3765e --- /dev/null +++ b/compiler/backend/units.ts @@ -0,0 +1,1095 @@ +/** + * Units on a map of our own (TrigScript 4, step 10 of the way off eudplib). A unit of the game is a record of the game's + * unit table — 1 700 places of 336 bytes — and `unitValues` rewrites a program's IR before the emitter sees it, so that + * every unit is two numbers of the program's: the EPD of its record (0: none), which a condition's player field and a + * Set Deaths' destination take as they are, and the uniqueness byte its place had when the unit was taken. The game + * gives a dead unit's place to the next unit made, so a unit kept in a variable is checked before each use — the place + * has a sprite, its order is not Die, its uniqueness byte is the one kept — in one trigger of four conditions; a field is + * then read through the current player pointed at it, a bit at a time from the highest group of eight it reaches, or + * written with a masked Set Deaths. The Python side's `UnitRef` and `when_alive`, with the pointer left out: it is the + * EPD times four plus the deaths table. + * + * A loop over units and a pick walk the table in the order the game hands its places out — place 0, then 1 699 down — + * the units oldest first (the Python side's `scan`), down to the lowest place the game has ever handed out (`scan` + * says why that is enough). The walk is a loop of the program's, two places a turn where it can be, its filter one + * trigger whose conditions read the place a variable is at. + * + * What it does not take — a unit an event gives (step 11), a pick near the mouse and the selection read (step 12) — + * it leaves as it was, for the emitter to refuse in a script's words. + */ +import type { ArrayDecl, At, BoolExpr, Call, CompareOp, FuncDecl, NumExpr, Program, Stmt, UnitExpr, UnitFilter, UnitFlag, UnitNumField, VarDecl } from "../ir"; +import type { ActionRecord, ConditionRecord } from "../../vendor/triggers"; +import { STATEMENT_KINDS } from "./emit"; +import { MOUSE_CELLS } from "./input"; + +/** The deaths table: what a condition's or an action's player field 0 is, an EPD a dword after it. */ +const DEATHS = 0x58a364; +const epd = (address: number): number => (address - DEATHS) / 4; +/** The game's unit table: where its first place is (as an EPD), how far apart the places are (in dwords), how many. */ +const UNIT_TABLE = epd(0x59cca8), STRIDE = 336 / 4, PLACES = 1700; +/** Where the current player is kept. */ +const CURRENT = epd(0x6509b0); +/** The game's locations: 20 bytes each, from 1. */ +const MRGN = epd(0x58dc60); +/** units.dat: hit points (a dword a type, 256 to a point), shields (a word a type), the group flags (a byte a type). */ +const MAX_HP = epd(0x662350), MAX_SHIELDS = epd(0x660e00), GROUPS = epd(0x6637a0); +/** The group bits Men, Buildings and Factories go by (a trigger's unit classes 230, 231, 232). */ +const GROUP_BITS: Record = { 230: 0x08, 231: 0x10, 232: 0x20 }; +/** The most statements a loop's body over units may have to be written twice, for two places a turn. */ +const UNROLL_MOST = 16; +/** Whether a `continue` at the top of statements (not inside a loop of their own; a switch's is the loop's) reaches the loop around them. */ +function continues(list: Stmt[]): boolean { + return list.some((t) => t.kind === "continue" || (t.kind === "if" && (continues(t.then) || continues(t.else ?? []))) || (t.kind === "block" && continues(t.body)) || (t.kind === "switch" && t.cases.some((c) => continues(c.body)))); +} +/** How many statements a list has, those inside them counted. */ +function size(x: unknown): number { + if (Array.isArray(x)) return x.reduce((n: number, y) => n + size(y), 0); + if (!x || typeof x !== "object") return 0; + const o = x as { kind?: string }; + const own = typeof o.kind === "string" && STATEMENT_KINDS.has(o.kind) ? 1 : 0; + return own + Object.values(o).reduce((n: number, v) => n + (v && typeof v === "object" ? size(v) : 0), 0); +} +/** The location an order goes through, made a box around the unit for its one action and put back after. */ +const SCRATCH_LOCATION = 255; + +/** A unit's record, by the dword: where each thing is (`dword`), its bits (`mask`) and how far up they are (`shift`). */ +interface Bits { dword: number; mask: number; shift: number } +const word = (byte: number, bytes: 1 | 2 | 4): Bits => { + const dword = byte >> 2, shift = 8 * (byte & 3); + return { dword, mask: bytes === 4 ? 0xffffffff : ((2 ** (8 * bytes) - 1) * 2 ** shift) >>> 0, shift }; +}; +const SPRITE = 0x0c >> 2, POSITION = 0x28 >> 2, UID = word(0xa5, 1); +/** The owner and the order share a dword: the owner its low byte, the order the next. */ +const OWNER = word(0x4c, 1), ORDER = word(0x4d, 1), TYPE = word(0x64, 2); +const X = word(0x28, 2), Y = word(0x2a, 2); +const FIELDS: Partial> = { + owner: OWNER, type: TYPE, x: X, y: Y, kills: word(0x8f, 1), orderId: ORDER, cooldown: word(0x55, 1), resources: word(0xd0, 2), + stim: word(0x115, 1), ensnare: word(0x116, 1), lockdown: word(0x117, 1), irradiate: word(0x118, 1), stasis: word(0x119, 1), plague: word(0x11a, 1), maelstrom: word(0x124, 1), + buildTime: word(0xac, 2), rank: word(0x8e, 1), matrix: word(0x114, 1), speed: word(0x38, 4), +}; +const HP = 0x08 >> 2, SHIELDS = 0x60 >> 2, ENERGY = word(0xa2, 2), STATUS = 0xdc >> 2, USER_ACTION = word(0x93, 1); +/** + * "Under attack": the attack-notify timer (+0x87), which the game starts when it raises its "under attack" alert for the + * unit — a hit out of the player's view — and runs for about a second. A unit hit on screen in a game played alone does + * not show it (Remastered, Magenta probe 6, 2026-10-25). The last-event bytes BWAPI reads instead (+0x8A timer, +0x8B + * 174 = was hit) are in a dword Remastered's EUD emulator ends the game on (data/allowlist.json, +0x88), so this is the + * one "recently hit" signal a map may read. + */ +const ATTACKED = word(0x87, 1); +/** The status bits a flag is (cloaked: both of two). */ +const FLAG_BITS: Partial> = { invincible: 0x04000000, hallucinated: 0x40000000, burrowed: 0x10, cloaked: 0x300, speedUpgrade: 0x10000000, inTransport: 0x40 }; +/** The unit an order is aimed at (a pointer, 0 for none), and the defensive matrix's hit points (a word, 256 a point) beside its timer. */ +const ORDER_TARGET = 0x5c >> 2, MATRIX_HP = word(0x112, 2), MATRIX_POINTS = 250 * 256; +/** The most a field written holds, as the simulator stops it: points for hit points and shields, a byte, a word. */ +const TOPS: Partial> = { hp: 0xffffff, shields: 0xffffff, energy: 255, kills: 255, cooldown: 255, resources: 0xffff, stim: 255, ensnare: 255, plague: 255, lockdown: 255, maelstrom: 255, irradiate: 255, stasis: 255, buildTime: 0xffff, rank: 255, matrix: 255 }; +/** The Order action's orders. */ +const ORDERS = { move: 0, patrol: 1, attack: 2 } as const; +const SET_DEATHS = 45, ORDER_ACTION = 46, SET_TO = 7, MASKED = 0x4353; +/** A record's player field that names the current player: a condition's Deaths of it reads the dword the pointer is the EPD of. */ +const CURRENT_PLAYER = 13; +/** The program's own player, read as it starts, when it is not one known when built: what the pointer goes back to. */ +const ME_ID = "@u.me"; +/** + * The lowest place of the unit table the game has handed out so far, as an EPD (0: none walked yet) — a cell for the + * whole map, as the heap's are, read and moved down by every walk (`scan`). + */ +export const UNIT_END = "@unit.end"; + +/** + * The frame's events pass (TrigScript 4, step 11, part 3), the map's one, before the programs' turns: a watch of every + * unit place (`emit.ts`'s `watch`), whose handler lists this frame's units, each an EPD — what came on, what died, what + * took up an order or turned into another type (with what it was) — and how many of each. + */ +const MADE = "@ev.made", GONE = "@ev.gone", TURNED = "@ev.turned", TURNED_WAS = "@ev.turnedWas", CHANGED = "@ev.changed", CHANGED_WAS = "@ev.changedWas"; +const MADE_N = "@ev.madeN", GONE_N = "@ev.goneN", TURNED_N = "@ev.turnedN", CHANGED_N = "@ev.changedN"; +/** + * What the watch hands its handler: the record's EPD, and the number of the watch trigger's first condition (its next + * ones five dwords on); the distance from place 0 of the place the game hands out next. + */ +const EV_RECORD = "@ev.e", EV_FIELDS = "@ev.wf", EV_BOUNDARY = "@ev.be"; +/** + * For the watch's gate (`emit.ts`): whether the handler found a unit come on or gone this frame, and a bit for each + * player (0 to 11) that had one — whose counts the gate reads again. + */ +const EV_TOUCHED = "@ev.touch", EV_OWNERS = "@ev.own"; +const AT0 = { file: "", line: 0, column: 0 }; +/** The pass's variables, one each for the map, which no program declares; and its lists, the map's (a program's events read them). */ +export const EVENT_GLOBALS: readonly VarDecl[] = [MADE_N, GONE_N, TURNED_N, CHANGED_N, EV_RECORD, EV_BOUNDARY, EV_FIELDS, EV_TOUCHED, EV_OWNERS].map((id) => ({ id, name: "(events)", kind: "number", shared: true, ...(id === EV_FIELDS ? {} : { routed: true }), global: true, at: AT0 })); +export const isEventGlobal = (id: string): boolean => id.startsWith("@ev."); +/** A unit's hit points (256ths, 0 when it died in a fight) and the last player whose attack hurt it. */ +const HP_DWORD = 0x08 >> 2, LAST_ATTACKER = word(0x90, 1); +/** The most units of each kind a pass hands on in a frame, as the Python side's. */ +const EVENT_CAP = 512; +type EventSource = "created" | "died" | "killed" | "ordered" | "morphed"; +/** The sources a piece of the IR loops over the events pass's units of. */ +export function eventSources(x: unknown, out = new Set()): Set { + if (Array.isArray(x)) { x.forEach((y) => eventSources(y, out)); return out; } + if (!x || typeof x !== "object") return out; + const o = x as { kind?: string; source?: EventSource }; + if (o.kind === "unitLoop" && o.source) out.add(o.source); + for (const v of Object.values(o)) if (v && typeof v === "object") eventSources(v, out); + return out; +} +export const UNIT_GLOBALS: readonly VarDecl[] = [{ id: UNIT_END, name: "(units)", kind: "number", shared: true, routed: true, global: true, at: { file: "", line: 0, column: 0 } }]; +/** + * Each player's list of units: its first unit (a pointer a player, 0 for none), and a unit's place in its owner's list — + * the unit before it and the one after it (pointers). A pointer read with its two low bits left out is its EPD plus the + * deaths table's (`DEATHS / 4`); a unit's is at least the unit table's. + */ +const FIRST_UNIT = epd(0x6283f8), PREV_UNIT = 0x68 >> 2, NEXT_UNIT = 0x6c >> 2, POINTER = 0xfffffffc, IN_TABLE = 0x59cca8 / 4; +/** + * `select()` (the Python side's): this computer's player (0x512684); its selection, twelve unit pointers (0x6284B8); the + * flag Remastered's panel refresh tests (0x59723C); the game's queue of commands to send (0x654880), how much of it is + * used (0x654AA0) and how much it holds (0x57F0D8). + */ +const LOCAL = epd(0x512684), SELECTION = epd(0x6284b8), PANEL = epd(0x59723c), QUEUE = epd(0x654880), QUEUED = epd(0x654aa0), QUEUE_MOST = epd(0x57f0d8); +const SELECT_COMMAND = 0x09, SELECTION_MOST = 12; +/** Kill Unit of Player 12's units of type 228, which no unit is: after it the game counts again (`emit.ts`'s `FORGET_COUNTS`). */ +const FORGET_COUNTS: ActionRecord = { location: 0, text: 0, wav: 0, time: 0, player: 11, target: 0, unitId: 228, type: 22, modifier: 0, flags: 0x10, padding: 0, mask: 0 }; + +/** + * A unit as the rewriting holds it: the EPD of its record (a variable, or the constant 0 for none) and its uniqueness + * byte as kept (null: the unit is there by construction — a loop's turn, a pick in the statement that made it — and + * nothing need be checked but `none` and `killed`). `none`: the EPD may be 0. `killed`: something since it was found may + * have killed it (its order checked). + */ +interface Unit { e: NumExpr; k: NumExpr | null; none: boolean; killed: boolean } +const NONE: Unit = { e: { kind: "const", value: 0 }, k: null, none: true, killed: false }; + +/** A unit construct this pass leaves as it was: the statement that holds it is kept whole, for the emitter to refuse. */ +class Leave extends Error {} + +const k = (value: number): NumExpr => ({ kind: "const", value }); +const V = (id: string): NumExpr => ({ kind: "var", id }); +const isNone = (u: Unit) => u.e.kind === "const" && u.e.value === 0; + +/** Whether a program has anything of units in it this pass rewrites. */ +function hasUnits(x: unknown): boolean { + if (Array.isArray(x)) return x.some(hasUnits); + if (!x || typeof x !== "object") return false; + const o = x as { kind?: string; decl?: { kind?: string } }; + if (typeof o.kind === "string" && (o.kind.startsWith("unit") || o.kind === "assignUnit" || o.kind === "select" || o.kind === "selected")) return true; + // (A pick: a unit's, not a printed text's, whose `by` is a value.) + if (o.kind === "pick" && typeof (o as { by?: unknown }).by === "string") return true; + if (o.decl && o.decl.kind === "unit") return true; + return Object.values(o).some((v) => v && typeof v === "object" && hasUnits(v)); +} + +/** Whether a statement list may kill or remove a unit of the game: a verb, a write, an action, a call. */ +function mayKill(x: unknown): boolean { + if (Array.isArray(x)) return x.some(mayKill); + if (!x || typeof x !== "object") return false; + const o = x as { kind?: string }; + if (o.kind === "unitDo" || o.kind === "unitWrite" || o.kind === "action" || o.kind === "call" || o.kind === "select") return true; + return Object.values(o).some((v) => v && typeof v === "object" && mayKill(v)); +} + +/** Whether a value is a list of statements (every member one). */ +function isStmtList(x: unknown): x is Stmt[] { + // (A block has no label; a call is a statement when it has one.) + return Array.isArray(x) && x.length > 0 && x.every((s) => s && typeof s === "object" && typeof (s as { kind?: unknown }).kind === "string" && (STATEMENT_KINDS.has((s as { kind: string }).kind) || ((s as { kind: string }).kind === "call" && "label" in s))); +} + +/** + * The program with its units as numbers: `unitValues` (see the file's comment). `owner`: the player the program runs + * as, when it is one player known when built (the current player of a filter); otherwise read where the game keeps it. + */ +/** + * `events`: the sources the map's programs' events loop over, and whether this program lays out the map's one events + * pass (`Program.pass`) — the first program that has any. + */ +/** + * `carrier`: the type of the input's carrier units (`input.ts`), which a walk of the table passes over — a hidden + * unit of Player 12's with a human's owner byte, which no script means. + */ +export function unitValues(program: Program, events?: { sources: Set; pass: boolean }, carrier?: number): Program { + if (!hasUnits(program)) return program; + let here: At = program.at; + let made = 0; + const fresh = (what: string) => `@u.${what}${made++}`; + const owner = !program.perPlayer && program.owners.length <= 1 && program.owner >= 0 && program.owner < 8 ? program.owner : undefined; + + // ── the IR's own pieces ── + const at = () => ({ at: here, label: "" }); + const bin = (op: "+" | "-" | "*" | "&" | "|" | ">>>", a: NumExpr, b: NumExpr): NumExpr => { + // (Of two constants, the constant: a field takes it as it is.) + if (a.kind === "const" && b.kind === "const") { + const [x, y] = [a.value >>> 0, b.value >>> 0]; + return k((op === "+" ? x + y : op === "-" ? x - y : op === "*" ? Number((BigInt(x) * BigInt(y)) & 0xffffffffn) : op === "&" ? x & y : op === "|" ? x | y : x >>> y) >>> 0); + } + return { kind: "binary", op, left: a, right: b, ...at() } as NumExpr; + }; + const cmp = (op: CompareOp, a: NumExpr, b: NumExpr): BoolExpr => ({ kind: "compare", op, left: a, right: b, unsigned: true, ...at() }); + const all = (items: BoolExpr[]): BoolExpr => (items.length === 0 ? { kind: "const", value: true } : items.length === 1 ? items[0] : { kind: "and", items }); + const any = (items: BoolExpr[]): BoolExpr => (items.length === 0 ? { kind: "const", value: false } : items.length === 1 ? items[0] : { kind: "or", items }); + const ranged = (e: NumExpr, lo: number, hi: number): NumExpr => ({ ...e, range: [lo, hi] }) as unknown as NumExpr; + /** `keep`: "routed" — kept in one cell, read where it is used; "everywhere" — written seldom, read often, never copied. */ + const num = (id: string, top?: number, keep?: "routed" | "everywhere"): VarDecl => ({ id, name: "(unit)", kind: "number", shared: false, at: here, temp: true, ...(top !== undefined ? { top } : {}), ...(keep ? { [keep]: true } : {}) } as VarDecl); + const dec = (id: string, init: NumExpr, top?: number, keep?: "routed" | "everywhere"): Stmt => ({ kind: "declare", decl: num(id, top, keep), init, ...at() }); + const set = (target: string, value: NumExpr): Stmt => ({ kind: "assign", target, value, ...at() }); + const when = (cond: BoolExpr | boolean, then: Stmt[], otherwise: Stmt[] = []): Stmt[] => { + if (cond === true || (typeof cond === "object" && cond.kind === "const" && cond.value === true)) return then; + if (cond === false || (typeof cond === "object" && cond.kind === "const" && cond.value === false)) return otherwise; + return [{ kind: "if", cond, then, ...(otherwise.length ? { else: otherwise } : {}), ...at() }]; + }; + const brk = (): Stmt => ({ kind: "break", ...at() }); + /** The dword at an EPD, or its bits of the mask, moved down `shift`. */ + const peek = (at_: NumExpr, mask = 0xffffffff, shift = 0): NumExpr => { + const whole = mask >>> 0 === 0xffffffff; + const record: ConditionRecord = { location: whole ? 0 : mask >>> 0, player: 0, amount: 0, unitId: 0, comparison: 0, type: 15, resource: 0, flags: 0, mask: whole ? 0 : MASKED }; + const r: NumExpr = { kind: "read", read: { source: "condition", record, epd: at_ }, ...at() } as NumExpr; + return shift ? bin(">>>", r, k(shift)) : r; + }; + /** A Set Deaths of the dword at an EPD: the value written, or its bits of the mask. */ + const poke = (at_: NumExpr, value: NumExpr, mask = 0xffffffff): Stmt => { + const whole = mask >>> 0 === 0xffffffff; + const record: ActionRecord = { location: whole ? 0 : mask >>> 0, text: 0, wav: 0, time: 0, player: 0, target: value.kind === "const" ? value.value >>> 0 : 0, unitId: 0, type: SET_DEATHS, modifier: SET_TO, flags: 0, padding: 0, mask: whole ? 0 : MASKED }; + const variables = [{ field: "player" as const, bits: 32 as const, name: "at", expr: at_ }, ...(value.kind === "const" ? [] : [{ field: "target" as const, bits: 32 as const, name: "n", expr: value }])]; + return { kind: "action", record, variables, ...at() } as Stmt; + }; + /** A unit's dword `off` dwords into its record. */ + const of = (u: NumExpr, off: number): NumExpr => (off ? bin("+", u, k(off)) : u); + const read = (u: NumExpr, b: Bits): NumExpr => peek(of(u, b.dword), b.mask, b.shift); + /** The same bits where they are, for a condition: a masked Deaths, no shift. */ + const bits = (u: NumExpr, b: Bits): NumExpr => peek(of(u, b.dword), b.mask); + /** + * Bits of a dword read through the current player, as eudplib reads: the pointer written once where the game keeps + * it — one place of the unit, where a read in place would make every condition of the read one — the conditions' + * player field 13, then the pointer given back to the program's own player (`ME`). + */ + let usesMe = false; + const ME: NumExpr = owner !== undefined ? k(owner) : V(ME_ID); + const through = (at_: NumExpr, inner: (here_: (mask?: number, shift?: number) => NumExpr) => Stmt[]): Stmt[] => { + if (owner === undefined) usesMe = true; + return [poke(k(CURRENT), at_), ...inner((mask = 0xffffffff, shift = 0) => peek(k(CURRENT_PLAYER), mask, shift)), poke(k(CURRENT), ME)]; + }; + /** A unit's bits into a variable, read through the current player. */ + const readInto = (target: string, u: NumExpr, b: Bits): Stmt[] => through(of(u, b.dword), (h) => [set(target, h(b.mask, b.shift))]); + /** The value stopped at a top, read from 0 up, as the simulator's `amount` and the Python side's `saturate` have it. */ + const atMost = (e: NumExpr, top: number): NumExpr => { + const r = (e as NumExpr & { range?: readonly [number, number] }).range; + if (e.kind === "const") return k(Math.min(e.value >>> 0, top)); + if (r && r[0] >= 0 && r[1] <= top) return e; + return { kind: "intrinsic", name: "min", args: [e, k(top)], unsigned: true, range: [0, top], ...at() } as NumExpr; + }; + /** A value into a variable of this pass's first, unless it is a variable or a constant: a field takes one of those. */ + const plain = (e: NumExpr, pre: Stmt[], top?: number): NumExpr => { + if (e.kind === "const" || e.kind === "var") return e; + const t = fresh("v"); + pre.push(dec(t, e, top)); + const r = (e as NumExpr & { range?: readonly [number, number] }).range; + return r ? ranged(V(t), r[0], r[1]) : V(t); + }; + + // ── a unit checked ── + /** The unit's place holds it still: a sprite, an order other than Die, the uniqueness byte kept. */ + const alive = (u: Unit): BoolExpr => { + if (isNone(u)) return { kind: "const", value: false }; + if (u.k === null) { + const items: BoolExpr[] = []; + if (u.none) items.push(cmp(">=", u.e, k(1))); + if (u.killed) items.push(cmp(">=", bits(u.e, ORDER), k(0x100))); + return all(items); + } + return all([ + cmp(">=", u.e, k(1)), + cmp(">=", peek(of(u.e, SPRITE)), k(1)), + cmp(">=", bits(u.e, ORDER), k(0x100)), + cmp("==", bits(u.e, UID), ranged(bin("*", u.k, k(256)), 0, 0xff00)), + ]); + }; + /** The unit's place holds its record still, dying or not: what a read of its fields asks. A unit handed to a died + * handler is on its way out — its order Die, its sprite still there — and its type and place are read as they are, + * as the simulator reads them (the first play of the events probe found "died 0 at 0, 0" for every unit, 2026-10-24). */ + const present = (u: Unit): BoolExpr => { + if (isNone(u)) return { kind: "const", value: false }; + if (u.k === null) return u.none ? cmp(">=", u.e, k(1)) : { kind: "const", value: true }; + return all([cmp(">=", u.e, k(1)), cmp(">=", peek(of(u.e, SPRITE)), k(1)), cmp("==", bits(u.e, UID), ranged(bin("*", u.k, k(256)), 0, 0xff00))]); + }; + /** The unit's uniqueness byte as it is now, for keeping (0 for none). */ + const uidOf = (u: Unit, pre: Stmt[]): NumExpr => { + if (u.k) return u.k; + if (isNone(u)) return k(0); + const t = fresh("k"); + pre.push(dec(t, k(0), 255)); + pre.push(...when(u.none ? cmp(">=", u.e, k(1)) : true, readInto(t, u.e, UID))); + return ranged(V(t), 0, 255); + }; + /** The unit, its EPD in a variable: what a field from a value and a condition's player take. */ + const held = (u: Unit, pre: Stmt[]): Unit => (u.e.kind === "var" || u.e.kind === "const" ? u : { ...u, e: plain(u.e, pre) }); + + // ── the unit table walked ── + /** A location's four sides, read where the game keeps them, as a filter and a pick compare positions with them. */ + const sides = (location: number, pre: Stmt[]): NumExpr[] => { + const base = MRGN + 5 * (location - 1); + return [0, 1, 2, 3].map((i) => { + const t = fresh("side"); + pre.push(dec(t, peek(k(base + i)))); + // (A side reaching past the map's edge is below zero, where no unit is: it is 0. Past 65 535, no unit is either.) + if (i < 2) pre.push(...when(cmp(">=", V(t), k(0x80000000)), [set(t, k(0))])); + pre.push(...when(cmp(">", V(t), k(0xffff)), [set(t, k(0xffff))])); + return ranged(V(t), 0, 0xffff); + }); + }; + /** The player a filter's owner is: a slot, or the current player (the program's owner, or read where the game keeps it). */ + const playerOf = (p: number, pre: Stmt[]): NumExpr => { + if (p !== 13) return k(p); + if (owner !== undefined) return k(owner); + const t = fresh("cp"); + pre.push(dec(t, peek(k(CURRENT), 0xff))); + return ranged(V(t), 0, 255); + }; + /** + * The statements of place 0's turn, which is a turn of its own ahead of the walk's loop: a `break` at their top (not + * inside a loop or a switch of their own) ends the walk — `skip` set first, which the loop after reads — and a + * `continue` goes on to the loop, as the turn is a `do … while (false)` of its own. + */ + const ending = (list: Stmt[], skip: string): { list: Stmt[]; jumps: boolean } => { + let jumps = false; + // (Inside a switch a `break` is the switch's, a `continue` still the loop's.) + const walk = (x: Stmt[], inSwitch = false): Stmt[] => x.map((t): Stmt => { + if (t.kind === "break" && !inSwitch) { jumps = true; return { kind: "block", body: [set(skip, k(1)), t], at: t.at } as Stmt; } + if (t.kind === "continue") { jumps = true; return t; } + if (t.kind === "if") return { ...t, then: walk(t.then, inSwitch), ...(t.else ? { else: walk(t.else, inSwitch) } : {}) }; + if (t.kind === "block") return { ...t, body: walk(t.body, inSwitch) }; + if (t.kind === "switch") return { ...t, cases: t.cases.map((c) => ({ ...c, body: walk(c.body, true) })) }; + return t; + }); + return { list: walk(list), jumps }; + }; + /** + * The units a filter matches, each once, oldest first: `body` with the EPD of the unit's record — a variable it must + * not write — run for each, where `stop()` ends the walk, and a `break` and a `continue` of the walk's loop do what + * they say. The walk is place 0's turn, then a loop from place 1 699 down to the lowest place the game has ever + * handed out (`UNIT_END`): a turn is the loop's test, the filter asked of the place in one trigger, and the step down, + * which goes back to the test as it is laid out. The places below that one have never held a unit — the game hands + * them out last, in order — so nothing is counted on the way. + */ + const scan = (filter: UnitFilter, pre: Stmt[], body: (q: NumExpr, stop: () => Stmt[]) => Stmt[]): Stmt[] => { + const type = filter.type; + if (type !== undefined && type >= 228 && GROUP_BITS[type] === undefined) throw new Leave(); + const n = made++; + const Q = `@u.q${n}`, LIM = `@u.lo${n}`, SKIP = `@u.skip${n}`; + const q = ranged(V(Q), UNIT_TABLE - STRIDE, UNIT_TABLE + STRIDE * (PLACES - 1)); + const who = filter.owner !== undefined ? playerOf(filter.owner, pre) : undefined; + const box = filter.at ? sides(filter.at, pre) : undefined; + // What the filter asks of a unit, read where it is: a sprite, an order other than Die, the owner, the type, inside + // the location — one trigger. + const living = (q: NumExpr) => cmp(">=", bits(q, ORDER), k(0x100)); + const asks = (q: NumExpr): BoolExpr[] => { + const out: BoolExpr[] = [cmp(">=", peek(of(q, SPRITE)), k(1))]; + if (who) out.push(cmp("==", read(q, OWNER), who)); + if (type !== undefined && type < 228) out.push(cmp("==", read(q, TYPE), k(type))); + if (box) { + const [l, t, r, b] = box; + const xy = (u: NumExpr, mask: number) => peek(of(u, POSITION), mask); + out.push(cmp(">=", xy(q, 0xffff), l), cmp("<=", xy(q, 0xffff), r), cmp(">=", xy(q, 0xffff0000), ranged(bin("*", t, k(0x10000)), 0, 0xffff0000)), cmp("<=", xy(q, 0xffff0000), ranged(bin("*", b, k(0x10000)), 0, 0xffff0000))); + } + return out; + }; + const turn = (q: NumExpr, inner: Stmt[], also: BoolExpr[] = []): Stmt[] => { + if (type !== undefined && GROUP_BITS[type] !== undefined) { + // A trigger's class (Men, Buildings, Factories): the type's group byte, read where units.dat keeps it. + const ty = fresh("ty"), qt = fresh("tq"), bit = GROUP_BITS[type]; + const tyv = ranged(V(ty), 0, 0xffff); + inner = [dec(ty, k(0), 0xffff), ...readInto(ty, q, TYPE), dec(qt, bin(">>>", tyv, k(2)), 0x3fff), + ...when(any([0, 1, 2, 3].map((r) => all([cmp("==", bin("&", tyv, k(3)), k(r)), cmp(">=", peek(bin("+", k(GROUPS), V(qt)), (bit * 2 ** (8 * r)) >>> 0), k(1))]))), inner)]; + } + // (The input's carriers, hidden units with a human's owner byte, are no script's: a unit the filter lets through + // is asked its type once more, unless the filter asks for one type already.) + if (carrier !== undefined && (type === undefined || type >= 228)) inner = when(cmp("!=", read(q, TYPE), k(carrier)), inner); + // (With a filter the order is asked only of the units it lets through: a place the walk passes costs a condition + // less, and every condition is a dword the walk's step moves on.) + const ask = [...also, ...asks(q)]; + return ask.length > also.length + 1 ? when(all(ask), when(living(q), inner)) : when(all([...ask, living(q)]), inner); + }; + // Place 0's turn, read at place 0 itself: its `break` ends the walk, its `continue` goes on to the loop. + const q0 = k(UNIT_TABLE); + const first = ending(body(q0, () => [set(SKIP, k(1))]), SKIP); + let zero = turn(q0, first.list); + if (first.jumps) zero = [{ kind: "do", body: zero, cond: { kind: "const", value: false }, ...at(), condLabel: "" } as Stmt]; + // The lowest place ever handed out, kept as the upper of the two places the loop's last turn walks (places 1 699 + // and 1 698 are a turn, 1 697 and 1 696 the next …): moved down a turn past each turn's places below it while the + // first of them has held a unit (a sprite, a position or a uniqueness byte: a place never handed out is all 0, as + // the game set the table up, and the game hands those out last, in order). + const top = UNIT_TABLE + STRIDE * (PLACES - 1); + const below = bin("-", V(LIM), k(2 * STRIDE)); + const used = any([cmp(">=", peek(of(below, SPRITE)), k(1)), cmp(">=", peek(of(below, POSITION)), k(1)), cmp(">=", bits(below, UID), k(0x100))]); + const lim = ranged(V(LIM), UNIT_TABLE + STRIDE, top + 2 * STRIDE); + // Two places a turn, when the body is small and no `continue` of its own reaches the walk's loop (the second + // place's turn is then not the update's; a `break` ends the walk from either); else one. Neither walks place 0 + // again, the lower of the last turn's two when every place has been handed out. + const loopBody = body(q, () => [brk()]); + const pairs = !continues(loopBody) && size(loopBody) <= UNROLL_MOST; + const second = bin("-", q, k(STRIDE)); + const turns = pairs ? [...turn(q, loopBody), ...turn(second, body(second, () => [brk()]), [cmp(">=", q, k(UNIT_TABLE + 2 * STRIDE))])] : turn(q, loopBody); + // (One place a turn: down to the lower of the last turn's two, or place 1.) + const LOW = `@u.low${n}`; + const low: Stmt[] = pairs ? [] : [dec(LOW, bin("-", V(LIM), k(STRIDE))), ...when(cmp("<", V(LOW), k(UNIT_TABLE + STRIDE)), [set(LOW, k(UNIT_TABLE + STRIDE))])]; + return [ + dec(LIM, V(UNIT_END)), + ...when(cmp("==", V(LIM), k(0)), [set(LIM, k(top + 2 * STRIDE))]), + { kind: "while", cond: all([cmp(">=", V(LIM), k(UNIT_TABLE + 3 * STRIDE)), used]), body: [set(LIM, below)], ...at() } as Stmt, + set(UNIT_END, V(LIM)), + ...low, + dec(Q, k(UNIT_TABLE)), dec(SKIP, k(0), 1), + ...zero, + ...when(cmp("==", V(SKIP), k(0)), [ + set(Q, k(top)), + { kind: "for", cond: pairs ? cmp(">=", q, lim) : cmp(">=", q, ranged(V(LOW), UNIT_TABLE + STRIDE, top + STRIDE)), update: [set(Q, bin("-", V(Q), k(pairs ? 2 * STRIDE : STRIDE)))], body: turns, ...at() } as Stmt, + ]), + ]; + }; + + // ── the events pass's units ── + const back = (): Stmt => poke(k(CURRENT), ME); + const store = (array: string, index: NumExpr, value: NumExpr): Stmt => ({ kind: "store", array, index, value, ...at() }); + const element = (array: string, index: NumExpr): NumExpr => ({ kind: "element", array, index, ...at() } as NumExpr); + /** + * A loop over the units this frame's events pass found (`on.*`'s), in the order it found them, the filter asked of + * each: one that came on, took up an order or turned into another type and is gone since is passed over; one that died + * is its record as it died (the game gives its place to no other unit before the next frame's dying pass). + */ + const eventLoop = (s: Extract): Stmt[] => { + const source = s.source as EventSource; + const list = { created: MADE, died: GONE, killed: GONE, ordered: TURNED, morphed: CHANGED }[source]; + const count = { created: MADE_N, died: GONE_N, killed: GONE_N, ordered: TURNED_N, morphed: CHANGED_N }[source]; + const was = source === "ordered" ? TURNED_WAS : source === "morphed" ? CHANGED_WAS : undefined; + const type = s.filter.type; + if (type !== undefined && type >= 228 && GROUP_BITS[type] === undefined) throw new Leave(); + const pre: Stmt[] = []; + const I = fresh("ei"), E = fresh("ee"); + const e = V(E); + const who = s.filter.owner !== undefined ? playerOf(s.filter.owner, pre) : undefined; + const box = s.filter.at ? sides(s.filter.at, pre) : undefined; + const by = s.event?.by !== undefined ? playerOf(s.event.by, pre) : undefined; + const asks: BoolExpr[] = []; + if (source !== "died" && source !== "killed") asks.push(cmp(">=", peek(of(e, SPRITE)), k(1)), cmp(">=", bits(e, ORDER), k(0x100))); + // (Died in a fight: no hit points, the last attack one of the eight players'.) + if (source === "killed") asks.push(cmp("==", peek(of(e, HP_DWORD)), k(0)), cmp("<=", read(e, LAST_ATTACKER), k(7))); + if (by) asks.push(cmp("==", read(e, LAST_ATTACKER), by)); + if (s.event?.order !== undefined) asks.push(cmp("==", bits(e, ORDER), k(s.event.order * 256))); + if (who) asks.push(cmp("==", read(e, OWNER), who)); + if (type !== undefined && type < 228) asks.push(cmp("==", read(e, TYPE), k(type))); + if (box) { + const [l, t, r, b] = box; + const xy = (mask: number) => peek(of(e, POSITION), mask); + asks.push(cmp(">=", xy(0xffff), l), cmp("<=", xy(0xffff), r), cmp(">=", xy(0xffff0000), ranged(bin("*", t, k(0x10000)), 0, 0xffff0000)), cmp("<=", xy(0xffff0000), ranged(bin("*", b, k(0x10000)), 0, 0xffff0000))); + } + let inner: Stmt[]; + turns.set(s.decl.id, { q: e, killed: mayKill(s.body) }); + try { + inner = stmts(s.body); + } finally { + turns.delete(s.decl.id); + } + if (s.was) inner = [{ kind: "declare", decl: s.was, init: source === "killed" ? k(0) : element(was!, V(I)), ...at() }, ...(source === "killed" ? readInto(s.was.id, e, LAST_ATTACKER) : []), ...inner]; + if (type !== undefined && GROUP_BITS[type] !== undefined) { + // (A trigger's class: the type's group byte, read where units.dat keeps it.) + const ty = fresh("ty"), qt = fresh("tq"), bit = GROUP_BITS[type], tyv = ranged(V(ty), 0, 0xffff); + inner = [dec(ty, k(0), 0xffff), ...readInto(ty, e, TYPE), dec(qt, bin(">>>", tyv, k(2)), 0x3fff), + ...when(any([0, 1, 2, 3].map((r) => all([cmp("==", bin("&", tyv, k(3)), k(r)), cmp(">=", peek(bin("+", k(GROUPS), V(qt)), (bit * 2 ** (8 * r)) >>> 0), k(1))]))), inner)]; + } + return [...pre, dec(I, k(0)), { kind: "for", cond: cmp("<", V(I), V(count)), update: [set(I, bin("+", V(I), k(1)))], body: [dec(E, element(list, V(I))), ...when(all(asks), inner)], ...at() } as Stmt]; + }; + + /** + * The frame's events pass: the counts back to 0, and the watch of every unit place (`emit.ts`), whose conditions read + * the place's record where it is against what it saw there last — its uniqueness byte, and as the program's events + * need them its order (whether it is alive, or which) and its type. The handler, for a place + * that changed: what the watch saw, read from its conditions' numbers through the current player pointed at the + * first, and the record as it is now; a unit of another uniqueness byte that is alive came on, the one seen + * alive and alive no more died, one that took up another order or turned into another type did; and what it sees now + * written back into the conditions' numbers. As the simulator, newest first: the places walked so. + */ + const pass = (sources: Set): Stmt[] => { + here = AT0; + const died = sources.has("died") || sources.has("killed"), ordered = sources.has("ordered"), morphed = sources.has("morphed"); + // The gate (`emit.ts`): the watch passed by on a frame when no unit can have come on or gone — only when neither + // orders nor types are watched, which change with nothing else of the game's to show for it. + const gate = !ordered && !morphed; + type Field = { name: string; dword: number; mask?: number; compare: "atLeast" | "exactly"; read: number }; + // (The uniqueness byte first: the watch of a place whose unit is not alive ends there. The game hands a place's + // first unit a byte above 0, and every unit dies through the order Die, its sprite still there.) + const fields: Field[] = [{ name: "uid", dword: UID.dword, mask: 0xff00, compare: "exactly", read: 0xff00 }]; + if (ordered) fields.push({ name: "order", dword: ORDER.dword, mask: 0xff00, compare: "exactly", read: 0xff00 }); + // (Whether it is alive is watched for the gate too: a unit gone marks its owner, listed or not.) + else if (died || gate) fields.push({ name: "alive", dword: ORDER.dword, mask: 0xff00, compare: "atLeast", read: 0x100 }); + if (morphed) fields.push({ name: "type", dword: TYPE.dword, mask: 0xffff, compare: "exactly", read: 0xffff }); + const old = (name: string) => `@ev.was.${name}`, now = (name: string) => `@ev.now.${name}`; + const e = V(EV_RECORD); + const step = (by: number): Stmt => ({ kind: "action", record: { location: 0, text: 0, wav: 0, time: 0, player: 0, target: by, unitId: 0, type: 45, modifier: 8, flags: 0, padding: 0, mask: 0 }, variables: [{ field: "player", bits: 32, name: "at", expr: k(CURRENT) }], ...at() } as Stmt); + const out: Stmt[] = [set(MADE_N, k(0))]; + if (gate) out.push(set(EV_TOUCHED, k(0)), set(EV_OWNERS, k(0))); + if (died) out.push(set(GONE_N, k(0))); + if (ordered) out.push(set(TURNED_N, k(0))); + if (morphed) out.push(set(CHANGED_N, k(0))); + const h: Stmt[] = []; + // What the watch saw, its conditions' numbers five dwords apart. + h.push(poke(k(CURRENT), V(EV_FIELDS))); + fields.forEach((f, i) => { if (i) h.push(step(5)); h.push(dec(old(f.name), peek(k(CURRENT_PLAYER), f.read))); }); + h.push(back()); + // What the place holds now. + // (Read into a variable only what is compared or written back; whether it is alive asked of the record.) + h.push(dec(now("uid"), peek(of(e, UID.dword), 0xff00))); + if (ordered) h.push(dec(now("order"), peek(of(e, ORDER.dword), 0xff00))); + if (morphed) h.push(dec(now("type"), peek(of(e, TYPE.dword), 0xffff))); + const ALIVE = "@ev.alive", OWNERV = "@ev.owner"; + if (gate) h.push(dec(OWNERV, k(0), 255)); + h.push(dec(ALIVE, k(0), 1), ...when(all([cmp(">=", peek(of(e, SPRITE)), k(1)), cmp(">=", ordered ? V(now("order")) : peek(of(e, ORDER.dword), 0xff00), k(0x100))]), [set(ALIVE, k(1))])); + const listed = (list: string, n: string, extra: Stmt[] = []): Stmt[] => when(cmp("<", V(n), k(EVENT_CAP)), [store(list, V(n), e), ...extra, set(n, bin("+", V(n), k(1)))]); + // (Gated: a unit come on or gone marks the frame and its owner's bit, for the gate to read that player's counts again.) + const marked: Stmt[] = gate ? [set(EV_TOUCHED, k(1)), set(OWNERV, peek(of(e, OWNER.dword), OWNER.mask)), ...Array.from({ length: 12 }, (_, p) => when(cmp("==", V(OWNERV), k(p)), [set(EV_OWNERS, bin("|", V(EV_OWNERS), k(2 ** p)))])).flat()] : []; + const same: Stmt[] = []; + if (died) same.push(...when(all([cmp(">=", V(old(ordered ? "order" : "alive")), k(0x100)), cmp("==", V(ALIVE), k(0))]), [...listed(GONE, GONE_N), ...marked])); + else if (gate) same.push(...when(all([cmp(">=", V(old("alive")), k(0x100)), cmp("==", V(ALIVE), k(0))]), marked)); + if (ordered) same.push(...when(all([cmp("==", V(ALIVE), k(1)), cmp("!=", V(now("order")), V(old("order")))]), listed(TURNED, TURNED_N, [store(TURNED_WAS, V(TURNED_N), bin(">>>", V(old("order")), k(8)))]))); + if (morphed) same.push(...when(all([cmp("==", V(ALIVE), k(1)), cmp("!=", V(now("type")), V(old("type")))]), listed(CHANGED, CHANGED_N, [store(CHANGED_WAS, V(CHANGED_N), V(old("type")))]))); + h.push(...when(cmp("!=", V(now("uid")), V(old("uid"))), when(cmp("==", V(ALIVE), k(1)), [...listed(MADE, MADE_N), ...marked]), same)); + // What it sees now, into the watch's numbers: whether it is alive, or its order. For a unit not alive, only another + // in its place is news: the second condition's kind (the top byte of its dword after the number) is 0 then, and + // the game reads no further. + const seen: Record = { uid: V(now("uid")), order: ordered ? V(now("order")) : k(0), alive: bin("*", V(ALIVE), k(0x100)), type: morphed ? V(now("type")) : k(0) }; + h.push(poke(k(CURRENT), V(EV_FIELDS))); + fields.forEach((f, i) => { + if (i) h.push(step(i === 2 ? 4 : 5)); + h.push(poke(k(CURRENT_PLAYER), seen[f.name])); + if (i === 1) h.push(step(1), poke(k(CURRENT_PLAYER), bin("*", V(ALIVE), k(0x0f000000)), 0xff000000)); + }); + h.push(back()); + out.push({ kind: "watch", fields: fields.map(({ dword, mask, compare }) => ({ dword, ...(mask !== undefined ? { mask } : {}), compare })), handler: h, ...(gate ? { gate } : {}), ...at() } as Stmt); + return out; + }; + + /** A pick: the first unit the filter matches, the nearest to a location's centre, or one drawn at random. */ + const pick = (p: Extract, pre: Stmt[]): Unit => { + // (Kept in its cell alone: written at each unit found, the walk's address would otherwise be read into every place.) + const R = fresh("pick"); + pre.push(dec(R, k(0), undefined, "routed")); + const found = (q: NumExpr): Stmt => set(R, q); + if (p.by === "first") pre.push(...scan(p.filter, pre, (q, stop) => [found(q), ...stop()])); + else if (p.by === "nearest") { + if (p.near === undefined && p.mouse === undefined) throw new Leave(); + const cx = fresh("cx"), cy = fresh("cy"), least = fresh("least"); + const X0 = fresh("bx"), X1 = fresh("bx"), Y0 = fresh("by"), Y1 = fresh("by"); + const c = (id: string) => ranged(V(id), 0, 0xffff); + // (Written seldom and read at every unit: kept in each place it is read, never copied there.) + if (p.mouse !== undefined) { + // `underMouse()` (step 12): the player's mouse on the map as the receive pass keeps it (`input.ts`); `within` so + // many pixels, as |dx| + |dy|, or anywhere. + const who = playerOf(p.mouse, pre); + pre.push(dec(cx, { kind: "element", array: MOUSE_CELLS.x, index: who, at: here } as NumExpr, 0xffff, "everywhere"), dec(cy, { kind: "element", array: MOUSE_CELLS.y, index: who, at: here } as NumExpr, 0xffff, "everywhere")); + } else { + const [l, t, r, b] = sides(p.near!, pre); + pre.push(dec(cx, bin(">>>", bin("+", l, r), k(1)), 0xffff, "everywhere"), dec(cy, bin(">>>", bin("+", t, b), k(1)), 0xffff, "everywhere")); + } + pre.push(dec(least, k(p.within !== undefined ? Math.min(0x20000, p.within + 1) : 0x20000), 0x20000, "everywhere")); + // A unit nearer than the nearest so far (by |dx| + |dy|) is inside the box |dx|, |dy| < that: four conditions on + // its position, read through the current player, ask it of each unit; only one inside is measured. The box is + // the whole map until the first. + pre.push(dec(X0, k(0), 0xffff, "everywhere"), dec(X1, k(0xffff), 0xffff, "everywhere"), dec(Y0, k(0), 0xffff, "everywhere"), dec(Y1, k(0xffff), 0xffff, "everywhere")); + const edges = (lo: string, hi: string, centre: string): Stmt[] => [ + set(lo, k(0)), ...when(cmp(">=", c(centre), V(least)), [set(lo, bin("+", bin("-", V(centre), V(least)), k(1)))]), + set(hi, bin("-", bin("+", V(centre), V(least)), k(1))), ...when(cmp(">", V(hi), k(0xffff)), [set(hi, k(0xffff))]), + ]; + pre.push(...scan(p.filter, pre, (q, stop) => { + const x = fresh("x"), y = fresh("y"), dx = fresh("dx"), dy = fresh("dy"), d = fresh("d"); + const away = (v: string, centre: string, into: string): Stmt[] => when(cmp(">=", c(v), c(centre)), [set(into, bin("-", V(v), V(centre)))], [set(into, bin("-", V(centre), V(v)))]); + return through(of(q, POSITION), (h) => when( + all([cmp(">=", h(0xffff), c(X0)), cmp("<=", h(0xffff), c(X1)), cmp(">=", h(0xffff0000), ranged(bin("*", V(Y0), k(0x10000)), 0, 0xffff0000)), cmp("<=", h(0xffff0000), ranged(bin("*", V(Y1), k(0x10000)), 0, 0xffff0000))]), + [ + // (Each of these is written at each unit measured and read once there: kept in its cell, read where it is + // used — written into every place it reaches, the nearest so far and the box, it would be copied there a + // bit at a time at each unit.) + dec(x, h(X.mask, X.shift), 0xffff, "routed"), dec(y, h(Y.mask, Y.shift), 0xffff, "routed"), dec(dx, k(0), 0xffff, "routed"), dec(dy, k(0), 0xffff, "routed"), + ...away(x, cx, dx), ...away(y, cy, dy), dec(d, bin("+", V(dx), V(dy)), 0x1fffe, "routed"), + ...when(cmp("<", ranged(V(d), 0, 0x1fffe), V(least)), [set(least, V(d)), found(q), ...edges(X0, X1, cx), ...edges(Y0, Y1, cy), + // (Nothing is nearer than the unit at the centre itself.) + ...when(cmp("==", V(least), k(0)), stop())]), + ], + )); + })); + } else { + // Counted, one drawn, taken on a second walk. + const C = fresh("count"), D = fresh("drawn"), I = fresh("i"); + pre.push(dec(C, k(0), 0xffff)); + pre.push(...scan(p.filter, pre, () => [set(C, bin("+", V(C), k(1)))])); + pre.push(dec(D, { kind: "randomInt", bound: ranged(V(C), 0, PLACES), ...at() } as NumExpr, 0xffff), dec(I, k(0), 0xffff)); + pre.push(...when(cmp(">=", V(C), k(1)), scan(p.filter, pre, (q, stop) => [...when(cmp("==", V(I), V(D)), [found(q), ...stop()]), set(I, bin("+", V(I), k(1)))]))); + } + return { e: ranged(V(R), 0, UNIT_TABLE + STRIDE * PLACES), k: null, none: true, killed: false }; + }; + + // ── the program's units ── + /** The units of the loops being rewritten, by their variable: the EPD the walk is at, and whether the body may kill. */ + const turns = new Map(); + /** Each unit variable's two numbers. */ + const pair = (id: string) => ({ e: `${id}@e`, k: `${id}@k` }); + const asNumber = (d: VarDecl, part: "e" | "k"): VarDecl => { + const { text: _t, ...rest } = d; + void _t; + return { ...rest, id: pair(d.id)[part], kind: "number", ...(part === "k" ? { top: 255 } : {}) }; + }; + /** What a `return` of the function or inlined call being rewritten writes its unit's uniqueness byte into. */ + const results: (string | null)[] = []; + const functions = new Map((program.functions ?? []).map((f) => [f.id, f])); + + const unit = (e: UnitExpr, pre: Stmt[]): Unit => { + switch (e.kind) { + case "unitNull": return NONE; + case "unitVar": { + const turn = turns.get(e.id); + if (turn) return { e: turn.q, k: null, none: false, killed: turn.killed }; + const p = pair(e.id); + return { e: ranged(V(p.e), 0, UNIT_TABLE + STRIDE * PLACES), k: ranged(V(p.k), 0, 255), none: true, killed: true }; + } + case "pick": return pick(e, pre); + case "selected": return fix(e, pre) as Unit; + case "unitAt": { + const ptr = plain(fix(e.ptr, pre) as NumExpr, pre); + fix(e.epd, pre); + const uid = plain(fix(e.uid, pre) as NumExpr, pre, 255); + return { e: ranged(ptr, 0, UNIT_TABLE + STRIDE * PLACES), k: ranged(uid, 0, 255), none: true, killed: true }; + } + case "call": { + const out = callOf(e.call, pre); + if (!out) throw new Leave(); + return out; + } + default: throw new Leave(); + } + }; + + /** A unit's number, 0 when it is none or gone. */ + const field = (u: Unit, f: UnitNumField, pre: Stmt[]): NumExpr => { + if (isNone(u)) return k(0); + u = held(u, pre); + const t = fresh("f"); + const body: Stmt[] = []; + const e = u.e; + const type = (): NumExpr => { + const ty = fresh("ty"); + body.push(dec(ty, k(0), 0xffff), ...readInto(ty, e, TYPE)); + return ranged(V(ty), 0, 0xffff); + }; + switch (f) { + // Hit points as the game shows them: a started point counts. + case "hp": body.push(...through(of(e, HP), (h) => [set(t, h(0xffffff00, 8)), ...when(cmp(">=", h(0xff), k(1)), [set(t, bin("+", V(t), k(1)))])])); break; + case "shields": body.push(...through(of(e, SHIELDS), (h) => [set(t, h(0xffffff00, 8))])); break; + case "energy": body.push(...through(of(e, ENERGY.dword), (h) => [set(t, h(0xff000000, 24))])); break; + case "maxHp": { const ty = type(); body.push(...through(bin("+", k(MAX_HP), ty), (h) => [set(t, h(0xffffff00, 8))])); break; } + case "maxShields": { + // A word a type: the type's half of the dword the pair of types share. + const ty = type(), th = fresh("th"); + body.push(dec(th, bin(">>>", ty, k(1)), 0x7fff)); + body.push(...through(bin("+", k(MAX_SHIELDS), V(th)), (h) => when(cmp("==", bin("&", ty, k(1)), k(0)), [set(t, h(0xffff))], [set(t, h(0xffff0000, 16))]))); + break; + } + default: { + const b = FIELDS[f]; + if (!b) throw new Leave(); + body.push(...readInto(t, e, b)); + } + } + pre.push(dec(t, k(0)), ...when(present(u), body)); + return V(t); + }; + + /** A unit's true / false, false when it is none or gone. */ + const flag = (u: Unit, f: UnitFlag, pre: Stmt[]): BoolExpr => { + if (isNone(u)) return { kind: "const", value: false }; + u = held(u, pre); + if (f === "underAttack") return all([alive(u), cmp(">=", bits(u.e, ATTACKED), k(1))]); + if (f === "hasTarget") return all([alive(u), cmp(">=", peek(of(u.e, ORDER_TARGET)), k(1))]); + const mask = FLAG_BITS[f]; + if (mask === undefined) throw new Leave(); + return all([alive(u), cmp("==", peek(of(u.e, STATUS), mask), k(mask))]); + }; + + /** The order Die: what kills a unit, as eudplib's `CUnit.die()`. */ + const die = (e: NumExpr): Stmt => poke(of(e, ORDER.dword), k(0), ORDER.mask); + + /** `u.hp = v` and the rest: nothing happens when the unit is none or gone. */ + const write = (s: Extract, pre: Stmt[]): void => { + const u0 = unit(s.unit, pre); + if (s.field === "invincible" || s.field === "hallucinated" || s.field === "speedUpgrade") { + const mask = FLAG_BITS[s.field]!; + const on = fixBool(s.value as BoolExpr, pre); + const t = fresh("b"); + pre.push(dec(t, k(0), 1), ...when(on, [set(t, k(1))])); + if (isNone(u0)) return; + const u = held(u0, pre); + pre.push(...when(alive(u), [poke(of(u.e, STATUS), bin("*", ranged(V(t), 0, 1), k(mask)), mask)])); + return; + } + const f = s.field as UnitNumField; + const top = TOPS[f]; + if (top === undefined) throw new Leave(); + const v = plain(atMost(fix(s.value, pre) as NumExpr, top), pre, top); + if (isNone(u0)) return; + const u = held(u0, pre); + const e = u.e; + let body: Stmt[]; + switch (f) { + // (Hit points at 0 are a dead unit, so that is what the write makes of it.) + case "hp": body = when(cmp("==", v, k(0)), [die(e)], [poke(of(e, HP), bin("*", v, k(256)))]); break; + case "shields": body = [poke(of(e, SHIELDS), bin("*", v, k(256)))]; break; + case "energy": body = [poke(of(e, ENERGY.dword), bin("*", v, k(2 ** 24)), ENERGY.mask)]; break; + // (Each of the three cooldowns: the ground weapon's, the air weapon's, the spells'.) + case "cooldown": body = [poke(of(e, 0x54 >> 2), bin("*", v, k(0x01010100)), 0xffffff00)]; break; + // (The matrix's frames, with 250 points for them to take off — as the Science Vessel's cast gives — or 0 points when the frames are 0.) + case "matrix": { + const b = FIELDS.matrix!; + body = [poke(of(e, b.dword), v, b.mask), ...when(cmp(">=", v, k(1)), [poke(of(e, MATRIX_HP.dword), k(MATRIX_POINTS * 2 ** MATRIX_HP.shift), MATRIX_HP.mask)], [poke(of(e, MATRIX_HP.dword), k(0), MATRIX_HP.mask)])]; + break; + } + default: { + const b = FIELDS[f]!; + body = [poke(of(e, b.dword), b.shift ? bin("*", v, k(2 ** b.shift)) : v, b.mask)]; + } + } + pre.push(...when(alive(u), body)); + }; + + /** `u.kill()` and the rest: nothing happens when the unit is none or gone. */ + const verb = (s: Extract, pre: Stmt[]): void => { + const u0 = unit(s.unit, pre); + const v = s.verb; + // (The amount is worked out whether or not there is a unit.) + const amount = v.do === "damage" || v.do === "heal" ? plain(fix(v.amount, pre) as NumExpr, pre) : null; + if (isNone(u0)) return; + const u = held(u0, pre); + const e = u.e; + const body: Stmt[] = []; + switch (v.do) { + case "kill": body.push(die(e)); break; + // (eudplib's `CUnit.remove()`: the unit leaves without dying.) + case "remove": body.push(poke(of(e, USER_ACTION.dword), k(4 * 2 ** USER_ACTION.shift), USER_ACTION.mask), die(e)); break; + case "locate": { + const x = fresh("x"), y = fresh("y"); + body.push(dec(x, k(0), 0xffff), dec(y, k(0), 0xffff), ...through(of(e, POSITION), (h) => [set(x, h(X.mask, X.shift)), set(y, h(Y.mask, Y.shift))]), { kind: "centerLocation", location: v.location, x: V(x), y: V(y), ...at() } as Stmt); + break; + } + case "order": { + // The game's own Order, reaching this unit alone: a scratch location made a box of 4 by 4 around it for the one + // action, then put back as it was (the Python side's `order`). + const base = MRGN + 5 * (SCRATCH_LOCATION - 1); + const kept = [0, 1, 2, 3, 4].map((i) => { + const t = fresh("loc"); + body.push(dec(t, peek(k(base + i)))); + return t; + }); + const x = fresh("x"), y = fresh("y"), ty = fresh("ty"), ow = fresh("ow"); + body.push(dec(x, k(0), 0xffff), dec(y, k(0), 0xffff), dec(ty, k(0), 0xffff), dec(ow, k(0), 255)); + body.push(...through(of(e, POSITION), (h) => [set(x, h(X.mask, X.shift)), set(y, h(Y.mask, Y.shift))]), ...readInto(ty, e, TYPE), ...readInto(ow, e, OWNER)); + const box = [bin("-", V(x), k(2)), bin("-", V(y), k(2)), bin("+", V(x), k(2)), bin("+", V(y), k(2)), k(0)]; + box.forEach((value, i) => body.push(poke(k(base + i), value))); + const record: ActionRecord = { location: SCRATCH_LOCATION, text: 0, wav: 0, time: 0, player: 0, target: v.target, unitId: 0, type: ORDER_ACTION, modifier: ORDERS[v.order], flags: 16, padding: 0, mask: 0 }; + body.push({ kind: "action", record, variables: [{ field: "player", bits: 32, name: "owner", expr: ranged(V(ow), 0, 255) }, { field: "unitId", bits: 16, name: "type", expr: ranged(V(ty), 0, 0xffff) }], ...at() } as Stmt); + kept.forEach((t, i) => body.push(poke(k(base + i), V(t)))); + break; + } + case "give": { + // eudplib's `CUnit.cgive`: the owner's byte, and the unit taken out of its owner's list and put in the new + // owner's after its first unit — or as its first, when it has none. (The Python side's `unit_do`.) + const to = playerOf(v.to, body); + const D = DEATHS / 4; + const ptrOf = (e_: NumExpr): NumExpr => bin("+", bin("*", e_, k(4)), k(DEATHS)); + const was = fresh("ow"), pp = fresh("pp"), np = fresh("np"), hp = fresh("hp"), nn = fresh("nn"); + const P = (id: string) => ranged(V(id), 0, 0x3fffffff); + const isUnit = (id: string) => cmp(">=", P(id), k(IN_TABLE)); + const at_ = (id: string, off: number) => bin("+", P(id), k(off - D)); + body.push( + dec(was, k(0), 15), dec(pp, k(0), 0x3fffffff), dec(np, k(0), 0x3fffffff), dec(nn, k(0), 0x3fffffff), + ...through(of(e, OWNER.dword), (h) => [set(was, h(0xf))]), poke(of(e, OWNER.dword), to, OWNER.mask), + ...through(of(e, PREV_UNIT), (h) => [set(pp, h(POINTER, 2))]), ...through(of(e, NEXT_UNIT), (h) => [set(np, h(POINTER, 2))]), + ...when(isUnit(pp), [poke(at_(pp, NEXT_UNIT), bin("*", P(np), k(4)))], [poke(bin("+", k(FIRST_UNIT), ranged(V(was), 0, 15)), bin("*", P(np), k(4)))]), + ...when(isUnit(np), [poke(at_(np, PREV_UNIT), bin("*", P(pp), k(4)))]), + dec(hp, peek(bin("+", k(FIRST_UNIT), to), POINTER, 2), 0x3fffffff), + ...when(isUnit(hp), [ + ...through(at_(hp, NEXT_UNIT), (h) => [set(nn, h(POINTER, 2))]), + poke(at_(hp, NEXT_UNIT), ptrOf(e)), poke(of(e, NEXT_UNIT), bin("*", P(nn), k(4))), poke(of(e, PREV_UNIT), bin("*", P(hp), k(4))), + ...when(isUnit(nn), [poke(at_(nn, PREV_UNIT), ptrOf(e))]), + ], [poke(bin("+", k(FIRST_UNIT), to), ptrOf(e)), poke(of(e, NEXT_UNIT), k(0)), poke(of(e, PREV_UNIT), k(0))]), + { kind: "action", record: FORGET_COUNTS, ...at() } as Stmt, + ); + break; + } + case "damage": case "heal": { + // Hit points down — at 0 the unit dies — or up to the type's most, in the game's own units (256 to a point). + const now = fresh("hp"), ty = fresh("ty"), top = fresh("top"), step = fresh("step"); + body.push(dec(now, k(0)), dec(ty, k(0), 0xffff), dec(top, k(0))); + body.push(...through(of(e, HP), (h) => [set(now, h())]), ...readInto(ty, e, TYPE), ...through(bin("+", k(MAX_HP), ranged(V(ty), 0, 0xffff)), (h) => [set(top, h())])); + body.push(dec(step, v.percent ? ({ kind: "binary", op: "/", left: bin("*", V(top), amount!), right: k(100), unsigned: true, ...at() } as NumExpr) : bin("*", atMost(amount!, 0xffffff), k(256)))); + if (v.do === "damage") body.push(...when(cmp("<=", V(now), V(step)), [die(e)], [poke(of(e, HP), bin("-", V(now), V(step)))])); + else { + const nw = fresh("hp"); + body.push(dec(nw, bin("+", V(now), V(step))), ...when(cmp(">=", V(nw), V(top)), [set(nw, V(top))]), poke(of(e, HP), V(nw))); + } + break; + } + } + pre.push(...when(alive(u), body)); + }; + + /** + * `select(player, units)`: on that player's own computer, the selection becomes the units still there — up to twelve, + * the rest of its slots emptied, the panel told — and a Select command of their tags (place + 1, uniqueness byte + * shifted up 11) is queued to every computer, so the player's orders go to them everywhere; nothing on any other. + */ + const select = (s: Extract, pre: Stmt[]): void => { + if (s.player !== CURRENT_PLAYER && (s.player < 0 || s.player > 11)) throw new Leave(); + const who = playerOf(s.player, pre); + const us = s.units.slice(0, SELECTION_MOST).map((x) => held(unit(x, pre), pre)).filter((u) => !isNone(u)); + const ks = us.map((u) => uidOf(u, pre)); + const N = fresh("n"), M = fresh("m"), L = fresh("len"), body: Stmt[] = [dec(N, k(0), SELECTION_MOST), dec(M, k(0), SELECTION_MOST)]; + const n = ranged(V(N), 0, SELECTION_MOST), m = ranged(V(M), 0, SELECTION_MOST); + const ptrOf = (e: NumExpr): NumExpr => bin("+", bin("*", e, k(4)), k(DEATHS)); + for (const u of us) body.push(...when(alive(u), [poke(bin("+", k(SELECTION), n), ptrOf(u.e)), set(N, bin("+", V(N), k(1)))])); + /** A byte into the queue, `at` bytes into it. */ + const putByte = (at: NumExpr, value: NumExpr): Stmt[] => { + const w = fresh("qw"), b = fresh("qb"); + const into = bin("+", k(QUEUE), V(w)); + const byte = (j: number): Stmt[] => [poke(into, j ? bin("*", value, k(2 ** (8 * j))) : value, (0xff * 2 ** (8 * j)) >>> 0)]; + return [dec(w, bin(">>>", at, k(2)), 0x3fffffff), dec(b, bin("&", at, k(3)), 3), + ...when(cmp("==", V(b), k(0)), byte(0), when(cmp("==", V(b), k(1)), byte(1), when(cmp("==", V(b), k(2)), byte(2), byte(3))))]; + }; + const tags: Stmt[] = []; + us.forEach((u, j) => { + const tag = fresh("tag"), slot = fresh("slot"); + tags.push(...when(alive(u), [ + dec(slot, { kind: "binary", op: "/", left: bin("-", u.e, k(UNIT_TABLE)), right: k(STRIDE), unsigned: true, ...at() } as NumExpr, PLACES - 1), + dec(tag, bin("+", bin("+", V(slot), k(1)), bin("*", ks[j], k(2048))), 0xffff), + ...putByte(bin("+", bin("+", V(L), k(2)), bin("*", m, k(2))), bin("&", V(tag), k(0xff))), + ...putByte(bin("+", bin("+", V(L), k(3)), bin("*", m, k(2))), bin(">>>", V(tag), k(8))), + set(M, bin("+", V(M), k(1))), + ])); + }); + body.push(...when(cmp(">=", n, k(1)), [ + ...Array.from({ length: SELECTION_MOST }, (_, i) => when(cmp("<=", n, k(i)), [poke(k(SELECTION + i), k(0))])).flat(), + poke(k(PANEL), k(1), 0xff), + dec(L, peek(k(QUEUED))), + // (Only when the queue has room for it, as eudplib's QueueGameCommand.) + ...when(cmp(">=", peek(k(QUEUE_MOST)), ranged(bin("+", bin("+", V(L), k(4)), bin("*", n, k(2))), 0, 0x7fffffff)), [ + ...putByte(V(L), k(SELECT_COMMAND)), ...putByte(bin("+", V(L), k(1)), n), + ...tags, + poke(k(QUEUED), bin("+", bin("+", V(L), k(2)), bin("*", n, k(2)))), + ]), + ])); + pre.push(...when(cmp("==", peek(k(LOCAL)), who), body)); + }; + + /** Two units the same unit of the game (and there is one). */ + const same = (a: Unit, b: Unit, pre: Stmt[]): BoolExpr => { + if (isNone(a) || isNone(b)) return { kind: "const", value: false }; + a = held(a, pre); + b = held(b, pre); + const items: BoolExpr[] = [cmp(">=", a.e, k(1)), cmp("==", a.e, b.e)]; + // (The uniqueness bytes: one kept against the other, or against the place's as it is now.) + if (a.k && b.k) items.push(cmp("==", a.k, b.k)); + else if (a.k || b.k) { + const [kept, now] = a.k ? [a, b] : [b, a]; + items.push(cmp("==", bits(now.e, UID), ranged(bin("*", kept.k!, k(256)), 0, 0xff00))); + } + return all(items); + }; + + // ── calls ── + /** + * A call, its parameters and result of units as two numbers each: the call a statement in `pre`, and the unit it gives + * back when it gives back one. + */ + const callOf = (c: Call, pre: Stmt[]): Unit | null => { + const params: Call["params"] = []; + for (const p of c.params) { + if (p.decl.kind !== "unit") { params.push({ ...p, init: fix(p.init, pre) as NumExpr }); continue; } + const u = unit(p.init as UnitExpr, pre); + params.push({ decl: asNumber(p.decl, "e"), init: u.e, label: p.label }, { decl: asNumber(p.decl, "k"), init: uidOf(u, pre), label: p.label }); + } + const unitResult = c.result?.kind === "unit"; + const res = c.result && unitResult ? { decl: asNumber(c.result.decl, "e"), kind: "number" as const } : c.result; + // (Inlined, the call's own result is what its `return`s write; called, the function's, copied after.) + if (c.fn === undefined) results.push(unitResult ? pair(c.result!.decl.id).k : null); + const body = c.fn === undefined ? stmts(c.body) : c.body; + if (c.fn === undefined) results.pop(); + const saves = c.saves ? { ...c.saves, vars: c.saves.vars.flatMap((id) => (unitIds.has(id) ? [pair(id).e, pair(id).k] : [id])) } : undefined; + if (unitResult) pre.push(dec(pair(c.result!.decl.id).k, k(0), 255)); + pre.push({ kind: "call", call: { ...c, params, body, ...(res ? { result: res } : {}), ...(saves ? { saves } : {}) }, at: c.at, label: c.label } as Stmt); + if (!unitResult) return null; + const r = pair(c.result!.decl.id); + if (c.fn !== undefined) { + const f = functions.get(c.fn); + if (!f?.result) throw new Leave(); + pre.push(set(r.k, V(pair(f.result.decl.id).k))); + } + return { e: ranged(V(r.e), 0, UNIT_TABLE + STRIDE * PLACES), k: ranged(V(r.k), 0, 255), none: true, killed: false }; + }; + + /** The ids of the program's unit variables, wherever declared. */ + const unitIds = new Set(); + const collect = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(collect); return; } + if (!x || typeof x !== "object") return; + const o = x as { decl?: VarDecl; params?: VarDecl[] }; + if (o.decl && typeof o.decl === "object" && o.decl.kind === "unit") unitIds.add(o.decl.id); + for (const v of Object.values(o)) if (v && typeof v === "object") collect(v); + }; + collect(program); + + // ── expressions ── + /** + * An expression with what it reads of units as numbers, worked out in `pre` first: a field, a flag, whether a unit is + * there, two compared, a part kept in an array. A call with units among its parameters or as its result, likewise. + */ + const fix = (x: unknown, pre: Stmt[]): unknown => { + if (Array.isArray(x)) return x.map((y) => fix(y, pre)); + if (!x || typeof x !== "object") return x; + const o = x as Record & { kind?: string }; + switch (o.kind) { + case "unitField": { + const e = o as unknown as Extract; + const v = field(unit(e.unit, pre), e.field, pre); + const r = (e as NumExpr & { range?: readonly [number, number] }).range; + return r && v.kind === "var" ? ranged(v, r[0], r[1]) : v; + } + case "unitPart": { + const e = o as unknown as Extract; + const u = unit(e.unit, pre); + // (A map of our own keeps a unit as its EPD and its uniqueness byte: the pointer is the EPD, 0 for none.) + return e.part === "uid" ? ranged(uidOf(u, pre), 0, 255) : u.e; + } + case "unitAlive": return alive(held(unit((o as { unit: UnitExpr }).unit, pre), pre)); + case "unitFlag": { const e = o as unknown as Extract; return flag(unit(e.unit, pre), e.flag, pre); } + case "unitSame": { const e = o as unknown as Extract; return same(unit(e.left, pre), unit(e.right, pre), pre); } + case "selected": { + // `local.selected` (step 12): the unit in a slot of this computer's selection, re-checked before use as a kept + // unit is (its uniqueness byte taken when read). The slot holds a pointer, or 0 for none. + const e = o as unknown as Extract; + const p = fresh("sel"); + pre.push(dec(p, peek(k(SELECTION + e.index), POINTER, 2), 0x3fffffff), ...when(cmp(">=", V(p), k(IN_TABLE)), [set(p, bin("-", V(p), k(DEATHS / 4)))], [set(p, k(0))])); + return { e: ranged(V(p), 0, UNIT_TABLE + STRIDE * PLACES), k: null, none: true, killed: true }; + } + } + // (A call inside an expression: a statement of its own first when it takes or gives back units.) + if (o.kind === "call" && o.call && typeof o.call === "object") { + const c = o.call as Call; + if (c.params.some((p) => p.decl.kind === "unit") || hasUnits(c.body)) { + callOf(c, pre); + if (!c.result) throw new Leave(); + return { kind: "var", id: c.result.decl.id }; + } + } + return Object.fromEntries(Object.entries(o).map(([key, v]) => [key, key === "at" || !v || typeof v !== "object" ? v : fix(v, pre)])); + }; + const fixBool = (e: BoolExpr, pre: Stmt[]): BoolExpr => fix(e, pre) as BoolExpr; + + // ── statements ── + const stmts = (list: Stmt[]): Stmt[] => list.flatMap(stmt); + const stmt = (s: Stmt): Stmt[] => { + if (!hasUnits(s)) return [s]; + const was = here; + here = s.at ?? here; + try { + return rewrite(s); + } catch (err) { + if (err instanceof Leave) return [s]; + throw err; + } finally { + here = was; + } + }; + const rewrite = (s: Stmt): Stmt[] => { + const pre: Stmt[] = []; + switch (s.kind) { + case "declare": case "assignUnit": { + if (s.kind === "declare" && s.decl.kind !== "unit") break; + const id = s.kind === "declare" ? s.decl.id : s.target; + const u = unit((s.kind === "declare" ? s.init : s.value) as UnitExpr, pre); + const p = pair(id); + const kv = uidOf(u, pre); + if (s.kind === "declare") return [...pre, { kind: "declare", decl: asNumber(s.decl, "e"), init: u.e, at: s.at, label: s.label }, { kind: "declare", decl: asNumber(s.decl, "k"), init: kv, at: s.at, label: s.label }]; + return [...pre, { kind: "assign", target: p.e, value: u.e, at: s.at, label: s.label }, { kind: "assign", target: p.k, value: kv, at: s.at, label: s.label }]; + } + case "unitLoop": { + if (s.source) return [...pre, ...eventLoop(s)]; + const killed = mayKill(s.body); + const out = scan(s.filter, pre, (q) => { + turns.set(s.decl.id, { q, killed }); + try { + return stmts(s.body); + } finally { + turns.delete(s.decl.id); + } + }); + return [...pre, ...out]; + } + // (A task's call as an inlined call's: a unit among its parameters two numbers, its body rewritten.) + case "start": { + callOf(s.call, pre); + const made = pre.pop() as Extract; + return [...pre, { ...s, call: made.call }]; + } + case "unitWrite": write(s, pre); return pre; + case "unitDo": verb(s, pre); return pre; + case "select": select(s, pre); return pre; + case "call": { callOf(s.call, pre); return pre; } + case "return": { + const into = results[results.length - 1]; + if (into === undefined || into === null || !s.value) break; + const u = unit(s.value as UnitExpr, pre); + const kv = uidOf(u, pre); + return [...pre, set(into, kv), { ...s, value: u.e }]; + } + case "while": case "for": { + const cond = s.cond ? fixBool(s.cond, pre) : undefined; + const body = stmts(s.body); + const update = s.kind === "for" ? stmts(s.update) : []; + if (!pre.length) return [s.kind === "for" ? { ...s, cond, body, update } : { ...s, cond, body }]; + // The test needs statements first: at the top of each turn, before it. + const leave = when({ kind: "not", expr: cond! }, [brk()]); + return [s.kind === "for" ? { ...s, cond: undefined, body: [...pre, ...leave, ...body], update } : { ...s, cond: undefined, body: [...pre, ...leave, ...body] }]; + } + case "do": { + const cond = fixBool(s.cond, pre); + if (pre.length) throw new Leave(); + return [{ ...s, body: stmts(s.body), cond }]; + } + } + // Anything else: its expressions, then its statements. + const out: Record = {}; + for (const [key, v] of Object.entries(s)) { + if (key === "at" || !v || typeof v !== "object") out[key] = v; + else if (isStmtList(v)) out[key] = stmts(v); + else if (key === "iterations") out[key] = (v as Stmt[][]).map(stmts); + else if (key === "cases") out[key] = (v as { value: unknown; body: Stmt[] }[]).map((c) => ({ ...c, body: stmts(c.body) })); + else if (key === "catch" || key === "finally") out[key] = v; + else out[key] = fix(v, pre); + } + return [...pre, out as unknown as Stmt]; + }; + + // The functions that are called: units among their parameters and result as two numbers each. + const rewritten: FuncDecl[] = (program.functions ?? []).map((f) => { + if (!hasUnits(f)) return f; + here = f.at; + const params = f.params.flatMap((p) => (p.kind === "unit" ? [asNumber(p, "e"), asNumber(p, "k")] : [p])); + const unitResult = f.result?.kind === "unit"; + results.push(unitResult ? pair(f.result!.decl.id).k : null); + const body = stmts(f.body); + results.pop(); + return { + ...f, params, + ...(f.result ? { result: unitResult ? { decl: asNumber(f.result.decl, "e"), kind: "number" as const } : f.result } : {}), + body: unitResult ? [dec(pair(f.result!.decl.id).k, k(0), 255), ...body] : body, + }; + }); + here = program.at; + const body = stmts(program.body); + // The map's events pass, and its lists, in the program that lays it out; the lists in every program that reads them. + const sources = events?.sources ?? new Set(); + const passed = events?.pass && sources.size ? { pass: pass(sources) } : {}; + const lists = sources.size ? [MADE, ...(sources.has("died") || sources.has("killed") ? [GONE] : []), ...(sources.has("ordered") ? [TURNED, TURNED_WAS] : []), ...(sources.has("morphed") ? [CHANGED, CHANGED_WAS] : [])] : []; + const arrays = [...(program.arrays ?? []), ...lists.map((id): ArrayDecl => ({ id, name: "(events)", kind: "number", length: EVENT_CAP, shared: true, at: AT0 }))]; + if (usesMe) body.unshift(dec(ME_ID, peek(k(CURRENT)))); + return { ...program, body, ...(program.functions ? { functions: rewritten } : {}), ...passed, ...(lists.length ? { arrays } : {}) }; +} diff --git a/compiler/eud.ts b/compiler/check.ts similarity index 64% rename from compiler/eud.ts rename to compiler/check.ts index c0af4d5..070328f 100644 --- a/compiler/eud.ts +++ b/compiler/check.ts @@ -1,17 +1,15 @@ /** - * What the compiler does with a program's IR before eudplib gets it: checks it - * (`checkProgram`) and serialises it with every text written out (`serializeIr`). The - * lowering itself is `python/trigscript.py`, run by the eudplib plugin when the map is saved. + * What the compiler checks a program's IR for before the backend gets it (`checkProgram`): + * what would freeze the game or cannot be built, said at the node, and what its lines are + * worth a word about (`hints`). * * The rule a program has to keep: a loop that never sleeps never gives the frame back. The * body of a program runs to completion within the frame, so a loop whose condition never * moves would freeze the game. A loop with no `sleep()` on some path around it, whose * condition does not mention a variable the body assigns, is an error naming the loop. */ -import type { ActionRecord, ConditionRecord } from "../vendor/triggers"; -import { HEAP_CELLS, STACK_DEPTH, bodiesOf, heapCells, stackDepth, isTextExpr, isUnitExpr, mapText, mapTextOperands, mapTextParts, programDeclarations, type At, type BoolExpr, type Call, type FuncDecl, type NumExpr, type Program, type Stmt, type TextExpr, type TextMap, type UnitExpr, type VarDecl } from "./ir"; -import type { LineHint, ScriptString } from "./compiler"; -import type { InputPlan } from "./input"; +import { bodiesOf, declarations, eachCall, isTextExpr, isUnitExpr, mapText, mapTextOperands, mapTextParts, programDeclarations, type ArrayDecl, type At, type BoolExpr, type Call, type FuncDecl, type NumExpr, type Program, type Stmt, type TextExpr, type TextMap, type UnitExpr, type VarDecl } from "./ir"; +import type { LineHint } from "./compiler"; export interface ProgramDiagnostic { at: At; message: string } @@ -26,7 +24,7 @@ export function checkProgram(program: Program): { errors: ProgramDiagnostic[]; h WINDOWS = new Map((program.arrays ?? []).flatMap((a) => (a.slice ? [[a.id, a.slice.of] as const] : []))); checkSleeps(body, errors, functions); checkDivisions(body, errors); - checkWidths(body, errors, decls); + checkWidths(body, errors, decls, new Map((program.arrays ?? []).map((a) => [a.id, a]))); checkUnitLoops(body, errors); checkTextLoops(body, errors); remarks(body, hints); @@ -57,6 +55,7 @@ function expressions(body: Stmt[], visit: (e: NumExpr | BoolExpr) => void, pick? case "not": expr(e.expr); break; case "test": expr(e.expr); break; case "edge": expr(e.cond); break; + case "running": expr(e.task); break; case "call": call(e.call); break; default: break; } @@ -78,27 +77,33 @@ function expressions(body: Stmt[], visit: (e: NumExpr | BoolExpr) => void, pick? case "unitLoop": s.body.forEach(stmt); break; case "unitWrite": unit(s.unit); expr(s.value); break; case "unitDo": unit(s.unit); if (s.verb.do === "damage" || s.verb.do === "heal") expr(s.verb.amount); break; + case "select": s.units.forEach(unit); break; case "tableWrite": any(s.value); break; case "if": expr(s.cond); s.then.forEach(stmt); s.else?.forEach(stmt); break; case "while": if (s.cond) expr(s.cond); s.body.forEach(stmt); break; case "do": s.body.forEach(stmt); expr(s.cond); break; case "for": if (s.cond) expr(s.cond); s.update.forEach(stmt); s.body.forEach(stmt); break; case "unrolled": s.iterations.forEach((i) => i.forEach(stmt)); break; + case "parallel": s.branches.forEach((b) => b.forEach(stmt)); break; + case "start": call(s.call); break; + case "cancel": expr(s.task); break; case "switch": expr(s.value); s.cases.forEach((c) => c.body.forEach(stmt)); break; case "return": if (s.value) any(s.value); break; case "action": for (const v of s.variables ?? []) expr(v.expr); if (s.text) text(s.text); break; - case "centerLocation": expr(s.x); expr(s.y); break; - case "print": mapTextParts(s.parts, looks); break; + case "centerLocation": expr(s.x); expr(s.y); if (s.size) { expr(s.size.width); expr(s.size.height); } break; + case "syncSend": expr(s.value); break; + case "print": case "buildText": mapTextParts(s.parts, looks); break; case "call": call(s.call); break; case "block": s.body.forEach(stmt); break; + case "try": s.body.forEach(stmt); s.catch.forEach(stmt); break; default: break; } }; body.forEach(stmt); } -/** Every statement of a list, nested ones and calls' bodies (as statements) included. */ -function statements(body: Stmt[], visit: (s: Stmt) => void) { +/** Every statement of a list, nested ones and calls' bodies (as statements) included; a started task's body unless `tasks` is false. */ +function statements(body: Stmt[], visit: (s: Stmt) => void, tasks = true) { const stmt = (s: Stmt) => { visit(s); switch (s.kind) { @@ -106,9 +111,12 @@ function statements(body: Stmt[], visit: (s: Stmt) => void) { case "while": case "do": case "unitLoop": case "textLoop": s.body.forEach(stmt); break; case "for": s.body.forEach(stmt); s.update.forEach(stmt); break; case "unrolled": s.iterations.forEach((i) => i.forEach(stmt)); break; + case "parallel": s.branches.forEach((b) => b.forEach(stmt)); break; + case "start": if (tasks) s.call.body.forEach(stmt); break; case "switch": s.cases.forEach((c) => c.body.forEach(stmt)); break; case "call": s.call.body.forEach(stmt); break; case "block": s.body.forEach(stmt); break; + case "try": s.body.forEach(stmt); s.catch.forEach(stmt); break; default: break; } }; @@ -120,8 +128,9 @@ function checkUnitLoops(body: Stmt[], out: ProgramDiagnostic[]) { statements(body, (s) => { if (s.kind !== "unitLoop") return; statements(s.body, (inner) => { + if (inner.kind === "parallel") out.push({ at: inner.at, message: "await inside a loop over units: the loop looks at the game's units as they are in one frame and cannot be left half way. Collect the units into an array first, and await in a loop over that array." }); if (inner.kind === "sleep") out.push({ at: inner.at, message: "sleep() inside a loop over units: the loop looks at the game's units as they are in one frame and cannot be left half way. To do something to one unit at a time, find it again after each sleep: while (true) { const u = first(…); if (!u) break; u.kill(); sleep(seconds(1)); }" }); - }); + }, false); }); } @@ -130,14 +139,15 @@ function checkTextLoops(body: Stmt[], out: ProgramDiagnostic[]) { statements(body, (s) => { if (s.kind !== "textLoop") return; statements(s.body, (inner) => { + if (inner.kind === "parallel") out.push({ at: inner.at, message: "await inside a for…of over a text: the loop walks the text once, within the frame. A loop over its places can wait between turns: for (let i = 0; i < s.length; i++) { … }" }); if (inner.kind === "sleep") out.push({ at: inner.at, message: "sleep() inside a for…of over a text: the loop walks the text once, within the frame. A loop over its places can sleep between turns: for (let i = 0; i < s.length; i++) { const ch = s[i]; … sleep(frames(2)); }" }); - }); + }, false); }); } /** What a loop over units, or a pick, costs: a word at the end of the line. */ function scans(program: Program, out: LineHint[]) { - const SLOTS = "the game's 1700 unit slots"; + const SLOTS = "every unit on the map (the game's unit table, as far as the units in use reach)"; const each = program.perPlayer ? ", once for each player the program runs for" : ""; const seen = new Set(); const hint = (at: At, label: string, note: string) => { @@ -149,7 +159,9 @@ function scans(program: Program, out: LineHint[]) { for (const body of bodiesOf(program)) statements(body, (s) => { if (s.kind === "for" && s.sorts) hint(s.at, "sorts in the frame", `${s.sorts} is sorted where it stands, within the frame: each item is taken in hand and the ones that go after it moved up. Nearly in order already, that is a pass over the list; in no order it is the length squared — a few dozen items are nothing, a few hundred every frame will be felt.`); }); // A call that stands in an expression (`unitsOf(P1).some(…)` in an if) has statements of its own, which the walk over statements does not reach. const everywhere = (body: Stmt[], visit: (s: Stmt) => void) => { statements(body, visit); expressions(body, (e) => { if (e.kind === "call") statements(e.call.body, visit); }); }; - for (const body of bodiesOf(program)) everywhere(body, (s) => { if (s.kind === "unitLoop") hint(s.at, "scans units", `Looks at ${SLOTS} every time the line runs${each}, and runs the body for the ones that match. Fine once or a few times a second; inside a loop that runs every frame, ask whether it needs to.`); }); + // on.order() / on.morphed(): the frame's events pass looks at every unit on the map, for as long as the game runs. + for (const body of bodiesOf(program)) everywhere(body, (s) => { if (s.kind === "unitLoop" && (s.source === "ordered" || s.source === "morphed")) hint(s.at, "watches every unit", `Every frame, for the rest of the game, each unit on the map has its ${s.source === "ordered" ? "order" : "type"} looked at: about 100 conditions and actions a unit (on.order() and on.morphed() together, about 140), so 200 units cost about 20,000 a frame of Remastered's 500,000. Paid once however many listeners there are.`); }); + for (const body of bodiesOf(program)) everywhere(body, (s) => { if (s.kind === "unitLoop" && !s.source) hint(s.at, "scans units", `Looks at ${SLOTS} every time the line runs${each}, and runs the body for the ones that match. Fine once or a few times a second; inside a loop that runs every frame, ask whether it needs to.`); }); for (const body of bodiesOf(program)) expressions(body, () => {}, (u) => hint(u.at, u.by === "random" ? "scans units ×2" : "scans units", u.by === "random" ? `Looks at ${SLOTS} twice every time the line runs${each}: once to count the units that match, once to take the one drawn.` : `Looks at ${SLOTS} every time the line runs${each}. Keep the unit in a variable when several lines need it.`)); @@ -164,47 +176,60 @@ function checkDivisions(body: Stmt[], out: ProgramDiagnostic[]) { }); } -/** A constant put into a `u8` / `u16` has to fit: the game would stop it at the top without a word. */ -function checkWidths(body: Stmt[], out: ProgramDiagnostic[], decls: Map) { +/** + * A constant put into a `u8` / `u16` has to fit: the game would stop it at the top without a word. A cell of a `u8[]` / + * `u16[]` as much as a variable — stored, pushed or declared with. + */ +function checkWidths(body: Stmt[], out: ProgramDiagnostic[], decls: Map, arrays: Map) { const fits = (id: string, value: NumExpr | BoolExpr | UnitExpr | TextExpr, at: At) => { const d = decls.get(id); if (!d?.bits || value.kind !== "const" || typeof value.value !== "number") return; const max = 2 ** d.bits - 1; if (value.value > max || value.value < 0) out.push({ at, message: `${d.name} is a u${d.bits} and holds 0 … ${max}, not ${value.value}.` }); }; + const cell = (id: string, value: NumExpr | BoolExpr | undefined, at: At) => { + const a = arrays.get(id); + if (!a?.bits || !value || value.kind !== "const" || typeof value.value !== "number") return; + const max = 2 ** a.bits - 1; + if (value.value > max || value.value < 0) out.push({ at, message: `${a.name} is an array of u${a.bits} and each cell holds 0 … ${max}, not ${value.value}.` }); + }; const stmt = (s: Stmt) => { switch (s.kind) { case "declare": if (!s.failed) fits(s.decl.id, s.init, s.at); break; case "assign": fits(s.target, s.value, s.at); break; + case "store": case "push": cell(s.array, s.value, s.at); break; + case "declareArray": for (const v of s.init ?? []) cell(s.array, v, s.at); cell(s.array, s.fill, s.at); break; case "if": s.then.forEach(stmt); s.else?.forEach(stmt); break; case "while": case "do": case "unitLoop": case "textLoop": s.body.forEach(stmt); break; case "for": s.body.forEach(stmt); s.update.forEach(stmt); break; case "unrolled": s.iterations.forEach((i) => i.forEach(stmt)); break; + case "parallel": s.branches.forEach((b) => b.forEach(stmt)); break; + case "start": for (const p of s.call.params) fits(p.decl.id, p.init, s.call.at); s.call.body.forEach(stmt); break; case "switch": s.cases.forEach((c) => c.body.forEach(stmt)); break; case "call": for (const p of s.call.params) fits(p.decl.id, p.init, s.call.at); s.call.body.forEach(stmt); break; case "block": s.body.forEach(stmt); break; + case "try": s.body.forEach(stmt); s.catch.forEach(stmt); break; default: break; } }; body.forEach(stmt); } -/** The `remark` statements, as hints for their lines. */ +/** The `remark` statements, as hints for their lines: those of calls inside expressions too. */ function remarks(body: Stmt[], out: LineHint[]) { - const stmt = (s: Stmt) => { - switch (s.kind) { - case "remark": if (s.short) out.push({ file: s.at.file, line: s.at.line, label: s.short, note: s.text }); break; - case "if": s.then.forEach(stmt); s.else?.forEach(stmt); break; - case "while": case "do": case "unitLoop": case "textLoop": s.body.forEach(stmt); break; - case "for": s.body.forEach(stmt); s.update.forEach(stmt); break; - case "unrolled": s.iterations.forEach((i) => i.forEach(stmt)); break; - case "switch": s.cases.forEach((c) => c.body.forEach(stmt)); break; - case "call": s.call.body.forEach(stmt); break; - case "block": s.body.forEach(stmt); break; - default: break; + const visit = (root: unknown): void => { + if (Array.isArray(root)) { root.forEach(visit); return; } + if (!root || typeof root !== "object") return; + const o = root as Record; + if (o.kind === "remark") { + // (A function's body inlined at several calls says the same of its line each time: once is enough.) + const r = o as Extract; + if (r.short && !out.some((h) => h.file === r.at.file && h.line === r.at.line && h.label === r.short)) out.push({ file: r.at.file, line: r.at.line, label: r.short, note: r.text }); + return; } + Object.values(o).forEach((v) => { if (v && typeof v === "object") visit(v); }); }; - body.forEach(stmt); + visit(body); } /** The variables a statement list assigns (declares count too), by id — and `THE_GAME` when it takes an action, which may change what a read finds. */ @@ -212,21 +237,23 @@ function assigned(body: Stmt[], functions: Map, into = new Set const followed = new Set(); const stmt = (s: Stmt) => { switch (s.kind) { - case "action": case "unitWrite": case "unitDo": case "tableWrite": case "centerLocation": into.add(THE_GAME); break; + case "action": case "unitWrite": case "unitDo": case "tableWrite": case "centerLocation": case "syncSend": case "select": into.add(THE_GAME); break; case "declare": into.add(s.decl.id); break; case "assign": case "assignBool": case "assignUnit": case "assignText": into.add(s.target); break; case "textLoop": into.add(s.decl.id); s.body.forEach(stmt); break; case "store": case "declareArray": case "push": case "pop": case "setLength": into.add(whole(s.array)); break; case "storeText": into.add(whole(s.addr)); break; case "releaseText": into.add(whole(s.block)); break; - case "unitLoop": into.add(s.decl.id); s.body.forEach(stmt); break; + case "unitLoop": into.add(s.decl.id); if (s.was) into.add(s.was.id); s.body.forEach(stmt); break; case "if": s.then.forEach(stmt); s.else?.forEach(stmt); break; case "while": case "for": s.body.forEach(stmt); if (s.kind === "for") s.update.forEach(stmt); break; case "do": s.body.forEach(stmt); break; case "unrolled": s.iterations.forEach((i) => i.forEach(stmt)); break; + case "parallel": s.branches.forEach((b) => b.forEach(stmt)); break; case "switch": s.cases.forEach((c) => c.body.forEach(stmt)); break; case "call": call(s.call); break; case "block": s.body.forEach(stmt); break; + case "try": s.body.forEach(stmt); s.catch.forEach(stmt); break; default: break; } }; @@ -238,8 +265,9 @@ function assigned(body: Stmt[], functions: Map, into = new Set const f = c.fn ? functions.get(c.fn) : undefined; if (f && !followed.has(f.id)) { followed.add(f.id); f.body.forEach(stmt); pops(f.body); } }; - // A pop() whose value is used — `const i = queue.pop()!` — is an expression, and shortens the array as the statement does. - const pops = (stmts: Stmt[]) => expressions(stmts, (e) => { if (e.kind === "pop") into.add(whole(e.array)); }); + // A pop() whose value is used — `const i = queue.pop()!` — is an expression, and shortens the array as the statement + // does; a call used as a value — `xs[n++] = 1`, whose index is the update's call — assigns what its body does. + const pops = (stmts: Stmt[]) => expressions(stmts, (e) => { if (e.kind === "pop") into.add(whole(e.array)); else if (e.kind === "call") call(e.call); }); body.forEach(stmt); pops(body); return into; @@ -290,11 +318,15 @@ function sleepsOnEveryPath(body: Stmt[]): boolean { for (const s of body) { switch (s.kind) { case "sleep": return true; - case "break": case "return": return true; // Leaves the loop: no freeze on this path. + case "break": case "return": case "throw": return true; // Leaves the loop: no freeze on this path. case "continue": return false; case "if": if (s.else && sleepsOnEveryPath(s.then) && sleepsOnEveryPath(s.else)) return true; break; case "block": if (sleepsOnEveryPath(s.body)) return true; break; + // A throw inside the body goes to the catch: that path sleeps when the body slept before anything that can throw, or the catch sleeps or throws on. + case "try": if (sleepsOnEveryPath(s.body) && (sleepsBeforeThrowing(s.body) || sleepsOnEveryPath(s.catch) || s.catch.some((c) => c.kind === "throw"))) return true; break; case "unrolled": if (s.iterations.some(sleepsOnEveryPath)) return true; break; + // `all` waits for every branch, so one that always sleeps is enough; `race` goes on when the first ends, so every one has to. + case "parallel": if (s.mode === "all" ? s.branches.some(sleepsOnEveryPath) : s.branches.length > 0 && s.branches.every(sleepsOnEveryPath)) return true; break; case "switch": { const hasDefault = s.cases.some((c) => c.value === null); if (hasDefault && s.cases.every((c) => sleepsOnEveryPath(c.body))) return true; @@ -308,12 +340,56 @@ function sleepsOnEveryPath(body: Stmt[]): boolean { return false; } +/** Whether a statement list reaches a `sleep` before anything that may throw (a `throw`, a call). */ +function sleepsBeforeThrowing(body: Stmt[]): boolean { + const mayThrow = (root: unknown): boolean => { + if (Array.isArray(root)) return root.some(mayThrow); + if (!root || typeof root !== "object") return false; + const o = root as Record; + if (o.kind === "throw" || ((o.kind === "call" || o.kind === "textCall") && o.call)) return true; + return Object.values(o).some((v) => !!v && typeof v === "object" && mayThrow(v)); + }; + for (const s of body) { + if (s.kind === "sleep") return true; + if (mayThrow(s)) return false; + } + return false; +} + +/** Whether a loop's body can leave it: a `break` of its own (not of a loop or a switch inside it), or a `return`. */ +function leaves(body: Stmt[]): boolean { + // `caught`: inside the body of a try, where a throw goes to its catch and not out of the loop. + const stmt = (s: Stmt, own: boolean, caught: boolean): boolean => { + switch (s.kind) { + case "break": return own; + case "return": return true; + case "throw": return !caught; + case "if": return s.then.some((x) => stmt(x, own, caught)) || !!s.else?.some((x) => stmt(x, own, caught)); + case "block": return s.body.some((x) => stmt(x, own, caught)); + case "try": return s.body.some((x) => stmt(x, own, true)) || s.catch.some((x) => stmt(x, own, caught)); + case "unrolled": return s.iterations.some((i) => i.some((x) => stmt(x, own, caught))); + case "while": case "do": case "for": return s.body.some((x) => stmt(x, false, caught)); + case "switch": return s.cases.some((c) => c.body.some((x) => stmt(x, false, caught))); + default: return false; + } + }; + return body.some((s) => stmt(s, true, false)); +} + function checkSleeps(body: Stmt[], out: ProgramDiagnostic[], functions: Map) { const stmt = (s: Stmt) => { switch (s.kind) { case "while": case "for": case "do": { - const moves = s.cond ? [...reads(s.cond)].some((id) => assigned(s.kind === "for" ? [...s.body, ...s.update] : s.body, functions).has(id)) : false; - if (!moves && !sleepsOnEveryPath(s.body)) out.push({ at: s.at, message: s.cond + let moves = s.cond ? [...reads(s.cond)].some((id) => assigned(s.kind === "for" ? [...s.body, ...s.update] : s.body, functions).has(id)) : false; + // A call in the condition that changes what is outside it may change what the condition finds: `while (move(0, 1, 0)) {}`. + if (s.cond && !moves) eachCall(s.cond, (c) => { + const fn = c.fn ? functions.get(c.fn) : undefined; + const own = new Set([...declarations(fn ? fn.body : c.body), ...(fn ? fn.params : c.params.map((p) => p.decl))].map((d) => d.id)); + if (c.result) own.add(c.result.decl.id); + if (fn?.result) own.add(fn.result.decl.id); + if ([...assigned([{ kind: "call", call: c, at: s.at, label: s.label }], functions)].some((id) => !own.has(id))) moves = true; + }); + if (!moves && !sleepsOnEveryPath(s.body) && !leaves(s.body) && !(s.kind === "do" && s.cond.kind === "const" && s.cond.value === false)) out.push({ at: s.at, message: s.cond ? "This loop's condition never changes inside it, and no path around it sleeps. A program runs until it sleeps or ends, all within one frame of the game, so this loop would never give the frame back and the game would freeze. Add sleep(frames(1)) inside it, or change what it tests." : "A loop without an end needs a sleep() on every path around it. A program runs until it sleeps or ends, all within one frame of the game, so this loop would freeze the game. Add sleep(frames(1)) at the end of its body." }); s.body.forEach(stmt); @@ -323,93 +399,14 @@ function checkSleeps(body: Stmt[], out: ProgramDiagnostic[], functions: Map i.forEach(stmt)); break; + case "parallel": s.branches.forEach((b) => b.forEach(stmt)); break; + case "start": s.call.body.forEach(stmt); break; case "switch": s.cases.forEach((c) => c.body.forEach(stmt)); break; case "call": s.call.body.forEach(stmt); break; case "block": s.body.forEach(stmt); break; + case "try": s.body.forEach(stmt); s.catch.forEach(stmt); break; default: break; } }; body.forEach(stmt); } - -/** - * The IR as the JSON the Python side reads. A record's text and sound fields hold the - * compile's *local* ids; here each becomes the text itself (`strings[k - 1].text`), which - * eudplib puts in the built map's string table, or the map's own index when the script - * named one (`{ index }`). Nothing a program says is written into the map the user edits. - * `input` is the compile's plan for what the players do (`input.ts`), when a program reads any. - */ -export function serializeIr(programs: Program[], strings: readonly ScriptString[], input: InputPlan | null = null, settings: { heapCells?: number; stackDepth?: number } = {}): string { - const resolve = (local: number): number | string => { - if (local <= 0) return 0; - const s = strings[local - 1]; - return !s ? 0 : "index" in s ? s.index : s.text; - }; - const action = (r: ActionRecord) => ({ ...r, text: resolve(r.text), wav: resolve(r.wav) }); - const condition = (r: ConditionRecord): ConditionRecord => ({ ...r }); - // Only actions carry strings, and only inside statements — but a call's body is statements inside an expression, so expressions are walked for calls. - const unit = (u: UnitExpr): UnitExpr => (u.kind === "call" ? { ...u, call: call(u.call) } : u.kind === "unitAt" ? { ...u, ptr: expr(u.ptr), epd: expr(u.epd), uid: expr(u.uid) } : u); - const any = (e: E): E => (isTextExpr(e) ? text(e) : isUnitExpr(e) ? unit(e) : expr(e as NumExpr | BoolExpr)) as E; - const copies: TextMap = { num: (e) => expr(e), bool: (e) => expr(e), call: (c) => call(c) }; - const text = (t: TextExpr): TextExpr => mapText(t, copies); - const expr = (e: E): E => { - const ofTexts = mapTextOperands(e, copies); - if (ofTexts) return ofTexts; - switch (e.kind) { - case "unitField": case "unitPart": case "unitAlive": case "unitFlag": return { ...e, unit: unit(e.unit) }; - case "unitSame": return { ...e, left: unit(e.left), right: unit(e.right) }; - case "unary": case "cast": return { ...e, expr: expr(e.expr) }; - case "element": return { ...e, index: expr(e.index) }; - case "binary": case "compare": return { ...e, left: expr(e.left), right: expr(e.right) }; - case "ternary": return { ...e, cond: expr(e.cond), whenTrue: expr(e.whenTrue), whenFalse: expr(e.whenFalse) } as E; - case "intrinsic": return { ...e, args: e.args.map(expr) }; - case "randomInt": return { ...e, bound: expr(e.bound) }; - case "call": return { ...e, call: call(e.call) }; - case "cond": return { ...e, record: condition(e.record) }; - case "test": return { ...e, expr: expr(e.expr) }; - case "and": case "or": return { ...e, items: e.items.map(expr) }; - case "not": return { ...e, expr: expr(e.expr) }; - case "edge": return { ...e, cond: expr(e.cond) }; - default: return e; - } - }; - const call = (c: Call): Call => ({ ...c, params: c.params.map((p) => ({ ...p, init: any(p.init) })), body: c.body.map(stmt) }); - const stmt = (s: Stmt): Stmt => { - switch (s.kind) { - case "declare": return { ...s, init: any(s.init) }; - case "assignUnit": return { ...s, value: unit(s.value) }; - case "unitLoop": return { ...s, body: s.body.map(stmt) }; - case "unitWrite": return { ...s, unit: unit(s.unit), value: expr(s.value) }; - case "unitDo": return { ...s, unit: unit(s.unit), verb: s.verb.do === "damage" || s.verb.do === "heal" ? { ...s.verb, amount: expr(s.verb.amount) } : s.verb }; - case "tableWrite": return { ...s, value: any(s.value) }; - case "assignText": return { ...s, value: text(s.value) }; - case "storeText": return { ...s, index: expr(s.index), value: text(s.value) }; - case "releaseText": return { ...s, index: expr(s.index) }; - case "textLoop": return { ...s, of: text(s.of), body: s.body.map(stmt) }; - case "assign": return { ...s, value: expr(s.value) }; - case "declareArray": return { ...s, ...(s.init ? { init: s.init.map(expr) } : {}), ...(s.fill ? { fill: expr(s.fill) } : {}) }; - case "store": return { ...s, index: expr(s.index), value: expr(s.value) }; - case "push": return { ...s, value: expr(s.value) }; - case "setLength": return { ...s, value: expr(s.value) }; - case "assignBool": return { ...s, value: expr(s.value) }; - case "if": return { ...s, cond: expr(s.cond), then: s.then.map(stmt), ...(s.else ? { else: s.else.map(stmt) } : {}) }; - case "while": return { ...s, ...(s.cond ? { cond: expr(s.cond) } : {}), body: s.body.map(stmt) }; - case "do": return { ...s, body: s.body.map(stmt), cond: expr(s.cond) }; - case "for": return { ...s, ...(s.cond ? { cond: expr(s.cond) } : {}), update: s.update.map(stmt), body: s.body.map(stmt) }; - case "unrolled": return { ...s, iterations: s.iterations.map((i) => i.map(stmt)) }; - case "switch": return { ...s, value: expr(s.value), cases: s.cases.map((c) => ({ ...c, body: c.body.map(stmt) })) }; - case "return": return s.value ? { ...s, value: any(s.value) } : s; - case "action": return { ...s, record: action(s.record) as unknown as ActionRecord, ...(s.variables ? { variables: s.variables.map((v) => ({ ...v, expr: expr(v.expr) })) } : {}), ...(s.text ? { text: text(s.text) } : {}) }; - case "centerLocation": return { ...s, x: expr(s.x), y: expr(s.y) }; - case "print": return { ...s, parts: mapTextParts(s.parts, copies) }; - case "call": return { ...s, call: call(s.call) }; - case "block": return { ...s, body: s.body.map(stmt) }; - default: return s; - } - }; - // The heap's size goes along only when an array grows and the map's settings changed it: the lowering has the same default. - const heap = settings.heapCells !== undefined && settings.heapCells !== HEAP_CELLS && programs.some((p) => p.arrays.some((a) => a.dynamic)) ? { heap: heapCells(settings.heapCells) } : {}; - // The same for how deep a function that calls itself may go: only when one does. - const stack = settings.stackDepth !== undefined && settings.stackDepth !== STACK_DEPTH && programs.some((p) => p.functions?.some((f) => f.recursive)) ? { stack: stackDepth(settings.stackDepth) } : {}; - return JSON.stringify({ version: programs[0]?.version ?? 1, ...heap, ...stack, ...(input ? { input } : {}), programs: programs.map((p) => ({ ...p, ...(p.functions ? { functions: p.functions.map((f) => ({ ...f, body: f.body.map(stmt) })) } : {}), body: p.body.map(stmt) })) }); -} diff --git a/compiler/closures.ts b/compiler/closures.ts new file mode 100644 index 0000000..fb2ef8f --- /dev/null +++ b/compiler/closures.ts @@ -0,0 +1,448 @@ +/** + * Closures over locals (TrigScript 4). A function kept as a value that uses a variable of the function or the block it + * was written in keeps that variable in an *environment*: a row of the pool here, one for each run of the block, the + * variable a cell of its own array at that row. What JavaScript keeps alive with its garbage collector is kept alive + * here by counting: every place that holds a reference — a variable, a cell of an array, a field, an environment's + * parent — adds one, and what stops holding it takes one away. A row whose count reaches nothing is queued, and the + * queue is emptied at the program's next wait (`sweep`), so a closure only just made, on its way to where it is kept, + * is never let go of under it. No new kind of IR: the pool is arrays, the counting is statements, and both backends + * run them as they run anything else. + * + * A reference is a number. A function that keeps nothing is `FN_BASE` plus its id, as it was before closures, only + * moved up out of the pool's way; a row of the pool (an environment, or a closure: the function's number and the + * environment it was made in) is its place, 1 to `CLOSURE_CAP` − 1. 0 is none. + */ +import type { ArrayDecl, At, BoolExpr, Call, FuncDecl, NumExpr, Program, Stmt, TextExpr, UnitExpr, VarDecl } from "./ir"; + +/** Where the numbers of functions that keep nothing start; below it a number is a row of the pool. */ +export const FN_BASE = 1 << 20; +/** How many environments and closures can be alive at once in one program (row 0 is none). */ +export const CLOSURE_CAP = 1024; +/** + * Added to a row's count while it is in the queue, so that one read of the count says both: exactly `QUEUED` is a row + * nothing holds and that is queued already. (A read of a cell at a place worked out in the game costs about seventy + * conditions and actions; a write, fifteen; an add, which reads nothing, as little.) + */ +export const QUEUED = 1 << 24; + +/** The pool of a program: its arrays, its counters, and the functions that make, let go of and sweep rows. */ +export interface ClosurePool { + /** References to each row, plus `QUEUED` while it is in the queue. */ + rc: ArrayDecl; + /** The function a closure runs (`FN_BASE` + its id); 0 for an environment. */ + fnOf: ArrayDecl; + /** The environment a row was made in: a closure's, or the one around an environment. */ + parent: ArrayDecl; + /** Rows whose count reached 0, to look at again at the next wait. */ + queue: ArrayDecl; + queueN: VarDecl; + /** Rows given back, to be made again; `next` is the highest row ever made. */ + free: ArrayDecl; + freeN: VarDecl; + next: VarDecl; + warned: VarDecl; + /** `(fn, parent) → row`: a new row, its parent held. A closure (fn not 0) starts queued, with nothing holding it yet. */ + make: FuncDecl; + /** `(ref)`: one reference fewer; a row of the pool that nothing holds is queued. */ + drop: FuncDecl; + /** Every queued row that nothing holds is given back, with what it held. Filled once the program is known. */ + sweep: FuncDecl; + /** The arrays of the variables kept in environments: of numbers and booleans (`cells`, those that hold functions let go of with the row), of texts (their blocks given back), of units. */ + cells: ArrayDecl[]; + texts: { addr: ArrayDecl; block: ArrayDecl; chars: ArrayDecl }[]; + /** The variables that hold the environment of a run of a block. */ + envVars: Set; + /** + * References (B9): the object table's columns of numbers and booleans — a row given back has them emptied, a reference + * among them let go of — and its arrays that grow, whose blocks a row given back gives back (`refs`: they may hold + * references, each let go of first). + */ + objects: Map; +} + +/** What a row of one class of the object table holds (B9), by its number in the function column: what a row given back lets go of. */ +export interface ObjectClass { + /** Its columns of numbers and booleans: emptied, a reference among them let go of first. */ + columns: ArrayDecl[]; + /** Those every constructor writes first: let go of, not emptied. */ + overwritten: ReadonlySet; + /** Its texts: their blocks given back. */ + texts: { addr: ArrayDecl; block: ArrayDecl; chars: ArrayDecl }[]; + /** Its arrays that grow: their blocks given back (`refs`: the references in them let go of first). */ + lists: { ptr: ArrayDecl; len: ArrayDecl; room: ArrayDecl; k: ArrayDecl; of: "number" | "boolean"; refs: boolean }[]; +} + +export interface PoolMaker { + newVar(name: string, kind: VarDecl["kind"], at: At, extra?: { shared?: boolean; temp?: boolean }): VarDecl; + newArray(name: string, kind: "number" | "boolean", length: number, at: At, extra?: { shared?: boolean }): ArrayDecl; + fnId(name: string): string; +} + +const num = (value: number): NumExpr => ({ kind: "const", value }); +const ref = (v: VarDecl): NumExpr => ({ kind: "var", id: v.id }); +const el = (a: ArrayDecl, index: NumExpr, at: At): NumExpr => ({ kind: "element", array: a.id, index, at }); +const plus = (left: NumExpr, right: NumExpr, at: At, label: string): NumExpr => ({ kind: "binary", op: "+", left, right, at, label }); +const minus = (left: NumExpr, right: NumExpr, at: At, label: string): NumExpr => ({ kind: "binary", op: "-", left, right, at, label }); +const cmp = (op: "<" | "<=" | ">" | ">=" | "==" | "!=", left: NumExpr, right: NumExpr, at: At, label: string): BoolExpr => ({ kind: "compare", op, left, right, at, label }); + +export function makePool(m: PoolMaker, at: At): ClosurePool { + const label = "(closures)"; + // Each player's run of a per-player program has its own pool: what it makes it calls. + const shared = { shared: false }; + const a = (name: string, kind: "number" | "boolean" = "number") => m.newArray(`(closures ${name})`, kind, CLOSURE_CAP, at, shared); + const v = (name: string, kind: VarDecl["kind"] = "number") => m.newVar(`(closures ${name})`, kind, at, shared); + const rc = a("references"), fnOf = a("function"), parent = a("parent"), queue = a("queue"), free = a("free"); + const queueN = v("queued count"), freeN = v("free count"), next = v("made"), warned = v("warned", "boolean"); + + // make(fn, parent): a row from those given back, else one never made; none left, said once, and 0. + const fn = m.newVar("(function)", "number", at), up = m.newVar("(parent)", "number", at), s = m.newVar("(row)", "number", at, { temp: true }); + const made = m.newVar("(made row)", "number", at, { temp: true }); + const make: FuncDecl = { id: m.fnId("(make an environment)"), name: "(make an environment)", params: [fn, up], result: { decl: made, kind: "number" }, body: [], at }; + const queueRow = (row: NumExpr): Stmt[] => [ + { kind: "store", array: queue.id, index: ref(queueN), value: row, at, label }, + { kind: "assign", target: queueN.id, value: plus(ref(queueN), num(1), at, label), at, label }, + ]; + make.body = [ + { kind: "declare", decl: s, init: num(0), at, label }, + { kind: "if", cond: cmp(">", ref(freeN), num(0), at, label), then: [ + { kind: "assign", target: freeN.id, value: minus(ref(freeN), num(1), at, label), at, label }, + { kind: "assign", target: s.id, value: el(free, ref(freeN), at), at, label }, + ], else: [ + { kind: "if", cond: cmp("<", ref(next), num(CLOSURE_CAP - 1), at, label), then: [ + { kind: "assign", target: next.id, value: plus(ref(next), num(1), at, label), at, label }, + { kind: "assign", target: s.id, value: ref(next), at, label }, + ], at, label }, + ], at, label }, + { kind: "if", cond: cmp("==", ref(s), num(0), at, label), then: [ + { kind: "if", cond: { kind: "not", expr: { kind: "var", id: warned.id } }, then: [ + { kind: "assignBool", target: warned.id, value: { kind: "const", value: true }, at, label }, + { kind: "print", parts: [{ kind: "text", text: `\x08TrigScript: more than ${CLOSURE_CAP - 1} closures and their variables are kept at once; a new one does nothing.` }], to: 17, position: "chat", at, label }, + ], at, label }, + { kind: "return", value: num(0), at, label }, + ], at, label }, + { kind: "store", array: fnOf.id, index: ref(s), value: ref(fn), at, label }, + { kind: "store", array: parent.id, index: ref(s), value: ref(up), at, label }, + ...hold(rc, ref(up), at, label), + // A closure starts with nothing holding it, queued: kept by the time of the next wait, or given back then. + { kind: "if", cond: cmp("!=", ref(fn), num(0), at, label), then: [{ kind: "store", array: rc.id, index: ref(s), value: num(QUEUED), at, label }, ...queueRow(ref(s))], + else: [{ kind: "store", array: rc.id, index: ref(s), value: num(0), at, label }], at, label }, + { kind: "return", value: ref(s), at, label }, + ]; + + // drop(ref): one fewer; nothing holding it and not queued yet (the count reaches exactly 0), queued. + const r = m.newVar("(reference)", "number", at), n = m.newVar("(count)", "number", at, { temp: true }); + const drop: FuncDecl = { id: m.fnId("(let go of an environment)"), name: "(let go of an environment)", params: [r], body: [ + { kind: "declare", decl: n, init: minus(el(rc, ref(r), at), num(1), at, label), at, label }, + { kind: "if", cond: cmp("==", ref(n), num(0), at, label), then: [{ kind: "assign", target: n.id, value: num(QUEUED), at, label }, ...queueRow(ref(r))], at, label }, + { kind: "store", array: rc.id, index: ref(r), value: ref(n), at, label }, + ], at }; + + const sweep: FuncDecl = { id: m.fnId("(sweep the environments)"), name: "(sweep the environments)", params: [], body: [], at }; + return { rc, fnOf, parent, queue, queueN, free, freeN, next, warned, make, drop, sweep, cells: [], texts: [], envVars: new Set(), objects: new Map() }; +} + +/** One reference more: `rc[x] += 1` for a row of the pool (0 and numbers of plain functions are left alone; rc[0] is never read). */ +function hold(rc: ArrayDecl, x: NumExpr, at: At, label: string): Stmt[] { + // None, or a function that keeps nothing, known when built: nothing to count. + if (x.kind === "const" && (x.value <= 0 || x.value >= FN_BASE)) return []; + return [{ kind: "if", cond: { kind: "and", items: [cmp(">", x, num(0), at, label), cmp("<", x, num(FN_BASE), at, label)] }, then: [{ kind: "store", array: rc.id, index: x, value: plus(el(rc, x, at), num(1), at, label), at, label }], at, label }]; +} + +/** One reference fewer, through `drop` when `x` is a row at all. A cell is read once, into a temporary. */ +function release(pool: ClosurePool, x: NumExpr, at: At, label: string, m?: PoolMaker): Stmt[] { + const out: Stmt[] = []; + if (x.kind !== "var" && x.kind !== "const") { + const t = m!.newVar("(reference)", "number", at, { temp: true }); + out.push({ kind: "declare", decl: t, init: x, at, label }); + x = ref(t); + } + const call: Call = { name: pool.drop.name, fn: pool.drop.id, at, label, params: [{ decl: pool.drop.params[0], init: x, label }], body: [] }; + out.push({ kind: "if", cond: { kind: "and", items: [cmp(">", x, num(0), at, label), cmp("<", x, num(FN_BASE), at, label)] }, then: [{ kind: "call", call, at, label }], at, label }); + return out; +} + +const isCall = (o: Record): boolean => Array.isArray(o.params) && Array.isArray(o.body) && typeof o.label === "string" && "at" in o && !("kind" in o); + +/** + * The counting, once the program is walked: which variables and arrays may hold a reference (what a `make` gave, and + * whatever was given one of those, to a fixed point), then every place that stores into one holds the new reference + * and lets go of the old, and every wait of the program sweeps first. Parameters and a call's result are not counted: + * they live no longer than the statement or the call that set them. Gives the problems it found, in words. + */ +export function countReferences(program: Pick, functions: FuncDecl[], pool: ClosurePool, m: PoolMaker, fresh?: WeakSet): { at: At; message: string }[] { + const problems: { at: At; message: string }[] = []; + const own = new Set([pool.make.id, pool.drop.id, pool.sweep.id]); + const byId = new Map(functions.map((f) => [f.id, f])); + const decls = new Map(); + const params = new Set(); + const vars = new Set(pool.envVars); + const arrays = new Set([pool.parent.id]); + const slices = new Map(); + const bodies = [program.body, ...functions.filter((f) => !own.has(f.id)).map((f) => f.body)]; + + const isRef = (e: NumExpr | BoolExpr | UnitExpr | TextExpr | undefined): boolean => { + if (!e) return false; + switch (e.kind) { + case "call": return e.call.fn === pool.make.id || (!!e.call.result && vars.has(e.call.result.decl.id)); + case "var": return vars.has(e.id); + case "element": case "pop": return arrays.has(e.array); + case "ternary": return isRef(e.whenTrue as NumExpr) || isRef(e.whenFalse as NumExpr); + default: return false; + } + }; + // What may hold a reference, to a fixed point. + for (let changed = true; changed;) { + changed = false; + const addVar = (id: string) => { if (!vars.has(id)) { vars.add(id); changed = true; } }; + const addArray = (id: string) => { if (!arrays.has(id)) { arrays.add(id); changed = true; } }; + const visit = (root: unknown, result: string | undefined): void => { + if (Array.isArray(root)) { for (const x of root) visit(x, result); return; } + if (!root || typeof root !== "object") return; + const o = root as Record; + if (isCall(o)) { + const c = o as unknown as Call; + for (const p of c.params) { decls.set(p.decl.id, p.decl); params.add(p.decl.id); if (isRef(p.init)) addVar(p.decl.id); visit(p.init, result); } + if (c.fn) { const f = byId.get(c.fn); if (f?.result && c.result && vars.has(f.result.decl.id)) addVar(c.result.decl.id); } + if (c.result) decls.set(c.result.decl.id, c.result.decl); + visit(c.body, c.fn ? undefined : c.result?.decl.id); + return; + } + const s = o as unknown as Stmt; + switch (s.kind) { + case "declare": decls.set(s.decl.id, s.decl); if (isRef(s.init)) addVar(s.decl.id); break; + case "assign": if (isRef(s.value)) addVar(s.target); break; + case "store": case "push": if (isRef(s.value)) addArray(s.array); break; + case "declareArray": if (s.init?.some(isRef) || isRef(s.fill)) addArray(s.array); break; + case "return": if (result && isRef(s.value)) addVar(result); break; + default: break; + } + for (const [k, v] of Object.entries(o)) if (k !== "decl" && v && typeof v === "object") visit(v, result); + }; + visit(program.body, undefined); + for (const f of functions) { + if (own.has(f.id)) continue; + for (const p of f.params) { decls.set(p.id, p); params.add(p.id); } + visit(f.body, f.result?.decl.id); + } + } + void slices; + + // Only what a wait may come between its declaration and a use of needs counting: a wait is where rows are given back, + // so a variable read before any (a loop's `const e = enemies[i]`, a called function's locals, which never wait) holds + // nothing a sweep could take from it. Environments are counted always: one is given back only when let go of. + const acrossWaits = new Set(); + const parks = (root: unknown): boolean => { + if (Array.isArray(root)) return root.some(parks); + if (!root || typeof root !== "object") return false; + const o = root as Record; + // A task started here may read the variable on later frames, and its own waits sweep: counted as a wait. + if (o.kind === "sleep" || o.kind === "parallel" || o.kind === "start") return true; + for (const v of Object.values(o)) if (v && typeof v === "object" && parks(v)) return true; + return false; + }; + const lists = (root: unknown): void => { + if (!root || typeof root !== "object") return; + if (Array.isArray(root)) { + // A list of statements (a `call` and a `pop` stand as statements too). + if (root.length && root.every((x) => x && typeof x === "object" && typeof (x as { kind?: unknown }).kind === "string" && (isStmtKind((x as { kind: string }).kind) || (x as { kind: string }).kind === "call" || (x as { kind: string }).kind === "pop"))) { + root.forEach((st, i) => { if ((st as Stmt).kind === "declare" && parks(root.slice(i + 1))) acrossWaits.add((st as Stmt & { kind: "declare" }).decl.id); }); + } + for (const x of root) lists(x); + return; + } + for (const v of Object.values(root as Record)) if (v && typeof v === "object") lists(v); + }; + lists(program.body); + const counted = (id: string) => vars.has(id) && !params.has(id) && !decls.get(id)?.temp && (acrossWaits.has(id) || pool.envVars.has(id)); + const at0 = (s: { at?: At }): At => s.at ?? pool.make.at; + const tmp = (name: string, at: At) => m.newVar(name, "number", at, { temp: true }); + const sweepCall = (at: At, label: string): Stmt => ({ kind: "if", cond: cmp(">", ref(pool.queueN), num(0), at, label), then: [{ kind: "call", call: { name: pool.sweep.name, fn: pool.sweep.id, at, label, params: [], body: [] }, at, label }], at, label }); + const rc = pool.rc; + const lengthOf = (a: string, at: At): NumExpr => ({ kind: "length", array: a, at }); + /** Over every cell of an array in use: `do` with the cell. */ + const eachCell = (a: string, at: At, label: string, from: NumExpr, act: (cell: NumExpr) => Stmt[]): Stmt[] => { + const k = tmp("(cell)", at); + const cell: NumExpr = { kind: "element", array: a, index: ref(k), at }; + return [ + { kind: "declare", decl: k, init: from, at, label }, + { kind: "while", cond: cmp("<", ref(k), lengthOf(a, at), at, label), body: [...act(cell), { kind: "assign", target: k.id, value: plus(ref(k), num(1), at, label), at, label }], at, label }, + ]; + }; + /** The pops of counted arrays among a statement's own expressions (not inside the statements it holds). */ + const popsIn = (s: Stmt): string[] => { + const found: string[] = []; + const look = (root: unknown, top: boolean): void => { + if (Array.isArray(root)) { for (const x of root) look(x, false); return; } + if (!root || typeof root !== "object") return; + const o = root as Record; + if (!top && typeof o.kind === "string" && isStmtKind(o.kind)) return; + if (isCall(o)) { for (const p of (o as unknown as Call).params) look(p.init, false); return; } + if (o.kind === "pop" && typeof o.array === "string" && !top && arrays.has(o.array)) found.push(o.array); + for (const v of Object.values(o)) if (v && typeof v === "object") look(v, false); + }; + look(s, true); + return found; + }; + + const rewrite = (list: Stmt[], waits: boolean): Stmt[] => { + const out: Stmt[] = []; + for (const s of list) { + nested(s, waits); + const at = at0(s), label = "label" in s ? s.label : "(closures)"; + if (s.kind !== "while" && s.kind !== "do" && s.kind !== "for") { + const pops = popsIn(s); + const seen = new Map(); + for (const a of pops) { + const k = (seen.get(a) ?? 0) + 1; + seen.set(a, k); + out.push(...release(pool, { kind: "element", array: a, index: minus(lengthOf(a, at), num(k), at, label), at }, at, label, m)); + } + } + switch (s.kind) { + case "declare": case "assign": { + const target = s.kind === "declare" ? s.decl.id : s.target; + if (!counted(target)) break; + if (s.kind === "declare") s.decl.held = true; + const t = tmp("(reference)", at); + out.push({ kind: "declare", decl: t, init: s.kind === "declare" ? s.init : s.value, at, label }, ...hold(rc, ref(t), at, label), ...release(pool, { kind: "var", id: target }, at, label)); + out.push(s.kind === "declare" ? { ...s, init: ref(t) } : { ...s, value: ref(t) }); + continue; + } + case "store": { + if (!arrays.has(s.array)) break; + // The first value of a field of an object only just made (B9): its row was empty, so nothing is let go of. + if (fresh?.has(s)) { + const t = tmp("(reference)", at); + out.push({ kind: "declare", decl: t, init: s.value as NumExpr, at, label }, ...hold(rc, ref(t), at, label), { ...s, value: ref(t) }); + continue; + } + const i = tmp("(place)", at), t = tmp("(reference)", at); + out.push({ kind: "declare", decl: i, init: s.index, at, label }, { kind: "declare", decl: t, init: s.value as NumExpr, at, label }, ...hold(rc, ref(t), at, label), + ...release(pool, { kind: "element", array: s.array, index: ref(i), at }, at, label, m), { ...s, index: ref(i), value: ref(t) }); + continue; + } + case "push": { + if (!arrays.has(s.array)) break; + const t = tmp("(reference)", at); + out.push({ kind: "declare", decl: t, init: s.value as NumExpr, at, label }, ...hold(rc, ref(t), at, label), { ...s, value: ref(t) }); + continue; + } + case "pop": { + if (!arrays.has(s.array)) break; + out.push(...release(pool, { kind: "element", array: s.array, index: minus(lengthOf(s.array, at), num(1), at, label), at }, at, label, m), s); + continue; + } + case "setLength": { + if (!arrays.has(s.array)) break; + const n = tmp("(length)", at); + out.push({ kind: "declare", decl: n, init: s.value, at, label }, ...eachCell(s.array, at, label, ref(n), (cell) => release(pool, cell, at, label, m)), { ...s, value: ref(n) }); + continue; + } + case "declareArray": { + if (!arrays.has(s.array)) break; + out.push(...eachCell(s.array, at, label, num(0), (cell) => release(pool, cell, at, label, m)), s, ...eachCell(s.array, at, label, num(0), (cell) => hold(rc, cell, at, label))); + continue; + } + case "start": { + // A closure handed to a task (its parameter, which nothing counts) is held by the program for the task's slot + // until another task takes the slot: kept while the task runs, let go of once a later one starts there. + const kept = s.call.params.filter((p) => isRef(p.init)); + if (!kept.length) break; + const temps = kept.map((p) => { const t = tmp("(handed to the task)", at); out.push({ kind: "declare", decl: t, init: p.init as NumExpr, at, label }); p.init = ref(t); return t; }); + const handle = s.handle ? decls.get(s.handle) ?? m.newVar("(task)", "number", at) : m.newVar("(task)", "number", at); + if (!s.handle) s.handle = handle.id; + out.push(s); + const slot = tmp("(slot)", at); + const then: Stmt[] = [{ kind: "declare", decl: slot, init: { kind: "binary", op: "%", left: { kind: "var", id: s.handle }, right: num(256), at, label }, at, label }]; + temps.forEach((t, i) => { + const held = m.newArray(`(closures of task ${s.task} ${i})`, "number", 256, at); + then.push(...hold(rc, ref(t), at, label), ...release(pool, el(held, ref(slot), at), at, label, m), { kind: "store", array: held.id, index: ref(slot), value: ref(t), at, label }); + }); + out.push({ kind: "if", cond: cmp(">", { kind: "var", id: s.handle }, num(0), at, label), then, at, label }); + continue; + } + case "sleep": if (waits) out.push(sweepCall(at, label)); break; + default: break; + } + out.push(s); + } + return out; + }; + /** The statement lists a statement holds, rewritten in place; calls inside its expressions too. */ + const nested = (s: Stmt, waits: boolean): void => { + const calls = (root: unknown): void => { + if (Array.isArray(root)) { for (const x of root) calls(x); return; } + if (!root || typeof root !== "object") return; + const o = root as Record; + if (typeof o.kind === "string" && isStmtKind(o.kind) && o !== s) return; + if (isCall(o)) { const c = o as unknown as Call; for (const p of c.params) calls(p.init); c.body = rewrite(c.body, waits); return; } + for (const v of Object.values(o)) if (v && typeof v === "object") calls(v); + }; + switch (s.kind) { + case "if": s.then = rewrite(s.then, waits); if (s.else) s.else = rewrite(s.else, waits); calls(s.cond); break; + case "while": s.body = rewrite(s.body, waits); if (s.cond) calls(s.cond); break; + case "do": s.body = rewrite(s.body, waits); calls(s.cond); break; + case "for": s.body = rewrite(s.body, waits); s.update = rewrite(s.update, waits); if (s.cond) calls(s.cond); break; + case "unrolled": s.iterations = s.iterations.map((it) => rewrite(it, waits)); break; + case "switch": for (const c of s.cases) c.body = rewrite(c.body, waits); calls(s.value); break; + case "parallel": s.branches = s.branches.map((b) => rewrite(b, waits)); break; + case "block": s.body = rewrite(s.body, waits); break; + case "try": s.body = rewrite(s.body, waits); s.catch = rewrite(s.catch, waits); break; + case "textLoop": case "unitLoop": s.body = rewrite(s.body, waits); calls(s); break; + case "start": s.call.body = rewrite(s.call.body, true); for (const p of s.call.params) calls(p.init); break; + default: calls(s); break; + } + }; + const start: Stmt[] = [ + ...[pool.queueN, pool.freeN, pool.next].map((v): Stmt => ({ kind: "declare", decl: v, init: num(0), at: pool.make.at, label: "(closures)" })), + { kind: "declare", decl: pool.warned, init: { kind: "const", value: false }, at: pool.make.at, label: "(closures)" }, + ]; + program.body.splice(0, program.body.length, ...start, ...rewrite(program.body, true)); + for (const f of functions) if (!own.has(f.id)) f.body = rewrite(f.body, false); + void bodies; + + // The sweep: every queued row nothing holds goes back, letting go of its parent and of the functions its variables held. + const at = pool.sweep.at, label = "(closures)"; + const s = m.newVar("(row)", "number", at, { temp: true }), count = m.newVar("(count)", "number", at, { temp: true }); + const row = ref(s); + /** What a row lets go of as it goes: an environment's or a closure's parent and kept variables; an object's own fields, by its class. */ + const ofEnvironment = (): Stmt[] => [ + ...release(pool, el(pool.parent, row, at), at, label, m), + ...pool.cells.filter((a) => arrays.has(a.id)).flatMap((a): Stmt[] => [...release(pool, el(a, row, at), at, label, m), { kind: "store", array: a.id, index: row, value: num(0), at, label }]), + ...pool.texts.map((t): Stmt => ({ kind: "releaseText", block: t.block.id, index: row, at, label })), + ]; + const ofObject = (c: ObjectClass): Stmt[] => [ + ...c.columns.flatMap((a): Stmt[] => [...(arrays.has(a.id) ? release(pool, el(a, row, at), at, label, m) : []), ...(c.overwritten.has(a.id) ? [] : [{ kind: "store", array: a.id, index: row, value: a.kind === "boolean" ? { kind: "const", value: false } : num(0), at, label } as Stmt])]), + ...c.texts.map((t): Stmt => ({ kind: "releaseText", block: t.block.id, index: row, at, label })), + ...c.lists.flatMap((l): Stmt[] => { + const inner = m.newArray(`(objects) ${l.ptr.name} (given back)`, l.of, 0, at); + inner.dynamic = true; + inner.through = { ptr: l.ptr.id, len: l.len.id, room: l.room.id, k: l.k.id, index: s.id }; + return [...(l.refs ? eachCell(inner.id, at, label, num(0), (cell) => release(pool, cell, at, label, m)) : []), { kind: "declareArray", array: inner.id, init: [], at, label }]; + }), + ]; + const rowGoes = (): Stmt[] => { + if (!pool.objects.size) return ofEnvironment(); + const cases = [...pool.objects].map(([tag, c]) => ({ value: tag, body: [...ofObject(c), { kind: "break", at, label } as Stmt] })); + return [{ kind: "switch", value: el(pool.fnOf, row, at), cases: [...cases, { value: null, body: ofEnvironment() }], at, label }]; + }; + pool.sweep.body = [{ kind: "while", cond: cmp(">", ref(pool.queueN), num(0), at, label), body: [ + { kind: "assign", target: pool.queueN.id, value: minus(ref(pool.queueN), num(1), at, label), at, label }, + { kind: "declare", decl: s, init: el(pool.queue, ref(pool.queueN), at), at, label }, + { kind: "declare", decl: count, init: el(pool.rc, row, at), at, label }, + { kind: "if", cond: cmp("==", ref(count), num(QUEUED), at, label), then: [ + { kind: "store", array: pool.rc.id, index: row, value: num(0), at, label }, + ...rowGoes(), + { kind: "store", array: pool.free.id, index: ref(pool.freeN), value: row, at, label }, + { kind: "assign", target: pool.freeN.id, value: plus(ref(pool.freeN), num(1), at, label), at, label }, + ], else: [ + // Held again since it was queued: out of the queue, its count as it is. + { kind: "store", array: pool.rc.id, index: row, value: minus(ref(count), num(QUEUED), at, label), at, label }, + ], at, label }, + ], at, label }]; + return problems; +} + +const STMT_KINDS = new Set(["declare", "assignText", "storeText", "releaseText", "textLoop", "assign", "declareArray", "store", "push", "setLength", "assignBool", "assignUnit", "unitLoop", "unitWrite", "unitDo", "tableWrite", "if", "while", "do", "for", "unrolled", "switch", "break", "continue", "return", "sleep", "parallel", "start", "cancel", "action", "syncSend", "select", "centerLocation", "print", "buildText", "block", "try", "throw", "uncaught", "remark"]); +/** Whether an IR node of this kind is a statement (`pop` and `call` are both: as an expression they are walked as one). */ +function isStmtKind(kind: string): boolean { return STMT_KINDS.has(kind); } diff --git a/compiler/compiler.ts b/compiler/compiler.ts index 6b881c1..2701fc5 100644 --- a/compiler/compiler.ts +++ b/compiler/compiler.ts @@ -5,9 +5,9 @@ * through whatever helpers and loops it likes, records one trigger — and `program()` * bodies are the structured level: the run records where each body is and the values * of its build-time parts (`hoist.ts`), and `structured.ts` turns the body into IR - * (`ir.ts`). The IR is the output for a program: eudplib builds it into the map when the - * map is saved (`python/trigscript.py`), so a program needs StarCraft: Remastered and - * costs the map no triggers, death counters or switches of its own. + * (`ir.ts`). The IR is the output for a program: the backend (`backend/`) lays it out into + * the map when the map is saved, so a program needs StarCraft: Remastered and costs the + * map no triggers, death counters or switches of its own. * * Strings are not interned here (the compiler runs in a worker, away from the map): * text/wav fields hold local ids into `strings`, resolved by the build step. The @@ -20,24 +20,34 @@ import type { TriggerRecord } from "../vendor/triggers"; import { MODULE_NAME } from "./api"; import { isTestFile, listTests, runTests, TestRegistry, TESTING_NAMES, type TestReport, type TestRunOptions } from "./testing"; import { DECLARATIONS_FILE, generateDeclarations } from "./declarations"; -import { libraryCallName, libraryName, planProgram, transformer, type ProgramPlan } from "./hoist"; -import { locate, resolveModule, runModules, type LinkedFile } from "./link"; -import { checkProgram } from "./eud"; +import { isScriptClass, isScriptFunction, scriptArrow, isTopListener, libraryCallName, libraryName, planClass, planFunction, planListener, planProgram, transformer, type ProgramPlan } from "./hoist"; +import { locate, resolveModule, runModules, STDLIB_DIR, type LinkedFile } from "./link"; +import { isMediaFile, mediaValue } from "./media"; +import { STDLIB, stdlibImports, stdlibPath } from "./stdlib"; +import { checkProgram } from "./check"; +import { checkLocal } from "./local"; import { typeNumbers } from "./numbers"; +import { rangeNumbers } from "./ranges"; +import type { MapPlayers } from "./backend/emit"; +import { emitOwn } from "./backend/own"; import { markRecursion, settleRecursion } from "./recursion"; -import { inputPlan, inputsOf, type InputPlan } from "./input"; -import { programDeclarations, type Program } from "./ir"; +import { inputPlan, numberSyncSlots, type InputPlan } from "./input"; +import { declarations, programDeclarations, type Program } from "./ir"; import { LowerError, PLAYER_SLOTS } from "./lower"; import { allTables, type ScriptNames } from "./names"; import { Collector, createRuntime, type GameFunctionValue, type ProgramDescriptor, type ScriptString } from "./runtime"; import { newBody, Structured, type Body } from "./structured"; import { english, type Translate } from "../i18n"; +import type { ClipFile } from "./sound"; export type { ScriptString } from "./runtime"; export { DEATHS_TABLE_ADDRESS } from "./runtime"; -/** The file a script starts from. */ +/** The file a script starts from: main.ts, or main.tsx for a script that writes JSX in it. */ export const ENTRY_FILE = "main.ts"; +export const ENTRY_TSX = "main.tsx"; +/** The entry of a script's files: main.tsx when there is one, else main.ts. */ +export const entryOf = (files: Iterable): string => ([...files].includes(ENTRY_TSX) ? ENTRY_TSX : ENTRY_FILE); const LIB_FILE = "lib.d.ts"; /** The script's files by path (`main.ts`, `bases.ts`, `ai/waves.ts`). */ @@ -84,15 +94,18 @@ export interface VariableInfo { bits?: number; /** A `u32`; unset, a number is signed. */ unsigned?: boolean; + /** A `fixed`: a number with a fraction (16.16). */ + fixed?: boolean; } /** What a program's variable holds, as the editor's hover says it: the kind, and for a number its range and what happens at the ends. */ -export function describeVariable(v: Pick, t: Translate = english): string { +export function describeVariable(v: Pick, t: Translate = english): string { if (v.kind === "unit") return t("a unit of the game, or none — checked before every use: once the unit is gone it reads 0 and takes no write"); if (v.kind === "boolean") return t("a boolean"); if (v.kind === "text") return t("a text — `length`, `s[i]` and `slice` count characters (code points), and a made text holds 1 023 bytes"); if (v.bits) return t("a u{bits} number (0 … {max}, stopping at either end)", { bits: v.bits, max: 2 ** v.bits - 1 }); if (v.unsigned) return t("a u32 number (0 … 4 294 967 295, wrapping at either end as `x >>> 0` does)"); + if (v.fixed) return t("a fixed number (−32 768 … 32 767.99998, in steps of 1/65 536): a number with a fraction"); return t("a number (−2 147 483 648 … 2 147 483 647, whole, wrapping at either end as `x | 0` does)"); } @@ -134,6 +147,12 @@ export interface CompileResult { sources: (TriggerSource | null)[]; /** Local string table: a record's `text` / `wav` field `k > 0` means `strings[k - 1]`. */ strings: ScriptString[]; + /** + * The clips the script plays (`sound.ts`), as the files the map's archive gets, by their names there: once each. An + * Ogg Vorbis one has its samples instead of bytes until `finishSounds` (`vorbis.ts`) encodes it; after a compile with + * `sounds: false` (a check) each is its name alone: the map is to get them from a build. + */ + sounds: ClipFile[]; diagnostics: ScriptDiagnostic[]; /** The programs' variables, in declaration order; a function's copies and results are left out. */ variables: VariableInfo[]; @@ -144,21 +163,58 @@ export interface CompileResult { hints: LineHint[]; /** Every `locations.X` / `switches.X` / … the files mention, whatever else went wrong (a rename is what makes them not type-check). */ refs: MapReference[]; - /** The programs as IR (`ir.ts`), one per `program()` in order — what eudplib builds into the map on save. A map with any needs StarCraft: Remastered. */ + /** The programs as IR (`ir.ts`), one per `program()` in order — what the build lays out into the map on save. A map with any needs StarCraft: Remastered. */ ir: Program[]; /** What the programs read of the players — keys, clicks, the mouse, typed lines — and what carrying it takes from the map (`input.ts`); null when they read none. */ input: InputPlan | null; /** The script's `test()`s, and what running them found; null when it has none, or did not compile that far. */ tests: TestReport | null; + /** + * The script as JavaScript, file by file, when it has tests and compiled: what runs them somewhere else — in the + * lifted game, beside the engine (`gameTests.ts`) — without TypeScript there. + */ + modules?: [string, LinkedFile][]; + /** + * Why a map of our own does not take the programs (`backend/own.ts`, `emitOwn`), when the compile was asked + * (`CompileOptions.own`) and it does not: the first part it never takes, with its line. Absent when it takes them, + * when there are none, or when the script did not compile. + */ + ownRefusal?: string; /** No errors: `triggers` and `ir` are the complete output. */ ok: boolean; } +/** The map a compile is checked against for a map of our own (`CompileOptions.own`): who plays, and the map's size in pixels. */ +export interface OwnCheck { + players: MapPlayers; + humans: number[]; + width: number; + height: number; +} + export interface CompileOptions { /** The standard library's declarations (`lib.es2023.d.ts` and what it references, concatenated). */ lib: string; /** Run the script's tests after a compile that went through: the world they start from, and which of them. Absent: they are listed and not run. */ tests?: TestRunOptions; + /** false: no value ranges (`ranges.ts`) — what a test compares a program with them against. */ + ranges?: boolean; + /** + * false: the clips the script plays are named, not made — a check as the user types, whose sounds the map does not + * get (`sound.ts`'s clips work their samples out only when asked). A build's compile leaves it out. + */ + sounds?: boolean; + /** + * Ask whether a map of our own takes the programs as they are, for this map (`ownRefusal`): what the editor shows + * as a problem while the script is typed, so that a map does not change builds without anyone seeing. + */ + own?: OwnCheck; + /** + * Files among `files` that run as entries of their own after the entry, in order — what another plugin contributes + * to the build (`service.ts`, `contribute`): their `program()`s come after the script's. One that is not among the + * files is an error. + */ + entries?: readonly string[]; } export { LowerError }; @@ -180,13 +236,25 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa const diagnostics: ScriptDiagnostic[] = []; const result = (extra: Partial = {}): CompileResult => { diagnostics.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column); - return { triggers: [], sources: [], strings: [], variables: [], programs: [], buildTime: [], hints: [], refs, ir: [], input: null, tests: null, ...extra, diagnostics, ok: diagnostics.length === 0 }; + // A function of the script's is compiled where each call is: its error once, not once a call. + const said = new Set(); + for (let i = diagnostics.length - 1; i >= 0; i--) { + const d = diagnostics[i], key = `${d.file}:${d.line}:${d.column}:${d.message}`; + if (said.has(key)) diagnostics.splice(i, 1); else said.add(key); + } + return { triggers: [], sources: [], strings: [], sounds: [], variables: [], programs: [], buildTime: [], hints: [], refs, ir: [], input: null, tests: null, ...extra, diagnostics, ok: diagnostics.length === 0 }; }; const refs: MapReference[] = []; const scripts = new Map(); - for (const [path, text] of Object.entries(files)) scripts.set(normalizePath(path), text); - if (!scripts.has(ENTRY_FILE)) { + // A video, an image or a sound among the files (`media.ts`) is no TypeScript: an import of it gives a value the linker makes. + const media = new Map(); + for (const [path, text] of Object.entries(files)) (isMediaFile(path) ? media : scripts).set(normalizePath(path), text); + // The standard library's modules the script imports ("trigscript/fog"), as files of its own: only those, since one + // may hold a program of its own. + for (const name of stdlibImports([...scripts.values()])) scripts.set(stdlibPath(name), STDLIB[name]); + const entry = entryOf(scripts.keys()); + if (!scripts.has(entry)) { diagnostics.push({ file: ENTRY_FILE, line: 1, column: 1, endLine: 1, endColumn: 1, message: `The script has no ${ENTRY_FILE}; that is the file a build starts from.`, source: "compiler" }); return result(); } @@ -197,12 +265,15 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa const compilerOptions: TS.CompilerOptions = { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.CommonJS, moduleResolution: ts.ModuleResolutionKind.Bundler, strict: true, types: [], sourceMap: true, noLib: false, + paths: { [`${MODULE_NAME}/*`]: [`./${STDLIB_DIR}/*`] }, + // JSX (in a .tsx file) is a text the game shows: elements are calls of __jsx, which the structured compiler lowers in a program. + jsx: ts.JsxEmit.React, jsxFactory: "__jsx", jsxFragmentFactory: "__fragment", }; const outputs = new Map(); const host: TS.CompilerHost = { getSourceFile: (name) => { const text = texts.get(name); - return text === undefined ? undefined : ts.createSourceFile(name, text, ts.ScriptTarget.ES2022, true); + return text === undefined ? undefined : ts.createSourceFile(name, text, ts.ScriptTarget.ES2022, true, name.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS); }, getDefaultLibFileName: () => LIB_FILE, writeFile: (name, text) => { outputs.set(name, text); }, @@ -263,12 +334,44 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa /* ── Plan the programs and the game functions ── */ const plans = new Map(); + /** The classes at the top of the script's files, each planned as a game function is (`planClass`). */ + const classPlans = new Map(); + /** The functions at the top of the script's files, each planned as a game function is (`planFunction`). */ + const functionPlans = new Map(); const byPosition = new Map(); const fileIndex = (sf: TS.SourceFile) => fileNames.indexOf(sf.fileName); const buildTime: SourceRange[] = []; for (const name of fileNames) { const sf = program.getSourceFile(name)!; const visit = (node: TS.Node) => { + // Its errors wait for a program that makes an instance of it (`classBody` below); a class only ever used when the + // script is built has none. + if (isScriptClass(ts, node) && !isTestFile(name)) { + const plan = planClass(ts, checker, node); + classPlans.set(node, plan); + byPosition.set(`${fileIndex(sf)}:${node.getStart(sf)}`, { plan, sf, name: node.name!.text }); + } + // A function at the top of a file: its errors, too, wait for a program that calls it with its own values. + if (isScriptFunction(ts, node) && !isTestFile(name)) { + const plan = planFunction(ts, checker, node); + functionPlans.set(node, plan); + byPosition.set(`${fileIndex(sf)}:${node.getStart(sf)}`, { plan, sf, name: node.name!.text }); + } + const arrowFn = !isTestFile(name) ? scriptArrow(ts, node) : undefined; + if (arrowFn) { + const plan = planFunction(ts, checker, arrowFn); + functionPlans.set(arrowFn, plan); + byPosition.set(`${fileIndex(sf)}:${arrowFn.getStart(sf)}`, { plan, sf, name: ((node as TS.VariableDeclaration).name as TS.Identifier).text }); + } + // `on.died(…)` at the top of a file: a program of its own, its body the statement. + if (isTopListener(ts, checker, node) && !isTestFile(name)) { + const plan = planListener(ts, checker, node); + plans.set(node, plan); + byPosition.set(`${fileIndex(sf)}:${node.getStart(sf)}`, { plan, sf }); + for (const e of plan.errors) nodeError(e.node, e.message); + for (const e of plan.hoisted) buildTime.push({ file: name, ...position(sf, e.getStart(sf), e.getEnd()) }); + return; + } if (ts.isCallExpression(node)) { const lib = libraryCallName(ts, checker, node); const arrow = node.arguments[0]; @@ -302,14 +405,14 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa for (const d of diagnostics) planned.add(`${d.file}:${d.line}`); /* ── Emit ── */ - const emitted = program.emit(undefined, undefined, undefined, false, { before: [transformer(ts, checker, { fileIndex, planFor: (arrow) => plans.get(arrow) })] }); + const emitted = program.emit(undefined, undefined, undefined, false, { before: [transformer(ts, checker, { fileIndex, planFor: (arrow) => plans.get(arrow), planForClass: (cls) => classPlans.get(cls), planForListener: (stmt) => plans.get(stmt), planForFunction: (fn) => functionPlans.get(fn) })] }); for (const d of emitted.diagnostics) { if (d.category === ts.DiagnosticCategory.Error) diagnostics.push({ file: ENTRY_FILE, line: 1, column: 1, endLine: 1, endColumn: 1, message: ts.flattenDiagnosticMessageText(d.messageText, "\n"), source: "typescript" }); } if (diagnostics.length > planned.size) return result(); const linked = new Map(); for (const name of fileNames) { - const base = name.replace(/\.ts$/, ""); + const base = name.replace(/\.tsx?$/, ""); const js = outputs.get(`${base}.js`); if (js === undefined) { diagnostics.push({ file: name, line: 1, column: 1, endLine: 1, endColumn: 1, message: "The file produced no JavaScript.", source: "compiler" }); continue; } linked.set(name, { js, map: outputs.get(`${base}.js.map`) }); @@ -318,13 +421,18 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa /* ── Run ── */ const collector = new Collector(); + collector.soundContents = options.sounds !== false; const registry = new TestRegistry(); const where = (err: unknown) => { const at = locate(err, linked); return at.file ? { file: at.file, line: at.line ?? 1, ...(at.column ? { column: at.column } : {}) } : null; }; registry.where = () => where(new Error()) ?? { file: ENTRY_FILE, line: 1 }; const runtime = { ...createRuntime(names, collector), ...registry.library() }; collector.running = true; - // The entry first, so the programs are there; then the test files, which nothing imports. - const failure = runModules(linked, ENTRY_FILE, runtime, MODULE_NAME, { imported: TESTING_NAMES, then: fileNames.filter(isTestFile).sort() }); + // The entry first, so the programs are there; then the contributed entries, in their order; then the test files, + // which nothing imports. + const entries = options.entries ?? []; + for (const e of entries) if (!scripts.has(normalizePath(e))) diagnostics.push({ file: ENTRY_FILE, line: 1, column: 1, endLine: 1, endColumn: 1, message: `The build names ${e} as an entry, and the script has no such file.`, source: "compiler" }); + if (diagnostics.length) return result(); + const failure = runModules(linked, entry, runtime, MODULE_NAME, { imported: TESTING_NAMES, then: [...entries.map(normalizePath), ...fileNames.filter(isTestFile).sort()], media: (path) => { const text = media.get(path); return text === undefined ? undefined : mediaValue(path, text); } }); collector.running = false; if (failure) { const line = failure.line ?? 1; @@ -347,6 +455,55 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa return b; }; const resolve = (fn: GameFunctionValue): Body | undefined => bodyOf(fn.descriptor) ?? undefined; + // A class of the script's own, when a program makes an instance of it: its plan, with what the run made of its bodies' + // build-time parts. Its plan's errors are reported now, once. + const classBodies = new Map(); + const classBody = (cls: TS.ClassDeclaration): Body | undefined => { + let b = classBodies.get(cls); + if (b === undefined) { + const sf = cls.getSourceFile(); + const key = `${fileIndex(sf)}:${cls.getStart(sf)}`; + const found = byPosition.get(key); + const ran = collector.classes.get(key); + b = null; + if (found && ran) { + try { b = newBody(found.plan, found.sf, ran.hoisted(), found.name); } catch { b = null; } + for (const e of found.plan.errors) nodeError(e.node, e.message); + } + classBodies.set(cls, b); + } + return b ?? undefined; + }; + + // A function of the script's own, when a program calls it with its own values: the same, from what `__fn` was handed. + const functionBodies = new Map(); + const functionBody = (fn: TS.FunctionDeclaration | TS.ArrowFunction | TS.FunctionExpression): Body | undefined => { + let b = functionBodies.get(fn); + if (b === undefined) { + const sf = fn.getSourceFile(); + const key = `${fileIndex(sf)}:${fn.getStart(sf)}`; + const found = byPosition.get(key); + const ran = collector.functions.get(key); + b = null; + if (found && ran) { + try { b = newBody(found.plan, found.sf, ran.hoisted(), found.name); } catch { b = null; } + for (const e of found.plan.errors) nodeError(e.node, e.message); + } + functionBodies.set(fn, b); + } + return b ?? undefined; + }; + + // Every class written in the script's files, for what a type of a program may be an instance of (B9). + let classList: TS.ClassDeclaration[] | null = null; + const scriptClasses = (): TS.ClassDeclaration[] => { + if (classList) return classList; + classList = []; + const found = classList; + const visit = (n: TS.Node) => { if (ts.isClassDeclaration(n) && n.name) found.push(n); ts.forEachChild(n, visit); }; + for (const name of fileNames) { const sf = program.getSourceFile(name); if (sf) visit(sf); } + return classList; + }; const triggers: TriggerRecord[] = []; const sources: (TriggerSource | null)[] = []; @@ -354,6 +511,7 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa const variables: VariableInfo[] = []; const hints: LineHint[] = []; const ir: Program[] = []; + let syncSlots = 0; for (const entry of collector.entries) { const from = sourceOf(entry.kind === "trigger" ? entry.at : entry.descriptor.at); if (from && isTestFile(from.file)) { @@ -367,33 +525,48 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa if (!body) { diagnostics.push({ file, line: at.line, column: 1, endLine: at.line, endColumn: 2, message: "program(): the body could not be found again.", source: "compiler" }); continue; } const owners = entry.options.owners; const owner = owners.find((o) => o < PLAYER_SLOTS) ?? 0; - const emitted = new Structured({ ts, checker, body, owner, owners, perPlayer: entry.options.perPlayer, strings: collector.strings, error: (node, message, source) => nodeError(node, message, source), resolve }).run(); + // Walked again when kept functions turned out to use variables of the blocks they were written in: those are kept in + // environments the next walk (closures over locals), and only the last walk's errors are said. + // Walked again, too, when classes turned out to be kept by reference (B9): their instances are rows of the object + // table the next walk. + const boxed = new Set(); + const refs = new Set(); + let emitted: ReturnType; + for (let walk = 0; ; walk++) { + const said: [TS.Node, string, "compiler" | "script" | undefined][] = []; + emitted = new Structured({ ts, checker, body, owner, owners, perPlayer: entry.options.perPlayer, strings: collector.strings, error: (node, message, source) => { said.push([node, message, source]); }, resolve, classBody, functionBody, boxed, refs, classes: scriptClasses() }).run(); + if ((emitted.boxes?.length || emitted.refs?.length) && walk < 6) { for (const d of emitted.boxes ?? []) boxed.add(d); for (const c of emitted.refs ?? []) refs.add(c); continue; } + for (const [node, message, source] of said) nodeError(node, message, source); + break; + } if (entry.options.name) emitted.program.name = entry.options.name; // What every number is read as, written into the operations that care, before anything looks at the program. const mixes = typeNumbers(emitted.program); + // The values each number can take, for the lowering to take only the bits a value has. + if (options.ranges !== false) rangeNumbers(emitted.program); + syncSlots += numberSyncSlots(emitted.program, syncSlots); markRecursion(emitted.program); + // A function that calls itself keeps its variables on the stack, and an environment's references would not be counted there. + if (emitted.envVars) for (const f of emitted.program.functions ?? []) { + if (f.recursive && declarations(f.body).some((d) => emitted.envVars!.has(d.id))) diagnostics.push({ file: f.at.file, line: f.at.line, column: f.at.column, endLine: f.at.line, endColumn: f.at.column + 1, message: `${f.name} calls itself, and a function kept as a value uses one of its variables: that is not supported yet. Keep the variable in the program, or hand it to the kept function as an argument.`, source: "compiler" }); + } const index = programs.length; ir.push(emitted.program); programs.push({ ...(emitted.program.name ? { name: emitted.program.name } : {}), owner, owners, perPlayer: entry.options.perPlayer, source: at }); - for (const d of programDeclarations(emitted.program)) if (!d.temp) variables.push({ name: d.name, kind: d.kind, program: index, shared: d.shared, at: d.at, ...(d.bits ? { bits: d.bits } : {}), ...(d.unsigned ? { unsigned: true } : {}) }); + for (const d of programDeclarations(emitted.program)) if (!d.temp) variables.push({ name: d.name, kind: d.kind, program: index, shared: d.shared, at: d.at, ...(d.bits ? { bits: d.bits } : {}), ...(d.unsigned ? { unsigned: true } : {}), ...(d.fixed ? { fixed: true } : {}) }); // What would freeze the game or cannot be built is a fault of the script, said where it is. const check = checkProgram(emitted.program); + // What only one computer knows, kept from what changes the game (B5). + const local = checkLocal(emitted.program); // Checked as the script wrote it; only then is a function that calls itself taken apart into what a backend can re-enter. const recursion = settleRecursion(emitted.program); - for (const d of [...mixes, ...check.errors, ...recursion]) { + for (const d of [...mixes, ...check.errors, ...local.errors, ...recursion]) { if (planned.has(`${d.at.file}:${d.at.line}`)) continue; diagnostics.push({ file: d.at.file, line: d.at.line, column: d.at.column, endLine: d.at.line, endColumn: d.at.column + 1, message: d.message, source: "compiler" }); } hints.push(...check.hints); } - let input: InputPlan | null = null; - try { - input = inputPlan(ir, names.locations, names.units); - } catch (err) { - // No room in the map for what carries input: said where the first input is asked for. - const at = inputsOf(ir).at ?? ir[0]?.at; - if (at) diagnostics.push({ file: at.file, line: at.line, column: at.column, endLine: at.line, endColumn: at.column + 1, message: err instanceof Error ? err.message : String(err), source: "compiler" }); - } + const input: InputPlan | null = inputPlan(ir, names.units); // The tests: listed whatever else, run when the caller gave them a world and the script is whole. let tests: TestReport | null = null; if (!registry.empty) { @@ -401,7 +574,17 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa ? runTests(registry, { triggers, sources, ir, strings: collector.strings, locate: where }, options.tests) : { list: listTests(registry).list, results: [], only: [], ms: 0 }; } - return result({ ir, input, triggers, sources, strings: collector.strings, variables, programs, buildTime, hints, tests }); + // A map of our own asked whether it takes the programs: on a copy, since its passes rewrite what they read. + let ownRefusal: string | undefined; + if (options.own && ir.length && diagnostics.length === 0) { + try { + const own = emitOwn({ ir: structuredClone(ir), triggers, strings: collector.strings, input }, options.own.players, 1, { humans: options.own.humans, width: options.own.width, height: options.own.height, cuwp: 64 }); + if (typeof own === "string") ownRefusal = own; + } catch (err) { + ownRefusal = `the build failed: ${err instanceof Error ? err.message : String(err)}`; + } + } + return result({ ir, input, triggers, sources, strings: collector.strings, sounds: [...collector.sounds.values()], variables, programs, buildTime, hints, tests, ...(registry.empty ? {} : { modules: [...linked] }), ...(ownRefusal !== undefined ? { ownRefusal } : {}) }); } /** diff --git a/compiler/cover.ts b/compiler/cover.ts new file mode 100644 index 0000000..75f1519 --- /dev/null +++ b/compiler/cover.ts @@ -0,0 +1,264 @@ +/** + * Covering a picture with shapes, when the script is built (TrigScript 4, B8): the brushes whose union is a picture's + * ink, each a shape that lies on ink only. `fog.picture` and a video's frames for `fog` are drawn this way — the shapes + * are the game's sight ranges — and a library of a renderer of its own can use it too (`cover()` in the language). + * + * Greedy, largest gain first: of every shape that fits on ink anywhere, the one that covers the most ink not yet + * covered, again and again. A gain only ever falls as more is covered, so a candidate taken off the heap whose fresh + * gain still beats the next one's stale gain is the best there is ("lazy" greedy); else it goes back with its fresh + * gain. Ink no shape fits on (a line thinner than the smallest) gets the smallest shape centred on it anyway, which + * covers a tile or two beside it: drawn thicker rather than not at all. + * + * Shapes that nest — each inside the next, the game's sight ranges do — need one candidate a tile, the largest that + * fits there: a smaller one centred on the same tile covers a part of what the larger does, so it never gains more. + * 128 × 96 frames of Bad Apple take a few milliseconds each; the encoder of S4's probe, which tried every size on + * every tile and tested each tile of each, took 130. + */ + +/** One shape of a cover: which of the shapes (0 is the first), centred on tile (x, y) of the picture. */ +export interface CoverBrush { x: number; y: number; shape: number } + +/** Shapes made ready to cover with: each as its tiles around the centre and as one run of tiles a row. */ +export interface PreparedShapes { + readonly count: number; + /** Shape i's tiles as (dx, dy) pairs from its centre. */ + readonly cells: readonly Int16Array[]; + /** Shape i's rows as (dy, from dx, to dx) triples, the run of tiles it covers on that row. */ + readonly runs: readonly Int16Array[]; + /** Each shape is inside the next, centred alike. */ + readonly nested: boolean; + /** + * Of nested shapes: for shape i, the places (dx, dy) of a larger one (shape `inside[i][0]`, then the next) that hold + * all of shape i centred on (0, 0) — where shape i is never worth more than that larger one would be. + */ + readonly inside: readonly (readonly [number, Int16Array])[][]; +} + +const prepared = new WeakMap(); + +/** + * Shapes as rows of text: `#` (or `@`) a tile of the shape, `@` its centre, anything else not. A row of a shape must + * be one run of tiles — the game's sight ranges are — which is what makes the test of a fit a row at a time. + */ +export function prepareShapes(shapes: readonly (readonly string[])[]): PreparedShapes { + const hit = prepared.get(shapes); + if (hit) return hit; + const cells: Int16Array[] = [], runs: Int16Array[] = []; + shapes.forEach((rows, i) => { + const cy = rows.findIndex((r) => r.includes("@")); + if (cy < 0) throw new Error(`Shape ${i + 1} has no centre: mark it with @.`); + const cx = rows[cy].indexOf("@"); + const c: number[] = [], r: number[] = []; + rows.forEach((row, y) => { + let from = -1, to = -1; + for (let x = 0; x < row.length; x++) { + if (row[x] !== "#" && row[x] !== "@") continue; + if (from >= 0 && to !== x - 1) throw new Error(`Shape ${i + 1}, row ${y + 1}: a row of a shape is one run of tiles.`); + if (from < 0) from = x; + to = x; + c.push(x - cx, y - cy); + } + if (from >= 0) r.push(y - cy, from - cx, to - cx); + }); + cells.push(Int16Array.from(c)); + runs.push(Int16Array.from(r)); + }); + const has = (i: number, dx: number, dy: number) => { const c = cells[i]; for (let k = 0; k < c.length; k += 2) if (c[k] === dx && c[k + 1] === dy) return true; return false; }; + let nested = true; + for (let i = 0; i + 1 < cells.length && nested; i++) for (let k = 0; k < cells[i].length; k += 2) if (!has(i + 1, cells[i][k], cells[i][k + 1])) { nested = false; break; } + // Shape i inside shape j (one or two sizes up) moved by (dx, dy): what lets a candidate a larger one holds be left out. + const inside: [number, Int16Array][][] = cells.map(() => []); + if (nested) { + for (let i = 0; i < cells.length; i++) for (let j = i + 1; j <= i + 2 && j < cells.length; j++) { + const places: number[] = []; + for (let dy = -3; dy <= 3; dy++) for (let dx = -3; dx <= 3; dx++) { + if (dx === 0 && dy === 0) continue; + let all = true; + for (let k = 0; k < cells[i].length && all; k += 2) all = has(j, cells[i][k] - dx, cells[i][k + 1] - dy); + if (all) places.push(dx, dy); + } + if (places.length) inside[i].push([j, Int16Array.from(places)]); + } + } + const out: PreparedShapes = Object.freeze({ count: shapes.length, cells, runs, nested, inside }); + prepared.set(shapes, out); + return out; +} + +/** + * The brushes that cover a picture's ink: `ink` is width × height, row by row, 1 for ink. Every brush lies on ink + * inside the picture, except those of the last pass, on ink too thin for any shape (which stay inside it where the + * picture is three tiles across and four down). In order of shape, the first first. + */ +export function cover(ink: Uint8Array, width: number, height: number, shapes: PreparedShapes): CoverBrush[] { + const n = width * height; + // Dark tiles before each place in its row: a run is all ink when the count does not change across it. + const dark = new Int32Array(n + height); + for (let y = 0; y < height; y++) { + const at = y * (width + 1); + for (let x = 0; x < width; x++) dark[at + x + 1] = dark[at + x] + (ink[y * width + x] ? 0 : 1); + } + const fits = (s: number, cx: number, cy: number): boolean => { + const r = shapes.runs[s]; + for (let k = 0; k < r.length; k += 3) { + const y = cy + r[k], x0 = cx + r[k + 1], x1 = cx + r[k + 2]; + if (y < 0 || y >= height || x0 < 0 || x1 >= width) return false; + const at = y * (width + 1); + if (dark[at + x1 + 1] !== dark[at + x0]) return false; + } + return true; + }; + const covered = new Uint8Array(n); + // Covered tiles before each place in its row, the same way, brought up to date a row at a time as brushes are chosen. + const done = new Int32Array(n + height); + const recount = (y: number) => { + const at = y * (width + 1); + for (let x = 0; x < width; x++) done[at + x + 1] = done[at + x] + covered[y * width + x]; + }; + const gain = (s: number, cx: number, cy: number): number => { + const r = shapes.runs[s]; + let g = 0; + for (let k = 0; k < r.length; k += 3) { + const at = (cy + r[k]) * (width + 1), x0 = cx + r[k + 1], x1 = cx + r[k + 2]; + g += x1 - x0 + 1 - (done[at + x1 + 1] - done[at + x0]); + } + return g; + }; + let left = 0; + for (let i = 0; i < n; i++) left += ink[i] ? 1 : 0; + const chosen: CoverBrush[] = []; + if (left) { + // A binary max-heap of (gain, candidate); a candidate is (shape * height + y) * width + x. + const room = shapes.nested ? n : n * shapes.count; + const gains = new Int32Array(room), keys = new Int32Array(room); + let size = 0; + const swap = (a: number, b: number) => { const g = gains[a], k = keys[a]; gains[a] = gains[b]; keys[a] = keys[b]; gains[b] = g; keys[b] = k; }; + const up = (i: number) => { while (i > 0) { const p = (i - 1) >> 1; if (gains[p] >= gains[i]) break; swap(p, i); i = p; } }; + const down = (i: number) => { for (;;) { const l = 2 * i + 1, r = l + 1; let m = i; if (l < size && gains[l] > gains[m]) m = l; if (r < size && gains[r] > gains[m]) m = r; if (m === i) break; swap(m, i); i = m; } }; + const push = (g: number, key: number) => { gains[size] = g; keys[size] = key; up(size++); }; + if (shapes.nested) { + // The largest that fits on each tile (a shape fits only where the one inside it does), -1 for none … + const largest = new Int8Array(n).fill(-1); + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + if (!ink[y * width + x]) continue; + // (Searched by halves: if a shape fits, every one inside it does.) + let lo = -1, hi = shapes.count - 1; + while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (fits(mid, x, y)) lo = mid; else hi = mid - 1; } + largest[y * width + x] = lo; + } + // … each a candidate unless a larger shape that fits beside it holds it all: then it never gains more than that one. + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + const s = largest[y * width + x]; + if (s < 0) continue; + let held = false; + for (const [j, places] of shapes.inside[s]) { + for (let k = 0; k < places.length && !held; k += 2) { + const X = x + places[k], Y = y + places[k + 1]; + held = X >= 0 && X < width && Y >= 0 && Y < height && largest[Y * width + X] >= j; + } + if (held) break; + } + if (!held) push(shapes.cells[s].length >> 1, (s * height + y) * width + x); + } + } else { + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + if (!ink[y * width + x]) continue; + for (let s = 0; s < shapes.count; s++) if (fits(s, x, y)) push(shapes.cells[s].length >> 1, (s * height + y) * width + x); + } + } + while (size && left) { + const key = keys[0], x = key % width, y = Math.floor(key / width) % height, s = Math.floor(key / (width * height)); + const g = gain(s, x, y); + size--; swap(0, size); down(0); + if (g === 0) continue; + if (size && g < gains[0]) { push(g, key); continue; } + chosen.push({ x, y, shape: s }); + const c = shapes.cells[s]; + for (let k = 0; k < c.length; k += 2) { const i = (y + c[k + 1]) * width + x + c[k]; if (!covered[i]) { covered[i] = 1; left--; } } + const r = shapes.runs[s]; + for (let k = 0; k < r.length; k += 3) recount(y + r[k]); + } + } + // Ink thinner than any shape: the smallest over each tile still uncovered, lighting a tile or two beside it — placed, + // of every place it covers that tile from, inside the picture where it can be and on the fewest tiles not ink. + if (left) { + const c = shapes.cells[0]; + for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) { + if (!ink[y * width + x] || covered[y * width + x]) continue; + let best = -1, bx = x, by = y; + for (let k = 0; k < c.length; k += 2) { + const cx = x - c[k], cy = y - c[k + 1]; + let cost = 0; + for (let j = 0; j < c.length; j += 2) { + const X = cx + c[j], Y = cy + c[j + 1]; + cost += X < 0 || X >= width || Y < 0 || Y >= height ? 1000 : ink[Y * width + X] ? 0 : 1; + } + if (best < 0 || cost < best) { best = cost; bx = cx; by = cy; } + } + chosen.push({ x: bx, y: by, shape: 0 }); + for (let k = 0; k < c.length; k += 2) { + const X = bx + c[k], Y = by + c[k + 1]; + if (X >= 0 && X < width && Y >= 0 && Y < height) covered[Y * width + X] = 1; + } + } + } + return chosen.sort((a, b) => a.shape - b.shape); +} + +/** What brushes cover inside a width × height picture: 1 a tile covered. For checks. */ +export function covers(brushes: readonly CoverBrush[], width: number, height: number, shapes: PreparedShapes): Uint8Array { + const out = new Uint8Array(width * height); + for (const b of brushes) { + const c = shapes.cells[b.shape]; + for (let k = 0; k < c.length; k += 2) { + const X = b.x + c[k], Y = b.y + c[k + 1]; + if (X >= 0 && X < width && Y >= 0 && Y < height) out[Y * width + X] = 1; + } + } + return out; +} + +/** What `cover()` gives a script: a brush as the shape's place in the list given and the tile it is centred on. */ +export interface ScriptBrush { readonly x: number; readonly y: number; readonly shape: number } + +const byKey = new Map(); +const ofVideos = new WeakMap>(); + +/** Rows of text (`#` ink) as width × height bytes, the rows' width the longest. */ +export function inkOf(rows: readonly string[]): { ink: Uint8Array; width: number; height: number } { + const height = rows.length, width = Math.max(0, ...rows.map((r) => r.length)); + const ink = new Uint8Array(width * height); + rows.forEach((r, y) => { for (let x = 0; x < r.length; x++) if (r[x] === "#") ink[y * width + x] = 1; }); + return { ink, width, height }; +} + +/** + * `cover(picture, shapes)` of the language: a picture's brushes, or a video's frame by frame. A video's are worked + * out once for as long as the script keeps the same file and shapes (a build reads them again on every compile); for + * Bad Apple that is fourteen seconds the first time. + */ +export function coverValue(picture: unknown, shapesArg: unknown, video: { isVideo(v: unknown): v is { length: number; width: number; height: number; tiles(i: number): Uint8Array } }): readonly ScriptBrush[] | readonly (readonly ScriptBrush[])[] { + if (!Array.isArray(shapesArg) || shapesArg.length === 0 || !shapesArg.every((s) => Array.isArray(s) && s.every((r) => typeof r === "string"))) { + throw new Error("cover(picture, shapes): the shapes are a list of shapes, each rows of text with # for its tiles and @ for its centre."); + } + const shapes = shapesArg as readonly (readonly string[])[]; + // Keyed by the text of the shapes: a library is run again on every compile, so its list is a new one each time. + const key = shapes.map((s) => s.join("\n")).join("\n\n"); + let ready = byKey.get(key); + if (!ready) { ready = prepareShapes(shapes.map((s) => [...s])); byKey.set(key, ready); } + const brushes = (bs: CoverBrush[]): readonly ScriptBrush[] => Object.freeze(bs.map((b) => ({ x: b.x, y: b.y, shape: b.shape }))); + if (video.isVideo(picture)) { + let byShapes = ofVideos.get(picture); + if (!byShapes) { byShapes = new Map(); ofVideos.set(picture, byShapes); } + const hit = byShapes.get(key); + if (hit) return hit; + const frames: (readonly ScriptBrush[])[] = []; + for (let i = 0; i < picture.length; i++) frames.push(brushes(cover(picture.tiles(i), picture.width, picture.height, ready))); + const out = Object.freeze(frames); + byShapes.set(key, out); + return out; + } + if (!Array.isArray(picture) || !picture.every((r) => typeof r === "string")) throw new Error("cover(picture, shapes): the picture is rows of text (# its ink), or a video a script imported."); + const { ink, width, height } = inkOf(picture as string[]); + return brushes(cover(ink, width, height, ready)); +} diff --git a/compiler/debugModel.ts b/compiler/debugModel.ts new file mode 100644 index 0000000..1f13954 --- /dev/null +++ b/compiler/debugModel.ts @@ -0,0 +1,351 @@ +/** + * Walking a debugged run (TrigScript 4, A5): a cursor over a `DebugRecording`, and the script's memory as it was at + * the cursor. Nothing here touches the game or the page: the record holds everything, so a step back is as quick as + * a step forward (the writes between the two put back or done again). + * + * Where the cursor stands is a frame of the record and a step of it: `{ k, s }`, with `s` from the frame's first step + * to its end (`frameSteps[k]` … `frameSteps[k + 1]`). Standing at step `s` is standing before it, its line about to + * run, the memory as every step before it left it; standing at the end of frame k is the game as the frame left it, + * the same memory as before frame k + 1's first step. + */ +import type { DebugRecording } from "./gameDebug"; +import { listCellAt, programVariables, type GameArray, type GameVariable, type VariableMap } from "./gameVariables"; + +export interface DebugPlace { file: string; line: number } +export interface DebugAt { k: number; s: number } + +/** Why a continue stopped: a breakpoint on a line, a watched variable that changed, or the run's end (or start). */ +export type DebugStop = { kind: "breakpoint"; place: DebugPlace } | { kind: "watch"; name: string; before: string; after: string } | { kind: "end" } | { kind: "start" }; + +/** What the variables view shows of one program: its name, for whom, and each variable's value as the source has it. */ +export interface DebugProgram { label: string; values: { name: string; value: unknown }[] } + +const key = (p: DebugPlace) => `${p.file}:${p.line}`; +const UNITS = 0x59cca8, UNIT_BYTES = 336; + +/** A unit a variable holds, as the debugger says it (the unit table is not in the record). */ +export interface DebugUnitRef { unitSlot: number } + +export class DebugCursor { + readonly rec: DebugRecording; + private readonly map: VariableMap | null; + /** The script's memory at the cursor (`rec.memory.at` on). */ + readonly memory: Uint8Array; + private readonly view: DataView; + private at: DebugAt = { k: 0, s: 0 }; + /** Writes done: everything before this index of `rec.writes` (by write). */ + private done = 0; + /** The step's line, or null for eudplib's own and the rest. */ + readonly placeOf: (s: number) => DebugPlace | null; + + constructor(rec: DebugRecording, map: VariableMap | null) { + this.rec = rec; + this.map = map; + this.memory = rec.memory.bytes.slice(); + this.view = new DataView(this.memory.buffer); + this.placeOf = (s) => { const l = rec.stepLine[s]; return l >= 0 ? rec.lines[l] ?? null : null; }; + if (rec.frames) this.moveTo({ k: 0, s: rec.frameSteps[0] }); + } + + get where(): DebugAt { return { ...this.at }; } + get frames(): number { return this.rec.frames; } + /** The game frame of record frame k. */ + frame(k = this.at.k): number { return this.rec.first + k; } + frameStart(k: number): number { return this.rec.frameSteps[k]; } + frameEnd(k: number): number { return this.rec.frameSteps[k + 1]; } + atEnd(): boolean { return this.at.s >= this.frameEnd(this.at.k); } + /** The memory is what the record says only up to where it was cut. */ + get known(): boolean { return !this.rec.cut || this.frame() < this.rec.cut.frame; } + + /** The line about to run (null at a frame's end, or for a step that is no line of the script's). */ + line(): DebugPlace | null { return this.atEnd() ? null : this.placeOf(this.at.s); } + /** The test's line that asked for the frame the cursor is in. */ + testLine(k = this.at.k): DebugPlace | null { + const f = this.rec.testFileOf[k], line = this.rec.testLine[k]; + return f >= 0 && line > 0 ? { file: this.rec.testFile[f], line } : null; + } + + moveTo(to: DebugAt): void { + const k = Math.max(0, Math.min(to.k, this.rec.frames - 1)); + const s = Math.max(this.frameStart(k), Math.min(to.s, this.frameEnd(k))); + const target = this.rec.stepWrites[s]; + const w = this.rec.writes, base = this.rec.memory.at, v = this.view; + while (this.done < target) { const i = 3 * this.done++; v.setUint32(w[i] - base, w[i + 2], true); } + while (this.done > target) { const i = 3 * --this.done; v.setUint32(w[i] - base, w[i + 1], true); } + this.at = { k, s }; + } + + /** A step the person stops at: a line of the script's, or every step with `all`. */ + private visible(s: number, all: boolean): boolean { return all || this.rec.stepLine[s] >= 0; } + + /** The next line (or the frame's end, then the next frame's first line). */ + stepForward(all = false): void { + const { k, s } = this.at; + for (let i = s + 1; i < this.frameEnd(k); i++) if (this.visible(i, all)) { this.moveTo({ k, s: i }); return; } + if (s < this.frameEnd(k)) { this.moveTo({ k, s: this.frameEnd(k) }); return; } + if (k + 1 >= this.rec.frames) return; + for (let i = this.frameStart(k + 1); i < this.frameEnd(k + 1); i++) if (this.visible(i, all)) { this.moveTo({ k: k + 1, s: i }); return; } + this.moveTo({ k: k + 1, s: this.frameEnd(k + 1) }); + } + + /** The line before (or the end of the frame before). */ + stepBack(all = false): void { + const { k, s } = this.at; + for (let i = s - 1; i >= this.frameStart(k); i--) if (this.visible(i, all)) { this.moveTo({ k, s: i }); return; } + if (k > 0) this.moveTo({ k: k - 1, s: this.frameEnd(k - 1) }); + else this.moveTo({ k: 0, s: this.frameStart(0) }); + } + + /** To the end of this frame; from a frame's end, to the end of the next. */ + nextFrame(): void { + const { k, s } = this.at; + if (s < this.frameEnd(k)) this.moveTo({ k, s: this.frameEnd(k) }); + else if (k + 1 < this.rec.frames) this.moveTo({ k: k + 1, s: this.frameEnd(k + 1) }); + } + + /** To the end of the frame before (the start of this one). */ + prevFrame(): void { + const { k } = this.at; + if (k > 0) this.moveTo({ k: k - 1, s: this.frameEnd(k - 1) }); + else this.moveTo({ k: 0, s: this.frameStart(0) }); + } + + /** Record frame k's end. */ + toFrame(k: number): void { this.moveTo({ k, s: this.frameEnd(Math.max(0, Math.min(k, this.rec.frames - 1))) }); } + + /** The cell addresses of every variable named in `watch` ("waves.wave", or "wave" in any program), at the cursor. */ + private watched(watch: readonly string[]): Map { + const out = new Map(); + if (!this.map) return out; + for (const p of this.map.programs) for (const name of watch) { + const [prog, v] = name.includes(".") ? name.split(".", 2) : [null, name]; + if (prog !== null && prog !== (p.name ?? String(p.index))) continue; + for (const [addr] of this.cellsOf(p, v)) out.set(addr, name); + } + return out; + } + + /** A variable's cells in a program: its own, an array's (a growing one's block as the cursor finds it). */ + private cellsOf(p: VariableMap["programs"][number], name: string): Map { + const out = new Map(); + for (const v of p.vars) if (top(v.id) === name) for (const at of Object.values(v.cells)) if (at != null) for (let r = 0; r < (v.rowed ? 12 : 1); r++) out.set(at + (v.stride ?? 4) * r, true); + for (const a of p.arrays) if (top(a.id) === name) for (const [addr] of this.arrayCells(a)) out.set(addr, true); + return out; + } + + private arrayCells(a: GameArray): Map { + const out = new Map(); + const rows = a.rowed ? 12 : 1, stride = a.stride ?? 4; + if (!a.dynamic && a.at != null) for (let i = 0; i < Math.min(a.length * rows, 4096); i++) out.set(a.at + stride * i, true); + if (a.dynamic) { + for (const c of [a.ptr, a.len]) if (c != null) for (let r = 0; r < rows; r++) out.set(c + stride * r, true); + // (The block the handle points at now, as far as its length says.) + for (let r = 0; r < rows; r++) { + const ptr = a.ptr != null ? this.read32(a.ptr + stride * r) : 0, len = a.len != null ? this.read32(a.len + stride * r) : 0; + if (ptr) for (let i = 0; i < Math.min(len, 4096); i++) { const at = this.map ? listCellAt(this.map, ptr, i) : null; if (at !== null) out.set(at, true); } + } + } + return out; + } + + private read32(addr: number): number { + const i = addr - this.rec.memory.at; + return i >= 0 && i + 4 <= this.memory.length ? this.view.getUint32(i, true) : 0; + } + + /** + * On to the next breakpoint (a line of `breakpoints` about to run, or a test's line that asks for a frame, at that + * frame's end) or the next change of a watched variable (stopped just after the step that made it), else the end. + */ + continueForward(breakpoints: ReadonlySet, watch: readonly string[] = []): DebugStop { + return this.run(1, breakpoints, watch); + } + /** The same, backwards: the last stop before the cursor, else the start. */ + continueBack(breakpoints: ReadonlySet, watch: readonly string[] = []): DebugStop { + return this.run(-1, breakpoints, watch); + } + + private run(dir: 1 | -1, breakpoints: ReadonlySet, watch: readonly string[]): DebugStop { + const rec = this.rec; + const cells = this.watched(watch); + const w = rec.writes; + const touches = (s: number): string | null => { + for (let i = rec.stepWrites[s]; i < rec.stepWrites[s + 1]; i++) { const name = cells.get(w[3 * i]); if (name) return name; } + return null; + }; + let { k, s } = this.at; + const from = { k, s }; + // Positions in order: each step of a frame, then its end. A position is checked as it is reached, never where + // the cursor stood; `passed` is the step gone over on the way to it. + const next = (): number | null => { + if (dir > 0) { + if (s < this.frameEnd(k)) { s++; return s - 1; } + if (k + 1 >= rec.frames) return Number.NaN; + k++; s = this.frameStart(k); return null; + } + if (s > this.frameStart(k)) { s--; return s; } + if (k === 0) return Number.NaN; + k--; s = this.frameEnd(k); return null; + }; + for (;;) { + const passed = next(); + if (Number.isNaN(passed)) break; + // A watched variable a passed step wrote, and whose value it changed: stop just after that step. + // (Backwards, the change the cursor stands just after is where it is already: the one before it is next.) + const here = dir < 0 && passed !== null && k === from.k && passed + 1 === from.s; + const name = passed !== null && cells.size && !here ? touches(passed) : null; + if (name && passed !== null) { + this.moveTo({ k, s: passed }); + const before = this.watchText(name); + this.moveTo({ k, s: passed + 1 }); + const after = this.watchText(name); + if (before !== after) return { kind: "watch", name, before, after }; + } + if (s < this.frameEnd(k)) { + const place = this.placeOf(s); + if (place && breakpoints.has(key(place))) { this.moveTo({ k, s }); return { kind: "breakpoint", place }; } + } else { + const t = this.testLine(k); + if (t && breakpoints.has(key(t))) { this.moveTo({ k, s }); return { kind: "breakpoint", place: t }; } + } + } + if (dir > 0) { this.moveTo({ k: rec.frames - 1, s: this.frameEnd(rec.frames - 1) }); return { kind: "end" }; } + this.moveTo({ k: 0, s: this.frameStart(0) }); + return { kind: "start" }; + } + + /** A watched variable's value as text, at the cursor ("?" where it cannot be read). */ + watchText(name: string): string { + const [prog, v] = name.includes(".") ? name.split(".", 2) : [null, name]; + for (const p of this.programs()) { + if (prog !== null && !p.label.startsWith(prog)) continue; + const found = p.values.find((x) => x.name === v); + if (found) return show(found.value); + } + return "?"; + } + + /** The reader `programVariables` reads the record's memory through. */ + private reader() { + const at = this.rec.memory.at, mem = this.memory, view = this.view; + const inside = (i: number, n: number) => i >= at && i + n <= at + mem.length; + const m = { + getUint32: (i: number) => (inside(i, 4) ? view.getUint32(i - at, true) : 0), + getInt32: (i: number) => (inside(i, 4) ? view.getInt32(i - at, true) : 0), + getUint16: (i: number) => (inside(i, 2) ? view.getUint16(i - at, true) : 0), + getInt16: (i: number) => (inside(i, 2) ? view.getInt16(i - at, true) : 0), + getUint8: (i: number) => (inside(i, 1) ? mem[i - at] : 0), + getInt8: (i: number) => (inside(i, 1) ? (mem[i - at] << 24) >> 24 : 0), + } as unknown as DataView; + const u8 = new Proxy(mem, { + get(t, p) { + if (typeof p === "string" && /^\d+$/.test(p)) { const i = Number(p); return i >= at && i < at + t.length ? t[i - at] : 0; } + if (p === "length") return at + t.length; + if (p === "subarray") return (a: number, b: number) => t.subarray(Math.max(0, a - at), Math.max(0, b - at)); + const v = Reflect.get(t, p, t); + return typeof v === "function" ? v.bind(t) : v; + }, + }) as Uint8Array; + return { m, u8, unitAt: (ptr: number) => (ptr ? ({ unitSlot: Math.round((ptr - UNITS) / UNIT_BYTES) } satisfies DebugUnitRef as never) : null) }; + } + + /** Every program's variables at the cursor, as the source names them. */ + programs(): DebugProgram[] { + const map = this.map; + if (!map) return []; + const out: DebugProgram[] = []; + const reader = this.reader(); + for (const p of map.programs) for (const who of p.perPlayer ? p.owners : [undefined]) { + const label = `${p.name ?? `program ${p.index}`}${who !== undefined ? ` (P${who + 1})` : ""}`; + let vars: Record; + try { vars = programVariables(reader, map, p.name ?? p.index, who); } catch { continue; } + const names = [...new Set([...p.vars.filter((v) => !isTemp(v)).map((v) => v.id), ...p.arrays.map((a) => a.id)].map(top))]; + const values: { name: string; value: unknown }[] = []; + for (const name of names) { let value: unknown; try { value = plain(vars[name]); } catch { value = "?"; } values.push({ name, value }); } + out.push({ label, values }); + } + return out; + } + + /** + * What each line run so far in the cursor's frame wrote: the variables, by line, with their values at the cursor; + * and how many steps of the frame each line took. For the values beside the lines (`label`). + */ + frameLines(): { place: DebugPlace; steps: number; names: string[] }[] { + const map = this.map; + const { k, s } = this.at; + const byLine = new Map }>(); + const owners = this.cellOwners(); + const w = this.rec.writes; + for (let i = this.frameStart(k); i < s; i++) { + const place = this.placeOf(i); + if (!place) continue; + let entry = byLine.get(key(place)); + if (!entry) { entry = { place, steps: 0, names: new Set() }; byLine.set(key(place), entry); } + entry.steps++; + if (map) for (let j = this.rec.stepWrites[i]; j < this.rec.stepWrites[i + 1]; j++) { const n = owners.get(w[3 * j]); if (n) entry.names.add(n); } + } + return [...byLine.values()].map((e) => ({ place: e.place, steps: e.steps, names: [...e.names] })); + } + + /** Which variable each cell is (by its name in the source), arrays' cells and growing arrays' blocks included. */ + private cellOwners(): Map { + const out = new Map(); + for (const p of this.map?.programs ?? []) { + for (const v of p.vars) if (!isTemp(v)) for (const at of Object.values(v.cells)) if (at != null) for (let r = 0; r < (v.rowed ? 12 : 1); r++) out.set(at + 4 * r, top(v.id)); + for (const a of p.arrays) for (const [addr] of this.arrayCells(a)) out.set(addr, top(a.id)); + } + return out; + } + + /** Each frame's work: the conditions and actions the script's lines ran (eudplib's own not counted). */ + frameCosts(): Int32Array { + const rec = this.rec, out = new Int32Array(rec.frames); + for (let k = 0; k < rec.frames; k++) for (let s = rec.frameSteps[k]; s < rec.frameSteps[k + 1]; s++) if (rec.stepLine[s] >= 0) out[k] += rec.stepRecords[s]; + return out; + } + + /** The record frames a breakpoint line ran in. */ + hits(place: DebugPlace): number[] { + const rec = this.rec, out: number[] = []; + const id = rec.lines.findIndex((l) => l.file === place.file && l.line === place.line); + for (let k = 0; k < rec.frames; k++) { + let hit = false; + if (id >= 0) for (let s = rec.frameSteps[k]; s < rec.frameSteps[k + 1] && !hit; s++) hit = rec.stepLine[s] === id; + const t = this.testLine(k); + if (!hit && t && t.file === place.file && t.line === place.line) hit = true; + if (hit) out.push(k); + } + return out; + } + + /** What was shown on the text lines up to the cursor's frame: the last `n`. */ + shown(n = 13): { frame: number; text: string }[] { + const f = this.frame(); + return this.rec.shown.filter((x) => x.frame <= f).slice(-n); + } +} + +function isTemp(v: GameVariable): boolean { return /^\$|^_/.test(v.id); } +/** The name a variable is read by: a record's field ("pos.x#5") is its record's ("pos"). */ +const top = (id: string) => id.replace(/#\d+$/, "").split(".")[0]; + +/** A value as plain data (a unit as its slot), for the view and for comparing. */ +function plain(v: unknown): unknown { + if (v === null || v === undefined || typeof v !== "object") return v; + if ("unitSlot" in (v as object)) return v; + return JSON.parse(JSON.stringify(v)); +} + +/** A value as one line: 3, true, "tick 3", [1, 2, 3], { x: 10, y: 2 }, unit 12. */ +export function show(v: unknown, most = 80): string { + const s = ((): string => { + if (v === null || v === undefined) return String(v); + if (typeof v === "string") return JSON.stringify(v); + if (typeof v !== "object") return String(v); + if ("unitSlot" in (v as object)) return `unit ${(v as DebugUnitRef).unitSlot}`; + if (Array.isArray(v)) return `[${v.map((x) => show(x, most)).join(", ")}]`; + return `{ ${Object.entries(v as object).map(([k, x]) => `${k}: ${show(x, most)}`).join(", ")} }`; + })(); + return s.length > most ? `${s.slice(0, most - 1)}…` : s; +} diff --git a/compiler/declarations.ts b/compiler/declarations.ts index 361019b..e9cf7e4 100644 --- a/compiler/declarations.ts +++ b/compiler/declarations.ts @@ -14,12 +14,15 @@ */ import { ACTION_FIELDS, CONDITION_FIELDS } from "./record"; import { READ_ARITY } from "./runtime"; +import { ORDER_OF_VERB } from "./orders"; import { ACTION_IDENTS, argType, CHOICE_TYPES, choiceWords, CONDITION_IDENTS, MODULE_NAME, propertyKey, scriptParams, TRIGGER_OPTION_NAMES } from "./api"; import type { ActionDef, ArgKind, ConditionDef } from "../vendor/triggerDefs"; import { allTables, defaultScriptNames, type NameTable, type ScriptNames } from "./names"; import { PLAYER_COLORS, TABLE_FIELDS, type TableKind } from "./tables"; +import { LOCAL_FIELDS, MEMORY_FIELDS, MEMORY_GROUPS, type MemoryField } from "./memory"; import { PLAYER_SLOTS } from "./lower"; import { KEY_NAMES } from "./input"; +import { TEXT_COLORS } from "./jsx"; export const DECLARATIONS_FILE = "trigscript.d.ts"; @@ -38,6 +41,8 @@ const HEADER = `// ── TrigScript ────────────── function types(kw: string): string { return ` ${kw}type Brand = { readonly __kind?: K }; +/** A number that is none of the named tables' (units.*, orders.* …): what an argument takes where a table's would be wrong. */ +${kw}type RawNumber = number & { readonly __kind?: never }; /** A player or player group (P1 … P12, CurrentPlayer, AllPlayers, players.*, or a raw group number). */ ${kw}type Player = N & Brand<"player">; /** A unit type (units.*, or a raw units.dat id): what a condition or an action names. A unit on the map is a Unit. */ @@ -48,6 +53,10 @@ ${kw}type Weapon = N & Brand<"weapon">; ${kw}type Upgrade = N & Brand<"upgrade">; /** A technology (techs.*, or a raw techdata.dat id), for stats(). */ ${kw}type Tech = N & Brand<"tech">; +/** An order (orders.*, or a raw orders.dat id): what a unit is doing. */ +${kw}type Order = N & Brand<"order">; +/** The orders the Order action gives: orders.Move, orders.Patrol and orders.AttackMove ("move", "patrol", "attack"). */ +${kw}type OrderOfAction = ${Object.values(ORDER_OF_VERB).map((n) => `Order<${n}>`).join(" | ")}; /** A player colour (colors.*), for stats(player).color. */ ${kw}type PlayerColor = N & Brand<"color">; ${kw}type ColorName = __COLOR_NAMES__; @@ -55,6 +64,8 @@ ${kw}type ColorName = __COLOR_NAMES__; ${kw}type Location = N & Brand<"location">; /** A switch (switches.*, or a raw 0-based switch number). */ ${kw}type Switch = N & Brand<"switch">; +/** A sound of the map (sounds.*, the Sound Editor's list): the string of its path, what playWav() takes. A path in the map's archive as text also works. */ +${kw}type Sound = N & Brand<"sound">; /** An AI script (aiScripts.*; a four-character code or StarEdit name as a string also works). */ ${kw}type AiScript = N & Brand<"aiScript">; /** A race (races.*), as race() returns it. */ @@ -73,6 +84,14 @@ ${kw}type u16 = number & Brand<"u16">; * but compare exactly: a number below zero is smaller than any u32. */ ${kw}type u32 = number & Brand<"u32">; +/** + * A number with a fraction: 16.16 fixed point, −32 768 … 32 767.99998 in steps of 1/65 536. A number is one as soon as a + * fraction is stored into it (\`let x = 0.5\`, \`x += speed * 0.1\`, \`Math.cos(a)\`), so the annotation is only needed + * to say so before anything does. + − × ÷ and the comparisons work as on any number, and a whole number beside one is + * turned into one; Math.floor / round / trunc / ceil make a whole number of it, which is what an index, a count or a + * place on the map takes. Math.sqrt, sin, cos, tan, atan, atan2 and hypot give one (angles in radians, as in JavaScript). + */ +${kw}type fixed = number & Brand<"fixed">; /** A function that runs in the game, as returned by game(): call it inside program() or another game function. */ ${kw}type GameFunction unknown> = F & { readonly __game: true }; @@ -124,10 +143,22 @@ ${kw}interface Unit { readonly y: number; /** How many units it has killed, 0 … 255. */ kills: number; - /** The orders.dat id of what the unit is doing (3 is standing guard, 6 moving, 10 attacking). */ - readonly orderId: number; - /** Frames until the unit can attack or cast again, 0 … 255; writing it holds the unit's fire that long. */ + /** What the unit is doing: \`if (u.orderId == orders.Move)\`. orders.PlayerGuard is standing guard, orders.AttackUnit attacking. */ + readonly orderId: Order; + /** Frames until the unit can attack or cast again, 0 … 255; writing it holds the unit's fire that long (the ground weapon's, the air weapon's and the spells' alike). */ cooldown: number; + /** + * Frames left of a unit's or building's making, 0 … 65535; 0 once complete. Writing it lengthens or shortens the making. + * A building finished early keeps the hit points it had reached, and a Terran one under a third of its full hit points + * burns down (Remastered, played): heal it after. + */ + buildTime: number; + /** The unit's rank, added to the type's (the one shown on its panel: Private, Corporal …), 0 … 255. */ + rank: number; + /** Frames left of a defensive matrix, 0 … 255: writing one gives the unit a matrix of 250 points for that long; 0 takes it away. */ + matrix: number; + /** How fast the unit is moving now, in 256ths of a pixel a frame: 0 standing still, 1024 a Marine's walk. Read only. */ + readonly speed: number; /** What a mineral field or a geyser still holds. */ resources: number; /** Frames left of each effect, 0 … 255: write one to start, lengthen or end it. Stim, ensnare and the rest tick down about every eighth frame. */ @@ -140,11 +171,21 @@ ${kw}interface Unit { stasis: number; /** Whether the unit cannot be hurt. */ invincible: boolean; - readonly hallucinated: boolean; + /** Whether the unit is a hallucination: made so, it takes double damage and dies after a while, as a Templar's do. */ + hallucinated: boolean; + /** Whether the unit moves at its upgraded speed (a Zergling's metabolic boost, a Vulture's ion thrusters) regardless of the upgrade. */ + speedUpgrade: boolean; readonly cloaked: boolean; readonly burrowed: boolean; - /** True for about a second after something hit the unit. */ + /** + * True for about a second after the game raised its "under attack" alert for the unit: a hit out of the player's view. + * A unit hit on the player's screen does not count — the game keeps no other "recently hit" mark a map may read. + */ readonly underAttack: boolean; + /** True while the unit's order is aimed at another unit (attacking, following, repairing …). */ + readonly hasTarget: boolean; + /** True while the unit rides in a transport or a bunker. */ + readonly inTransport: boolean; /** Send the unit somewhere, as the Order action does, this unit alone. */ order(order: "move" | "patrol" | "attack", target: Location): void; /** Hand the unit to another player. */ @@ -166,7 +207,7 @@ ${kw}interface UnitFilter { /** Inside this location. */ at?: Location; } -/** A key keyPressed() knows. F6 is not among them: the game reports no press of it. */ +/** A key keyPressed() knows. F2 to F6 are not among them: StarCraft: Remastered reports no press of them. */ ${kw}type Key = __KEY_NAMES__; /** What a chatted() pattern's captures are read as: {n} a number, {unit:unit} a unit type, {kind:ore|gas} the place of the word in its list. */ ${kw}type ChatCapture = C extends \`\${infer N}:unit\` ? { readonly [K in N]: UnitType } : C extends \`\${infer N}:\${string}\` ? { readonly [K in N]: number } : { readonly [K in C]: number }; @@ -187,6 +228,7 @@ ${kw}interface ProgramOptions { } const COLOR_NAMES = Object.keys(PLAYER_COLORS).map((w) => JSON.stringify(w)).join(" | "); +const TEXT_COLOR_TABLE = Object.entries(TEXT_COLORS).map(([w, n]) => `readonly ${w}: ${n}`).join("; "); const COLOR_TABLE = Object.entries(PLAYER_COLORS).map(([w, n]) => `readonly ${w}: PlayerColor<${n}>`).join("; "); function choiceTypes(kw: string): string { @@ -252,10 +294,43 @@ ${kw}function frames(n: number): Duration; ${kw}function cycles(n: number): Duration; /** * Pause the program, inside program() only: the statements after it run that much later, and nothing - * else of this program runs meanwhile (other programs and triggers go on). \`while (true) { spawn(); sleep(seconds(15)); }\` + * else of this program runs meanwhile (other programs and triggers go on). \`while (true) { spawn(); await sleep(seconds(15)); }\` * is a wave every fifteen seconds. Unlike wait(), it takes nothing from the triggers: a Wait uses the player's one wait slot. + * In an async function it is awaited; in a program that is not async, \`sleep(…)\` on its own pauses as before. + */ +${kw}function sleep(duration: Duration): Promise; +/** + * Wait for the next frame — the same as sleep(frames(1)), as a frame loop reads: + * \`while (true) { …draw…; await nextFrame(); }\`. Inside program() only. + */ +${kw}function nextFrame(): Promise; +/** + * Wait until a condition holds, inside an async program only: \`await until(() => countUnits(P8, units.ZergZergling) === 0)\`. + * The function is asked once now and then once every frame; the program goes on in the frame it answers true. + * A condition on its own is asked again the same way: \`await until(bring(P1, units.AnyUnit, locations.Exit, ">=", 1))\`. + */ +${kw}function until(test: (() => boolean) | Condition): Promise; +/** + * A task that start() started: \`t.cancel()\` stops it where it is, \`t.running\` is true until it ends or is cancelled, + * and \`await t\` waits for it to end. Kept in a variable, it is a number of the program, 0 for none. + */ +${kw}interface Task extends Promise { + readonly __task: true; + /** Stop the task where it is. Nothing happens when it has ended already. */ + cancel(): void; + /** True until the task ends or is cancelled. */ + readonly running: boolean; +} +/** + * Start a task and go on without waiting for it, inside an async program only: \`start(wave(3))\`, or + * \`start(async () => { … })\`. The arguments are worked out now; the task runs beside the program, from this frame on, + * and keeps running after the program ends. \`void wave(3)\` starts one too, without a Task to keep. + * + * Each start() is a body of its own, and \`max\` of its tasks (16 unless said) can run at once: a start with all of + * them running starts nothing, and the game says so. \`for (…) start(wave(n), { max: 40 })\`. A loop unrolled when the + * script is built has a start() for each of its turns. */ -${kw}function sleep(duration: Duration): void; +${kw}function start(task: Promise | (() => Promise), options?: { max?: number }): Task; /** True on the frame its condition becomes true, false until it becomes false and true again. Inside program(), in an if: \`if (rose(bring(…)))\`. */ ${kw}function rose(condition: Condition | boolean): boolean; /** True the first time its condition holds, never again. Inside program(), in an if. */ @@ -271,6 +346,8 @@ ${kw}function clamp(value: number, low: number, high: number): number; ${kw}function u32(value: number): u32; /** The same 32 bits read as a signed number: i32(4294967295) is -1. Costs nothing in a program; \`x | 0\` of a u32 says the same. */ ${kw}function i32(value: number): number; +/** A whole number as a fixed one, so that what is computed with it keeps the fraction: 3 / 2 is 1, 3 / fixed(2) is 1.5. Costs a shift in a program. */ +${kw}function fixed(value: number): fixed; /** * Reads, inside program() only: a value the game holds, read when the line runs. Use it wherever a * number goes — \`let ore = minerals(P1)\`, \`if (minerals(CurrentPlayer) > price * 2)\`, @@ -342,12 +419,12 @@ ${kw}function unitsAt(location: Location, filter?: Omit): Unit ${kw}function unitsOf(player: Player, filter?: Omit): UnitSet; /** Every unit on the map the filter matches. */ ${kw}function allUnits(filter?: UnitFilter): UnitSet; -/** The first unit the filter matches, or null. */ -${kw}function first(filter?: UnitFilter): Unit | null; +/** The first unit the filter matches, or null; a set of units — \`first(unitsAt(locations.Base, { owner: P1 }))\` — is its filter. */ +${kw}function first(filter?: UnitFilter | UnitSet): Unit | null; /** The unit of a type nearest to the centre of a location, or null; units.AnyUnit for any type. */ ${kw}function nearest(unit: UnitType, location: Location, filter?: Omit): Unit | null; /** One of the units the filter matches, picked by the game, or null. */ -${kw}function randomUnit(filter?: UnitFilter): Unit | null; +${kw}function randomUnit(filter?: UnitFilter | UnitSet): Unit | null; /** * The game's own tables, inside program() only: what a unit type costs, what a weapon does, a player's * upgrades. Read a field as a number, assign to it, += it: \`stats(units.TerranMarine).minerals = 25;\` @@ -359,6 +436,261 @@ ${kw}function stats(weapon: Weapon): WeaponStats; ${kw}function stats(upgrade: Upgrade): UpgradeStats; ${kw}function stats(tech: Tech): TechStats; ${kw}function stats(player: Player): PlayerStats; +/** + * For a library: a location of the map's first 63 that the map does not use, taken when the script is + * built, so the library's users never have to give it one. The same reason gives the same location; the + * reason is what the map's requirements show. \`const spot = reserveLocation("fog: where each brush is made");\` + */ +${kw}function reserveLocation(why: string): Location; +/** + * A video the script imports, when the script is built: \`import badApple from "./bad-apple.video"\`. + * A .video file is made by Import Video… in the Explorer — the frames the editor decoded, a tile a pixel, + * ink or not, a frame for every frame of the game — and kept in the map with the script. + */ +${kw}interface Video { + /** Tiles across. */ + readonly width: number; + /** Tiles down. */ + readonly height: number; + /** How many frames. */ + readonly length: number; + /** How long a frame shows, in milliseconds: 42 is a frame of the game on Fastest. */ + readonly frameMs: number; + /** How long the whole video plays, in seconds. */ + readonly seconds: number; + /** Frame i as rows of text, # ink and . not: what fog.picture takes. When the script is built. */ + frame(i: number): readonly string[]; +} +/** + * An image the script imports, when the script is built: \`import logo from "./logo.image"\`. + * An .image file is made by Import Video… in the Explorer from a picture — its pixels at the size + * chosen, in grey or in colour, with its transparency — and kept in the map with the script. + * Everything here is worked out when the script is built; what reaches the game is what the + * script makes of it: \`fog.picture(logo.scale(64).threshold(140))\`. + */ +${kw}interface Image { + /** Pixels across. */ + readonly width: number; + /** Pixels down. */ + readonly height: number; + /** Whether it was kept in colour; when not, a pixel's red, green and blue are its grey. */ + readonly color: boolean; + /** Whether any pixel is less than opaque. */ + readonly transparent: boolean; + /** Pixel (x, y)'s grey, 0 black to 255 white. (0, 0) is the top left. */ + grey(x: number, y: number): number; + /** Pixel (x, y)'s colour, each 0 to 255. */ + rgb(x: number, y: number): { readonly r: number; readonly g: number; readonly b: number }; + /** How opaque pixel (x, y) is: 0 transparent to 255. A pixel under 128 is never ink. */ + alpha(x: number, y: number): number; + /** The part of the image width × height pixels in size whose top left is (x, y). */ + crop(x: number, y: number, width: number, height: number): Image; + /** The image at another size (up to 1024 each way), each pixel the average of what it covers; height kept in shape when left out. */ + scale(width: number, height?: number): Image; + /** The image mirrored: "horizontal" swaps left and right, "vertical" top and bottom. */ + flip(axis: "horizontal" | "vertical"): Image; + /** The image turned clockwise. */ + rotate(turn: 90 | 180 | 270): Image; + /** Light for dark: each colour 255 minus itself. */ + invert(): Image; + /** + * Rows of text for fog.picture, # the ink: with light ink (the default) a pixel whose grey is + * level or more, with dark ink one under it. \`logo.threshold(100, { ink: "dark" })\` + */ + threshold(level?: number, options?: { readonly ink?: "light" | "dark" }): readonly string[]; + /** Rows of digits, a pixel each from 0 (darkest) to count − 1 (lightest), . where it is transparent: 2 to 10 tones. */ + tones(count: number): readonly string[]; + /** + * Rows of text for fog.picture that show grey: the image cut into cells (cellHeight one more than the width + * when left out, as the fog's brushes are), each lit by one brush or none — larger for lighter, the largest + * running into its neighbours — what one cell misses handed on to the next so an area's share of light is + * its grey. fog.picture draws it a brush a cell at most: 6 × 7 cells over 128 × 96 tiles are about 300. + * { diffuse: false }: each cell by its own grey alone, an even grid whose greys come in steps. + * \`fog.picture(photo.halftone(6))\` + */ + halftone(cellWidth: number, cellHeight?: number, options?: { readonly diffuse?: boolean }): readonly string[]; +} +/** + * Notes when the script is built, imported from a MIDI file (\`import tune from "./tune.notes"\`, made by + * Import Video… from a .mid): each track's notes, in beats from the start. \`music.midi(tune, { Piano: keys })\` + * makes a song of the tracks it names, each played by an instrument. + */ +${kw}interface Notes { + /** The tempo the file starts at, beats a minute. */ + readonly bpm: number; + /** Quarter notes a bar, by the file's time signature (4 for 4/4, 3 for 6/8). */ + readonly beats: number; + /** How often the tempo changes later in the file: a song plays at one tempo, the first. */ + readonly tempoChanges: number; + readonly tracks: readonly NotesTrack[]; + /** The MIDI file it came from. */ + readonly source?: string; +} + +/** A track of imported notes: one channel of one track of the MIDI file. */ +${kw}interface NotesTrack { + /** Its name in the file (unique among the tracks), or "Track 2", "Track 1 channel 3". */ + readonly name: string; + /** 1 … 16. */ + readonly channel: number; + /** The General MIDI instrument the file asks for, 1 (piano) … 128. */ + readonly program: number; + /** Whether it is on channel 10, the drums. */ + readonly drums: boolean; + /** Its notes in the order they start: \`at\` and \`beats\` in beats (quarter notes), \`pitch\` the MIDI number (60 is C4), \`note\` its name, \`velocity\` 1 … 127. */ + readonly notes: readonly { readonly at: number; readonly beats: number; readonly pitch: number; readonly note: string; readonly velocity: number }[]; +} + +/** + * A sound when the script is built: imported (\`import boom from "./boom.sound"\`, made by Import Video… in + * the Explorer from any sound file) or made by tone(), noise() and silence(). Every method makes a new clip. + * A program plays one with playWav(boom); the build puts each clip it plays in the map once. + * In the game a sound starts within 21 ms of its frame; playing it again adds a second copy beside the + * first, and nothing stops one, so a clip's length and loudness are what is heard. 32 play at once, and + * copies add with no limiter: keep peak under 1 where they overlap. A clip of 2 seconds or more that a + * program plays goes in the map as Ogg Vorbis (Remastered plays it). A shorter one, or one a trigger() + * plays (which 1.16.1 plays too, and it plays no Ogg), is a WAV: IMA ADPCM, a quarter of 16-bit, unless + * that would distort it (the jumps of a square or saw wave) or it has two channels: then 16-bit. + */ +${kw}interface Clip { + /** How long it plays. */ + readonly seconds: number; + /** Samples a second. */ + readonly rate: number; + /** 1, or 2 for a clip with a left and a right (pan(), or a mix with one). */ + readonly channels: 1 | 2; + /** The loudest sample, 0 to 1; above 1 is cut off when the map's file is written. */ + readonly peak: number; + /** The part from \`from\` to \`to\` seconds (the end when left out). */ + trim(from: number, to?: number): Clip; + /** Louder or quieter: 0.5 is half as loud, 2 twice. The game plays it as loud as written. */ + gain(factor: number): Clip; + /** Rising from silence over the first \`inSeconds\`, falling to it over the last \`outSeconds\`. */ + fade(inSeconds: number, outSeconds?: number): Clip; + /** Played backwards. */ + reverse(): Clip; + /** Higher or lower by semitones, as a sampler plays a note: 12 is an octave up and half as long. */ + pitch(semitones: number): Clip; + /** This clip, then each of these after it. */ + then(...clips: Clip[]): Clip; + /** \`other\` played over this one, starting \`at\` seconds in (0 when left out). */ + mix(other: Clip, at?: number): Clip; + /** Played \`times\` times, one after another. */ + repeat(times: number): Clip; + /** + * Its loudness through time, a straight line between [seconds, level] points — an instrument's attack, + * decay, sustain and release: \`tone("C4", 0.5).envelope([0, 0], [0.01, 1], [0.1, 0.6], [0.4, 0.6], [0.5, 0])\` + */ + envelope(...points: readonly (readonly [number, number])[]): Clip; + /** + * Where it is heard, −1 left to 1 right: a balance, the far side turned down, so 0 is the clip as it + * was. The clip then has two channels. + */ + pan(position: number): Clip; + /** Its two channels as one. */ + mono(): Clip; + /** + * Never louder than \`ceiling\` (0.84, −1.5 dB, when left out), between the samples too: turned down smoothly over the 5 ms before + * each peak that would go over, and back up over \`release\` seconds (0.1). What a mix goes through last, + * so that the parts can add up without cutting off: \`mixdown(parts).limit()\`. + */ + /** + * The same clip, put in the map as Ogg Vorbis at \`quality\`, −1 … 10 (5, about 160 kbit/s for two channels, when + * left out): 4 is about 130, 3 about 110 — smaller files, for clips many of which play at once. However short it is + * (a clip shorter than two seconds is otherwise a WAV). + */ + ogg(quality?: number): Clip; + limit(options?: { readonly ceiling?: number; readonly release?: number }): Clip; + /** Louder or quieter, so its loudest sample is \`peak\` (0.84, −1.5 dB, when left out). */ + normalize(peak?: number): Clip; + /** In the map as a WAV however long it is, for StarCraft 1.16.1, which plays no Ogg. */ + wav(): Clip; + /** + * A filter. "lowpass" keeps what is below \`cutoff\` hertz, "highpass" what is above, "bandpass" what is + * near it, "notch" all but it — \`cutoff\` a number, or [seconds, hertz] points to sweep it (a synth's + * filter opening: \`saw.filter("lowpass", [[0, 300], [0.1, 4000], [0.6, 800]], { resonance: 0.6 })\`); + * \`resonance\` 0 to 1 rings at the cutoff. "lowshelf" and "highshelf" turn everything below or above + * \`cutoff\` up or down by \`gain\` dB, "peak" what is around it, \`q\` wide (0.707). + */ + filter(type: "lowpass" | "highpass" | "bandpass" | "notch" | "lowshelf" | "highshelf" | "peak", cutoff: number | readonly (readonly [number, number])[], options?: { readonly resonance?: number; readonly gain?: number; readonly q?: number }): Clip; + /** + * Saturation, \`dB\` of push (6 warm, 18 gritty, 30 fuzz): quiet parts as they were, loud ones rounded + * off toward a square — the harmonics a guitar amp or a tape adds. + */ + drive(dB: number): Clip; + /** + * An echo \`seconds\` later (a beat's worth: 60 / bpm), each repeat \`feedback\` as loud (0.35) and a little + * duller (\`tone\`, the hertz kept, 5 000), \`mix\` its level (0.35). \`pingPong\`: left, right, left (two + * channels). The clip gets longer by the repeats. + */ + delay(seconds: number, options?: { readonly feedback?: number; readonly mix?: number; readonly tone?: number; readonly pingPong?: boolean }): Clip; + /** + * A plate reverb: the room around the sound, different left and right (two channels), the clip longer + * by its tail. \`decay\` 0 to 1 (0.6; 0.3 a small room, 0.9 a hall), \`damping\` 0 to 1 (0.4: how fast the + * top dies), \`predelay\` seconds before it starts (0.015), \`mix\` the reverb's level (0.3), \`dry\` the + * clip's (1), \`width\` 0 to 1 (1). + */ + reverb(options?: { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly mix?: number; readonly dry?: number; readonly width?: number }): Clip; + /** A chorus: copies a few ms late and wobbling, left and right apart (two channels). \`speed\` Hz (0.8), \`depth\` seconds (0.003), \`mix\` (0.5). */ + chorus(options?: { readonly speed?: number; readonly depth?: number; readonly mix?: number }): Clip; + /** + * A compressor: above \`threshold\` dB (−18), every \`ratio\` dB louder (4) comes out one, turned down + * over \`attack\` seconds (0.005) and back over \`release\` (0.12), eased over a \`knee\` dB wide (6); + * \`makeup\` dB after (0). Evens a part out, or glues a mix. + */ + compress(options?: { readonly threshold?: number; readonly ratio?: number; readonly attack?: number; readonly release?: number; readonly knee?: number; readonly makeup?: number }): Clip; +} +/** + * When the script is built: a tone of \`pitch\` — hertz, or a note's name: "A4" (440), "C#5", "Eb3" — for + * \`seconds\`, or [seconds, pitch] points to glide (a kick's drop: [[0, 150], [0.08, 45]]). wave: "sine" + * (the default), "square", "triangle" or "saw"; volume 0 to 1 (0.5). \`voices\` copies (1 to 16) spread + * \`detune\` cents apart from lowest to highest (20), and \`width\` 0 to 1 across left and right: a + * supersaw is \`tone("A3", 1, { wave: "saw", voices: 7, detune: 30, width: 0.8 })\`. + * const beep = tone("A5", 0.1, { wave: "square", volume: 0.3 }).fade(0, 0.05); + */ +${kw}function tone(pitch: number | string | readonly (readonly [number, number | string])[], seconds: number, options?: { readonly wave?: "sine" | "square" | "triangle" | "saw"; readonly volume?: number; readonly rate?: number; readonly voices?: number; readonly detune?: number; readonly width?: number }): Clip; +/** + * When the script is built: two-operator FM — a sine whose phase another, \`ratio\` times its pitch, + * pushes by \`index\` (2; or [seconds, index] points, bright at the start and mellow after). ratio 1 a + * reed or an electric piano, 3.5 or 7 a bell, 0.5 a bass; \`feedback\` 0 to 2 gives the push an edge. + * const bell = fm("E5", 2, { ratio: 3.5, index: [[0, 6], [2, 0.3]], volume: 0.3 }).fade(0, 1.5); + */ +${kw}function fm(pitch: number | string | readonly (readonly [number, number | string])[], seconds: number, options?: { readonly ratio?: number; readonly index?: number | readonly (readonly [number, number])[]; readonly feedback?: number; readonly volume?: number; readonly rate?: number }): Clip; +/** + * When the script is built: a plucked string (Karplus–Strong) — a guitar, a harp, a koto. \`brightness\` + * 0 to 1 (0.5), \`decay\` seconds to fade away (2). + */ +${kw}function pluck(pitch: number | string, seconds: number, options?: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly rate?: number }): Clip; +/** When the script is built: noise for \`seconds\` (the same every build) — with fade(), a hit or a burst. */ +${kw}function noise(seconds: number, options?: { readonly volume?: number; readonly rate?: number }): Clip; +/** When the script is built: nothing for \`seconds\`, to put a gap between clips: \`a.then(silence(0.2), b)\`. */ +${kw}function silence(seconds: number, options?: { readonly rate?: number }): Clip; +/** + * When the script is built: clips laid over one another, each from its own time in seconds, as one clip — + * a song mixed before the game, every note exactly in time. \`mixdown([{ clip: kick, at: 0 }, { clip: hat, at: 0.25 }])\` + * At the first part's rate, or \`rate\`; two channels when a part has two. \`seconds\`: that long at least. + */ +${kw}function mixdown(parts: readonly { readonly clip: Clip; readonly at: number }[], seconds?: number, rate?: number): Clip; +/** + * Clips the game will play together, each from its time — the bars of a song played one after another and the layers + * over them — each turned down by one gain, so what they add up to never goes over \`peak\` (0.84, −1.5 dB), between + * the samples too: what a limiter does for a mix, for sounds the game adds up itself. Never turned up. The same clip + * given twice comes back as one. \`over\`: clips that may play on top, each at any of its times (a stinger on any bar + * line), room left for them too; they come back after the parts, turned down with them. When the script is built: \`const [a, b] = together([{ clip: x, at: 0 }, { clip: y, at: 1 }])\` + */ +${kw}function together(parts: readonly { readonly clip: Clip; readonly at: number }[], options?: { readonly peak?: number; readonly over?: readonly { readonly clip: Clip; readonly at: readonly number[] }[] }): Clip[]; +/** A brush of cover(): which of the shapes (0 the first), centred on tile (x, y) of the picture. */ +${kw}interface Brush { readonly x: number; readonly y: number; readonly shape: number } +/** + * When the script is built: the brushes whose union is a picture's ink — rows of text, # the ink — each one + * of \`shapes\` (rows of text, # its tiles and @ its centre, a row one run of tiles) lying on ink only. Ink + * thinner than the first shape gets the first shape on it anyway. Greedy, the largest gain first. For a + * renderer of a library's own: fog.picture is this with the game's sight ranges. + * cover([".##.", "####"], [["#@#"]]) // brushes of a 3-wide bar + * Of a video, each frame's brushes (worked out once while the file and the shapes stay the same). + */ +${kw}function cover(picture: readonly string[], shapes: readonly (readonly string[])[]): readonly Brush[]; +${kw}function cover(video: Video, shapes: readonly (readonly string[])[]): readonly (readonly Brush[])[]; /** The player colours stats(player).color takes. */ ${kw}const colors: { __COLOR_TABLE__ }; /** @@ -374,17 +706,118 @@ ${kw}function color(player: Player): string; * game's own messages ("Not enough minerals") appear: print(\`Wave \${wave}\`, { to: AllPlayers, position: "center" }). */ ${kw}function print(text: string, options?: { to?: Player; position?: "chat" | "center" }): void; +/** + * The colours a text in the game takes, as their colour codes: for an array that holds a colour a cell, + * \`board[i] = textColors.orange\`, and \`\` shows it (0 is an empty cell). + * grey ends what colour a line can change: everything after it on its line stays grey. + */ +${kw}const textColors: { __TEXT_COLORS__ }; +/** A colour of a text: one of textColors' names, or a colour code (0 for none). */ +${kw}type TextColor = keyof typeof textColors | number; +/** What a program shows on the screen, redrawn by the game as the program's variables change. */ +${kw}const ui: { + /** + * Show a text in the player's text lines, top left, and keep it there: the function is worked out again every + * frame from the program's variables, and shown when it changed. Inside an async program only, as a task: it keeps + * going after the program moves on, and the Task it gives back stops it (\`board.cancel()\`). Eleven lines: one + * player's text lines are this text's only, so give a player one ui.lines at a time. + * + * ui.lines(P1, () => ( + * <> + * Score {score} + * {rows.map((r) => {r.map((c) => )})} + * + * )); + */ + lines(player: Player, view: () => string): Task; +}; +/** A unit as it was when it died, which is what on.died() hands its function: the unit itself is gone. */ +${kw}interface DeadUnit { + readonly type: UnitType; + readonly owner: Player; + /** Where it died, in pixels (32 a tile). */ + readonly x: number; + readonly y: number; +} +/** How many of an event's handlers can be waiting at once, when the handler is async (16 unless said, up to 255). */ +${kw}interface EventOptions { max?: number } +/** What on.order() looks for: a unit filter and, if you like, the order the unit takes up. */ +${kw}interface OrderFilter extends UnitFilter { order?: Order } +/** What on.killed() looks for: a unit filter and, if you like, the player who killed it. */ +${kw}interface KillFilter extends UnitFilter { by?: Player } +/** + * What happens in the game, as it happens: on.created(…), on.died(…), on.chat(…) and the rest each runs its function for + * every time it happens from the next frame on. Each is a task (it keeps listening after the program moves on, and the + * Task it gives back stops it), inside an async program — or at the top of a script, where it listens from the start + * of the game as a program of its own. A function that is not async runs within the frame, once for each time; an + * async one is started as a task each time, so it can wait: \`max\` of them at once. + */ +${kw}const on: { + /** + * A unit comes onto the map — made by an action or a program, trained, hatched, landed, placed by a test — and the + * function is handed it in the next frame. A unit in a transport or a hangar comes on when it first comes out. + * \`on.created(units.ZergZergling, (u) => { u.energy = 50; })\` + */ + created(handler: (unit: Unit) => void, options?: EventOptions): Task; + created(filter: UnitType | UnitFilter, handler: (unit: Unit) => void, options?: EventOptions): Task; + /** + * A unit dies or is removed, and the function is handed what it was and where, in the next frame — once for every + * unit on.created() would have been handed. The filter is of the unit as it died. + * \`on.died({ owner: P8 }, async (u) => { await sleep(seconds(2)); createUnit(P8, u.type, 1, locations.Spawn); })\` + */ + died(handler: (unit: DeadUnit) => void, options?: EventOptions): Task; + died(filter: UnitType | UnitFilter, handler: (unit: DeadUnit) => void, options?: EventOptions): Task; + /** + * A unit is killed in a fight — shot, burned, caught in a storm — and the function is handed what it was, where, and + * the player whose attack killed it. A unit a trigger or a program kills or removes, or one that goes down with its + * transport, has no killer and is not handed on (on.died() hears every one); nor does one its own player killed (a + * storm on your own units), for which the game keeps no attacker, as it counts no kill. + * \`on.killed({ owner: P8, by: P1 }, (u, by) => setResources(by, "add", 25, "ore"))\` + */ + killed(handler: (unit: DeadUnit, by: Player) => void, options?: EventOptions): Task; + killed(filter: UnitType | KillFilter, handler: (unit: DeadUnit, by: Player) => void, options?: EventOptions): Task; + /** + * A unit takes up a new order — told to move, starting to cast, sieging, going back to guard — and the function is + * handed it in the next frame, with the order it took up and the one it had. An order held for less than a frame is + * not seen. This looks at every unit on the map each frame: the hint says what it costs. + * \`on.order(units.ProtossHighTemplar, orders.CastPsionicStorm, (u) => print("Storm!"))\` + */ + order(handler: (unit: Unit, order: Order, was: Order) => void, options?: EventOptions): Task; + order(filter: UnitType | OrderFilter, handler: (unit: Unit, order: Order, was: Order) => void, options?: EventOptions): Task; + order(filter: UnitType | UnitFilter, order: Order, handler: (unit: Unit, order: Order, was: Order) => void, options?: EventOptions): Task; + /** + * A unit turns into another type and stays the same unit: a Siege Tank sieging, a Larva becoming an egg and the egg + * its unit, a Drone becoming a building, a High Templar becoming an Archon (its partner leaves, as on.died() hears). + * The function is handed the unit, now of its new type, and the type it was. The filter is of the new type. + * \`on.morphed(units.TerranSiegeTankSiegeMode, (u) => print("Sieged"))\` + */ + morphed(handler: (unit: Unit, was: UnitType) => void, options?: EventOptions): Task; + morphed(filter: UnitType | UnitFilter, handler: (unit: Unit, was: UnitType) => void, options?: EventOptions): Task; + /** + * A player types a line that fits the pattern, which is chatted()'s: \`on.chat("-give {n}", (p, { n }) => setResources(p, "add", n, "ore"))\`. + * Every player P1 … P8 unless \`from\` names one (CurrentPlayer in a program for several players); \`p\` is known + * when the script is built, so an action can be for them. + */ + chat(pattern: P, handler: (player: Player, values: ChatValues

) => void, options?: EventOptions & { from?: Player }): Task; + /** A player presses a key, as keyPressed() sees it: \`on.key("F7", (p) => print("Help", { to: p }))\`. Every player P1 … P8 unless \`from\` names one. */ + key(key: Key, handler: (player: Player) => void, options?: EventOptions & { from?: Player }): Task; +}; /** * What the players do, inside program() only. A key, a click and a typed line are true on the one * frame they arrive, so look for them in a loop that runs every frame: - * \`while (true) { if (keyPressed(CurrentPlayer, "F2")) …; sleep(frames(1)); }\`. They reach every + * \`while (true) { if (keyPressed(CurrentPlayer, "F7")) …; sleep(frames(1)); }\`. They reach every * player's computer in step, a few frames after they happen. The player is one of P1 … P8 or * CurrentPlayer — in a program with \`{ owner: AllPlayers }\`, each player's own keys. - * The map gives up a little for it: one free location among the first 63 (nine when the mouse is read), - * the Valkyrie unit type, and Player 12 to hold the units that carry the input. + * The map gives up a little for it: one unit-properties slot, one unit type the map does not use (the + * Valkyrie, unless the map uses it), and Player 12 to hold the hidden units that carry the input. */ /** True on the frame a player's press of a key arrives. Not while the player is typing a message. */ ${kw}function keyPressed(player: Player, key: Key): boolean; +/** + * True on every frame a key is down — for moving while it is held. It reaches every player's computer + * in step, a few frames after it happens, as a press does. Not while the player is typing a message. + */ +${kw}function keyHeld(player: Player, key: Key): boolean; /** True on the frame a player's press of a mouse button arrives ("left" when none is named). */ ${kw}function clicked(player: Player, button?: "left" | "right" | "middle"): boolean; /** Where a player's mouse is on the map, in pixels (32 to a tile): \`const at = mouse(CurrentPlayer);\` keeps the place as it is now. */ @@ -403,6 +836,17 @@ ${kw}function underMouse(player: Player, filter?: UnitFilter & { within?: number ${kw}function chatted(player: Player, pattern: P): ChatValues

| null; /** Centre a location on a point of the map, in pixels, its size kept; inside program() only. With mouse(): \`centerLocation(locations.Cursor, at.x, at.y)\`, then createUnit() there. */ ${kw}function centerLocation(location: Location, x: number, y: number): void; +/** + * Centre a location on a point of the map and give it a size, all in pixels; inside program() only. Nothing of its + * old size is read, so it costs less than keeping the size: \`centerLocation(spot, x, y, 32, 32)\`. + */ +${kw}function centerLocation(location: Location, x: number, y: number, width: number, height: number): void; +/** + * A location's edges as the game holds them now, in pixels (32 to a tile), inside program() only: \`const b = + * bounds(locations.Pen);\` then \`b.left\`, \`b.top\`, \`b.right\`, \`b.bottom\` read them, and \`b.left += 32\` + * moves one. A location centerLocation() or Move Location moved is read where it is now. + */ +${kw}function bounds(location: Location): { left: number; top: number; right: number; bottom: number }; /** Keep a condition or action in the trigger but switched off (StarEdit's disabled state). */ ${kw}function disabled(item: T): T; /** @@ -428,7 +872,12 @@ function signature(kw: string, ident: string, def: ConditionDef | ActionDef, ret const all = scriptParams(def); const params = all.map((p) => `${p.name}${p.optional ? "?" : ""}: ${argType(p.arg.kind)}`); const doc = def.args.length ? `${def.name} — ${def.args.map((a) => a.label).join(", ")}` : def.name; - const out = `/** ${doc} */\n${kw}function ${ident}(${params.join(", ")}): ${returns};`; + let out = `/** ${doc} */\n${kw}function ${ident}(${params.join(", ")}): ${returns};`; + const order = all.findIndex((p) => p.arg.kind === "order"); + if (order >= 0) { + const raw = all.map((p, i) => `${p.name}${p.optional ? "?" : ""}: ${i === order ? "RawNumber" : argType(p.arg.kind)}`); + out += `\n/** ${doc}: the action's own number for the order, 0 move, 1 patrol, 2 attack. */\n${kw}function ${ident}(${raw.join(", ")}): ${returns};`; + } if (returns !== "Condition" || !READ_ARITY.has(ident)) return out; // Without the comparison and the amount, the quantity itself: a read, inside program(). const read = all.filter((p) => p.arg.kind !== "comparison" && p.arg.kind !== "amount").map((p) => `${p.name}: ${argType(p.arg.kind)}`); @@ -468,6 +917,109 @@ function statsTypes(kw: string): string { }).join("\n"); } +/** The interfaces of `memory`, from its one list of fields (`memory.ts`): each field's doc and Remastered's verdict on it. */ +function memoryTypes(kw: string): string { + const groups = Object.entries(MEMORY_GROUPS).map(([group, [name, doc]]) => { + const lines = [`/** ${doc} */`, `${kw}interface ${name} {`]; + for (const f of MEMORY_FIELDS.filter((x) => x.path[0] === group)) { + lines.push(` /**\n * ${f.doc}\n *\n * ${f.remastered}\n * @address 0x${f.base.toString(16).toUpperCase()}, ${f.width * 8} bits\n */`); + lines.push(` ${f.readonly ? "readonly " : ""}${f.path[1]}: number;`); + } + lines.push("}"); + return lines.join("\n"); + }); + return groups.join("\n"); +} + +/** + * `memory.vision.countdown`: the groups as a namespace merged with the trigger level's \`memory(address, …)\` + * condition, which keeps its name. + */ +function memoryNamespace(kw: string): string { + const lines = [ + `/**`, + ` * Cells of the game's memory that have no other word in the language, inside program() only:`, + ` * \\\`memory.vision.countdown = 1;\\\` \\\`if (memory.game.frame % 24 == 0) …\\\`. Each field's hover says what`, + ` * StarCraft: Remastered does with it; one the game keeps for itself is readonly.`, + ` */`, + `${kw}namespace memory {`, + ]; + for (const [group, [name, doc]] of Object.entries(MEMORY_GROUPS)) lines.push(` /** ${doc} */`, ` export const ${group}: ${name};`); + lines.push("}"); + return lines.join("\n"); +} + +/** + * `local` and `sync()` (B5): what only this computer knows, typed `Local`, and the way one player's value reaches + * every computer. The check that keeps a `Local` value away from the game is the compiler's (`local.ts`), not the type's: + * a sum of a `Local` number is as local, and TypeScript would lose the mark. + */ +function localNamespace(kw: string, typeKw: string): string { + const field = (f: MemoryField, indent: string) => `${indent}/**\n${indent} * ${f.doc}\n${indent} *\n${indent} * ${f.remastered}\n${indent} * @address 0x${f.base.toString(16).toUpperCase()}, ${f.width * 8} bits\n${indent} */`; + const lines = [ + `/**`, + ` * A value only this computer knows: it may be different on every player's computer. It can be shown — in text, in`, + ` * ui.lines(), a sound, the camera — but nothing that changes the game may depend on it, or the game would go`, + ` * differently on each computer and split. The compiler says so where it happens. To act on one, send it to every`, + ` * computer first: \\\`const x = await sync(P1, local.camera.x);\\\``, + ` */`, + `${typeKw}type Local = T;`, + `/**`, + ` * What only this computer knows, inside program() only: who sits at it, where its screen is, where its mouse is on the`, + ` * screen. Each is a Local value: show it as you like; to act on it, sync() it first.`, + ` */`, + `${kw}namespace local {`, + ]; + const groups = [...new Set(LOCAL_FIELDS.map((f) => f.path[0]))]; + for (const g of groups) { + const fs = LOCAL_FIELDS.filter((f) => f.path[0] === g); + if (fs.length === 1 && !fs[0].path[1]) { lines.push(field(fs[0], " "), ` export const ${g}: Local;`); continue; } + lines.push(` export const ${g}: {`); + for (const f of fs) lines.push(field(f, " "), ` readonly ${f.path[1]}: Local;`); + lines.push(` };`); + } + lines.push( + ` /**`, + ` * The unit this computer has selected — the first of a group, the one the panel at the bottom shows — or null. A`, + ` * Local value: show it (its hit points, its order, …) as you like; to act on it, select() or sync() first.`, + ` *`, + ` * Remastered: the selection, read live (its own EUD emulator: each slot a unit's 1.16.1 address). Played.`, + ` * @address 0x6284B8, 32 bits a unit, twelve`, + ` */`, + ` export const selected: Local;`, + ` /** How many units this computer has selected, 0 to 12. */`, + ` export const selectionCount: Local;`, + ); + lines.push(`}`); + lines.push( + `/**`, + ` * What a player has selected becomes these units — at their computer (the panel shows them) and in the orders every`, + ` * computer runs for them, as if they had dragged a box round them: a Select command, sent from their computer as`, + ` * MSQC sends one. The units may be any, Local ones too: select(P1, local.selected) is safe. Up to twelve, written out;`, + ` * the selection circles may not follow.`, + ` */`, + `${kw}function select(player: Player, units: Unit | readonly Unit[]): void;`, + ); + lines.push( + `/**`, + ` * One player's value, sent from their computer to every computer: what \\\`value\\\` is on \\\`player\\\`'s computer, the same`, + ` * on all of them, a few frames later (5 or 6 in a game played alone; a game between computers adds its turns).`, + ` * \\\`const x = await sync(CurrentPlayer, local.camera.x + 400);\\\` Every computer runs the program and reaches the same`, + ` * sync(): only the named player's sends. A whole number from 0 to 1 048 574. A player who is not a person, or has`, + ` * left, gives 0 at once. Inside an async program, awaited where it is assigned.`, + ` */`, + `${kw}function sync(player: Player, value: Local): Promise;`, + `/**`, + ` * Several of one player's values, read on the same frame and sent together: a point stays one point while the mouse`, + ` * moves, and it takes one trip, not one a value. \\\`const [x, y] = await sync(P1, local.camera.x + local.mouse.x, local.camera.y + local.mouse.y);\\\``, + ` */`, + `${kw}function sync(player: Player, a: Local, b: Local): Promise<[number, number]>;`, + `${kw}function sync(player: Player, a: Local, b: Local, c: Local): Promise<[number, number, number]>;`, + `${kw}function sync(player: Player, a: Local, b: Local, c: Local, d: Local): Promise<[number, number, number, number]>;`, + ); + return lines.join("\n"); +} + function playerAliases(kw: string, names: ScriptNames): string { const lines: string[] = []; for (const e of names.players.entries) { @@ -489,15 +1041,17 @@ function tables(kw: string, names: ScriptNames, compact: boolean): string { tableDecl(kw, names.locations), tableDecl(kw, names.switches, (k) => { const m = /^Switch(\d+)$/.exec(k); return m ? Number(m[1]) <= 16 : !isDefaultSwitch(k); }, "Switch1 … Switch256 exist; the first sixteen are listed. A switch given a name in the map is listed by that name."), `/** AI scripts, by StarEdit name ("Terran Custom Level") or four-character code. */\n${kw}const ${names.aiScripts.object}: { readonly [name: string]: AiScript };`, - ...[names.weapons, names.upgrades, names.techs].map((t) => tableDecl(kw, t, identifierKey)), + ...[names.weapons, names.upgrades, names.techs, names.orders].map((t) => tableDecl(kw, t, identifierKey)), ].join("\n"); } function body(kw: string, typeKw: string, names: ScriptNames, compact: boolean): string { return [ - types(typeKw).replace("__COLOR_NAMES__", COLOR_NAMES).replace("__KEY_NAMES__", KEY_NAMES.map((k) => JSON.stringify(k)).join(" | ")).replace("__STATS_TYPES__", statsTypes(typeKw)), + types(typeKw).replace("__COLOR_NAMES__", COLOR_NAMES).replace("__KEY_NAMES__", KEY_NAMES.map((k) => JSON.stringify(k)).join(" | ")).replace("__STATS_TYPES__", `${statsTypes(typeKw)}\n${memoryTypes(typeKw)}`), choiceTypes(typeKw), - functions(kw).replace("__COLOR_TABLE__", COLOR_TABLE), + functions(kw).replace("__COLOR_TABLE__", COLOR_TABLE).replace("__TEXT_COLORS__", TEXT_COLOR_TABLE), + memoryNamespace(kw), + localNamespace(kw, typeKw), "// ── Conditions ──", ...[...CONDITION_IDENTS].map(([ident, def]) => signature(kw, ident, def, "Condition")), "", @@ -516,6 +1070,30 @@ function body(kw: string, typeKw: string, names: ScriptNames, compact: boolean): * array of numbers unless it says otherwise (`new Array(12).fill(false)`), as a number is what everything is. */ const ARRAYS = ` +/** JSX is a text the game shows: \`Score {score}\` is a string. */ +declare namespace JSX { + type Element = string; + interface ElementChildrenAttribute { children: {} } + interface IntrinsicElements { + /** A line of its own, in its colour (white unless it says). The lines of a text are put one under another. */ + line: { color?: TextColor; children?: unknown }; + /** What is inside it in its colour; the colour around it comes back after it. */ + span: { color?: TextColor; children?: unknown }; + /** One square: filled in its colour, or empty (lavender) when it has none or 0. A board's cell. */ + cell: { color?: TextColor }; + /** \`width\` squares (10 unless it says), as many of them filled as value is of max. */ + bar: { value: number; max: number; width?: number; color?: TextColor }; + } +} +/** What a component's \`children\` is: the text between its tags (\`function Box(props: { title: string; children?: Children })\`). */ +type Children = string | number | boolean | null | undefined | Children[]; +declare function __jsx(tag: string, props: object | null, ...children: unknown[]): string; +/** What the build adds after each class at the top of a file, for a program that makes an instance of it. Not for scripts. */ +declare function __class(cls: unknown, descriptor: unknown): void; +declare function __listen(descriptor: unknown, at?: unknown): void; +/** What the build adds after each function at the top of a file, for a program that calls it with its own values. Not for scripts. */ +declare function __fn(fn: unknown, descriptor: unknown): void; +declare const __fragment: string; interface ArrayConstructor { new (arrayLength: number): T[]; (arrayLength: number): T[]; @@ -533,7 +1111,8 @@ const TESTING = ` export interface SimUnit { readonly type: UnitType; readonly owner: Player; readonly x: number; readonly y: number; readonly hp: number; readonly maxHp: number; readonly shields: number; readonly maxShields: number; readonly energy: number; - readonly kills: number; readonly resources: number; readonly invincible: boolean; readonly burrowed: boolean; readonly cloaked: boolean; readonly hallucinated: boolean; + readonly kills: number; readonly orderId: Order; readonly resources: number; readonly invincible: boolean; readonly burrowed: boolean; readonly cloaked: boolean; readonly hallucinated: boolean; + readonly buildTime: number; readonly rank: number; readonly matrix: number; readonly speed: number; readonly speedUpgrade: boolean; readonly hasTarget: boolean; readonly inTransport: boolean; /** Still on the map. */ readonly alive: boolean; } @@ -546,11 +1125,13 @@ export interface SimUnit { export interface Sim { /** Units for a player at a location's centre, as createUnit() makes them. They are given back. */ place(player: Player, type: UnitType, at: Location, count?: number): SimUnit[]; - /** A unit dies: what a fight is in a test. \`by\`: the player whose kill it is. */ + /** A unit dies: what a fight is in a test. \`by\`: the player whose kill it is, and on.killed()'s killer. */ kill(unit: SimUnit, by?: Player): this; /** A unit goes without dying. */ remove(unit: SimUnit): this; give(unit: SimUnit, to: Player): this; + /** A unit turns into another type and stays the same unit, as a Siege Tank sieges: what on.morphed() hears. In the simulator only. */ + morph(unit: SimUnit, type: UnitType): this; /** A unit to a location's centre, or to a point in pixels. */ move(unit: SimUnit, to: Location | { x: number; y: number }): this; @@ -570,14 +1151,24 @@ export interface Sim { /** A line of chat, for the next frame: what chatted() finds. */ type(line: string, player?: Player): this; moveMouse(x: number, y: number, player?: Player): this; + /** The player's screen to a place on the map, its top left corner in pixels: what local.camera reads. The mouse stays where it is on the map. */ + moveCamera(x: number, y: number, player?: Player): this; + /** The player selects these units (up to twelve), as a drag would at their computer: what local.selected reads. */ + selectUnits(units: readonly SimUnit[], player?: Player): this; count(player: Player, type: UnitType, at?: Location): number; - /** The units on the map, in the order of the game's unit table. */ + /** The units on the map, oldest first, as a loop over them comes to them. */ units(filter?: { owner?: Player; type?: UnitType; at?: Location }): SimUnit[]; resources(player: Player): { ore: number; gas: number }; deaths(player: Player, type: UnitType): number; kills(player: Player, type: UnitType): number; switch(n: Switch): boolean; + /** + * What \`player\` sees of the map's fog of war this frame, a text a row of tiles: "#" lit now, "+" seen before (fog), + * "." never seen. \`area\` takes a part of the map (tiles), the whole of it by default. Null where the world has no + * fog to read: the simulator does not draw it, the game does — run the tests in the game to check a picture. + */ + fog(player: Player, area?: { x: number; y: number; width: number; height: number }): string[] | null; /** Where a location is now: a program may have moved it. */ location(n: Location): { left: number; top: number; right: number; bottom: number } | undefined; @@ -622,6 +1213,13 @@ export function expect(actual: T): Expectation & { readonly not: Expectati export interface TestOptions { /** Without the map's player settings the world has one player: this one. */ as?: Player; + /** + * In the game: these players, each at a computer of their own — a copy of the game for each, kept in step as a + * game between people is. What a player does (press, click, type, moveMouse) is done at their computer, and the + * test fails at the frame the copies stop agreeing, the lines that made them differ named. The simulator runs the + * test once, as without it. + */ + players?: readonly Player[]; } type TestBody = (sim: Sim, ...args: A) => void; interface TestEach { (cases: readonly T[]): (name: string, fn: F extends "suite" ? (...args: T extends readonly unknown[] ? T : [T]) => void : TestBody) => void } @@ -638,11 +1236,34 @@ export function afterEach(fn: (sim: Sim) => void): void; `; /** The whole declaration file for a set of names. */ +/** What importing one of the script's media files gives (`media.ts`). */ +const MEDIA_MODULES = `// ── A video the script imports: import badApple from "./bad-apple.video"; ── +declare module "*.video" { + const video: Video; + export default video; +} +// ── An image the script imports: import logo from "./logo.image"; ── +declare module "*.image" { + const image: Image; + export default image; +} +// ── A sound the script imports: import boom from "./boom.sound"; ── +declare module "*.sound" { + const sound: Clip; + export default sound; +} +// ── Notes the script imports, from a MIDI file: import tune from "./tune.notes"; ── +declare module "*.notes" { + const notes: Notes; + export default notes; +} +`; + export function generateDeclarations(names: ScriptNames = defaultScriptNames(), options: DeclarationOptions = {}): string { const compact = options.compact === true; const globals = body("declare ", "", names, compact); if (compact) return `${HEADER}${globals}\n${ARRAYS}`; const module = `${body("export ", "export ", names, false)}\n${TESTING}`.split("\n").map((l) => (l ? ` ${l}` : l)).join("\n"); - return `${HEADER}${globals}\n${ARRAYS}\n// ── The same names, as a module: import { trigger, units } from "${MODULE_NAME}"; ──\ndeclare module "${MODULE_NAME}" {\n${module}\n}\n`; + return `${HEADER}${globals}\n${ARRAYS}\n// ── The same names, as a module: import { trigger, units } from "${MODULE_NAME}"; ──\ndeclare module "${MODULE_NAME}" {\n${module}\n}\n${MEDIA_MODULES}`; } diff --git a/compiler/dsp.ts b/compiler/dsp.ts new file mode 100644 index 0000000..6a4907c --- /dev/null +++ b/compiler/dsp.ts @@ -0,0 +1,316 @@ +/** + * What a clip goes through when the script is built (`sound.ts`'s `Clip` methods, and the music library's tracks): a + * filter, saturation, an echo, a reverb, a chorus, a compressor — and the sources beside `tone`: a stack of detuned + * voices, two-operator FM, a plucked string. Each works on channels of samples (−1 … 1) and gives new ones; nothing + * here knows about clips. The game hears only what comes out: none of this costs it anything, only the build's time. + * + * Everything is the same on every build: noise and phases come from fixed seeds. + */ + +export type Channels = readonly Float32Array[]; + +/** A value through time: [seconds, value] points, a straight line between them; the first before, the last after. */ +export type Points = readonly (readonly [number, number])[]; + +/** Each sample's value from points (or one value throughout), in their own units or, with `log`, by ratios. */ +export function curve(v: number | Points, rate: number, n: number, log = false): Float32Array { + const out = new Float32Array(n); + if (typeof v === "number") return out.fill(v); + const ps = v.map(([t, x]) => [t * rate, log ? Math.log(x) : x] as const); + for (let i = 0, k = 0; i < n; i++) { + while (k < ps.length && ps[k][0] <= i) k++; + const x = k === 0 ? ps[0][1] : k === ps.length ? ps[k - 1][1] : ps[k - 1][1] + ((ps[k][1] - ps[k - 1][1]) * (i - ps[k - 1][0])) / (ps[k][0] - ps[k - 1][0] || 1); + out[i] = log ? Math.exp(x) : x; + } + return out; +} + +/* ── Filters ── */ + +export type FilterType = "lowpass" | "highpass" | "bandpass" | "notch" | "lowshelf" | "highshelf" | "peak"; + +/** + * A state-variable filter (the trapezoidal form: stable however fast its cutoff moves): lowpass, highpass, bandpass or + * notch, its cutoff a frequency or points through time, `resonance` 0 (none, Q 0.5) to 1 (ringing at the cutoff). + */ +export function svf(chans: Channels, rate: number, type: "lowpass" | "highpass" | "bandpass" | "notch", cutoff: number | Points, resonance: number): Float32Array[] { + const n = chans[0].length, hz = curve(cutoff, rate, n, true); + const k = 2 - 1.96 * Math.max(0, Math.min(1, resonance)); + return chans.map((x) => { + const out = new Float32Array(n); + let ic1 = 0, ic2 = 0, lastHz = -1, a1 = 0, a2 = 0, a3 = 0, g = 0; + for (let i = 0; i < n; i++) { + const f = Math.min(hz[i], rate * 0.49); + if (f !== lastHz) { + lastHz = f; + g = Math.tan((Math.PI * f) / rate); + a1 = 1 / (1 + g * (g + k)); a2 = g * a1; a3 = g * a2; + } + const v0 = x[i], v3 = v0 - ic2, v1 = a1 * ic1 + a2 * v3, v2 = ic2 + a2 * ic1 + a3 * v3; + ic1 = 2 * v1 - ic1; ic2 = 2 * v2 - ic2; + out[i] = type === "lowpass" ? v2 : type === "highpass" ? v0 - k * v1 - v2 : type === "bandpass" ? v1 : v0 - k * v1; + } + return out; + }); +} + +/** A shelf or a peak (the cookbook biquads): `gain` dB below or above `hz`, or around it, `q` wide (0.707). */ +export function eq(chans: Channels, rate: number, type: "lowshelf" | "highshelf" | "peak", hz: number, gain: number, q = Math.SQRT1_2): Float32Array[] { + const w = (2 * Math.PI * Math.min(hz, rate * 0.49)) / rate, cos = Math.cos(w), sin = Math.sin(w), A = 10 ** (gain / 40); + let b0: number, b1: number, b2: number, a0: number, a1: number, a2: number; + if (type === "peak") { + const alpha = sin / (2 * q); + [b0, b1, b2, a0, a1, a2] = [1 + alpha * A, -2 * cos, 1 - alpha * A, 1 + alpha / A, -2 * cos, 1 - alpha / A]; + } else { + const alpha = (sin / 2) * Math.sqrt(2), s = 2 * Math.sqrt(A) * alpha; + [b0, b1, b2, a0, a1, a2] = type === "lowshelf" + ? [A * (A + 1 - (A - 1) * cos + s), 2 * A * (A - 1 - (A + 1) * cos), A * (A + 1 - (A - 1) * cos - s), A + 1 + (A - 1) * cos + s, -2 * (A - 1 + (A + 1) * cos), A + 1 + (A - 1) * cos - s] + : [A * (A + 1 + (A - 1) * cos + s), -2 * A * (A - 1 + (A + 1) * cos), A * (A + 1 + (A - 1) * cos - s), A + 1 - (A - 1) * cos + s, 2 * (A - 1 - (A + 1) * cos), A + 1 - (A - 1) * cos - s]; + } + return chans.map((x) => { + const out = new Float32Array(x.length); + let x1 = 0, x2 = 0, y1 = 0, y2 = 0; + for (let i = 0; i < x.length; i++) { + const y = (b0 * x[i] + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2) / a0; + x2 = x1; x1 = x[i]; y2 = y1; y1 = y; out[i] = y; + } + return out; + }); +} + +/* ── Saturation ── */ + +/** + * Saturation: each sample through tanh after `dB` more gain, and the gain taken off again — a quiet part as it was, a + * loud one rounded, more and more like a square. Worked at twice the rate (`up`, `down`: the clip's resampler), so the + * harmonics it makes above half the rate do not fold back down. + */ +export function saturate(chans: Channels, dB: number, up: (s: Float32Array) => Float32Array, down: (s: Float32Array) => Float32Array): Float32Array[] { + const g = 10 ** (dB / 20); + return chans.map((x) => { + const n = x.length, hi = up(x); + for (let i = 0; i < hi.length; i++) hi[i] = Math.tanh(g * hi[i]) / g; + const back = down(hi); + return back.length === n ? back : Float32Array.from({ length: n }, (_, i) => back[i] ?? 0); + }); +} + +/* ── Time ── */ + +/** A delay line read a whole or a fractional number of samples back; its length a power of two, so wrapping is a mask. */ +class Line { + private readonly buf: Float32Array; + private readonly mask: number; + private at = 0; + constructor(size: number) { + let n = 4; + while (n < size + 2) n <<= 1; + this.buf = new Float32Array(n); + this.mask = n - 1; + } + write(x: number): void { this.buf[this.at] = x; this.at = (this.at + 1) & this.mask; } + /** `d` whole samples back (1 is the last written). */ + tap(d: number): number { return this.buf[(this.at - d) & this.mask]; } + /** `d` samples back, between samples when `d` is not whole. */ + read(d: number): number { + const p = this.at - d, i = Math.floor(p), f = p - i; + const a = this.buf[i & this.mask]; + return a + (this.buf[(i + 1) & this.mask] - a) * f; + } +} + +/** Seconds until a repeat that falls by `feedback` each time is 60 dB down, at most `cap`. */ +const tailOf = (feedback: number, every: number, cap: number) => Math.min(cap, feedback <= 0 ? every : every * Math.ceil(Math.log(0.001) / Math.log(Math.min(0.999, feedback)))); +/** Samples an echo adds to a clip: its repeats until they are 60 dB down, 30 seconds at most. */ +export const echoTail = (rate: number, seconds: number, feedback: number) => Math.round(tailOf(feedback, seconds, 30) * rate); + +/** + * An echo `seconds` after, each repeat `feedback` as loud and a little duller (a lowpass at `tone` Hz in the loop), + * `mix` its level beside the clip. `pingPong`: the repeats go left, right, left — the clip then has two channels. + */ +export function echo(chans: Channels, rate: number, seconds: number, feedback: number, mix: number, tone: number, pingPong: boolean): Float32Array[] { + const d = seconds * rate, n = chans[0].length, total = n + echoTail(rate, seconds, feedback); + const ins = pingPong && chans.length === 1 ? [chans[0], chans[0]] : chans; + const lp = Math.exp((-2 * Math.PI * tone) / rate); + if (!pingPong) { + return ins.map((x) => { + const out = new Float32Array(total), line = new Line(d); + let z = 0; + for (let i = 0; i < total; i++) { + const dry = i < n ? x[i] : 0, back = line.read(d); + z = back + (z - back) * lp; + line.write(dry + z * feedback); + out[i] = dry + back * mix; + } + return out; + }); + } + // Ping-pong: what goes in is heard left first, and each line feeds the other. + const [l, r] = ins, L = new Float32Array(total), R = new Float32Array(total), a = new Line(d), b = new Line(d); + let za = 0, zb = 0; + for (let i = 0; i < total; i++) { + const dl = i < n ? l[i] : 0, dr = i < n ? r[i] : 0, ba = a.read(d), bb = b.read(d); + za = ba + (za - ba) * lp; zb = bb + (zb - bb) * lp; + a.write((dl + dr) / 2 + zb * feedback); + b.write(za * feedback); + L[i] = dl + ba * mix; R[i] = dr + bb * mix; + } + return [L, R]; +} + +/** + * A plate reverb (after Dattorro's, 1997): the clip, summed to one, through a pre-delay, a little top cut and four + * diffusers into a figure-eight tank of two halves, each a wobbling allpass, a delay, damping, the decay and another + * allpass and delay, feeding the other; left and right are read from seven taps across the tank each, so the two are + * different rooms' worth of the same sound. `decay` 0 … 1 (0.5 a room, 0.9 a hall), `damping` 0 … 1 (how fast the top + * dies), `predelay` seconds, `mix` the reverb's level, `dry` the clip's. Two channels, `tail` samples longer than the + * clip (`plateTail`), the last 50 ms of them faded out. + */ +export function plate(chans: Channels, rate: number, o: { decay: number; damping: number; predelay: number; mix: number; dry: number; width: number }, tail: number): Float32Array[] { + const s = rate / 29761, n = chans[0].length; + const len = (x: number) => Math.round(x * s); + const preTap = Math.max(1, Math.round(o.predelay * rate)), pre = new Line(preTap + 1); + const diffusers = [[142, 0.75], [107, 0.75], [379, 0.625], [277, 0.625]].map(([d, g]) => ({ line: new Line(len(d)), d: len(d), g })); + const half = (ap1: number, d1: number, ap2: number, d2: number) => ({ + ap1: new Line(len(ap1) + len(16) + 2), ap1d: len(ap1), d1: new Line(len(d1)), d1n: len(d1), + ap2: new Line(len(ap2)), ap2d: len(ap2), d2: new Line(len(d2)), d2n: len(d2), damp: 0, + }); + const A = half(672, 4453, 1800, 3720), B = half(908, 4217, 2656, 3163); + const decay = Math.max(0, Math.min(0.97, o.decay)), damping = Math.max(0, Math.min(0.99, o.damping)), bw = 0.9995; + const excursion = len(16), lfo = (2 * Math.PI * 1.0) / rate; + // The output taps (Dattorro's table), left then right. + const T = [266, 2974, 1913, 1996, 1990, 187, 1066, 353, 3627, 1228, 2673, 2111, 335, 121].map((d) => Math.max(1, len(d))); + const cap = n + tail, fadeFrom = cap - Math.min(tail, Math.round(0.05 * rate)); + const L = new Float32Array(cap), R = new Float32Array(cap); +// The two halves of the tank, each fed by the other's end. + const run = (h: typeof A, input: number, phase: number, at: number) => { + const md = h.ap1d + excursion * Math.sin(lfo * at + phase); + const back1 = h.ap1.read(md), w1 = input + 0.7 * back1; + h.ap1.write(w1); + const y1 = back1 - 0.7 * w1; + h.d1.write(y1); + const d1 = h.d1.tap(h.d1n); + h.damp = (1 - damping) * d1 + damping * h.damp; + const into = h.damp * decay; + const back2 = h.ap2.tap(h.ap2d), w2 = into - 0.5 * back2; + h.ap2.write(w2); + h.d2.write(back2 + 0.5 * w2); + return h.d2.tap(h.d2n) * decay; + }; + let band = 0, lastA = 0, lastB = 0; + for (let i = 0; i < cap; i++) { + let x = 0; + if (i < n) { for (const c of chans) x += c[i]; x /= chans.length; } + pre.write(x); + let v = pre.tap(preTap); + band = bw * v + (1 - bw) * band; + v = band; + for (const ap of diffusers) { + const back = ap.line.tap(ap.d), w = v - ap.g * back; + ap.line.write(w); + v = back + ap.g * w; + } + const outA = run(A, v + lastB, 0, i), outB = run(B, v + lastA, Math.PI / 2, i); + lastA = outA; lastB = outB; + const yl = B.d1.tap(T[0]) + B.d1.tap(T[1]) - B.ap2.tap(T[2]) + B.d2.tap(T[3]) - A.d1.tap(T[4]) - A.ap2.tap(T[5]) - A.d2.tap(T[6]); + const yr = A.d1.tap(T[7]) + A.d1.tap(T[8]) - A.ap2.tap(T[9]) + A.d2.tap(T[10]) - B.d1.tap(T[11]) - B.ap2.tap(T[12]) - B.d2.tap(T[13]); + const mid = (yl + yr) / 2, side = ((yl - yr) / 2) * o.width; + const wl = 0.6 * o.mix * (mid + side), wr = 0.6 * o.mix * (mid - side); + const dl = i < n ? chans[0][i] : 0, dr = i < n ? chans[chans.length - 1][i] : 0; + const out = i < fadeFrom ? 1 : (cap - i) / (cap - fadeFrom); + L[i] = (o.dry * dl + wl) * out; R[i] = (o.dry * dr + wr) * out; + } + return [L, R]; +} + +/** + * How long a plate's tail lasts, in seconds: until a sound has gone round the tank enough times to be 60 dB down. A + * half of the tank is 0.36 s (its delays at Dattorro's rate) and takes `decay` off twice: 1.24 / −ln(decay) seconds, + * 12 at most, after the pre-delay. + */ +export const plateTail = (decay: number, predelay: number) => predelay + Math.min(12, 1.24 / -Math.log(Math.max(0.01, Math.min(0.97, decay)))); + +/** A chorus: the clip beside copies of itself a few milliseconds late and wobbling, left and right out of step. Two channels. */ +export function chorus(chans: Channels, rate: number, speed: number, depth: number, mix: number): Float32Array[] { + const n = chans[0].length, base = 0.012 * rate, swing = depth * rate, w = (2 * Math.PI * speed) / rate; + const ins = chans.length === 1 ? [chans[0], chans[0]] : chans; + return ins.map((x, side) => { + const out = new Float32Array(n), line = new Line(base + swing + 2); + for (let i = 0; i < n; i++) { + line.write(x[i]); + const wet = line.read(base + swing * Math.sin(w * i + side * (Math.PI / 2))); + out[i] = (1 - mix) * x[i] + mix * wet; + } + return out; + }); +} + +/** + * A compressor: above `threshold` dB, every `ratio` dB in is one out, eased over a `knee` dB wide; turning down over + * `attack` seconds, back up over `release`; both sides by the louder; `makeup` dB after. + */ +export function compressor(chans: Channels, rate: number, o: { threshold: number; ratio: number; attack: number; release: number; knee: number; makeup: number }): Float32Array[] { + const n = chans[0].length, att = Math.exp(-1 / Math.max(1, o.attack * rate)), rel = Math.exp(-1 / Math.max(1, o.release * rate)); + const gain = new Float32Array(n), slope = 1 - 1 / o.ratio, k = Math.max(0, o.knee); + let env = 0; + for (let i = 0; i < n; i++) { + let m = 0; + for (const c of chans) m = Math.max(m, Math.abs(c[i])); + const level = 20 * Math.log10(Math.max(m, 1e-9)), over = level - o.threshold; + const reduce = k > 0 && Math.abs(over) <= k / 2 ? (slope * (over + k / 2) ** 2) / (2 * k) : over > 0 ? slope * over : 0; + env = reduce > env ? att * env + (1 - att) * reduce : rel * env + (1 - rel) * reduce; + gain[i] = 10 ** ((o.makeup - env) / 20); + } + return chans.map((c) => { const out = new Float32Array(c.length); for (let i = 0; i < c.length; i++) out[i] = c[i] * gain[i]; return out; }); +} + +/* ── Sources ── */ + +/** A fixed sequence of numbers 0 … 1 from a seed: the same on every build. */ +export function seeded(seed: number): () => number { + let s = seed >>> 0 || 0x9e3779b9; + return () => { s ^= s << 13; s >>>= 0; s ^= s >>> 17; s ^= s << 5; s >>>= 0; return s / 0x100000000; }; +} + +/** + * Two-operator FM: a sine whose phase is pushed by another sine `ratio` times its pitch, `index` how far (points + * through time, for a bright start that mellows), `feedback` the modulator fed its own output (a saw-like edge). + */ +export function fmVoice(hz: Float32Array, rate: number, ratio: number, index: Float32Array, feedback: number): Float32Array { + const n = hz.length, out = new Float32Array(n); + let pc = 0, pm = 0, last = 0; + for (let i = 0; i < n; i++) { + const m = Math.sin(2 * Math.PI * pm + feedback * last); + last = m; + out[i] = Math.sin(2 * Math.PI * pc + index[i] * m); + pc += hz[i] / rate; pm += (hz[i] * ratio) / rate; + pc -= Math.floor(pc); pm -= Math.floor(pm); + } + return out; +} + +/** + * A plucked string (Karplus–Strong): a burst of noise in a loop one period long, averaged as it goes round so the top + * dies first. `brightness` 0 … 1 how much of the top the burst has and the loop keeps; `decay` seconds to fade 60 dB. + */ +export function pluckVoice(hz: number, rate: number, n: number, brightness: number, decay: number): Float32Array { + const period = rate / hz, out = new Float32Array(n), random = seeded(Math.round(hz * 1000)); + // The averaging delays half a sample as much as it averages: the loop is that much shorter, the rest a fraction + // read between samples. + const keep = 0.5 + 0.5 * brightness; + const line = new Line(period + 2), p = Math.max(2, period - 0.5 * (1 - keep)); + let lp = 0; + const burstLp = 0.15 + 0.85 * brightness; + for (let i = 0; i < Math.ceil(period) + 2; i++) { lp += burstLp * (random() * 2 - 1 - lp); line.write(lp); } + // Loss a round so the note is 60 dB down after `decay` seconds. + const rounds = (decay * rate) / period, loss = Math.pow(0.001, 1 / Math.max(1, rounds)); + let prev = 0; + for (let i = 0; i < n; i++) { + const x = line.read(p); + const y = loss * (keep * x + (1 - keep) * ((x + prev) / 2)); + prev = x; + line.write(y); + out[i] = x; + } + return out; +} diff --git a/compiler/entry.ts b/compiler/entry.ts index 4885a35..88a34be 100644 --- a/compiler/entry.ts +++ b/compiler/entry.ts @@ -9,6 +9,10 @@ * module bundled on its own as `dist/compiler.js`. */ export { compileScript } from "./compiler"; +/** The worker encodes a build's Ogg Vorbis sounds with these (`vorbis.ts`). */ +export { finishSounds, loadVorbis } from "./vorbis"; +/** The worker builds a map of our own with this (`backend/build.ts`): the programs into the scenario, no eudplib. */ +export { buildOwnMap } from "./backend/build"; /** Where this module was loaded from — what the worker is told to import. */ export const ENTRY_URL: string = import.meta.url; diff --git a/compiler/eudError.ts b/compiler/eudError.ts new file mode 100644 index 0000000..e42a756 --- /dev/null +++ b/compiler/eudError.ts @@ -0,0 +1,236 @@ +/** + * Remastered's "EUD ERROR: 0x…", back to the line of the script (TrigScript 4, from R0 and A1). + * + * When Remastered's emulation of 1.16.1's memory refuses a read or a write, it ends the game and shows a code. R0 + * found what the code is: the address the trigger named, its bits inverted (probe D of the dump's probe ended on + * "EUD ERROR: 0xFFA632E3", a read of 0x59CD1C, the first unit's +0x74). So the code says exactly which address, and + * not which trigger. Which line it was comes from a run in the lifted game with every EUD access written down under + * the line that made it (`EudTracer`): the lines that touched that address are the answer. + * + * The run is not the person's game. A unit's place in the unit table differs from game to game, so a unit field is + * matched by the field in any unit as well as by the exact address; a line the run never reached is not found. + */ +import { verdictOf, type Allowlist, type Verdict } from "./allowlist"; +import type { CostLines } from "./gameCost"; +import { TABLE_FIELDS, type TableKind } from "./tables"; +import { MEMORY_FIELDS } from "./memory"; + +/** The address an error code names, or null when the text holds no code. Takes "EUD ERROR: 0xFFA632E3", "0xFFA632E3" or "FFA632E3". */ +export function addressOfEudError(text: string): number | null { + const m = /EUD\s*ERROR\s*:?\s*(?:0x)?([0-9a-f]{1,8})\b/i.exec(text) ?? /^\s*(?:0x)?([0-9a-f]{1,8})\s*$/i.exec(text); + return m ? ~parseInt(m[1], 16) >>> 0 : null; +} + +/** The code Remastered shows for an address (the other way round, for a probe's answer sheet and the tests). */ +export function eudErrorOf(address: number): string { + return `0x${(~address >>> 0).toString(16).toUpperCase().padStart(8, "0")}`; +} + +const hex = (n: number) => `0x${n.toString(16)}`; + +/** 1.16.1's unit table: 1,700 units of 336 bytes. */ +export const UNIT_TABLE = { base: 0x59cca8, stride: 336, count: 1700 }; + +/** The fields of a unit (CUnit), by offset and width, as 1.16.1 lays them out. */ +const UNIT_FIELDS: [number, number, string][] = [ + [0x00, 4, "previous unit"], [0x04, 4, "next unit"], [0x08, 4, "hit points"], [0x0c, 4, "sprite"], + [0x10, 4, "move target position"], [0x14, 4, "move target unit"], [0x18, 4, "next waypoint"], [0x1c, 4, "next target waypoint"], + [0x20, 1, "movement flags"], [0x21, 1, "facing direction"], [0x22, 1, "turn radius"], [0x23, 1, "velocity direction"], + [0x24, 2, "flingy"], [0x26, 1, "unknown 0x26"], [0x27, 1, "movement type"], [0x28, 2, "x"], [0x2a, 2, "y"], + [0x2c, 4, "halt x"], [0x30, 4, "halt y"], [0x34, 4, "top speed"], [0x38, 4, "speed 1"], [0x3c, 4, "speed 2"], + [0x40, 4, "current speed"], [0x44, 2, "acceleration"], [0x46, 1, "direction 2"], [0x47, 1, "velocity direction 2"], + [0x48, 4, "unknown 0x48"], [0x4c, 1, "owner"], [0x4d, 1, "order"], [0x4e, 1, "order state"], [0x4f, 1, "order signal"], + [0x50, 2, "order unit type"], [0x52, 2, "unknown 0x52"], [0x54, 1, "order timer"], [0x55, 1, "ground weapon cooldown"], + [0x56, 1, "air weapon cooldown"], [0x57, 1, "spell cooldown"], [0x58, 4, "order target position"], [0x5c, 4, "order target unit"], + [0x60, 4, "shields"], [0x64, 2, "unit type"], [0x66, 2, "unknown 0x66"], [0x68, 4, "previous unit of its player"], + [0x6c, 4, "next unit of its player"], [0x70, 4, "subunit"], [0x74, 4, "order queue head"], [0x78, 4, "order queue tail"], + [0x7c, 4, "auto target unit"], [0x80, 4, "connected unit"], [0x84, 1, "order queue count"], [0x85, 1, "order queue timer"], + [0x86, 1, "unknown 0x86"], [0x87, 1, "attack notify timer"], [0x88, 2, "previous unit type"], [0x8a, 1, "last event timer"], + [0x8b, 1, "last event colour"], [0x8c, 2, "unused 0x8c"], [0x8e, 1, "rank increase"], [0x8f, 1, "kills"], + [0x90, 1, "last attacking player"], [0x91, 1, "secondary order timer"], [0x92, 1, "AI action flag"], [0x93, 1, "user action flags"], + [0x94, 2, "button set"], [0x96, 1, "cloaked"], [0x97, 1, "movement state"], [0x98, 10, "build queue"], [0xa2, 2, "energy"], + [0xa4, 1, "build queue slot"], [0xa5, 1, "unique id"], [0xa6, 1, "secondary order"], [0xa7, 1, "building overlay state"], + [0xa8, 2, "hit point gain"], [0xaa, 2, "shield gain"], [0xac, 2, "remaining build time"], [0xae, 2, "previous hit points"], + [0xb0, 16, "loaded units"], [0xc0, 16, "what its type keeps (mines, interceptors, nuke…)"], [0xd0, 12, "what a building keeps (resources, …)"], + [0xdc, 4, "status flags"], [0xe0, 1, "resource type"], [0xe1, 1, "wireframe randomiser"], [0xe2, 1, "secondary order state"], + [0xe3, 1, "recent order timer"], [0xe4, 4, "visibility"], [0xe8, 4, "secondary order position"], [0xec, 4, "unit being built"], + [0xf0, 4, "previous burrowed unit"], [0xf4, 4, "next burrowed unit"], [0xf8, 8, "rally point"], [0x100, 4, "path"], + [0x104, 1, "pathing collision interval"], [0x105, 1, "pathing flags"], [0x106, 1, "unused 0x106"], [0x107, 1, "being healed"], + [0x108, 8, "contour bounds"], [0x110, 2, "remove timer"], [0x112, 2, "defensive matrix hit points"], [0x114, 1, "defensive matrix timer"], + [0x115, 1, "stim timer"], [0x116, 1, "ensnare timer"], [0x117, 1, "lockdown timer"], [0x118, 1, "irradiate timer"], + [0x119, 1, "stasis timer"], [0x11a, 1, "plague timer"], [0x11b, 1, "storm timer"], [0x11c, 4, "irradiated by"], + [0x120, 1, "irradiating player"], [0x121, 1, "parasites"], [0x122, 1, "cycle counter"], [0x123, 1, "blind"], + [0x124, 1, "maelstrom timer"], [0x125, 1, "unused 0x125"], [0x126, 1, "acid spore count"], [0x127, 9, "acid spore timers"], + [0x130, 2, "3×3 attack sequence"], [0x132, 2, "unused 0x132"], [0x134, 4, "computer player's data"], [0x138, 2, "air strength"], + [0x13a, 2, "ground strength"], [0x13c, 16, "unit finder bounds"], [0x14c, 4, "repulse"], +]; + +/** A unit field's name, by the byte. */ +export function unitFieldAt(offset: number): string { + const f = UNIT_FIELDS.find(([at, n]) => offset >= at && offset < at + n); + return f ? (offset === f[0] ? f[2] : `${f[2]} (+${offset - f[0]})`) : `+${hex(offset)}`; +} + +/** Other tables of 1.16.1's a script reaches, by name; a row of `stride` bytes, `count` of them. */ +const TABLES: { name: string; base: number; stride: number; count: number; row?: (i: number) => string }[] = [ + { name: "deaths", base: 0x58a364, stride: 4, count: 12 * 228, row: (i) => `of unit type ${Math.floor(i / 12)} for P${(i % 12) + 1}` }, + { name: "kills", base: 0x5878a4, stride: 4, count: 12 * 228, row: (i) => `of unit type ${Math.floor(i / 12)} for P${(i % 12) + 1}` }, + { name: "minerals", base: 0x57f0f0, stride: 4, count: 12, row: (i) => `of P${i + 1}` }, + { name: "gas", base: 0x57f120, stride: 4, count: 12, row: (i) => `of P${i + 1}` }, + { name: "switches", base: 0x58dc40, stride: 4, count: 8, row: (i) => `${32 * i + 1} to ${32 * i + 32}` }, + { name: "locations", base: 0x58dc60, stride: 20, count: 255, row: (i) => `location ${i + 1}` }, + { name: "text lines", base: 0x640b60, stride: 218, count: 13, row: (i) => `line ${i + 1}` }, + { name: "keys of the local machine", base: 0x596a18, stride: 1, count: 256 }, + { name: "mouse buttons of the local machine", base: 0x6cddc0, stride: 4, count: 1 }, + { name: "mouse position of the local machine", base: 0x6cddc4, stride: 8, count: 1 }, + { name: "current player", base: 0x6509b0, stride: 4, count: 1 }, +]; + +/** Where an address lies, in words: "unit slot 0 of 1,700, order queue head (+0x74)". With `list`, an entry's name where nothing else knows it. */ +export function placeOfAddress(address: number, list?: Allowlist): string { + const u = address - UNIT_TABLE.base; + if (u >= 0 && u < UNIT_TABLE.stride * UNIT_TABLE.count) { + const at = u % UNIT_TABLE.stride; + return `the unit table: slot ${Math.floor(u / UNIT_TABLE.stride)} of ${UNIT_TABLE.count.toLocaleString("en")}, ${unitFieldAt(at)} (+${hex(at)})`; + } + for (const t of TABLES) { + const off = address - t.base; + if (off < 0 || off >= t.stride * t.count) continue; + const i = Math.floor(off / t.stride), within = off % t.stride; + return `${t.name}${t.row ? `, ${t.row(i)}` : ""}${t.stride > 4 && within ? ` (+${within})` : ""}`; + } + const cell = MEMORY_FIELDS.find((f) => address >= f.base && address < f.base + f.width); + if (cell) return `memory.${cell.path.join(".")}`; + for (const [kind, fields] of Object.entries(TABLE_FIELDS) as [TableKind, (typeof TABLE_FIELDS)[TableKind]][]) { + for (const f of fields) { + const width = f.width === "bit" ? 4 : f.width, off = address - f.base; + if (off < 0 || off % f.stride >= width) continue; + const i = Math.floor(off / f.stride); + if (i < (f.keyed ? 12 : TABLE_SIZE_OF[kind])) return `stats(): the ${kind} table's ${f.name}, entry ${i}`; + } + } + const named = list?.entries.find((e) => !/^0x[0-9a-f]+$/i.test(e.name) && covers(e, address)); + return named ? named.name : "no table TrigScript knows"; +} +const TABLE_SIZE_OF: Record = { unit: 228, weapon: 130, upgrade: 61, tech: 44, player: 12 }; +function covers(e: Allowlist["entries"][number], byte: number): boolean { + const off = byte - e.base; + if (off < 0) return false; + if (!e.stride) return off < e.width; + return off % e.stride < e.width && Math.floor(off / e.stride) < (e.count ?? 1); +} + +/** One dword a run read or wrote, under the line that did it (null: the build's own triggers, or the map's other triggers). */ +export interface EudTraceRow { file: string | null; line: number | null; address: number; write: boolean; mask: number; times: number } + +/** + * Every EUD read and write of a run in the lifted game, by line: what the host's EUD tap says (scm-sim's + * `installEud`), each put under the line its record ran for. The cost tap says the line, as it does for the debugger + * (A5): it is told of each record before the record runs, with the build's own triggers put under the line that ran + * just before them, so a read a variable's route makes is the line that read the variable. + */ +export class EudTracer { + private group = -1; + private readonly seen = new Map(); + private readonly places: readonly { file: string; line: number }[]; + private readonly host: EudTraceHost; + + constructor(host: EudTraceHost, lines: CostLines | null) { + this.host = host; + this.places = lines?.places ?? []; + } + + /** Whether the host can trace at all: an EUD tap, and a cost tap to say the line. */ + static can(host: EudTraceHost): boolean { return !!host.eud && !!host.cost && "trace" in host.cost; } + + start(): void { + const { cost, eud } = this.host; + if (cost) cost.trace = (_record, group) => { this.group = group; }; + if (eud) eud.listen = (_record, address, write, mask) => { + const key = `${this.group}|${address}|${write ? 1 : 0}`, was = this.seen.get(key); + if (was) { was[0] = (was[0] | mask) >>> 0; was[1]++; } else this.seen.set(key, [mask >>> 0, 1]); + }; + } + + stop(): void { + const { cost, eud } = this.host; + if (cost) cost.trace = null; + if (eud) eud.listen = null; + this.group = -1; + } + + /** What was traced; plain data for the way back to the editor. */ + rows(): EudTraceRow[] { + return [...this.seen].map(([key, [mask, times]]) => { + const [g, address, write] = key.split("|").map(Number); + const at = g >= 0 ? this.places[g] : undefined; + return { file: at?.file ?? null, line: at?.line ?? null, address, write: write === 1, mask, times }; + }); + } +} + +/** The two taps of a host's game, by shape (scm-sim's `EudTap` and `CostTap`: TrigScript never imports the engine). */ +export interface EudTraceHost { + readonly eud?: { listen: ((record: number, address: number, write: boolean, mask: number, was: number) => void) | null }; + readonly cost?: { trace?: ((record: number, group: number, action: boolean) => void) | null }; +} + +/** A line that touched the address, or the same field of another unit. */ +export interface EudErrorLine { + file: string; + line: number; + /** "exact": this very address; "field": the same field of another unit (units sit elsewhere in the person's game). */ + match: "exact" | "field"; + read: boolean; + write: boolean; + times: number; +} + +export interface EudErrorAnswer { + /** As Remastered shows it, "0xFFA632E3". */ + code: string; + address: number; + place: string; + /** What the allowlist says of a read and a write there, and what that rests on. */ + read: Verdict; + write: Verdict; + evidence: string[]; + /** The lines, those that touched the very address first, then by how often. */ + lines: EudErrorLine[]; + /** Accesses to it under no line: the start-up and the build's own work at the top of a frame, or the map's other triggers. */ + unlined: { exact: number; field: number }; +} + +/** Which lines of a traced run touched `address` (or, in the unit table, the same field of any unit), and what it is. */ +export function findEudError(address: number, rows: readonly EudTraceRow[], list?: Allowlist): EudErrorAnswer { + const dword = address & ~3; + const u = address - UNIT_TABLE.base; + const field = u >= 0 && u < UNIT_TABLE.stride * UNIT_TABLE.count ? u % UNIT_TABLE.stride & ~3 : null; + const byLine = new Map(); + const unlined = { exact: 0, field: 0 }; + for (const r of rows) { + let match: EudErrorLine["match"] | null = null; + if (r.address === dword) match = "exact"; + else if (field !== null) { + const o = r.address - UNIT_TABLE.base; + if (o >= 0 && o < UNIT_TABLE.stride * UNIT_TABLE.count && o % UNIT_TABLE.stride === field) match = "field"; + } + if (!match) continue; + if (r.file === null || r.line === null) { unlined[match] += r.times; continue; } + const key = `${r.file}:${r.line}`, had = byLine.get(key); + if (!had) byLine.set(key, { file: r.file, line: r.line, match, read: !r.write, write: r.write, times: r.times }); + else { + had.times += r.times; + if (match === "exact") had.match = "exact"; + if (r.write) had.write = true; else had.read = true; + } + } + const lines = [...byLine.values()].sort((a, b) => (a.match === b.match ? 0 : a.match === "exact" ? -1 : 1) || b.times - a.times || a.file.localeCompare(b.file) || a.line - b.line); + const read = list ? verdictOf(list, address, false) : null, write = list ? verdictOf(list, address, true) : null; + const evidence = [...new Set([...(read?.entry?.evidence ?? []), ...(write?.entry?.evidence ?? [])])]; + if (list && !read?.entry && !write?.entry && list.outside) evidence.push(...list.outside.evidence); + return { + code: eudErrorOf(address), address, place: placeOfAddress(address, list), + read: read?.verdict ?? "unknown", write: write?.verdict ?? "unknown", evidence, lines, unlined, + }; +} diff --git a/compiler/fixedMath.ts b/compiler/fixedMath.ts new file mode 100644 index 0000000..c0c7e52 --- /dev/null +++ b/compiler/fixedMath.ts @@ -0,0 +1,92 @@ +/** + * Fixed-point arithmetic (16.16) as the game computes it (`python/trigscript.py`'s `f_fixmul` and the rest), for the + * simulator: every function gives the very bits the built map does. A value here is a raw cell — the number times + * 65 536 — read signed. A product and a quotient go through the magnitudes, rounded down, and take the sign after, + * which is rounding towards zero; both wrap at 32 bits. + */ + +const U32 = 0x1_0000_0000n; + +/** A cell's distance from 0, from 0 up (−2³¹'s is 2³¹). */ +const magnitude = (v: number): bigint => BigInt(v < 0 ? -v : v); +/** A magnitude taken back to a signed cell, negated when the signs differed, wrapping at 32 bits. */ +const signedCell = (m: bigint, negative: boolean): number => Number(BigInt.asIntN(32, (negative ? U32 - (m % U32) : m) % U32)); + +/** a × b / 65 536, towards zero. */ +export function fixedMul(a: number, b: number): number { + return signedCell((magnitude(a) * magnitude(b)) >> 16n, (a < 0) !== (b < 0)); +} + +/** a × 65 536 / b, towards zero; a divisor of 0 gives 0. */ +export function fixedDiv(a: number, b: number): number { + if (b === 0) return 0; + return signedCell((magnitude(a) << 16n) / magnitude(b), (a < 0) !== (b < 0)); +} + +/** The largest r with r² ≤ n. */ +function isqrt(n: bigint): bigint { + if (n < 2n) return n; + let x = BigInt(Math.floor(Math.sqrt(Number(n)))); + while (x * x > n) x--; + while ((x + 1n) * (x + 1n) <= n) x++; + return x; +} + +/** + * The square root of a fixed number, rounded down; 0 below zero. `whole`: of a whole number read from 0 up, the root + * still a fixed one — from 2³⁰ up past what a fixed number holds, which gives the largest. + */ +export function fixedSqrt(v: number, whole = false): number { + if (whole) return v >>> 0 >= 2 ** 30 ? 0x7fffffff : signedCell(isqrt(BigInt(v >>> 0) << 32n), false); + if (v <= 0) return 0; + return signedCell(isqrt(BigInt(v) << 16n), false); +} + +/** π and π/2 as fixed numbers. */ +export const FIXED_PI = Math.round(Math.PI * 65536); +export const FIXED_HALF_PI = Math.round((Math.PI / 2) * 65536); + +/** a + (b − a) × f / 2^bits, the part of the step towards zero: a table read between its cells. */ +function between(a: number, b: number, f: number, bits: number): number { + const d = b - a; + const part = Math.floor((Math.abs(d) * f) / 2 ** bits); + return (d < 0 ? a - part : a + part) | 0; +} + +/** + * atan2(y, x) of fixed numbers, a fixed number of radians from −π to π, read from `table` (atan(i / 1 024), 1 025 + * cells): the smaller side over the larger, a fixed number from 0 to 1, read between the table's cells (ten bits of it + * the cell, six the way to the next), then put in its octant. + */ +export function fixedAtan2(y: number, x: number, table: readonly number[]): number { + const ax = magnitude(x), ay = magnitude(y); + if (ax === 0n && ay === 0n) return 0; + const read = (small: bigint, large: bigint) => { + const r = Number((small << 16n) / large); + return between(table[r >> 6] ?? 0, table[(r >> 6) + 1] ?? 0, r & 63, 6); + }; + let t = ay <= ax ? read(ay, ax) : FIXED_HALF_PI - read(ax, ay); + if (x < 0) t = FIXED_PI - t; + if (y < 0) t = -t; + return t | 0; +} + +/** + * sin(x) of a fixed number of radians, from `table` (sin(2π i / 4 096), 4 096 cells): x times `scale` (4 096 / 2π) + * is the place, sixteen bits of it the way to the next cell. `cos`: a quarter turn on. + */ +export function fixedSin(x: number, scale: number, table: readonly number[], cos = false): number { + const at = fixedMul(x, scale); + const i = ((at >>> 16) + (cos ? 1024 : 0)) & 4095; + return between(table[i] ?? 0, table[(i + 1) & 4095] ?? 0, at & 0xffff, 16); +} + +/** A fixed number as a decimal, as a program prints it: up to four places, rounded, without trailing zeros. */ +export function fixedText(v: number): string { + const m = Math.abs(v); + let whole = Math.floor(m / 65536); + let places = Math.floor(((m % 65536) * 10000 + 32768) / 65536); + if (places === 10000) { whole++; places = 0; } + const sign = v < 0 && (whole || places) ? "-" : ""; + return sign + String(whole) + (places ? "." + String(places).padStart(4, "0").replace(/0+$/, "") : ""); +} diff --git a/compiler/fogShapes.ts b/compiler/fogShapes.ts new file mode 100644 index 0000000..6e2eb7f --- /dev/null +++ b/compiler/fogShapes.ts @@ -0,0 +1,29 @@ +/** + * The fog library's brush shapes (`stdlib/fog.ts`'s SHAPES), for what the editor draws as the fog would — the video + * viewer's "as the fog draws it". The library keeps its own copy, as TrigScript it shows a map maker; a test holds the + * two the same. Sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game. + */ +export const FOG_SHAPES: readonly (readonly string[])[] = [ + // 1 + ["###", "###", "#@#", "###"], + // 2 + [".###.", "#####", "#####", "##@##", "#####", "#####"], + // 3 + ["..###..", "#######", "#######", "#######", "###@###", "#######", "#######", "#######"], + // 4 + ["..#####...", ".#######..", "#########.", "##########", "##########", "####@#####", "##########", "##########", "#########.", ".#######..", "......#..."], + // 5 + ["...#####....", "..#######...", ".#########..", "###########.", "############", "############", "#####@######", "############", "############", "###########.", ".#########..", "..#######...", ".......#...."], + // 6 + ["....#####.....", "..#########...", ".###########..", "#############.", "#############.", "##############", "##############", "######@#######", "##############", "##############", "#############.", "#############.", ".###########..", "..#########...", "........#....."], + // 7 + [".....#####......", "...#########....", "..###########...", ".#############..", "###############.", "###############.", "################", "################", "#######@########", "################", "################", "###############.", "###############.", ".#############..", "..###########...", "...#########....", ".........#......"], + // 8 + ["......#####.......", "....#########.....", "..#############...", ".###############..", ".###############..", "#################.", "#################.", "##################", "##################", "########@#########", "##################", "##################", "#################.", "#################.", ".###############..", ".###############..", "..#############...", "....#########.....", "..........#......."], + // 9 + ["......#######.......", "....###########.....", "...#############....", "..###############...", ".#################..", "###################.", "###################.", "####################", "####################", "####################", "#########@##########", "####################", "####################", "####################", "###################.", "###################.", ".#################..", "..###############...", "...#############....", "....###########.....", "........#####......."], + // 10 + [".......#######........", ".....###########......", "....#############.....", "..#################...", "..#################...", ".###################..", "#####################.", "#####################.", "######################", "######################", "######################", "##########@###########", "######################", "######################", "######################", "#####################.", "#####################.", ".###################..", "..#################...", "..#################...", "....#############.....", ".....###########......", ".........#####........"], + // 11 + ["........#######.........", "......###########.......", "....###############.....", "...#################....", "..###################...", ".#####################..", ".#####################..", "#######################.", "#######################.", "########################", "########################", "########################", "###########@############", "########################", "########################", "########################", "#######################.", "#######################.", ".#####################..", ".#####################..", "..###################...", "...#################....", "....###############.....", "......###########.......", "..........#####........."], +]; \ No newline at end of file diff --git a/compiler/gameCopies.ts b/compiler/gameCopies.ts new file mode 100644 index 0000000..43b9eeb --- /dev/null +++ b/compiler/gameCopies.ts @@ -0,0 +1,29 @@ +/** + * A test of several players in the lifted game (TrigScript 4, A3): a copy of the game for each player, each with a + * report of its own, made into one result a test. Apart from the rest of the game's tests so that the editor takes + * this without the compiler. + */ +import type { TestReport, TestResult } from "./testing"; + +/** + * A test of several players, from each copy's report (its answer, in the order of the players): it passed when it + * passed at every computer; the first copy that failed says why, and at whose computer. A copy that could not run + * fails every test with what went wrong. + */ +export function mergeCopies(ids: readonly string[], copies: readonly { player: number; result: unknown; error: string | null }[]): TestResult[] { + return ids.map((id) => { + const each = copies.map((c) => { + const report = c.result as (TestReport & { error?: string }) | null; + const problem = c.error ?? report?.error ?? null; + const r = report?.results.find((x) => x.id === id); + return { player: c.player, r: problem || !r ? ({ id, status: "failed", message: problem ?? "the test did not run at this computer", printed: [], events: [], frames: 0, ms: 0 } as TestResult) : r }; + }); + const bad = each.find((e) => e.r.status === "failed"); + const first = each[0].r; + const ms = Math.max(...each.map((e) => e.r.ms)); + if (!bad) return { ...first, ms }; + // (A desync fails it alike at every computer, and says whose computer is whose itself.) + const same = each.every((e) => e.r.status === "failed" && e.r.message === bad.r.message); + return { ...bad.r, ms, message: same ? bad.r.message : `at P${bad.player + 1}'s computer: ${bad.r.message}` }; + }); +} diff --git a/compiler/gameCost.ts b/compiler/gameCost.ts new file mode 100644 index 0000000..7e57470 --- /dev/null +++ b/compiler/gameCost.ts @@ -0,0 +1,191 @@ +/** + * What a frame costs (TrigScript 4, A4): the conditions and actions the game ran in each frame of a test, and by + * line of the script, read from the lifted game's count (scm-sim's `src/exact/cost.ts`) through the build's map of + * triggers. Lifted wall time is not the game's, so the count is the measure; `probes/frame-cost.ts` is the dial that + * says what count Remastered feels. + * + * A record is put down to a line by the trigger it is in: the build's map says which statement made each trigger. + * The build's own triggers — a variable's cell, the routes into its places, a run's wake, the gates — and the triggers + * no statement made are counted under the line that ran just before them in the frame, since a line's work is its own + * triggers and the variables they go through. What runs before any line in a frame (the start-up, once, and the + * build's work at the top of every frame) and the map's triggers outside the payload are counted under no line: + * `TestCost.other`. + * + * Where the host's tap can weigh (scm-sim's frame-weights change: it counts triggers too and asks a weigher what each + * costs), every count also comes as time: `RemasteredWeigh` says what Remastered spends on each trigger and record + * (`compiler/remasteredCost.ts`), and `TestCost.ms` / `LineCost.ms` hold the milliseconds. Without it, counts alone. + */ +import { ENTRY_FILE } from "./compiler"; +import type { TriggerMap } from "./gameVariables"; + +/** What the host counted (scm-sim's `CostTap`, by shape: TrigScript never imports the engine). */ +export interface GameCostTap { + /** + * A record's address → the group it is counted under: a group's number, -1 for none, or -2 (`CARRY`) for "the + * group counted just before it in the frame, or none when it is the first". + */ + group: ((record: number) => number) | null; + /** What each trigger and record costs (a host from before frame-weights has no such field, and counts only). */ + weigh?: { trigger(trigger: number): number; record(record: number, type: number, action: boolean): number; reset?(): void } | null; + report(from?: number): { + from: number; + conditions: number[]; + actions: number[]; + /** Of those, the records under no group. */ + ungrouped: number[]; + groups: Map; + /** Triggers passed, and what the weigher said each frame cost (nanoseconds), all and under no group: a weighing host's. */ + triggers?: number[]; + ns?: number[]; + ungroupedNs?: number[]; + }; +} + +/** A line's cost over a test's run. */ +export interface LineCost { + file: string; + line: number; + /** Conditions and actions over the run. */ + total: number; + /** The most in one frame, and the first frame it was that many. */ + peak: number; + peakFrame: number; + /** Frames the line ran in. */ + frames: number; + /** The same as time in Remastered, when the host weighs: milliseconds over the run, the most in one frame and that frame. */ + ms?: { total: number; peak: number; peakFrame: number }; +} + +/** A test's frames as time in Remastered (milliseconds), beside the counts, when the host weighs. */ +export interface TestCostMs { + /** The script's build, a frame: its lines and the build's own triggers, as `perFrame` counts them. */ + perFrame: number[]; + /** The map's other triggers a frame, as `other`. */ + other: number[]; + peak: number; + peakFrame: number; +} + +/** What a test's run in the game cost. */ +export interface TestCost { + /** The frame `perFrame` starts at. */ + from: number; + /** Conditions and actions a frame that the script's build ran: its lines and the build's own triggers. */ + perFrame: number[]; + /** + * What the map's other triggers ran a frame: the start-up (once, by frame 2) and any trigger the map has besides the + * build's. Kept apart so that the script's own peak is not that start. + */ + other: number[]; + /** The most of `perFrame`, and the first frame it was that many. */ + peak: number; + peakFrame: number; + /** By line, the costliest first. */ + lines: LineCost[]; + /** Triggers the walk passed a frame, the map's other triggers included; when the host counts them. */ + triggers?: number[]; + /** The frames as time, when the host weighs. */ + ms?: TestCostMs; +} + +/** What `GameCostTap.group` answers for a record counted under the record before it (scm-sim's `CARRY`). */ +const CARRY = -2; + +/** How records become lines, for one build: set on the host's tap once, read after every test. */ +export class CostLines { + private readonly at: { file: string; line: number }[] = []; + private readonly ids = new Map(); + private readonly byRecord = new Map(); + private readonly payloadStart: number; + private readonly payloadEnd: number; + + /** `payloadEnd`: where the build's payload ends in the game's memory (the host says; the end of `STRx`). */ + constructor(map: TriggerMap, payloadEnd: number) { + this.payloadEnd = payloadEnd; + this.payloadStart = payloadEnd - map.payload; + for (const [offset, conditions, actions, origin, byAction] of map.triggers) { + const where = lineOf(origin); + const g = where ? this.idOf(where) : CARRY; + const at = this.payloadStart + offset; + for (let i = 0; i < conditions; i++) this.byRecord.set(at + 8 + 20 * i, g); + // (A trigger holding several lines' work gives each its actions.) + for (let i = 0; i < actions; i++) { + const own = byAction?.[i] !== undefined ? lineOf(byAction[i]) : where; + this.byRecord.set(at + 8 + 320 + 32 * i, own ? this.idOf(own) : g); + } + } + } + + private idOf(where: { file: string; line: number }): number { + const key = `${where.file}:${where.line}`; + let id = this.ids.get(key); + if (id === undefined) { id = this.at.length; this.at.push(where); this.ids.set(key, id); } + return id; + } + + /** The lines groups stand for, by group: what the tap's groups are numbers into. */ + get places(): readonly { file: string; line: number }[] { return this.at; } + + /** For the host's tap. */ + readonly group = (record: number): number => { + const g = this.byRecord.get(record); + if (g !== undefined) return g; + return record >= this.payloadStart && record < this.payloadEnd ? CARRY : -1; + }; + + /** The tap's count since the game was put back, as a test's cost. */ + read(tap: GameCostTap): TestCost { + const r = tap.report(0); + const perFrame = r.conditions.map((c, i) => c + r.actions[i] - r.ungrouped[i]); + let peak = 0, peakFrame = r.from; + perFrame.forEach((n, i) => { if (n > peak) { peak = n; peakFrame = r.from + i; } }); + const weighed = !!tap.weigh && !!r.ns && !!r.ungroupedNs; + const lines: LineCost[] = []; + for (const [g, k] of r.groups) { + const where = this.at[g]; + if (!where) continue; + lines.push({ file: where.file, line: where.line, total: k.conditions + k.actions, peak: k.peak, peakFrame: k.peakFrame, frames: k.frames, + ...(weighed ? { ms: { total: (k.ns ?? 0) / 1e6, peak: (k.peakNs ?? 0) / 1e6, peakFrame: k.peakNsFrame ?? k.peakFrame } } : {}) }); + } + lines.sort(weighed ? (a, b) => b.ms!.peak - a.ms!.peak || b.ms!.total - a.ms!.total : (a, b) => b.peak - a.peak || b.total - a.total); + const cost: TestCost = { from: r.from, perFrame, other: [...r.ungrouped], peak, peakFrame, lines, ...(r.triggers ? { triggers: [...r.triggers] } : {}) }; + if (weighed) { + const other = r.ungroupedNs!.map((n) => n / 1e6), mine = r.ns!.map((n, i) => (n - r.ungroupedNs![i]) / 1e6); + let top = 0, topFrame = r.from; + mine.forEach((x, i) => { if (x > top) { top = x; topFrame = r.from + i; } }); + cost.ms = { perFrame: mine, other, peak: top, peakFrame: topFrame }; + } + return cost; + } +} + +/** "main.ts:12:5" or "12:5" → the file and line; null for a trigger no line made ("" — or, from a build before step 17, "fn:…" and "eudplib"). */ +function lineOf(origin: string): { file: string; line: number } | null { + const m = /^(?:(.*):)?(\d+):\d+$/.exec(origin); + return m ? { file: m[1] || ENTRY_FILE, line: Number(m[2]) } : null; +} + +/** One number a frame, short: 640, 12.3k, 1.0M. */ +export function shortCount(n: number): string { + if (n < 1000) return String(n); + if (n < 1e6) return `${(n / 1000).toFixed(n < 1e4 ? 1 : 0)}k`; + return `${(n / 1e6).toFixed(1)}M`; +} + +/** Milliseconds, short: 0.004, 0.31, 2.7, 15. */ +export function shortMs(ms: number): string { + return ms < 0.1 ? ms.toFixed(3) : ms < 1 ? ms.toFixed(2) : ms < 10 ? ms.toFixed(1) : ms.toFixed(0); +} + +/** + * Every test's cost folded into one per line: the most a frame the line ran in any test, and the test it was in. + * What the editor puts beside each line after a run in the game. + */ +export function costByLine(costs: { id: string; cost: TestCost }[]): (LineCost & { test: string })[] { + const out = new Map(); + for (const { id, cost } of costs) for (const l of cost.lines) { + const key = `${l.file}:${l.line}`, was = out.get(key); + if (!was || (l.ms && was.ms ? l.ms.peak > was.ms.peak : l.peak > was.peak)) out.set(key, { ...l, test: id }); + } + return [...out.values()]; +} diff --git a/compiler/gameDebug.ts b/compiler/gameDebug.ts new file mode 100644 index 0000000..a3c8ff1 --- /dev/null +++ b/compiler/gameDebug.ts @@ -0,0 +1,379 @@ +/** + * The debugger in the game (TrigScript 4, A5): one test run once in the lifted game with everything it did written + * down, so that stepping forward and back, by frame or by line, is a walk over the record and never the game again. + * + * What is recorded, frame by frame: + * + * - **Steps.** The game runs a frame's triggers one record after another; the host's cost tap says each record, in + * order, under the line the build's map of triggers puts it (A1, A4). Records of one line in a row are one step: + * "line 12 ran 9 conditions and actions". A loop's turns come out as the lines of its body, again and again. + * - **Writes.** Every dword of the script's memory a step changed (the variables' cells and the arrays, the heap, the + * made texts: everything between the start of `STRx` and the end of the payload), with what it held before and after. + * With both, a step back is the writes put back in reverse, and the variables at any step are read the way + * `sim.program()` reads them (`gameVariables.ts`), over a copy of that memory the debugger keeps. What the game wrote + * there without a record (a routine of its own) is found at the frame's end, compared, and written down as the + * frame's last step, so the copy never drifts. + * - **The test's line.** Which line of the test asked for the frame (a `sim.frames`, `until`, `seconds`). + * - **What was shown** on the text lines, by frame. + * + * And, for a picture of any frame, kept on the host's side and never sent: the game kept every so many frames + * (`TestGameHandle.keep`), and everything the test did to it between frames (its actions, typed lines, routines + * called and its own writes to memory: keys, the mouse, a scratch location). A picture of frame F is the nearest + * kept game before it, those things done again at their frames, and the frames in between played. + * + * A test is synchronous JavaScript, so a run cannot stop in the middle; a frame cannot either (the game's trigger + * loop runs to its end), which is why a step inside a frame comes from the record and not from a pause. + */ +import type { GameHandle } from "./gameSim"; +import type { TestResult } from "./testing"; + +/** Where the script's memory starts: Remastered's strings (`STRx`), the build's payload after them. */ +export const SCRIPT_MEMORY = 0x191943c8; + +/** A step's line when it is not a line of the script: the build's own triggers (a run's wake, the gates, the routes into a variable's places). */ +export const STEP_BUILD = -1; +/** What the test did between frames (its own actions), recorded as the step before the frame's first. */ +export const STEP_TEST = -3; +/** What the game changed in the script's memory with no record to say so, found at the frame's end. */ +export const STEP_UNSEEN = -4; + +/** What is recorded at most; past it the record says where it stopped, and the run goes on without it. */ +export interface DebugLimits { + steps?: number; + writes?: number; + /** What the kept games may hold together, in bytes; past it every other one goes and they are kept half as often. */ + keptBytes?: number; + /** A game kept every this many frames at first. */ + every?: number; +} +const LIMITS: Required = { steps: 4_000_000, writes: 6_000_000, keptBytes: 256 * 2 ** 20, every: 48 }; + +/** + * A test's run in the game, as plain data (structured-clone safe; the typed arrays transfer). + * + * Frame `k` of the record is game frame `first + k`. Its steps are `frameSteps[k]` to `frameSteps[k + 1]`; a step's + * writes are `stepWrites[s]` to `stepWrites[s + 1]`, three numbers each in `writes` (address, before, after). + */ +export interface DebugRecording { + version: 1; + test: { id: string; name: string; file: string; line: number }; + result: TestResult; + /** The lines a step can be, by number (a step's `stepLine`). */ + lines: { file: string; line: number }[]; + /** The script's memory as the test found it (frame `first` - 1, before its first frame): `memory.at` on. */ + memory: { at: number; bytes: Uint8Array }; + /** The frame the test started at (the game put back: 0), and the first recorded. */ + start: number; + first: number; + frames: number; + frameSteps: Int32Array; + /** By frame: the test's line that asked for it (0: none known). */ + testLine: Int32Array; + testFile: string[]; + /** By frame: an index into `testFile`. */ + testFileOf: Int32Array; + stepLine: Int32Array; + stepRecords: Int32Array; + stepWrites: Int32Array; + writes: Uint32Array; + shown: { frame: number; text: string }[]; + /** Where the record stopped short, in words, and from which frame; null when it is whole. */ + cut: { frame: number; why: string } | null; + /** The frames a picture can be had of quickly: the kept games'. */ + kept: number[]; + ms: number; +} + +/** A host's game, as the debugger needs it: a test's handle that can keep and restore, and its two taps. */ +export interface DebugHost extends GameHandle { + keep(): K; + restore(kept: K): void; + portable(kept: K): { kept: P; transfer: ArrayBuffer[] }; + readonly eud?: { listen: ((record: number, address: number, write: boolean, mask: number, was: number) => void) | null }; + readonly cost?: GameHandle["cost"] & { trace: ((record: number, group: number, action: boolean) => void) | null; paused: boolean }; +} + +/** What the test did to the game between frames, to do again on the way to a picture. */ +type Op = + | { kind: "action"; record: Uint8Array; player: number } + | { kind: "call"; address: number; registers: Record; args?: number[] } + | { kind: "chat"; text: string } + | { kind: "poke"; address: number; bytes: Uint8Array }; + +class Ints { + a: Int32Array; + n = 0; + constructor(size = 1024) { this.a = new Int32Array(size); } + push(v: number) { if (this.n === this.a.length) { const b = new Int32Array(this.a.length * 2); b.set(this.a); this.a = b; } this.a[this.n++] = v; } + done(): Int32Array { return this.a.slice(0, this.n); } +} +class Words { + a: Uint32Array; + n = 0; + constructor(size = 3072) { this.a = new Uint32Array(size); } + push3(x: number, y: number, z: number) { + if (this.n + 3 > this.a.length) { const b = new Uint32Array(this.a.length * 2); b.set(this.a); this.a = b; } + this.a[this.n++] = x; this.a[this.n++] = y; this.a[this.n++] = z; + } + done(): Uint32Array { return this.a.slice(0, this.n); } +} + +/** + * Wraps a host's handle for one test: everything `GameSim` does goes through it and is written down. `where` says the + * test's line from inside a step (the stack's innermost line of a linked file). + */ +export class RecordingHandle implements GameHandle { + private readonly host: DebugHost; + private readonly where: () => { file: string; line: number } | null; + private readonly limits: Required; + private readonly lo: number; + private readonly hi: number; + + private recording = false; + private stepping = false; + private group = Number.NaN; + private image: Uint8Array = new Uint8Array(0); + private mirror: Uint32Array = new Uint32Array(0); + private start = 0; + private first = -1; + private frameSteps = new Ints(); + private testLine = new Ints(); + private testFileOf = new Ints(); + private testFile: string[] = []; + private stepLine = new Ints(); + private stepRecords = new Ints(); + private stepWrites = new Ints(); + private writes = new Words(); + private shownLines: { frame: number; text: string }[] = []; + private cut: { frame: number; why: string } | null = null; + private keptGames: { frame: number; kept: K; bytes: number }[] = []; + private keptBytes = 0; + private every: number; + private ops = new Map(); + private lastFrame = 0; + private began = 0; + + /** `from`: where the script's memory starts (Remastered's strings; another for a host of its own, a test's). */ + constructor(host: DebugHost, where: () => { file: string; line: number } | null, limits: DebugLimits = {}, from = SCRIPT_MEMORY) { + this.host = host; + this.where = where; + this.limits = { ...LIMITS, ...limits }; + this.every = this.limits.every; + const end = host.payloadEnd ?? from; + this.lo = from; + this.hi = Math.max(from, Math.min(end, host.u8.length)) & ~3; + } + + get m() { return this.host.m; } + get u8() { return this.host.u8; } + get frame() { return this.host.frame; } + get localPlayer() { return this.host.localPlayer; } + get over() { return this.host.over; } + get cost() { return this.host.cost; } + get payloadEnd() { return this.host.payloadEnd; } + + /** The test's world is made: the game back at its start, and the record begins. */ + reset(): void { + this.host.reset(); + this.begin(); + } + + private begin() { + this.recording = true; + this.began = Date.now(); + this.start = this.host.frame; + this.first = -1; + this.image = this.host.u8.slice(this.lo, this.hi); + this.mirror = new Uint32Array(this.image.slice().buffer); + for (const k of ["frameSteps", "testLine", "testFileOf", "stepLine", "stepRecords", "stepWrites"] as const) this[k] = new Ints(); + this.writes = new Words(); + this.testFile = []; + this.shownLines = []; + this.cut = null; + this.keptGames = []; + this.keptBytes = 0; + this.every = this.limits.every; + this.ops.clear(); + this.group = Number.NaN; + this.frameSteps.push(0); + this.keepNow(); + const cost = this.host.cost, eud = this.host.eud; + if (cost) cost.trace = (_record, group) => this.ran(group); + if (eud) eud.listen = (_record, address, write, _mask, was) => { if (write) this.wrote32(address, was); }; + } + + /** Stops listening (the run is over, or a picture is being made). */ + stop(): void { + const cost = this.host.cost, eud = this.host.eud; + if (cost) cost.trace = null; + if (eud) eud.listen = null; + this.recording = false; + } + + private full(): boolean { + if (this.cut) return true; + if (this.stepLine.n >= this.limits.steps || this.writes.n / 3 >= this.limits.writes) { + this.cut = { frame: this.host.frame, why: this.stepLine.n >= this.limits.steps ? `more than ${this.limits.steps.toLocaleString("en")} steps` : `more than ${this.limits.writes.toLocaleString("en")} writes` }; + return true; + } + return false; + } + + private newStep(line: number) { + this.stepLine.push(line); + this.stepRecords.push(0); + this.stepWrites.push(this.writes.n / 3); + } + + /** A record ran under `group`. */ + private ran(group: number) { + if (!this.recording || !this.stepping || this.full()) return; + const line = group < 0 ? STEP_BUILD : group; + if (line !== this.group) { this.newStep(line); this.group = line; } + this.stepRecords.a[this.stepRecords.n - 1]++; + } + + /** A dword was written by an EUD action; `was` what it held before. */ + private wrote32(address: number, was: number) { + if (!this.recording || address < this.lo || address + 4 > this.hi || (address & 3) !== 0 || this.full()) return; + const now = this.host.m.getUint32(address, true); + const k = (address - this.lo) >>> 2; + if (now === this.mirror[k]) return; + // Between frames (the test's own action did it): the frame-to-be's first step is the test's. In a frame with no + // record said yet (a host that does not trace), the build's own. + if (!this.stepping && this.group !== STEP_TEST) { this.newStep(STEP_TEST); this.group = STEP_TEST; } + else if (this.stepping && Number.isNaN(this.group)) { this.newStep(STEP_BUILD); this.group = STEP_BUILD; } + void was; + this.writes.push3(address, this.mirror[k], now); + this.mirror[k] = now; + } + + step(): void { + if (!this.recording) { this.host.step(); return; } + const at = this.where(); + // (A frame's steps start where the last frame's ended: a step the test made between frames is the first of them.) + if (this.first < 0) this.first = this.host.frame + 1; + this.group = Number.NaN; + this.stepping = true; + try { this.host.step(); } finally { this.stepping = false; } + this.unseen(); + this.group = Number.NaN; + this.frameSteps.push(this.stepLine.n); + let file = -1; + if (at) { file = this.testFile.indexOf(at.file); if (file < 0) { file = this.testFile.length; this.testFile.push(at.file); } } + this.testLine.push(at?.line ?? 0); + this.testFileOf.push(file); + this.lastFrame = this.host.frame; + if (this.host.frame - (this.keptGames.at(-1)?.frame ?? -Infinity) >= this.every) this.keepNow(); + } + + /** The script's memory as the game left it, against the copy: what no record said is the frame's last step. */ + private unseen() { + if (this.cut) return; + const u8 = this.host.u8; + const words = u8.byteOffset % 4 === 0 && (u8.byteOffset + this.lo) % 4 === 0 ? new Uint32Array(u8.buffer, u8.byteOffset + this.lo, (this.hi - this.lo) >>> 2) : null; + const read = words ? (k: number) => words[k] : (k: number) => this.host.m.getUint32(this.lo + 4 * k, true); + const mirror = this.mirror; + let opened = false; + for (let k = 0; k < mirror.length; k++) { + const now = read(k); + if (now === mirror[k]) continue; + if (!opened) { this.newStep(STEP_UNSEEN); opened = true; } + this.writes.push3(this.lo + 4 * k, mirror[k], now); + mirror[k] = now; + } + } + + private keepNow() { + const kept = this.host.keep(); + const bytes = (kept as { bytes?: number }).bytes ?? 0; + this.keptGames.push({ frame: this.host.frame, kept, bytes }); + this.keptBytes += bytes; + if (this.keptBytes > this.limits.keptBytes && this.keptGames.length > 2) { + // Every other one goes (never the first), and from now on they are kept half as often. + this.keptGames = this.keptGames.filter((_, i) => i === 0 || i % 2 === 0); + this.keptBytes = this.keptGames.reduce((n, k) => n + k.bytes, 0); + this.every *= 2; + } + } + + private op(o: Op) { + if (!this.recording) return; + const f = this.host.frame; + const list = this.ops.get(f); + if (list) list.push(o); else this.ops.set(f, [o]); + } + + lines(): { frame: number; text: string }[] { + const l = this.host.lines(); + if (this.recording) this.shownLines.push(...l); + return l; + } + action(record: Uint8Array, player: number): number { + this.op({ kind: "action", record: record.slice(0, 32), player }); + return this.host.action(record, player); + } + chat(text: string): void { this.op({ kind: "chat", text }); this.host.chat(text); } + call(address: number, registers: Record, args?: number[]): number { + this.op({ kind: "call", address, registers: { ...registers }, ...(args ? { args: [...args] } : {}) }); + return this.host.call(address, registers, args); + } + wrote(address: number, length: number): void { + this.op({ kind: "poke", address, bytes: this.host.u8.slice(address, address + length) }); + } + + /** The record, once the test has run. */ + finish(test: DebugRecording["test"], result: TestResult, lines: DebugRecording["lines"]): DebugRecording { + this.stop(); + const frames = this.testLine.n; + // (Steps the test made after its last frame belong to no frame: the last frame's list ends before them.) + const frameSteps = this.frameSteps.done(); + this.stepWrites.push(this.writes.n / 3); + return { + version: 1, test, result, lines, + memory: { at: this.lo, bytes: this.image }, + start: this.start, first: this.first < 0 ? this.start + 1 : this.first, frames, + frameSteps, testLine: this.testLine.done(), testFile: this.testFile, testFileOf: this.testFileOf.done(), + stepLine: this.stepLine.done(), stepRecords: this.stepRecords.done(), stepWrites: this.stepWrites.done(), writes: this.writes.done(), + shown: this.shownLines, cut: this.cut, kept: this.keptGames.map((k) => k.frame), ms: Date.now() - this.began, + }; + } + + /** Where the game stands after a picture (so the next, a frame or two on, plays on from there). */ + private standing: number | null = null; + + /** + * The game at frame `frame` (clamped to the run), for another engine to draw: the nearest kept game before it, + * what the test did between frames done again, and the frames in between played. Only once the record is finished. + */ + picture(frame: number): { frame: number; kept: P; transfer: ArrayBuffer[] } { + const host = this.host; + const target = Math.max(this.start, Math.min(frame, this.lastFrame)); + const cost = host.cost; + const paused = cost?.paused ?? false; + if (cost) cost.paused = true; + try { + const from = this.keptGames.filter((k) => k.frame <= target).at(-1)!; + const here = this.standing; + // Playing on from where the last picture left the game is quicker when it is behind the target and nearer. + if (here === null || here > target || here < from.frame) host.restore(from.kept); + while (host.frame < target && !host.over) { + for (const o of this.ops.get(host.frame) ?? []) this.redo(o); + host.step(); + } + host.lines(); + this.standing = host.frame; + const kept = host.keep(); + const p = host.portable(kept); + return { frame: host.frame, kept: p.kept, transfer: p.transfer }; + } finally { if (cost) cost.paused = paused; } + } + + private redo(o: Op) { + const host = this.host; + if (o.kind === "action") host.action(o.record, o.player); + else if (o.kind === "call") host.call(o.address, o.registers, o.args); + else if (o.kind === "chat") host.chat(o.text); + else host.u8.set(o.bytes, o.address); + } +} diff --git a/compiler/gameSim.ts b/compiler/gameSim.ts new file mode 100644 index 0000000..18820ef --- /dev/null +++ b/compiler/gameSim.ts @@ -0,0 +1,526 @@ +/** + * The world a test is handed when it runs in the lifted game (TrigScript 4, A2): `sim` as the simulator's `TestSim` + * has it, but every frame is the game's own and every read is the game's memory. It drives a `GameHandle`, which + * whatever hosts the game supplies (scm-sim's `src/exact/testGame.ts`); nothing here imports the game. + * + * What a test does to the world goes through the game's own trigger actions, run between frames as the player's + * trigger would run them: Create Unit for `place`, and for one unit Kill, Remove, Give or Move with a location + * centred on it (the last of the map's 255, put back afterwards). A typed line is the game's chat command; a key and + * the mouse are the local machine's memory, where MSQC reads them. + * + * A program's variables (`program()`) are read where the build said they are (`gameVariables.ts`), when it was asked + * to say. Not in the game yet, and said so when asked: what another player was shown. A program's fault is the red + * line it prints ("TrigScript: …"), its line not known. + */ +import { ActionType } from "../vendor/triggers"; +import type { At } from "./ir"; +import { virtualKey } from "./input"; +import { programVariables, type VariableMap } from "./gameVariables"; +import type { CostLines, GameCostTap, TestCost } from "./gameCost"; +import { ExpectError, WORLD, type TestEvent, type TestWorldLike } from "./testing"; +import type { SimBounds, SimUnit } from "./world"; + +/** What a host of the lifted game hands a test (scm-sim's `TestGameHandle`). */ +export interface GameHandle { + readonly m: DataView; + readonly u8: Uint8Array; + readonly frame: number; + readonly localPlayer: number; + readonly over: boolean; + step(): void; + lines(): { frame: number; text: string }[]; + action(record: Uint8Array, player: number): number; + chat(text: string): void; + call(address: number, registers: Record, args?: number[]): number; + reset(): void; + /** What each frame of the map's triggers ran (TrigScript 4, A4), when the host counts it; `reset()` clears it. */ + readonly cost?: GameCostTap; + /** Where the build's payload ends in the game's memory, when the host knows: with the build's map of triggers, each record's line. */ + readonly payloadEnd?: number; + /** When the game is one of several copies kept in step, a copy for each of a test's players (scm-sim's `CopyHandle`). */ + readonly copy?: GameCopy; + /** + * Told after the test wrote the game's memory itself between frames (a key, the mouse, a scratch location, hit + * points), when the handle listens: the debugger (A5) keeps every such write, to play the run again to a frame. + */ + wrote?(address: number, length: number): void; +} + +/** One copy of several (TrigScript 4, A3): which, the slot each copy plays, and where they stopped agreeing. */ +export interface GameCopy { + readonly index: number; + readonly copies: number; + readonly players: readonly number[]; + readonly desync: Desync | null; + label: string | null; + linesOf(slot: number): { frame: number; text: string }[] | null; +} +export interface Desync { + frame: number; + /** Each copy's frame, when they were not even at the same one. */ + apart: number[] | null; + differences: { what: string; values: (number | null)[] }[]; + /** By copy: the lines that wrote what differs in the turn before, and those that read the local machine's own memory. */ + wrote: string[][]; + readLocal: string[][]; + players: number[]; + /** The build's payload, watched: dwords that had come to differ before the split, from which frame, by copy. */ + watched?: { address: number; since: number; values: (number | null)[] }[]; +} + +/** The script's variables by the address of their cells (`program.name` or `program #k.name`), from the build's map. */ +export function variableNames(map: VariableMap | null): Map { + const out = new Map(); + for (const p of map?.programs ?? []) { + const program = p.name ? p.name : `program #${p.index}`; + for (const v of p.vars) for (const [cell, at] of Object.entries(v.cells)) if (at !== null && at !== undefined) { + const name = `${v.id.replace(/#\d+$/, "")}${cell === "value" ? "" : ` (${cell})`}`; + if (v.rowed) for (let k = 0; k < 12; k++) out.set(at + (v.stride ?? 4) * k, `${name} of P${k + 1}, in ${program}`); + else out.set(at, `${name}, in ${program}`); + } + for (const a of p.arrays) if (a.at !== null && a.at !== undefined && !a.dynamic) { + const cells = a.length * (a.rowed ? 12 : 1); + const id = a.id.replace(/#\d+$/, ""); + for (let i = 0; i < cells && i < 4096; i++) out.set(a.at + (a.stride ?? 4) * i, `${id}[${a.rowed ? `${Math.floor(i / 12)}] of P${(i % 12) + 1}` : `${i}]`}, in ${program}`); + } + } + return out; +} + +/** "12:5" → "line 12"; "lib.ts:3:1" → "lib.ts line 3"; anything else as it is. */ +const lineWords = (origin: string) => origin.replace(/(?:^|(?<= ))(?:([^\s:]+):)?(\d+):\d+(?= |$)/g, (_m, file: string | undefined, line: string) => `${file ? `${file} ` : ""}line ${line}`); + +/** + * A desync in words: what differs, where, what each computer's triggers did in the turn before, and which of the + * script's variables had already come apart (`names`: by the address of their cells). + */ +export function desyncMessage(d: Desync, names: Map = new Map()): string { + const who = (c: number) => `P${d.players[c] + 1}'s computer`; + if (d.apart) return `the players' games stopped agreeing: they were at different frames (${d.apart.map((f, c) => `${f} at ${who(c)}`).join(", ")})`; + const first = d.differences[0]; + const what = first ? `${first.what} is ${first.values.map((v, c) => `${v ?? "?"} at ${who(c)}`).join(", ")}` : "their state differs"; + const more = d.differences.length > 1 ? ` (and ${d.differences.length - 1} more)` : ""; + const notes = d.players.map((_, c) => { + const said = [...d.wrote[c] ?? [], ...d.readLocal[c] ?? []].map(lineWords); + return said.length ? ` At ${who(c)}: ${said.slice(0, 4).join("; ")}.` : ""; + }).join(""); + const known = (d.watched ?? []).filter((w) => names.has(w.address)); + const theirs = (d.watched ?? []).length - known.length; + const before = known.length + ? ` Before that, the script's ${known.slice(0, 3).map((w) => `${names.get(w.address)} from frame ${w.since} (${w.values.map((v, c) => `${v ?? "?"} at ${who(c)}`).join(", ")})`).join("; ")}${known.length > 3 ? ` and ${known.length - 3} more` : ""} had come apart.` + : ""; + const own = theirs ? ` (${theirs} of the build's own cells differed too; some are each computer's own on purpose.)` : ""; + return `the players' games stopped agreeing at frame ${d.frame}: ${what}${more}.${notes}${before}${own}`; +} + +// 1.16.1's addresses, which Remastered's emulation keeps. +const UNITS = 0x59cca8, UNIT_BYTES = 336, UNIT_COUNT = 1700, FIRST_UNIT = 0x628430; +const ORE = 0x57f0f0, GAS = 0x57f120, DEATHS = 0x58a364, KILLS = 0x5878a4, SWITCHES = 0x58dc40, LOCATIONS = 0x58dc60; +/** The fog of war's tiles (a pointer) and the map's size in tiles. */ +const TILES = 0x6d1260, MAP_WIDTH = 0x57f1d4; +const MAX_HP = 0x662350, MAX_SHIELDS = 0x660e00, KEYS = 0x596a18, BUTTONS = 0x6cddc0, MOUSE = 0x6cddc4, SCREEN_X = 0x62848c, SCREEN_Y = 0x6284a8, SELECTION = 0x6284b8; +const SCRATCH = 255, SCRATCH_TO = 254; +/** The routine a unit that dies in the game goes through to be counted: its owner's death, the killer's kill (0x488AF0). */ +const COUNT_DEATH = 0x488af0; +const BUTTON_BITS = { left: 2, right: 8, middle: 32 } as const; +const FRAMES_PER_SECOND = 24, UNTIL_FRAMES = 2400; + +/** The frame from which the game's own locations are the map's (a build through eudplib kept them scrambled until its start put them right; a map of our own has them right from the start, and the frame is kept). */ +export const LOCATIONS_READY = 2; + +function readLocation(game: GameHandle, n: number): SimBounds | undefined { + const m = game.m, at = LOCATIONS + 20 * (n - 1); + const b = { left: m.getUint32(at, true), top: m.getUint32(at + 4, true), right: m.getUint32(at + 8, true), bottom: m.getUint32(at + 12, true) }; + return b.right > b.left || b.bottom > b.top ? b : undefined; +} + +/** + * The map's locations as the game holds them once the build's start has run: played to `LOCATIONS_READY` and put + * back to frame 0. For `GameSim`'s `locations`, and for the simulator's world of the same map. + */ +export function readLocations(game: GameHandle): Record { + game.reset(); + while (game.frame < LOCATIONS_READY && !game.over) game.step(); + const out: Record = {}; + for (let n = 1; n <= 255; n++) { const b = readLocation(game, n); if (b) out[n] = b; } + game.reset(); + return out; +} + +/** Each unit type's hit points and shields as the game's tables have them: the simulator's `unitStats` for the same map. */ +export function readUnitStats(game: GameHandle): Record { + const out: Record = {}; + for (let t = 0; t < 228; t++) out[t] = { hp: Math.floor(game.m.getInt32(MAX_HP + 4 * t, true) / 256), shields: game.m.getUint16(MAX_SHIELDS + 2 * t, true) }; + return out; +} + +/** A line as a player reads it: the game's colour codes and the build's padding (carriage returns) taken out. */ +const clean = (text: string) => text.replace(/[\x01-\x1f]/g, ""); + +/** A unit as the game holds it: every property read from its slot while it is the same unit, its last value after. */ +class GameUnit implements SimUnit { + readonly slot: number; + readonly uid: number; + private readonly at: number; + private last: Partial = {}; + private readonly game: GameHandle; + constructor(game: GameHandle, slot: number) { + this.game = game; + this.slot = slot; + this.at = UNITS + UNIT_BYTES * slot; + this.uid = game.u8[this.at + 0xa5]; + } + get alive(): boolean { + const { m, u8 } = this.game; + return u8[this.at + 0xa5] === this.uid && m.getUint32(this.at + 0x0c, true) !== 0 && u8[this.at + 0x4d] !== 0; + } + private read(key: K, f: () => SimUnit[K]): SimUnit[K] { + if (this.alive) (this.last as Record)[key] = f(); + return (this.last[key] ?? 0) as SimUnit[K]; + } + private get m() { return this.game.m; } + private flag(bit: number): boolean { return (this.m.getUint32(this.at + 0xdc, true) & bit) !== 0; } + get type() { return this.read("type", () => this.m.getUint16(this.at + 0x64, true)); } + get owner() { return this.read("owner", () => this.game.u8[this.at + 0x4c]); } + get x() { return this.read("x", () => this.m.getInt16(this.at + 0x28, true)); } + get y() { return this.read("y", () => this.m.getInt16(this.at + 0x2a, true)); } + get hp() { return this.read("hp", () => Math.ceil(this.m.getInt32(this.at + 0x08, true) / 256)); } + set hp(v: number) { if (this.alive) { this.m.setInt32(this.at + 0x08, Math.max(0, v) * 256, true); this.game.wrote?.(this.at + 0x08, 4); } } + get maxHp() { return Math.floor(this.m.getInt32(MAX_HP + 4 * this.type, true) / 256); } + get shields() { return this.read("shields", () => Math.floor(this.m.getInt32(this.at + 0x60, true) / 256)); } + get maxShields() { return this.m.getUint16(MAX_SHIELDS + 2 * this.type, true); } + get energy() { return this.read("energy", () => Math.floor(this.m.getUint16(this.at + 0xa2, true) / 256)); } + get kills() { return this.read("kills", () => this.game.u8[this.at + 0x8f]); } + get orderId() { return this.read("orderId", () => this.game.u8[this.at + 0x4d]); } + get cooldown() { return this.read("cooldown", () => this.game.u8[this.at + 0x55]); } + get resources() { return this.read("resources", () => this.m.getUint16(this.at + 0xd0, true)); } + get invincible() { return this.read("invincible", () => this.flag(0x04000000)); } + get cloaked() { return this.read("cloaked", () => this.flag(0x00000300)); } + get burrowed() { return this.read("burrowed", () => this.flag(0x00000010)); } + get hallucinated() { return this.read("hallucinated", () => this.flag(0x40000000)); } + get buildTime() { return this.read("buildTime", () => this.m.getUint16(this.at + 0xac, true)); } + get rank() { return this.read("rank", () => this.game.u8[this.at + 0x8e]); } + get matrix() { return this.read("matrix", () => this.game.u8[this.at + 0x114]); } + get speed() { return this.read("speed", () => this.m.getInt32(this.at + 0x38, true)); } + get speedUpgrade() { return this.read("speedUpgrade", () => this.flag(0x10000000)); } + get hasTarget() { return this.read("hasTarget", () => this.m.getUint32(this.at + 0x5c, true) !== 0); } + get inTransport() { return this.read("inTransport", () => this.flag(0x00000040)); } + // Not read from the game yet: the spell timers and whether the unit is under attack. + stim = 0; ensnare = 0; plague = 0; lockdown = 0; maelstrom = 0; irradiate = 0; stasis = 0; + /** The attack-notify timer (+0x87) running: the game raised its under-attack alert for the unit within the last second or so. */ + get underAttack() { return this.read("underAttack", () => this.game.u8[this.at + 0x87] !== 0); } +} + +export class GameSim implements TestWorldLike { + readonly [WORLD] = true as const; + faultsExpected = false; + private shown: { frame: number; text: string }[] = []; + private readonly units_ = new Map(); + private readonly game: GameHandle; + private readonly known: Record; + private readonly variables: VariableMap | null; + private readonly costLines: CostLines | null; + + /** + * `locations`: the map's locations as the script means them. A build through eudplib kept them scrambled until its + * first frames put them right (frame 2 on a probe's map), so a test that acts at frame 0 takes them from elsewhere + * (`readLocations`); from then on the game's own are read. `variables`: where the build put each program's + * variables, for `program()`. + */ + constructor(game: GameHandle, options: { locations?: Record; variables?: VariableMap | null; test?: { id: string; players?: number[] }; costLines?: CostLines | null } = {}) { + this.game = game; + this.costLines = options.costLines ?? null; + this.known = options.locations ?? {}; + this.variables = options.variables ?? null; + if (game.copy) { + game.copy.label = options.test?.id ?? null; + for (const p of game.copy.players) if (p !== game.localPlayer) this.shownBy.set(p, []); + } + game.reset(); + this.take(); + } + + /** In a test of several players: what each other player's computer showed, by slot. */ + private readonly shownBy = new Map(); + private take(): void { + for (const l of this.game.lines()) this.shown.push(l); + for (const [p, list] of this.shownBy) for (const l of this.game.copy!.linesOf(p) ?? []) list.push(l); + } + /** The copies stopped agreeing: the test fails here, at every computer alike. */ + private checkStep(): void { + const d = this.game.copy?.desync; + if (d) throw new ExpectError(desyncMessage(d, this.names ??= variableNames(this.variables))); + } + private names: Map | null = null; + private record(type: number, fields: { location?: number; player?: number; number?: number; unit?: number; count?: number }): Uint8Array { + const r = new Uint8Array(32), v = new DataView(r.buffer); + v.setUint32(0x00, fields.location ?? 0, true); + v.setUint32(0x10, fields.player ?? 0, true); + v.setUint32(0x14, fields.number ?? 0, true); + v.setUint16(0x18, fields.unit ?? 0, true); + r[0x1a] = type; + r[0x1b] = fields.count ?? 0; + r[0x1c] = 0x04; // (always display: the flag every action the editor writes carries) + return r; + } + private act(record: Uint8Array, player: number): void { this.game.action(record, player); this.take(); } + + get frame(): number { return this.game.frame; } + seed(_n: number): this { return this; } + /** What the test's frames cost in the game, by line; null when the host does not count it or the build gave no map of triggers. */ + cost(): TestCost | null { return this.costLines && this.game.cost ? this.costLines.read(this.game.cost) : null; } + + /* ── time ── */ + + frames(n = 1): this { + for (let i = 0; i < n && !this.game.over; i++) { this.game.step(); this.take(); this.releaseKeys(); this.checkStep(); } + return this; + } + seconds(n: number): this { return this.frames(Math.round(n * FRAMES_PER_SECOND)); } + until(done: () => unknown, most = UNTIL_FRAMES): this { + for (let i = 0; ; i++) { + if (done()) return this; + if (i >= most) throw new ExpectError(`until: still not true after ${most} frames`); + this.frames(1); + } + } + + /* ── what the test does to the world ── */ + + private slots(): Set { + const out = new Set(), m = this.game.m; + for (let p = m.getUint32(FIRST_UNIT, true), guard = 0; p && guard < UNIT_COUNT; p = m.getUint32(p + 0x04, true), guard++) out.add((p - UNITS) / UNIT_BYTES); + return out; + } + private unit(slot: number): GameUnit { + const u8 = this.game.u8, uid = u8[UNITS + UNIT_BYTES * slot + 0xa5]; + const was = this.units_.get(slot); + if (was && was.uid === uid) return was; + const u = new GameUnit(this.game, slot); + this.units_.set(slot, u); + return u; + } + + /** + * `run(location)` with a scratch location set to `b` — its four sides, and its elevation flags cleared (a flag + * left set keeps the units of that elevation out of it, and at frame 0 a build through eudplib left scrambled bits there) — then + * put back as it was, all twenty bytes. + */ + private scratch(n: number, b: SimBounds, run: (location: number) => T): T { + const u8 = this.game.u8, m = this.game.m, at = LOCATIONS + 20 * (n - 1), was = u8.slice(at, at + 20); + [b.left, b.top, b.right, b.bottom].forEach((v, i) => m.setUint32(at + 4 * i, Math.max(0, v), true)); + m.setUint16(at + 0x12, 0, true); + this.game.wrote?.(at, 20); + try { return run(n); } finally { u8.set(was, at); this.game.wrote?.(at, 20); } + } + private within255(b: SimBounds, run: (location: number) => T): T { return this.scratch(SCRATCH, b, run); } + place(player: number, type: number, at: number, n = 1): SimUnit[] { + const where = this.location(at); + if (!where) throw new ExpectError(`place: there is no location ${at}`); + const before = this.slots(); + this.within255(where, (loc) => this.act(this.record(ActionType.CreateUnit, { location: loc, player, unit: type, count: Math.min(255, n) }), player)); + return [...this.slots()].filter((s) => !before.has(s)).map((s) => this.unit(s)).filter((u) => u.type === type && u.owner === player); + } + /** One unit, by the scratch location centred on it for one action. */ + private onOne(unit: SimUnit, run: (location: number) => void): this { + if (unit.alive) this.within255({ left: unit.x - 1, top: unit.y - 1, right: unit.x + 1, bottom: unit.y + 1 }, run); + return this; + } + /** + * What a fight is in a test, as `TestSim` has it: the unit dies and counts as its owner's death, and as a kill of + * `by`'s. The game's Kill Unit counts nothing (only a death in a fight does), so the game's own routine that counts + * one is called first, the unit and the killer handed to it as the game hands them, then the unit is killed. + */ + kill(unit: SimUnit, by?: number): this { + if (!unit.alive) return this; + this.game.call(COUNT_DEATH, { edi: UNITS + UNIT_BYTES * unit.slot, edx: by ?? unit.owner, ecx: 0 }); + // A killer named is a death in a fight, as the game leaves one behind: no hit points, and the killer the last player + // whose attack hurt the unit (+0x90), which is what on.killed() reads. + if (by !== undefined) { + const at = UNITS + UNIT_BYTES * unit.slot; + // (A unit killed by its own player keeps no attacker: the game records none for friendly fire.) + this.game.m.setInt32(at + 0x08, 0, true); if (by !== unit.owner) this.game.u8[at + 0x90] = by; + this.game.wrote?.(at + 0x08, 4); this.game.wrote?.(at + 0x90, 1); + } + return this.onOne(unit, (loc) => this.act(this.record(ActionType.KillUnitAt, { location: loc, player: unit.owner, unit: unit.type, count: 1 }), unit.owner)); + } + remove(unit: SimUnit): this { + return this.onOne(unit, (loc) => this.act(this.record(ActionType.RemoveUnitAt, { location: loc, player: unit.owner, unit: unit.type, count: 1 }), unit.owner)); + } + morph(): this { throw new ExpectError("morph: the lifted game turns a unit into another type only through a player's command, which a test cannot give yet; test on.morphed() in the simulator."); } + give(unit: SimUnit, to: number): this { + return this.onOne(unit, (loc) => this.act(this.record(ActionType.GiveUnits, { location: loc, player: unit.owner, number: to, unit: unit.type, count: 1 }), unit.owner)); + } + move(unit: SimUnit, to: number | { x: number; y: number }): this { + // (The destination through a second scratch location, so that a location still scrambled at frame 0 is not used.) + const b = typeof to === "number" ? this.location(to) : { left: to.x, top: to.y, right: to.x, bottom: to.y }; + if (!b) throw new ExpectError(`move: there is no location ${String(to)}`); + return this.scratch(SCRATCH_TO, b, (dest) => this.onOne(unit, (loc) => this.act(this.record(ActionType.MoveUnit, { location: loc, player: unit.owner, number: dest, unit: unit.type, count: 1 }), unit.owner))); + } + + /* ── what the players do: at their own computer ── */ + + private held: number[] = []; + /** + * Whether what `player` does is done here. One game: only the player at this computer can act. Copies of a game + * (a test's `players`): each player acts at their own copy, and no player named is the first of them. + */ + private local(player: number | undefined, what: string): boolean { + const copy = this.game.copy; + if (!copy) { + if (player !== undefined && player !== this.game.localPlayer) throw new ExpectError(`${what}: in the game only the player at this computer (P${this.game.localPlayer + 1}) can do it; a test of several players says so: test(…, { players: [P1, P${player + 1}] }, …)`); + return true; + } + const who = player ?? copy.players[0]; + if (!copy.players.includes(who)) throw new ExpectError(`${what}: P${who + 1} is not one of this test's players (${copy.players.map((p) => `P${p + 1}`).join(", ")})`); + return who === this.game.localPlayer; + } + /** A key down for one frame, as a press is (MSQC sees it down, then up). */ + press(key: string, player?: number): this { const k = virtualKey(key); if (k === null) throw new ExpectError(`press: "${key}" is not a key the game reports`); if (!this.local(player, "press")) return this; this.game.u8[KEYS + k] = 1; this.game.wrote?.(KEYS + k, 1); this.held.push(k); return this; } + click(button: "left" | "right" | "middle" = "left", player?: number): this { + if (!this.local(player, "click")) return this; + const m = this.game.m; + m.setUint32(BUTTONS, m.getUint32(BUTTONS, true) | BUTTON_BITS[button], true); + this.game.wrote?.(BUTTONS, 4); + this.held.push(-BUTTON_BITS[button]); + return this; + } + private releaseKeys(): void { + if (!this.held.length) return; + const m = this.game.m; + for (const k of this.held.splice(0)) { + if (k >= 0) { this.game.u8[KEYS + k] = 0; this.game.wrote?.(KEYS + k, 1); } + else { m.setUint32(BUTTONS, m.getUint32(BUTTONS, true) & ~-k, true); this.game.wrote?.(BUTTONS, 4); } + } + } + type(line: string, player?: number): this { if (this.local(player, "type")) this.game.chat(line); return this; } + /** + * The mouse to a place on the map, in pixels: the game keeps it on the screen, x then y in 32 bits each (0x6CDDC4, + * 0x6CDDC8, where MSQC reads them), from the screen's corner (0x62848C, 0x6284A8). + */ + moveMouse(x: number, y: number, player?: number): this { + if (!this.local(player, "moveMouse")) return this; + const m = this.game.m; + m.setInt32(MOUSE, x - m.getInt32(SCREEN_X, true), true); + m.setInt32(MOUSE + 4, y - m.getInt32(SCREEN_Y, true), true); + this.game.wrote?.(MOUSE, 8); + return this; + } + /** + * The player selects units, as a drag would at their computer: the twelve slots of the selection the computer keeps + * (0x6284B8), each a unit's address, the rest 0 — what local.selected reads. The panel and the orders are not told. + */ + selectUnits(units: readonly SimUnit[], player?: number): this { + if (!this.local(player, "selectUnits")) return this; + const m = this.game.m; + for (let i = 0; i < 12; i++) { + const u = units[i] as (SimUnit & { slot?: number }) | undefined; + m.setUint32(SELECTION + i * 4, u && typeof u.slot === "number" ? UNITS + u.slot * UNIT_BYTES : 0, true); + } + this.game.wrote?.(SELECTION, 48); + return this; + } + /** The screen to a place on the map, its top left corner in pixels: what local.camera reads (B5). The mouse stays where it is on the map. */ + moveCamera(x: number, y: number, player?: number): this { + if (!this.local(player, "moveCamera")) return this; + const m = this.game.m; + const mx = m.getInt32(MOUSE, true) + m.getInt32(SCREEN_X, true), my = m.getInt32(MOUSE + 4, true) + m.getInt32(SCREEN_Y, true); + m.setInt32(SCREEN_X, x, true); + m.setInt32(SCREEN_Y, y, true); + m.setInt32(MOUSE, mx - x, true); + m.setInt32(MOUSE + 4, my - y, true); + this.game.wrote?.(SCREEN_X, 4); + this.game.wrote?.(SCREEN_Y, 4); + this.game.wrote?.(MOUSE, 8); + return this; + } + + /* ── the world read back ── */ + + private owners(player: number): number[] { + if (player < 12) return [player]; + if (player === 13) return [this.game.localPlayer]; + if (player === 17) return Array.from({ length: 12 }, (_, i) => i); + throw new ExpectError(`player ${player}: only one player, Current Player or All Players are read in the game yet`); + } + private within(u: SimUnit, at: number): boolean { + const b = this.location(at); + return !!b && u.x >= b.left && u.x <= b.right && u.y >= b.top && u.y <= b.bottom; + } + units(filter: { owner?: number; type?: number; at?: number } = {}): SimUnit[] { + const owners = filter.owner === undefined ? null : this.owners(filter.owner); + return [...this.slots()].sort((a, b) => a - b).map((s) => this.unit(s)) + .filter((u) => (owners === null || owners.includes(u.owner)) && (filter.type === undefined || u.type === filter.type) && (filter.at === undefined || this.within(u, filter.at))); + } + count(player: number, type: number, at?: number): number { return this.units({ owner: player, type, at }).length; } + resources(player: number): { ore: number; gas: number } { + const m = this.game.m; + return { ore: m.getUint32(ORE + 4 * player, true), gas: m.getUint32(GAS + 4 * player, true) }; + } + deaths(player: number, type: number): number { return this.game.m.getUint32(DEATHS + 4 * (player + 12 * type), true); } + kills(player: number, type: number): number { return this.owners(player).reduce((n, p) => n + this.game.m.getUint32(KILLS + 4 * (p + 12 * type), true), 0); } + switch(n: number): boolean { return ((this.game.u8[SWITCHES + (n >> 3)] >> (n & 7)) & 1) === 1; } + /** The fog of war as the game keeps it: a dword a tile from the pointer at 0x6D1260, its low byte a bit a player set while the tile is not seen now, the next one while it was never seen. */ + fog(player: number, area?: { x: number; y: number; width: number; height: number }): string[] | null { + const m = this.game.m, tiles = m.getUint32(TILES, true), width = m.getUint16(MAP_WIDTH, true), height = m.getUint16(MAP_WIDTH + 2, true); + if (!tiles || !width || player < 0 || player > 7) return null; + const { x = 0, y = 0, width: w = width, height: h = height } = area ?? {}; + const rows: string[] = []; + for (let ty = y; ty < Math.min(height, y + h); ty++) { + let row = ""; + for (let tx = x; tx < Math.min(width, x + w); tx++) { + const at = tiles + 4 * (ty * width + tx); + row += !(this.game.u8[at] & (1 << player)) ? "#" : !(this.game.u8[at + 1] & (1 << player)) ? "+" : "."; + } + rows.push(row); + } + return rows; + } + location(n: number): SimBounds | undefined { + if (n < 1 || n > 255) return undefined; + if (this.game.frame < LOCATIONS_READY) return this.known[n] ? { ...this.known[n] } : undefined; + return readLocation(this.game, n); + } + program(name: string | number = 0, player?: number): Record { + if (!this.variables) throw new ExpectError("sim.program(…): the build did not say where the programs' variables are, so the game cannot be read for them"); + return programVariables({ m: this.game.m, u8: this.game.u8, unitAt: (ptr, uid) => this.unitAt(ptr, uid) }, this.variables, name, player); + } + /** The unit a variable holds: by its pointer, while its slot still has the uniqueness byte the variable kept with it. */ + private unitAt(ptr: number, uid: number): SimUnit | null { + const slot = (ptr - UNITS) / UNIT_BYTES; + if (!Number.isInteger(slot) || slot < 0 || slot >= UNIT_COUNT || this.game.u8[ptr + 0xa5] !== ((uid >>> 8) & 0xff)) return null; + const u = this.unit(slot); + return u.alive ? u : null; + } + + /** What was shown, in order, with the game's colour codes taken out. `player`: only the player at this computer is seen. */ + printed(player?: number): string[] { + if (player === undefined) { + // (A line every computer showed at once is one line, as the simulator has it.) + const seen = new Set(); + return this.everyone().filter((l) => { const k = `${l.frame}\u0000${l.text}`; if (seen.has(k)) return false; seen.add(k); return true; }).map((l) => clean(l.text)); + } + if (player === this.game.localPlayer) return this.shown.map((l) => clean(l.text)); + const theirs = this.shownBy.get(player); + if (theirs) return theirs.map((l) => clean(l.text)); + throw new ExpectError(this.game.copy + ? `printed(P${player + 1}): P${player + 1} is not one of this test's players (${this.game.copy.players.map((p) => `P${p + 1}`).join(", ")})` + : `printed(P${player + 1}): in the game only what the player at this computer (P${this.game.localPlayer + 1}) was shown is seen; a test of several players sees each one's: test(…, { players: [P1, P${player + 1}] }, …)`); + } + /** Every player's lines, by frame (one game: the local player's). */ + private everyone(): { frame: number; text: string; player?: number }[] { + const all = [...this.shown.map((l) => ({ ...l, player: this.game.localPlayer })), ...[...this.shownBy].flatMap(([player, list]) => list.map((l) => ({ ...l, player })))]; + return all.sort((a, b) => a.frame - b.frame || a.player - b.player); + } + log(): TestEvent[] { return this.everyone().map((l) => ({ frame: l.frame, text: `Display Text Message — ${clean(l.text)}`, player: l.player ?? this.game.localPlayer })); } + get events(): TestEvent[] { return this.log(); } + /** A program's faults, as it says them in the game: a red line that begins "TrigScript:". */ + get faults(): { at: At; message: string; cycle: number; frame: number; program: number }[] { + return this.shown.filter((l) => /^\W*TrigScript: /.test(l.text)).map((l) => { + const message = clean(l.text).replace(/^TrigScript: /, ""); + // "uncaught RangeError: too far, line 12." (B4): the line it says, and the words as the simulator has them. + const uncaught = /^uncaught (.*), line (\d+)\.$/.exec(message); + return { at: { file: "", line: uncaught ? Number(uncaught[2]) : 0, column: 0 } as At, message: uncaught ? `Uncaught ${uncaught[1]}` : message, cycle: 0, frame: l.frame, program: 0 }; + }); + } +} diff --git a/compiler/gameTestEntry.ts b/compiler/gameTestEntry.ts new file mode 100644 index 0000000..3914c0a --- /dev/null +++ b/compiler/gameTestEntry.ts @@ -0,0 +1,9 @@ +/** + * `dist/gametest.js`: what a host of the lifted game imports to run a script's tests beside it (see `gameTests.ts`). + * The plugin never loads it itself; it hands its text to the host. + */ +export { debugGameTest, drive, runGameTests, type GameDebugPayload, type GameTestPayload } from "./gameTests"; +export type { DebugRecording } from "./gameDebug"; +export { mergeCopies } from "./gameCopies"; +export { desyncMessage, GameSim, readLocations, readUnitStats, type Desync, type GameCopy, type GameHandle } from "./gameSim"; +export { programVariables, readVariableMap, type VariableMap } from "./gameVariables"; diff --git a/compiler/gameTests.ts b/compiler/gameTests.ts new file mode 100644 index 0000000..0d6ece6 --- /dev/null +++ b/compiler/gameTests.ts @@ -0,0 +1,136 @@ +/** + * A script's tests run in the lifted game (TrigScript 4, A2), where the game is: beside the engine, in whatever + * worker hosts it, with no TypeScript there. The compile hands over the script as JavaScript (`CompileResult.modules`) + * and the names it was compiled with; this runs the modules again to register the tests, and runs each with a + * `GameSim` over the host's `GameHandle`, the game put back to frame 0 before each. What it returns is plain data, + * the `TestReport` the editor already shows for the simulator. + * + * Bundled on its own as `dist/gametest.js`, which the plugin hands to the host as text. + */ +import { ENTRY_FILE, entryOf } from "./compiler"; +import { MODULE_NAME } from "./api"; +import { GameSim, readLocations, type GameHandle } from "./gameSim"; +import type { TriggerMap, VariableMap } from "./gameVariables"; +import { CostLines } from "./gameCost"; +import { RemasteredWeigh } from "./remasteredCost"; +import { EudTracer, type EudTraceHost, type EudTraceRow } from "./eudError"; +import { RecordingHandle, type DebugHost, type DebugLimits, type DebugRecording } from "./gameDebug"; +import { here, locate, runModules, type LinkedFile } from "./link"; +import type { ScriptNames } from "./names"; +import { Collector, createRuntime } from "./runtime"; +import { isTestFile, listTests, runTests, TestRegistry, TESTING_NAMES, type TestReport, type TestResult } from "./testing"; +export { mergeCopies } from "./gameCopies"; +import type { SimBounds } from "./world"; + +export interface GameTestPayload { + modules: [string, LinkedFile][]; + names: ScriptNames; + /** Run only these: tests of these files, or with these ids. Absent: all. */ + files?: string[]; + ids?: string[]; + /** The map's locations as the script means them; read from the game (frame 2) when absent. */ + locations?: Record; + /** Where the build put each program's variables (`readVariableMap` of its log), for `sim.program()`. */ + variables?: VariableMap | null; + /** The build's map of triggers (`readTriggerMap` of its log): with it, what each test's frames cost is read by line. */ + triggers?: TriggerMap | null; + /** + * Every EUD read and write written down by line (`EudTracer`), and returned as the report's `trace`: what an "EUD + * ERROR" code is looked up in. Needs `triggers` and a host with both taps; said in `traceError` when it has not. + */ + trace?: boolean; + /** Before the tests, the map played this many frames with nobody at it (a script with no tests is traced so). */ + play?: number; +} + +/** What `runGameTests` returns: the report, and with `trace` asked, the run's reads and writes by line. */ +export type GameTestReport = TestReport & { error?: string; trace?: { rows: EudTraceRow[]; frames: number }; traceError?: string }; + +/** The modules run again to register the tests: the registry, or what stopped them. */ +function register(payload: GameTestPayload) { + const linked = new Map(payload.modules); + const collector = new Collector(); + const registry = new TestRegistry(); + const where = (err: unknown) => { const at = locate(err, linked); return at.file ? { file: at.file, line: at.line ?? 1, ...(at.column ? { column: at.column } : {}) } : null; }; + registry.where = () => where(new Error()) ?? { file: ENTRY_FILE, line: 1 }; + const runtime = { ...createRuntime(payload.names, collector), ...registry.library() }; + collector.running = true; + const failure = runModules(linked, entryOf(linked.keys()), runtime, MODULE_NAME, { imported: TESTING_NAMES, then: [...linked.keys()].filter(isTestFile).sort() }); + collector.running = false; + return { linked, registry, where, error: failure ? `${failure.file ?? ENTRY_FILE}:${failure.line ?? 1}: ${failure.message}` : null }; +} + +export function runGameTests(game: GameHandle, payload: GameTestPayload): GameTestReport { + // (What a frame costs, where the host counts it and knows where the payload is.) + const costLines = payload.triggers && game.cost && game.payloadEnd ? new CostLines(payload.triggers, game.payloadEnd) : null; + if (costLines && game.cost) game.cost.group = costLines.group; + // (… and in time, where the host's tap can weigh: what Remastered spends on each trigger and record.) + if (costLines && game.cost && "weigh" in game.cost) game.cost.weigh = new RemasteredWeigh(game.m); + const host = game as GameHandle & EudTraceHost; + const traceError = !payload.trace ? undefined : !costLines ? "the build said nothing of where its triggers are" : !EudTracer.can(host) ? "the engine's page is from before EUD traces" : undefined; + const tracer = payload.trace && !traceError ? new EudTracer(host, costLines) : null; + let played = 0; + tracer?.start(); + try { + if (payload.play) { + game.reset(); + while (played < payload.play && !game.over) { game.step(); played++; } + } + const report: GameTestReport = payload.modules.length ? runRegistered(game, payload, costLines) : { list: [], results: [], only: [], ms: 0 }; + if (tracer) report.trace = { rows: tracer.rows(), frames: played + report.results.reduce((n, r) => n + r.frames, 0) }; + if (traceError) report.traceError = traceError; + return report; + } finally { + tracer?.stop(); + } +} + +function runRegistered(game: GameHandle, payload: GameTestPayload, costLines: CostLines | null): GameTestReport { + const { registry, where, error } = register(payload); + if (error) return { list: [], results: [], only: [], ms: 0, error }; + const locations = payload.locations ?? readLocations(game); + // A test of several players runs only where the game is one of several copies, one a player (A3); one game says so. + const several = listTests(registry).list.filter((t) => t.kind === "test" && t.players); + const ids = game.copy ? payload.ids : payload.ids ?? listTests(registry).list.filter((t) => t.kind === "test" && !t.players).map((t) => t.id); + const report: GameTestReport = runTests(registry, { triggers: [], sources: [], ir: [], strings: [], locate: where }, { files: payload.files, ids, makeWorld: (_as, test) => new GameSim(game, { locations, variables: payload.variables, test, costLines }) }); + if (!game.copy && !payload.ids) { + const held: TestResult[] = several.filter((t) => !payload.files || payload.files.includes(t.file)).map((t) => ({ id: t.id, status: "skipped", message: `a test of ${t.players!.length} players runs as a copy of the game for each`, printed: [], events: [], frames: 0, ms: 0 })); + report.results.push(...held); + } + return report; +} + +/** What a copy of the game runs (scm-sim's `runCopies` driver): the same tests, at this copy's computer. */ +export function drive(game: GameHandle, payload: GameTestPayload): GameTestReport { + return runGameTests(game, payload); +} + + +/** What `debugGameTest` is asked: the tests' payload and the one test to run. */ +export interface GameDebugPayload extends GameTestPayload { + test: string; + limits?: DebugLimits; +} + +/** + * One test run in the game with everything written down (A5, `gameDebug.ts`): the record goes to the editor, and the + * session stays with the host for pictures of the run's frames (`picture`). Throws when the test cannot be run so. + */ +export function debugGameTest(game: DebugHost, payload: GameDebugPayload): { recording: DebugRecording; picture(frame: number): { frame: number; kept: P; transfer: ArrayBuffer[] }; close(): void } { + const { linked, registry, where, error } = register(payload); + if (error) throw new Error(error); + const info = listTests(registry).list.find((t) => t.id === payload.test); + if (!info || info.kind !== "test") throw new Error(`There is no test with the id ${payload.test} in the script any more.`); + if (info.players) throw new Error(`"${info.name}" is a test of ${info.players.length} players: the debugger runs one game, and a test of several players runs as a copy for each. Not yet.`); + const locations = payload.locations ?? readLocations(game); + const costLines = payload.triggers && game.cost && game.payloadEnd ? new CostLines(payload.triggers, game.payloadEnd) : null; + if (costLines && game.cost) game.cost.group = costLines.group; + // (… and in time, where the host's tap can weigh: what Remastered spends on each trigger and record.) + if (costLines && game.cost && "weigh" in game.cost) game.cost.weigh = new RemasteredWeigh(game.m); + // (The test's line from inside a step: the stack's innermost line of a file of the script.) + const recorder = new RecordingHandle(game, () => here(linked), payload.limits); + const report = runTests(registry, { triggers: [], sources: [], ir: [], strings: [], locate: where }, { ids: [info.id], makeWorld: (_as, test) => new GameSim(recorder, { locations, variables: payload.variables, test, costLines }) }); + const result = report.results.find((r) => r.id === info.id) ?? { id: info.id, status: "skipped", message: "the test did not run", printed: [], events: [], frames: 0, ms: 0 } as TestResult; + const recording = recorder.finish({ id: info.id, name: info.name, file: info.file, line: info.line }, result, costLines ? [...costLines.places] : []); + return { recording, picture: (frame) => recorder.picture(frame), close: () => recorder.stop() }; +} diff --git a/compiler/gameVariables.ts b/compiler/gameVariables.ts new file mode 100644 index 0000000..17d6b1a --- /dev/null +++ b/compiler/gameVariables.ts @@ -0,0 +1,188 @@ +/** + * Where a built map keeps each program's variables, and reading them back out of the game's memory (TrigScript 4, + * A2): `sim.program()` for a test that runs in the lifted game. The lowering says where every cell is when the build + * asks it to (the `variableMap` setting of `python/trigscript.py`), in lines of the build's log; this reads those + * lines, and reads a program's variables by the names in the source as the simulator's `ProgramRun.lookup` does. + * + * Two things differ from the simulator, both because the game has every cell from the start: a variable not yet + * declared reads as its zero rather than as missing, and of two declarations with one name the later in the source + * wins rather than the later to run. A list inside a list and a row of a grid are not read (a record of them is). + */ +import type { SimUnit } from "./world"; + +export interface GameVariable { + id: string; + kind: "number" | "boolean" | "unit" | "text"; + /** How a text is kept: `id` a string of the table, `made` characters somewhere in memory. */ + text?: "id" | "made" | null; + bits?: 8 | 16 | null; + unsigned: boolean; + /** A per-player program's own: each cell is a row of twelve, one a player. */ + rowed: boolean; + /** The address of each of its cells (`value`; `addr` + `len`; `ptr` + `uid`), null for one nothing in the map uses. */ + cells: Record; + /** Bytes from a row's cell to the next (4; 72 where the rows are an EUDVArray, a variable each); 4 when not said. */ + stride?: number; + /** A map of our own keeps a unit's `ptr` and a made text's `addr` as EPDs (dwords from the deaths table), not addresses. */ + epd?: boolean; + /** A map of our own keeps a text of the table as its number in the pool of texts known when built: the table's string `textBase` + it. */ + textBase?: number; +} + +/** The deaths table: what an EPD counts dwords from. */ +const DEATHS = 0x58a364; + +export interface GameArray { + id: string; + kind: "number" | "boolean"; + bits?: 8 | 16 | null; + unsigned: boolean; + length: number; + rowed: boolean; + /** Where the cells are; or, of one that grows, its handle's `ptr` (its block among the heap's cells) and `len`. */ + at?: number | null; + dynamic?: boolean; + ptr?: number | null; + len?: number | null; + /** Bytes from a cell to the next, or from a row's handle to the next (4; 72 for an EUDVArray); 4 when not said. */ + stride?: number; +} + +export interface GameProgramPlaces { + index: number; + name?: string | null; + perPlayer: boolean; + owners: number[]; + vars: GameVariable[]; + arrays: GameArray[]; +} + +export interface VariableMap { + /** The heap's first cell, where made texts keep theirs; null when nothing grows. */ + heap: number | null; + /** + * Where the growing arrays' first cell keeps its value: an EUDVArray as long as the heap (blocks are handed out of + * both alike), 72 bytes a cell; null when nothing grows (or the build kept them on the heap, as 3.x did). + */ + cells?: number | null; + /** Where the map's strings are: a text of the table is read through it. */ + strings: number; + programs: GameProgramPlaces[]; +} + +const HEAD = "TRIGSCRIPT-VARS ", PROGRAM = "TRIGSCRIPT-PROGRAM "; + +/** A line of the build's log that is for a run in the lifted game and not for a person (the trigger map's too). */ +export const isMachineLine = (line: string): boolean => /^TRIGSCRIPT-[A-Z]+ /.test(line); + +/** The variable map in a build's log; null when the build did not say one (or said it cut short). */ +export function readVariableMap(lines: Iterable): VariableMap | null { + let head: { programs: number; heap: number | null; cells?: number | null; strings: number } | null = null; + const programs: GameProgramPlaces[] = []; + for (const line of lines) { + if (line.startsWith(HEAD)) head = JSON.parse(line.slice(HEAD.length)); + else if (line.startsWith(PROGRAM)) programs.push(JSON.parse(line.slice(PROGRAM.length))); + } + if (!head || programs.length !== head.programs) return null; + return { heap: head.heap, ...(head.cells != null ? { cells: head.cells } : {}), strings: head.strings, programs: programs.sort((a, b) => a.index - b.index) }; +} + +/** Where cell `i` of a growing array's block (`ptr`, its place among the cells) keeps its value; null when nothing grows. */ +export function listCellAt(map: VariableMap, ptr: number, i: number): number | null { + if (map.cells != null) return map.cells + 72 * (ptr + i); + return map.heap == null ? null : map.heap + 4 * (ptr + i); +} + +/** What reading needs of the game: its memory, and a unit by its pointer and the uniqueness byte kept with it. */ +export interface VariableReader { + m: DataView; + u8: Uint8Array; + unitAt(ptr: number, uid: number): SimUnit | null; +} + +const MOST_TEXT = 1 << 16; +const utf8 = new TextDecoder(); + +/** The program's variables by their names in the source, as `TestSim.program()` gives them. Throws for no such program. */ +export function programVariables(game: VariableReader, map: VariableMap, name: string | number = 0, player?: number): Record { + const p = map.programs.find((x) => (typeof name === "number" ? x.index === name : x.name === name) && (player === undefined || x.owners.includes(player))); + if (!p) { + const known = [...new Set(map.programs.map((x) => x.name).filter(Boolean))]; + throw new Error(typeof name === "number" + ? `There is no program ${name}: the script has ${map.programs.length}.` + : `There is no program named "${name}"${player !== undefined ? ` that runs for P${player + 1}` : ""}. ${known.length ? `Named: ${known.join(", ")}.` : "A program is named by its option: program(() => { … }, { name: \"waves\" })."}`); + } + const who = player ?? p.owners[0] ?? 0; + const { m, u8 } = game; + const raw = (at: number | null | undefined, rowed: boolean, stride = 4): number => (at == null ? 0 : m.getUint32(rowed ? at + stride * who : at, true)); + const num = (v: number, t: { kind: string; bits?: number | null; unsigned: boolean }): number | boolean => (t.kind === "boolean" ? v !== 0 : t.bits || t.unsigned ? v >>> 0 : v | 0); + const cString = (at: number): string => { let end = at; while (end < u8.length && end - at < MOST_TEXT && u8[end] !== 0) end++; return utf8.decode(u8.subarray(at, end)); }; + // (A map of our own keeps where a unit or a made text is as an EPD: the address is the deaths table's plus four times it.) + const where = (v: GameVariable, cell: number | null | undefined): number => { const x = raw(cell, v.rowed, v.stride); return x && v.epd ? (DEATHS + 4 * x) >>> 0 : x; }; + const text = (v: GameVariable): string => { + if (v.text === "made") { const at = where(v, v.cells.addr); return at ? cString(at) : ""; } + const id = raw(v.cells.value, v.rowed, v.stride) + (v.textBase ?? 0); + return id ? cString(map.strings + m.getUint32(map.strings + 4 * id, true)) : ""; + }; + const value = (v: GameVariable): unknown => { + // (A map of our own keeps the uniqueness byte itself; a build through eudplib kept the word it sits in, the byte above.) + if (v.kind === "unit") { const ptr = where(v, v.cells.ptr); const uid = raw(v.cells.uid, v.rowed, v.stride); return ptr ? game.unitAt(ptr, v.epd ? uid << 8 : uid) : null; } + if (v.kind === "text") return text(v); + return num(raw(v.cells.value, v.rowed, v.stride), v); + }; + const cells = (a: GameArray): (number | boolean)[] => { + if (a.dynamic) { + if (map.heap == null && map.cells == null) return []; + const ptr = raw(a.ptr, a.rowed, a.stride), n = Math.min(raw(a.len, a.rowed, a.stride), MOST_TEXT); + return Array.from({ length: n }, (_, i) => num(m.getUint32(listCellAt(map, ptr, i)!, true), a)); + } + if (a.at == null) return new Array(a.length).fill(num(0, a)); + const row = a.rowed ? who * a.length : 0; + const stride = a.stride ?? 4; + return Array.from({ length: a.length }, (_, i) => num(m.getUint32(a.at! + stride * (row + i), true), a)); + }; + + // As the simulator looks a name up: a number or a boolean, a text, an array, a unit, then a record of fields. + const named = (id: string, n: string) => id === n || id.startsWith(`${n}#`); + const last = (xs: T[], n: string): T | undefined => { let found: T | undefined; for (const x of xs) if (named(x.id, n)) found = x; return found; }; + const lookup = (n: string): unknown => { + const plain = last(p.vars.filter((v) => v.kind === "number" || v.kind === "boolean"), n); + if (plain) return value(plain); + const t = last(p.vars.filter((v) => v.kind === "text"), n); + if (t) return text(t); + const a = last(p.arrays, n); + if (a) return cells(a); + const u = last(p.vars.filter((v) => v.kind === "unit"), n); + if (u) return value(u); + const field = (id: string) => (id.startsWith(`${n}.`) ? id.slice(n.length + 1).split("#")[0] : null); + const record: Record = {}; + for (const v of p.vars) { const f = field(v.id); if (f && v.kind !== "unit" && !(v.kind !== "text" && f.includes(" ("))) record[f] = value(v); } + if (Object.keys(record).length) return record; + const rows: Record[] = []; + for (const arr of p.arrays) { const f = field(arr.id); if (f) cells(arr).forEach((cell, i) => { (rows[i] ??= {})[f] = cell; }); } + return rows.length ? rows : undefined; + }; + return new Proxy({}, { get: (_, key) => (typeof key === "string" ? lookup(key) : undefined), has: (_, key) => typeof key === "string" && lookup(key) !== undefined }); +} + +/** Where each trigger the build made sits in the payload, and the line it came from. */ +export interface TriggerMap { + /** The payload's length in bytes: it ends where the strings section does. */ + payload: number; + /** + * Offset in the payload, conditions, actions, origin ("line:column", "file:line:column", or "" for a trigger no statement made) — + * and, for a trigger of the emitter's that holds several statements' work, each action's origin. + */ + triggers: [number, number, number, string, string[]?][]; +} + +/** The trigger map in a build's log (TRIGSCRIPT-MAP and its TRIGSCRIPT-ROWS); null when there is none or it is cut short. */ +export function readTriggerMap(lines: Iterable): TriggerMap | null { + let head: { payload: number; count: number } | null = null; + const triggers: TriggerMap["triggers"] = []; + for (const line of lines) { + if (line.startsWith("TRIGSCRIPT-MAP ")) head = JSON.parse(line.slice("TRIGSCRIPT-MAP ".length)); + else if (line.startsWith("TRIGSCRIPT-ROWS ")) triggers.push(...JSON.parse(line.slice("TRIGSCRIPT-ROWS ".length))); + } + return head && triggers.length === head.count ? { payload: head.payload, triggers } : null; +} diff --git a/compiler/generated/stdlib.ts b/compiler/generated/stdlib.ts new file mode 100644 index 0000000..9aa321b --- /dev/null +++ b/compiler/generated/stdlib.ts @@ -0,0 +1,13 @@ +// Generated by scripts/embed-stdlib.mts from stdlib/*.ts and *.tsx — do not edit. +export const STDLIB_SOURCES: Readonly> = { + "cutscene": "/**\n * Scenes acted out in the game: the standard library's \"trigscript/cutscene\" (TrigScript 4).\n *\n * import { Camera, walk, line, play, SECOND } from \"trigscript/cutscene\";\n *\n * async function intro(raider: Unit) {\n * const cam = new Camera(raider.x, raider.y);\n * await line(units.JimRaynorMarine, \"Something's moving out there.\", 3 * SECOND);\n * const there = start(walk(raider, 1200, 640)); // the walk goes on while the camera follows\n * await cam.follow(raider, 2 * SECOND);\n * await there;\n * await cam.pan(1600, 400, 2 * SECOND);\n * await cam.shake(12);\n * }\n *\n * program(async () => {\n * const raider = first(unitsAt(locations.Base, { owner: P1, type: units.TerranMarine }));\n * if (!raider) return;\n * const skipped = await play(P1, start(intro(raider))); // Escape skips the rest of it\n * if (skipped) print(\"Skipped.\");\n * });\n *\n * Everything here is what a trigger can do, done a frame at a time: the camera is Center View on a location the library\n * keeps for itself, moved to where the camera should look; a unit walks by a Move order to another; a line is the\n * speaker's portrait talking and the words in the middle of the screen. Places are pixels (32 a tile), (0, 0) the map's\n * top left; time is frames of the game, 24 a second on Fastest (`SECOND`). The camera is the one of the player the\n * program runs as, as Center View's is.\n */\nimport { centerLocation, centerView, reserveLocation, talkingPortrait, print, sleep, frames, nextFrame, keyPressed, type Player, type Task, type Unit, type UnitType } from \"trigscript\";\nimport { easeInOutQuad, easeInQuad, easeOutQuad, easeOutBack } from \"trigscript/math\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\nconst VIEW = reserveLocation(\"cutscene: where the camera looks\");\nconst GOAL = reserveLocation(\"cutscene: where a unit is sent\");\n\n/** How a glide speeds up and slows down: evenly, starting slow, ending slow, both, or ending past the mark and back. */\nexport type Ease = \"linear\" | \"in\" | \"out\" | \"inOut\" | \"back\";\n\n/** How far along a glide is at `t` (0 at its start, 1 at its end), as `ease` has it. */\nfunction eased(t: fixed, ease: Ease): fixed {\n if (ease === \"in\") return easeInQuad(t);\n if (ease === \"out\") return easeOutQuad(t);\n if (ease === \"inOut\") return easeInOutQuad(t);\n if (ease === \"back\") return easeOutBack(t);\n return t;\n}\n\n/**\n * The camera of the player the program runs as: where it looks, and how it gets somewhere else. It looks at its first\n * place as soon as it is made.\n */\nexport class Camera {\n /** The point the camera is centred on, in pixels. */\n x: number;\n y: number;\n\n constructor(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /** Look at a point, now. */\n look(x: number, y: number) {\n this.x = x;\n this.y = y;\n centerLocation(VIEW, x, y);\n centerView(VIEW);\n }\n\n /**\n * Glide to a point over `time` frames, looking at each place on the way a frame at a time.\n * @param ease How it speeds up and slows down; \"inOut\" unless it says.\n */\n async pan(x: number, y: number, time: number, ease: Ease = \"inOut\") {\n const fromX = this.x;\n const fromY = this.y;\n let i = 1;\n while (i <= time) {\n const k = eased(fixed(i) / time, ease);\n this.look(fromX + Math.round((x - fromX) * k), fromY + Math.round((y - fromY) * k));\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n\n /** Keep a unit in the middle of the screen for `time` frames, or until it is gone. */\n async follow(u: Unit, time: number) {\n let i = 0;\n while (i < time && u) {\n this.look(u.x, u.y);\n await nextFrame();\n i++;\n }\n }\n\n /** Shake for `time` frames, `size` pixels each way (8 unless it says), and settle where it was. */\n async shake(time: number, size: number = 8) {\n const x = this.x;\n const y = this.y;\n let i = 0;\n while (i < time) {\n const left = (i % 4) < 2;\n const up = (i % 2) === 0;\n centerLocation(VIEW, left ? x - size : x + size, up ? y - size : y + size);\n centerView(VIEW);\n await nextFrame();\n i++;\n }\n this.look(x, y);\n }\n}\n\n/**\n * Send a unit to a point by a Move order, and wait until it is there — within `near` pixels (48 unless it says) — or\n * `limit` frames have passed (30 seconds unless it says), or it is gone.\n * @returns Whether it got there.\n */\nexport async function walk(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"move\", near, limit);\n}\n\n/** The same with an Attack order: the unit fights what it meets on the way. */\nexport async function charge(u: Unit, x: number, y: number, near: number = 48, limit: number = 30 * SECOND): Promise {\n return go(u, x, y, \"attack\", near, limit);\n}\n\nasync function go(u: Unit, x: number, y: number, how: \"move\" | \"attack\", near: number, limit: number): Promise {\n centerLocation(GOAL, x, y);\n u.order(how, GOAL);\n let waited = 0;\n while (u && Math.abs(u.x - x) + Math.abs(u.y - y) > near && waited < limit) {\n await nextFrame();\n waited++;\n }\n return !!u && Math.abs(u.x - x) + Math.abs(u.y - y) <= near;\n}\n\n/**\n * Send every unit of a group to a point together, and wait until each is there, gone, or `limit` frames have passed.\n * @returns How many got there.\n */\nexport async function walkAll(group: Unit[], x: number, y: number, near: number = 64, limit: number = 30 * SECOND): Promise {\n centerLocation(GOAL, x, y);\n for (const u of group) if (u) u.order(\"move\", GOAL);\n let waited = 0;\n let there = 0;\n while (waited < limit) {\n there = 0;\n let moving = 0;\n for (const u of group) {\n if (!u) continue;\n if (Math.abs(u.x - x) + Math.abs(u.y - y) <= near) there++;\n else moving++;\n }\n if (moving === 0) break;\n await nextFrame();\n waited++;\n }\n return there;\n}\n\n/**\n * A line of the scene: the speaker's portrait talks and the words show in the middle of the screen, for `time` frames\n * (3 seconds unless it says), and then the scene goes on.\n */\nexport async function line(speaker: UnitType, text: string, time: number = 3 * SECOND) {\n talkingPortrait(speaker, Math.round((time * 1000) / SECOND));\n print(text, { position: \"center\" });\n await sleep(frames(time));\n}\n\n/**\n * Play a scene, started as a task (`start(intro())`), for a player who may skip it: when they press `key` (Escape unless\n * it says) the scene stops where it is. Put things where the scene would have left them when it gives true.\n * @returns Whether it was skipped.\n */\nexport async function play(player: Player, scene: Task, key: \"Escape\" | \"Space\" | \"Enter\" = \"Escape\"): Promise {\n while (scene.running) {\n if (keyPressed(player, key)) {\n scene.cancel();\n return true;\n }\n await nextFrame();\n }\n return false;\n}\n", + "fog": "/**\n * Fog of war as a picture: the standard library's \"trigscript/fog\" (TrigScript 4).\n *\n * import { fog } from \"trigscript/fog\";\n *\n * const heart = fog.picture([\".##.##.\", \"#######\", \".#####.\", \"..###..\"]);\n *\n * program(async () => {\n * fog.reveal(P1); // the whole map lit once: from now on dark is fog, not black\n * while (true) {\n * fog.light(P1, 30, 20, 5); // a disc of size 5 around tile (30, 20), this frame only\n * fog.light(P1, 60, 40, heart); // a picture, its top left on tile (60, 40)\n * await nextFrame();\n * }\n * });\n *\n * The fog is a canvas that starts every frame dark: what a program lights this frame is what shows this frame, and\n * nothing else needs doing. There is no dark brush — the game's sight only ever reveals — so dark is whatever is not\n * lit; to draw dark shapes, light around them (a picture with `{ ink: \"dark\" }`). Dark is also the sharper ink: a lit\n * part is at least a brush wide (3 tiles across, 4 down), while a dark line one tile wide stays exactly that. Nothing\n * else needs doing — the library writes the game's vision countdown after every frame (memory.vision.countdown), so\n * the next frame starts from a wiped board. Watch it on the minimap. With Classic graphics (Options > Graphics) a tile\n * of the map is a pixel there, sharp; Remastered graphics blurs it a few tiles wide.\n *\n * What the board is made of decides how far apart lit and fog look. Remastered draws a tile on the minimap as the\n * average of its art and fog as 0.64 of lit, whatever the tile, so the brightest art is the best board there: plain\n * Ice snow (lit 130 against fog 82, measured 2026-10-19). Classic draws a tile as one colour of its own; Badlands\n * tile 23778 is the widest apart there, and dim and textured in Remastered. Snow played the better of the two.\n *\n * How it draws, played in StarCraft: Remastered (2026-10-17): a brush is a unit made on a tile and taken away in the\n * same trigger pass; the tiles its sight revealed stay lit through the frame. A brush's size is the unit's sight\n * range, so each size is a unit type of its own — the flying heroes below, which no ordinary map places — whose sight\n * is set every frame (Remastered takes a sight write a frame late, so one type cannot change size within a frame).\n * Coordinates are tiles, (0, 0) the map's top left. About 800 brushes a frame is the most the game makes: it keeps a\n * removed unit's place until the next frame.\n */\nimport { game, program, sleep, frames, stats, units, createUnit, removeUnit, centerLocation, reserveLocation, memory, cover, nextFrame, type Player, type UnitType, type Video } from \"trigscript\";\n\n/**\n * Each brush's shape, sizes 1 to 11: the tiles a flying unit of that sight range reveals, read out of the game.\n * \"@\" is the tile the brush is centred on. The game's air sight leaves the centre's row one short below, so every\n * shape reaches a row further up than down.\n */\nexport const SHAPES: readonly (readonly string[])[] = [\n // 1\n [\"###\", \"###\", \"#@#\", \"###\"],\n // 2\n [\".###.\", \"#####\", \"#####\", \"##@##\", \"#####\", \"#####\"],\n // 3\n [\"..###..\", \"#######\", \"#######\", \"#######\", \"###@###\", \"#######\", \"#######\", \"#######\"],\n // 4\n [\"..#####...\", \".#######..\", \"#########.\", \"##########\", \"##########\", \"####@#####\", \"##########\", \"##########\", \"#########.\", \".#######..\", \"......#...\"],\n // 5\n [\"...#####....\", \"..#######...\", \".#########..\", \"###########.\", \"############\", \"############\", \"#####@######\", \"############\", \"############\", \"###########.\", \".#########..\", \"..#######...\", \".......#....\"],\n // 6\n [\"....#####.....\", \"..#########...\", \".###########..\", \"#############.\", \"#############.\", \"##############\", \"##############\", \"######@#######\", \"##############\", \"##############\", \"#############.\", \"#############.\", \".###########..\", \"..#########...\", \"........#.....\"],\n // 7\n [\".....#####......\", \"...#########....\", \"..###########...\", \".#############..\", \"###############.\", \"###############.\", \"################\", \"################\", \"#######@########\", \"################\", \"################\", \"###############.\", \"###############.\", \".#############..\", \"..###########...\", \"...#########....\", \".........#......\"],\n // 8\n [\"......#####.......\", \"....#########.....\", \"..#############...\", \".###############..\", \".###############..\", \"#################.\", \"#################.\", \"##################\", \"##################\", \"########@#########\", \"##################\", \"##################\", \"#################.\", \"#################.\", \".###############..\", \".###############..\", \"..#############...\", \"....#########.....\", \"..........#.......\"],\n // 9\n [\"......#######.......\", \"....###########.....\", \"...#############....\", \"..###############...\", \".#################..\", \"###################.\", \"###################.\", \"####################\", \"####################\", \"####################\", \"#########@##########\", \"####################\", \"####################\", \"####################\", \"###################.\", \"###################.\", \".#################..\", \"..###############...\", \"...#############....\", \"....###########.....\", \"........#####.......\"],\n // 10\n [\".......#######........\", \".....###########......\", \"....#############.....\", \"..#################...\", \"..#################...\", \".###################..\", \"#####################.\", \"#####################.\", \"######################\", \"######################\", \"######################\", \"##########@###########\", \"######################\", \"######################\", \"######################\", \"#####################.\", \"#####################.\", \".###################..\", \"..#################...\", \"..#################...\", \"....#############.....\", \".....###########......\", \".........#####........\"],\n // 11\n [\"........#######.........\", \"......###########.......\", \"....###############.....\", \"...#################....\", \"..###################...\", \".#####################..\", \".#####################..\", \"#######################.\", \"#######################.\", \"########################\", \"########################\", \"########################\", \"###########@############\", \"########################\", \"########################\", \"########################\", \"#######################.\", \"#######################.\", \".#####################..\", \".#####################..\", \"..###################...\", \"...#################....\", \"....###############.....\", \"......###########.......\", \"..........#####.........\"],\n];\n\n/** Each size's unit, size 1 first: flying heroes without a sight upgrade. A map that places one gets odd sight from it. */\nconst BRUSHES: readonly UnitType[] = [\n units.KukulzaMutalisk, units.KukulzaGuardian, units.YggdrasillOverlord, units.MagellanScienceVessel,\n units.GantrithorCarrier, units.HyperionBattlecruiser, units.NoradIIBattlecruiser, units.TomKazanskyWraith,\n units.DanimothArbiter, units.MojoScout, units.ArtanisScout,\n];\n\n/** Where each brush is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"fog: where each brush is made\");\n\n/**\n * One disc: the tiles a brush of `size` covers (SHAPES), centred on tile (x, y). What light() does with a size. A brush\n * is a unit made where SPOT is and taken away again at once; each branch makes its own size's, so that the unit type\n * of the action is known when the script is built (one given by the program costs writing it into the action).\n */\nconst disc = game((player: Player, x: number, y: number, size: number) => {\n discPx(player, x * 32 + 16, y * 32 + 16, size);\n});\n\n/**\n * disc() in pixels, (px, py) the centre of the tile: what a caller with many brushes uses, its products by 32 worked\n * out once and the brushes' offsets added (a product by 32 of a number the game holds is a test a bit, about 0.4 µs).\n */\nconst discPx = game((player: Player, px: number, py: number, size: number) => {\n centerLocation(SPOT, px, py, 32, 32);\n switch (size) {\n case 0: case 1: createUnit(player, BRUSHES[0], 1, SPOT); removeUnit(player, BRUSHES[0]); break;\n case 2: createUnit(player, BRUSHES[1], 1, SPOT); removeUnit(player, BRUSHES[1]); break;\n case 3: createUnit(player, BRUSHES[2], 1, SPOT); removeUnit(player, BRUSHES[2]); break;\n case 4: createUnit(player, BRUSHES[3], 1, SPOT); removeUnit(player, BRUSHES[3]); break;\n case 5: createUnit(player, BRUSHES[4], 1, SPOT); removeUnit(player, BRUSHES[4]); break;\n case 6: createUnit(player, BRUSHES[5], 1, SPOT); removeUnit(player, BRUSHES[5]); break;\n case 7: createUnit(player, BRUSHES[6], 1, SPOT); removeUnit(player, BRUSHES[6]); break;\n case 8: createUnit(player, BRUSHES[7], 1, SPOT); removeUnit(player, BRUSHES[7]); break;\n case 9: createUnit(player, BRUSHES[8], 1, SPOT); removeUnit(player, BRUSHES[8]); break;\n case 10: createUnit(player, BRUSHES[9], 1, SPOT); removeUnit(player, BRUSHES[9]); break;\n default: createUnit(player, BRUSHES[10], 1, SPOT); removeUnit(player, BRUSHES[10]);\n }\n});\n\n/**\n * Light the whole map once, so that from now on a tile left dark shows as fog (grey) rather than as never seen\n * (black) — every picture then has the same two tones. Takes one frame.\n * @param player Whose fog: P1 … P8, known when the script is built.\n */\nexport const reveal = game((player: Player) => {\n const width = memory.map.width;\n const height = memory.map.height;\n // Size 11 is 23 tiles wide: a brush every 14 tiles leaves no gap, and one more past the end, kept inside, reaches the edge.\n for (let y = 2; y < height + 12; y += 14) {\n for (let x = 2; x < width + 12; x += 14) disc(player, Math.min(x, width - 3), Math.min(y, height - 3), 11);\n }\n});\n\n/**\n * Light a rectangle of tiles for this frame, worked out while the game runs: its top left on tile (x, y), `width`\n * tiles across and `height` down. Every one of the five may change from frame to frame — the walls of a raycaster, a\n * bar of a chart. It is exact from 3 × 4 up (the smallest brush); a thinner one is lit a brush wide, centred.\n *\n * fog.rect(P1, 10, 20, 3, wall); // a column 3 tiles wide, `wall` tall\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of its left edge.\n * @param y The row of its top edge.\n * @param width How many tiles across.\n * @param height How many tiles down.\n */\nexport const rect = game((player: Player, x: number, y: number, width: number, height: number) => {\n if (width < 1 || height < 1) return;\n // Size 1 is 3 tiles across and 4 down, its centre the middle column and the third row: a grid of them, the last\n // column and row moved back to end on the edge (overlapping the one before), covers the rectangle exactly.\n const right = Math.max(x + width - 2, x + 1);\n const bottom = Math.max(y + height - 2, y + 2);\n // (The brush made here, not through disc(): its unit type is then known when the script is built, which makes the\n // action a constant — a size from the program costs a branch and a written field a brush.)\n let cy = y + 2;\n while (true) {\n let cx = x + 1;\n while (true) {\n centerLocation(SPOT, cx * 32 + 16, cy * 32 + 16, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (cx >= right) break;\n cx = Math.min(cx + 3, right);\n }\n if (cy >= bottom) break;\n cy = Math.min(cy + 4, bottom);\n }\n});\n\n/** A picture for the fog, made when the script is built: its size in tiles and the brushes that draw it. */\nexport interface Picture {\n readonly width: number;\n readonly height: number;\n readonly brushes: readonly { readonly x: number; readonly y: number; readonly size: number }[];\n}\n\n/**\n * A picture from rows of text, worked out when the script is built: `#` is the ink, anything else is not.\n *\n * fog.picture([\"..##..\", \".####.\", \"######\"]) // light ink: the # tiles are lit\n * fog.picture([\"..##..\", \".####.\", \"######\"], { ink: \"dark\" }) // dark ink: the # tiles stay dark, the rest is lit\n *\n * With light ink a lit part thinner than a brush (3 tiles across, 4 down) is drawn a tile or two thicker. An image's\n * halftone (`fog.picture(photo.halftone(6))`) is drawn with the brushes it was made of, one a cell. With dark\n * ink every tile of the picture's rectangle that is not `#` is lit and the `#` tiles keep their shape exactly,\n * however thin — the ink for line art and silhouettes.\n */\nexport function picture(rows: readonly string[], options: { readonly ink?: \"light\" | \"dark\" } = {}): Picture {\n const height = rows.length;\n const width = Math.max(0, ...rows.map((r) => r.length));\n // An image's halftone (image.halftone(6)) comes with the brushes that drew it: a brush a cell, as they are.\n const drawn = (rows as { readonly brushes?: readonly { readonly x: number; readonly y: number; readonly shape: number }[] }).brushes;\n if (drawn && options.ink !== \"dark\") return Object.freeze({ width, height, brushes: Object.freeze(drawn.map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }))) });\n // Dark ink: the picture's rectangle lit but for the # tiles, so what is covered is the rest.\n const ink = options.ink === \"dark\" ? Array.from({ length: height }, (_, y) => Array.from({ length: width }, (_, x) => (rows[y][x] === \"#\" ? \".\" : \"#\")).join(\"\")) : rows;\n const brushes = cover(ink, SHAPES).map((b) => ({ x: b.x, y: b.y, size: b.shape + 1 }));\n return Object.freeze({ width, height, brushes: Object.freeze(brushes) });\n}\n\n/**\n * A video's frames as brushes, worked out when the script is built: a word a brush (x | y << 8 | size << 16), the\n * frames one after another, and where each one starts — the tables the game reads a frame from. Once a build.\n */\ninterface Film { readonly words: readonly number[]; readonly starts: readonly number[] }\nconst films = new Map();\nfunction film(video: Video): Film {\n const had = films.get(video);\n if (had) return had;\n const words: number[] = [], starts: number[] = [0];\n for (const brushes of cover(video, SHAPES)) {\n for (const b of brushes) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n starts.push(words.length);\n }\n const made = { words, starts };\n films.set(video, made);\n return made;\n}\n\n/**\n * Light the fog for this frame: a disc, a picture or a frame of a video. Tiles count from the map's top left, (0, 0).\n *\n * fog.light(P1, 30, 20, 5); // a disc of size 5 centred on tile (30, 20)\n * fog.light(P1, 60, 40, heart); // a picture (fog.picture), its top left on tile (60, 40)\n * fog.light(P1, 0, 0, badApple, f); // frame f of a video the script imports, its top left on tile (0, 0)\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The tile's column: a disc's centre, a picture's left edge.\n * @param y The tile's row: a disc's centre, a picture's top edge.\n * @param shape A size, 1 (3 tiles wide) to 11 (23 wide) — it may change while the game runs — a picture, or a video.\n * @param frame Of a video, which frame, from 0: it may change while the game runs. Past the end, nothing is lit.\n */\nexport const light = game((player: Player, x: number, y: number, shape: number | Picture | Video, frame: number = 0) => {\n if (typeof shape === \"number\") disc(player, x, y, shape);\n else {\n // The picture's corner in pixels once; each brush is its offset from there (a constant of a picture).\n const px = x * 32 + 16, py = y * 32 + 16;\n if (\"brushes\" in shape) for (const b of shape.brushes) discPx(player, px + b.x * 32, py + b.y * 32, b.size);\n else {\n const { words, starts } = film(shape);\n const end = starts[frame + 1];\n for (let i = starts[frame]; i < end; i++) {\n const w = words[i];\n // (A row's offset in pixels straight from its byte: the same as ((w >> 8) & 255) * 32, and the shift only has\n // the byte's eight bits to look at.)\n discPx(player, px + (w & 255) * 32, py + ((w & 0xff00) >> 3), w >> 16);\n }\n }\n }\n});\n\n/**\n * Play a video in the fog, a frame of it every frame of the game, from its first to its last; resolves when it ends.\n * Inside an async program: `await fog.play(P1, badApple)`. On Fastest a game frame is 42 ms, which is what Import\n * Video makes a video's frames by default; the game runs a little slower when a frame is heavy, and so does the video.\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param video A video the script imports.\n * @param x The column of its left edge, 0 by default.\n * @param y The row of its top edge, 0 by default.\n */\nexport const play = game(async (player: Player, video: Video, x: number = 0, y: number = 0) => {\n // (A while: a for over a length known when the script is built would be unrolled, a copy of the body a frame.)\n let f = 0;\n while (f < video.length) {\n light(player, x, y, video, f);\n await nextFrame();\n f++;\n }\n});\n\n/**\n * How fog.columns() lays its columns out, known when the script is built: `count` columns side by side, each `width`\n * tiles across (3, a brush's width, by default) and up to `height` tiles tall, centred on the middle row. `ink`:\n * \"light\" lights each column (the walls of a raycaster bright, on fog), \"dark\" lights all but the columns (dark walls\n * between a lit ceiling and floor); dark is cheaper when columns fill most of the board, light when they are short.\n */\nexport interface ColumnLayout {\n readonly count: number;\n readonly height: number;\n readonly width?: number;\n readonly ink?: \"light\" | \"dark\";\n /**\n * How the walls are told apart, with fog.columns()'s `faces`. \"outline\", the default: every wall drawn as its edges —\n * its top and bottom, a brush deep, and a column lit whole wherever one face meets another (on the nearer of the\n * two) — so that blocks read as boxes, the look of the raycasters of monochrome screens; with dark ink, the edges are\n * lit lines through dark walls. A few brushes a column. Chosen over the rest when played (2026-10-17).\n *\n * The others shade the faces whose number is odd (a raycaster's other side) and leave the rest whole: \"hollow\" —\n * dark, lit only along its top and bottom edges; \"ribbed\" — hollow and lit across the middle of each half; \"panelled\"\n * — hollow and lit across its quarters; \"line\" / \"lines\" — dark lines a tile thick across it at its middle or its\n * quarters (the finest, and two dozen brushes a column near the eye: for a view with room to spare).\n */\n readonly shading?: \"outline\" | \"lines\" | \"line\" | \"hollow\" | \"ribbed\" | \"panelled\";\n}\n\n/**\n * What fog.columns() reads, worked out once a layout when the script is built: for each level of the drawing that is\n * wider than a column (all the columns, a group of them, the last group) and each depth from 0 to half the height, the\n * brushes that cover a rectangle that wide and that deep exactly — the encoder's covers (cover()), a few large brushes\n * in the middle and small ones at the edges. A brush is a word, dx | dy << 8 | size << 16 from the rectangle's top\n * left; `starts` says where each (level, depth) begins.\n */\ninterface ColumnPlan { readonly words: readonly number[]; readonly starts: readonly number[]; readonly group: number; readonly half: number }\nconst columnPlans = new Map();\nconst COLUMN_GROUP = 6;\nfunction columnPlan(layout: ColumnLayout): ColumnPlan {\n const width = layout.width ?? 3, half = layout.height >> 1, group = Math.min(COLUMN_GROUP, layout.count);\n const key = `${layout.count}:${width}:${half}`;\n const had = columnPlans.get(key);\n if (had) return had;\n const words: number[] = [], starts: number[] = [];\n for (const columns of [layout.count, group, layout.count % group || group]) {\n for (let depth = 0; depth <= half; depth++) {\n starts.push(words.length);\n if (depth === 0) continue;\n // A rectangle shallower than a brush is drawn a brush deep, the rest of it on what the level before lit.\n const rows = Array.from({ length: Math.max(depth, 4) }, () => \"#\".repeat(columns * width));\n for (const b of cover(rows, SHAPES)) words.push(b.x | (b.y << 8) | ((b.shape + 1) << 16));\n }\n }\n starts.push(words.length);\n const made = { words, starts, group, half };\n columnPlans.set(key, made);\n return made;\n}\n\n/**\n * One rectangle of fog.columns() wider than a column: the cover at `at` (a level and a depth) drawn twice, the half\n * above the middle and the half below, its top rows `top` and `bottom`, from column `x`.\n */\nconst columnRect = game((player: Player, words: readonly number[], starts: readonly number[], at: number, x: number, top: number, bottom: number) => {\n let i = starts[at];\n const end = starts[at + 1];\n // In pixels once; each brush is its offset (a few bits, a few tests) from there.\n const px = x * 32 + 16, pt = top * 32 + 16, pb = bottom * 32 + 16;\n while (i < end) {\n const w = words[i];\n const dx = (w & 255) * 32, dy = (w & 0xff00) >> 3, size = w >> 16;\n discPx(player, px + dx, pt + dy, size);\n discPx(player, px + dx, pb + dy, size);\n i++;\n }\n});\n\n/**\n * One column's part of fog.columns(), in pixels: brushes (3 × 4) a column of 4 rows apart and the last moved back to\n * end on the edge, twice — from `pt` and from `pb`, the centres of the first brush of each — down a column `k32`\n * pixels deep (32 a row; under 4 rows, a brush deep), centred on `px`. What the smallest brush covers exactly, worked out\n * here rather than read: it is the most common rectangle by far. In pixels because the caller has them, stepped: a\n * product by 32 of a number the game holds is a test a bit, about 0.4 µs, where a step is an add.\n */\nconst columnBarPx = game((player: Player, px: number, pt: number, pb: number, k32: number) => {\n // How far down the last brush is from the first.\n const end = Math.max(k32, 128) - 128;\n let r = 0;\n while (true) {\n centerLocation(SPOT, px, pt, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n centerLocation(SPOT, px, pb, 32, 32);\n createUnit(player, BRUSHES[0], 1, SPOT);\n removeUnit(player, BRUSHES[0]);\n if (r >= end) break;\n const step = Math.min(end - r, 128);\n r += step;\n pt += step;\n pb += step;\n }\n});\n\n/** columnBarPx() in tiles: `k` rows from row `top` down and from row `bottom` down, centred on column `x`. */\nconst columnBar = game((player: Player, x: number, top: number, bottom: number, k: number) => {\n columnBarPx(player, x * 32 + 16, top * 32 + 80, bottom * 32 + 80, k * 32);\n});\n\n/**\n * A shaded column of fog.columns() (ColumnLayout's `shading`): `a` is half its height, `lines` how many lines across\n * each half — 0 hollow, 1 at its middle, 3 at its quarters. A line is a dark row through a lit column (light ink), or\n * a lit band a brush deep through a dark one; a column too short for its lines is drawn plain.\n */\nconst columnShaded = game((player: Player, cx: number, mid: number, a: number, dark: boolean, lines: number) => {\n if (lines <= 0) {\n if (dark) { if (a >= 6) columnBar(player, cx, mid - 4, mid, 4); }\n else if (a < 8) columnBar(player, cx, mid - a, mid, a);\n else {\n // Hollow: the edges. Ribbed (-1) and panelled (-3): bands across each half too, centred on its middle or\n // quarters, where there is room for them between the edges.\n columnBar(player, cx, mid - a, mid + a - 4, 4);\n const ribs = -lines;\n if (ribs > 0 && a >= 6 * (ribs + 1)) {\n let k = 1;\n while (k <= ribs) {\n const at = (a * k) / (ribs + 1);\n columnBar(player, cx, mid - at - 2, mid + at - 2, 4);\n k++;\n }\n }\n }\n } else if (a < 5 * (lines + 1)) {\n if (!dark) columnBar(player, cx, mid - a, mid, a);\n } else {\n // From the middle out: lit up to each line, the line's row dark (light ink); or a lit band on each line (dark ink).\n let from = 0, k = 1;\n while (k <= lines + 1) {\n const to = k > lines ? a : (a * k) / (lines + 1);\n if (dark) { if (k <= lines) columnBar(player, cx, mid - to - 2, mid + to - 2, 4); }\n else columnBar(player, cx, mid - to, mid + from, to - from);\n from = to + 1;\n k++;\n }\n }\n});\n\n/**\n * Light columns for this frame — the walls of a raycaster, a bar chart — each as tall as the program says, centred on\n * the layout's middle row. The covers are worked out when the script is built (see ColumnLayout), so a frame costs a\n * brush for each part of the picture that differs from its neighbours, not one for every few tiles: a wall that fills\n * the board is a few large brushes.\n *\n * const VIEW = { count: 42, height: 96, ink: \"dark\" } as const;\n * fog.columns(P1, 1, 0, VIEW, heights); // heights: one a column, in tiles, the program's\n * fog.columns(P1, 1, 0, VIEW, heights, faces); // faces: which face of which wall each column shows\n *\n * @param player Whose fog: P1 … P8, known when the script is built.\n * @param x The column of the layout's left edge.\n * @param y The row of its top edge.\n * @param layout How the columns are laid out: known when the script is built.\n * @param heights Each column's height in tiles (0 to the layout's height), an array of the program's.\n * @param faces Which face of which wall each column shows, a number the same for every column of one face — in a\n * raycaster, \\`(mapY * width + mapX) * 2 + side\\` — an array of the program's as long as `heights`: what the\n * layout's `shading` tells walls apart by. Optional: without it, every column is lit whole (a bar chart).\n */\nexport const columns = game((player: Player, x: number, y: number, layout: ColumnLayout, heights: readonly number[], faces?: readonly number[]) => {\n const { words, starts, half, group } = columnPlan(layout);\n const count = layout.count, width = layout.width ?? 3, dark = layout.ink === \"dark\";\n // (Dark lines through a lit column count up; lit bands through a dark one, ribbed and panelled, down.)\n // (Without faces there is nothing to tell apart, and every column is whole.)\n const outline = faces !== undefined && (layout.shading === undefined || layout.shading === \"outline\");\n const lines = layout.shading === \"lines\" ? 3 : layout.shading === \"line\" ? 1 : layout.shading === \"ribbed\" ? -1 : layout.shading === \"hollow\" ? 0 : -3;\n const span = half + 1;\n // Each column's depth from where its lit part starts: the middle row for light ink, the top and bottom for dark.\n // A shaded column of light ink is drawn on its own, after the rest: to the rest it is as if it were not there — and\n // so is every column of a light outline, which is all edges.\n const depth: number[] = new Array(count).fill(0);\n // Half of each column's height, at most half the layout's: worked out once. (Clamped before the shift, the same\n // number: the shift then has the few bits the clamp leaves, where a height from the program could have any.)\n const halves: number[] = new Array(count).fill(0);\n // And in pixels (32 a row), for the bars, which place their brushes in pixels: a small number times 32 is a few tests.\n const halves32: number[] = new Array(count).fill(0);\n let least = half, most = 0;\n // (A light outline is all edges: every depth is 0, nothing to work out.)\n const flat = !dark && outline;\n if (flat) least = 0;\n let i = 0;\n while (i < count) {\n const a = Math.min(Math.max(heights[i], 0), 2 * half + 1) >> 1;\n halves[i] = a;\n halves32[i] = a * 32;\n if (!flat) {\n let d = dark ? half - a : a;\n if (faces !== undefined && !dark && !outline && (faces[i] & 1) != 0) d = 0;\n depth[i] = d;\n if (d < least) least = d;\n if (d > most) most = d;\n }\n i++;\n }\n // A rectangle from depth p, k deep, starts on the rows above and below the middle (light) or below the top and above\n // the bottom (dark); one shallower than 4 is drawn 4 deep, reaching back over what is lit already.\n const mid = y + half;\n // Every column, from depth 0 to the least.\n if (least > 0) {\n const k = Math.max(least, 4);\n if (dark) columnRect(player, words, starts, least, x, y + least - k, y + 2 * half - least);\n else columnRect(player, words, starts, least, x, mid - least, mid + least - k);\n }\n // Each group of columns, from the least to the group's least; then each column, from there to its own. (Nothing,\n // when every column's depth is the least: a light outline's always.)\n let g = most > least ? 0 : count;\n while (g < count) {\n const last = Math.min(g + group, count);\n let gl = half;\n let j = g;\n while (j < last) { if (depth[j] < gl) gl = depth[j]; j++; }\n const level = last - g === group ? 1 : 2;\n if (gl > least) {\n const k = gl - least, kk = Math.max(k, 4);\n if (dark) columnRect(player, words, starts, level * span + k, x + g * width, y + gl - kk, y + 2 * half - gl);\n else columnRect(player, words, starts, level * span + k, x + g * width, mid - gl, mid + gl - kk);\n }\n j = g;\n while (j < last) {\n const k = depth[j] - gl;\n if (k > 0) {\n const kk = Math.max(k, 4), cx = x + j * width + 1;\n if (dark) columnBar(player, cx, y + depth[j] - kk, y + 2 * half - depth[j], k);\n else columnBar(player, cx, mid - depth[j], mid + depth[j] - kk, k);\n }\n j++;\n }\n g = last;\n }\n if (outline) {\n // Where one face meets another, the nearer (taller) of the two columns is an edge.\n const edge: number[] = new Array(count).fill(0);\n let face = faces[0], tall = heights[0];\n i = 1;\n while (i < count) {\n const f = faces[i], h = heights[i];\n if (f != face) { if (h >= tall) edge[i] = 1; else edge[i - 1] = 1; }\n face = f;\n tall = h;\n i++;\n }\n // The bars in pixels: a column's centre stepped across, the middle row's worked out once.\n const mid80 = mid * 32 + 80;\n let px = (x + 1) * 32 + 16;\n i = 0;\n while (i < count) {\n const a = halves[i], a32 = halves32[i];\n if (dark) { if (edge[i] != 0 && a >= 2) columnBarPx(player, px, mid80 - a32, mid80, a32); }\n else if (a > 0) {\n if (edge[i] != 0 || a < 8) columnBarPx(player, px, mid80 - a32, mid80, a32);\n else columnBarPx(player, px, mid80 - a32, mid80 + a32 - 128, 128);\n }\n px += width * 32;\n i++;\n }\n } else if (faces !== undefined) {\n i = 0;\n while (i < count) {\n if ((faces[i] & 1) != 0) columnShaded(player, x + i * width + 1, mid, halves[i], dark, lines);\n i++;\n }\n }\n});\n\n/**\n * Keep what was lit this frame on the board for `n` frames, and wait them out: \\`await fog.show(2)\\` in place of\n * \\`await nextFrame()\\` draws every other frame at half the cost. StarCraft: Remastered redraws the fog on the minimap\n * every other frame (measured in two played recordings, 2026-10-17), so a picture drawn every other frame looks the\n * same there as one drawn every frame. The board is wiped at the start of the frame after them, ready for the next.\n *\n * @param n How many frames, 1 to 24, known when the script is built.\n */\nexport const show = game(async (n: number) => {\n memory.vision.countdown = n;\n await sleep(frames(n));\n});\n\n/** Everything above as one name: import { fog } from \"trigscript/fog\"; fog.light(P1, x, y, 3). */\nexport const fog = { light, rect, columns, show, play, reveal, picture, SHAPES };\n\n/** The longest fog.show() keeps a picture: the countdown the game sets itself after a wipe is longer than this. */\nconst SHOW_MOST = 24;\n\n// The canvas: every brush's sight set, every frame, and the board wiped on the next — for as long as the game runs —\n// unless a program asked for the picture to stay (fog.show), when the countdown is that program's to run down.\nprogram(() => {\n while (true) {\n for (let s = 1; s <= 11; s++) stats(BRUSHES[s - 1]).sight = s;\n const left = memory.vision.countdown;\n if (left == 0 || left > SHOW_MOST) memory.vision.countdown = 1;\n sleep(frames(1));\n }\n});\n", + "math": "/**\n * Numbers for motion and play: the standard library's \"trigscript/math\" (TrigScript 4).\n *\n * import { Vec, lerp, easeOutQuad, distance, angleTo, approach, wrap } from \"trigscript/math\";\n *\n * const t = fixed(frame) / 48; // 0 to 1 over two seconds\n * const x = lerp(fromX, toX, easeOutQuad(t)); // eased from one place to another\n * const v = Vec.fromAngle(angleTo(x, y, tx, ty), fixed(4));\n * pos = pos.add(v);\n *\n * Fractions are the language's fixed numbers (16.16: four decimal places or so, from about −32 768 to 32 767): a\n * number becomes one when a fraction is stored into it, or written `fixed(…)`. Angles are radians, as JavaScript's are.\n * Whole numbers stay whole where they can — `distanceSq`, `manhattan`, `wrap`, `sign` — and cost less.\n */\n\n/** π, and a whole turn. */\nexport const PI = Math.PI;\nexport const TAU = Math.PI * 2;\n\n/** From a to b by t: a at 0, b at 1, between them in between (t outside 0 … 1 goes past). */\nexport function lerp(a: fixed, b: fixed, t: fixed): fixed {\n return a + (b - a) * t;\n}\n\n/** Where v is from a to b, as lerp's t: 0 at a, 1 at b. */\nexport function inverseLerp(a: fixed, b: fixed, v: fixed): fixed {\n return b === a ? fixed(0) : (v - a) / (b - a);\n}\n\n/** v from the range a1 … b1 into a2 … b2. */\nexport function remap(v: fixed, a1: fixed, b1: fixed, a2: fixed, b2: fixed): fixed {\n return lerp(a2, b2, inverseLerp(a1, b1, v));\n}\n\n/** t kept inside 0 … 1. */\nexport function clamp01(t: fixed): fixed {\n return t < 0 ? fixed(0) : t > 1 ? fixed(1) : t;\n}\n\n/** From `current` towards `target` by at most `step`, never past it: a value that follows another at a speed. */\nexport function approach(current: number, target: number, step: number): number {\n if (current < target) return current + step > target ? target : current + step;\n return current - step < target ? target : current - step;\n}\n\n/** n into 0 … size − 1, going round: wrap(-1, 10) is 9, wrap(12, 10) is 2. */\nexport function wrap(n: number, size: number): number {\n return ((n % size) + size) % size;\n}\n\n/** −1, 0 or 1, by whether n is below, at or above zero. */\nexport function sign(n: number): number {\n return n > 0 ? 1 : n < 0 ? -1 : 0;\n}\n\n/** The distance from one point to another, as the crow flies. */\nexport function distance(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.hypot(x2 - x1, y2 - y1);\n}\n\n/** The distance squared: whole, and cheaper — compare it with a range squared. */\nexport function distanceSq(x1: number, y1: number, x2: number, y2: number): number {\n return (x2 - x1) * (x2 - x1) + (y2 - y1) * (y2 - y1);\n}\n\n/** The distance along the axes, |dx| + |dy|: what the game's own \"nearest\" goes by. */\nexport function manhattan(x1: number, y1: number, x2: number, y2: number): number {\n return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n\n/** The angle from one point to another, in radians from −π to π: 0 is to the right (east), π / 2 down (south, y grows down). */\nexport function angleTo(x1: number, y1: number, x2: number, y2: number): fixed {\n return Math.atan2(y2 - y1, x2 - x1);\n}\n\n/** Degrees as radians. */\nexport function radians(degrees: fixed): fixed {\n return (degrees * PI) / 180;\n}\n\n/** Radians as degrees. */\nexport function degrees(radians: fixed): fixed {\n return (radians * 180) / PI;\n}\n\n/* Easing: t from 0 to 1, how far along a motion is at that point of its time — slow then fast, fast then slow, … */\n\n/** Slow at the start. */\nexport function easeInQuad(t: fixed): fixed {\n return t * t;\n}\n\n/** Slow at the end. */\nexport function easeOutQuad(t: fixed): fixed {\n return t * (2 - t);\n}\n\n/** Slow at both ends. */\nexport function easeInOutQuad(t: fixed): fixed {\n return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t;\n}\n\n/** Slower at the start than easeInQuad. */\nexport function easeInCubic(t: fixed): fixed {\n return t * t * t;\n}\n\n/** Slower at the end than easeOutQuad. */\nexport function easeOutCubic(t: fixed): fixed {\n const u = t - 1;\n return u * u * u + 1;\n}\n\n/** Goes a little past the end and comes back. */\nexport function easeOutBack(t: fixed): fixed {\n const u = t - 1;\n return 1 + 2.70158 * u * u * u + 1.70158 * u * u;\n}\n\n/** Slow at both ends, smoothly (Hermite: 3t² − 2t³). */\nexport function smoothstep(t: fixed): fixed {\n return t * t * (3 - 2 * t);\n}\n\n/**\n * A point, or a direction and a length: two fixed numbers. Each operation gives a new Vec.\n *\n * let pos = new Vec(fixed(x), fixed(y));\n * const toward = new Vec(fixed(tx), fixed(ty)).sub(pos).normalized().scale(fixed(3));\n * pos = pos.add(toward);\n */\nexport class Vec {\n x: fixed;\n y: fixed;\n\n constructor(x: fixed, y: fixed) {\n this.x = x;\n this.y = y;\n }\n\n /** A direction `length` long at `angle` radians (0 to the right, π / 2 down). */\n static fromAngle(angle: fixed, length: fixed): Vec {\n return new Vec(Math.cos(angle) * length, Math.sin(angle) * length);\n }\n\n add(o: Vec): Vec {\n return new Vec(this.x + o.x, this.y + o.y);\n }\n\n sub(o: Vec): Vec {\n return new Vec(this.x - o.x, this.y - o.y);\n }\n\n scale(k: fixed): Vec {\n return new Vec(this.x * k, this.y * k);\n }\n\n dot(o: Vec): fixed {\n return this.x * o.x + this.y * o.y;\n }\n\n /** How long it is. */\n length(): fixed {\n return Math.sqrt(this.x * this.x + this.y * this.y);\n }\n\n /** The same direction, 1 long (or none, for none). */\n normalized(): Vec {\n const l = this.length();\n const k: fixed = l === 0 ? fixed(0) : 1 / l;\n return new Vec(this.x * k, this.y * k);\n }\n\n /** Its angle, in radians from −π to π. */\n angle(): fixed {\n return Math.atan2(this.y, this.x);\n }\n\n /** Turned by `angle` radians (clockwise on the map, where y grows down). */\n rotate(angle: fixed): Vec {\n const c = Math.cos(angle);\n const s = Math.sin(angle);\n return new Vec(this.x * c - this.y * s, this.x * s + this.y * c);\n }\n\n /** How far it is from another point. */\n distanceTo(o: Vec): fixed {\n return this.sub(o).length();\n }\n\n /** From this towards another by t (0 here, 1 there). */\n lerp(o: Vec, t: fixed): Vec {\n return new Vec(this.x + (o.x - this.x) * t, this.y + (o.y - this.y) * t);\n }\n}\n", + "music": "/**\n * Music in a program: the standard library's \"trigscript/music\" (TrigScript 4).\n *\n * import { music } from \"trigscript/music\";\n * import pluck from \"./pluck.sound\";\n *\n * const lead = music.synth({ wave: \"square\", volume: 0.2 });\n * const bass = music.sampler(pluck, \"A3\");\n * const drums = music.kit({ k: kick, s: snare, h: hat });\n *\n * const song = music.song({ bpm: 120 },\n * music.track(lead, \"C5 - E5 - G5 - - . | A5 - G5 - E5 - - .\"),\n * music.track(bass, \"C3 - - - - - - - | F3 - - - - - - -\"),\n * music.track(drums, \"kh h sh h kh h sh h | kh h sh h kh kh sh h\"),\n * );\n *\n * program(async () => {\n * start(music.loop(song)); // the song again and again, beside the program\n * playWav(lead.note(\"C6\", 0.1)); // an instrument's note on its own, for a sound effect\n * });\n *\n * A pattern is steps, a word each, a quarter of a beat each by default: a note's name starts it (\"C4\", \"F#5\", \"Bb2\"),\n * several with + sound together (\"C4+E4+G4\"), \"-\" holds what sounds a step longer, \".\" is a rest, \"|\" is only for the\n * eye; \"@\" after a step's notes says how loud (\"C4@0.5\"). A kit's step is its sounds' letters (\"kh\": kick and hat).\n * Everything is worked out when the script is built: each note an instrument plays becomes a clip once (`trigscript`'s\n * Clip, put in the map once however often it plays), and the song a table of which clip starts on which frame, which\n * `play` reads while the game runs.\n *\n * A longer song is written in sections — parts named once, each section saying what they play in it, a pattern played\n * again until the section is full — and chords by name, their notes drawn by a template:\n *\n * const parts = { keys: music.part(music.presets.keys()), bass: music.part(music.presets.bass()), drums: music.part(music.drums()) };\n * const verse = music.section(4, {\n * keys: music.chords(\"Am F C G\", \"1+2+3 - . 1+2+3 . . 1+2+3 -\", { octave: 4, lead: true }),\n * bass: music.chords(\"Am F C G\", \"1 - . 1 . 1 3 -\", { octave: 2 }),\n * drums: music.beat({ k: [0, 3, 4], s: [2, 6], h: [0, 1, 2, 3, 4, 5, 6, 7] }, 8),\n * });\n * const song = music.arrange({ bpm: 120, steps: 2 }, parts, intro, verse, verse.with({ keys: null }), outro);\n *\n * Music that follows the game is a song of sections cut into bars and layers, a clip each, which the program steers\n * bar by bar — which section, which layers, a stinger on the next beat:\n *\n * const cut = music.stems(song, { layers: { drums: [\"drums\"], rest: [\"keys\", \"bass\"] } });\n * program(async () => {\n * const band = new Band(); // import { music, Band } from \"trigscript/music\"\n * start(music.conduct(cut, band));\n * if (danger) band.go(cut.section(\"chorus\")); // at the next bar line\n * if (cleared) band.sting(cut.stinger(\"crash\")); // on the next beat\n * });\n *\n * What the game does with it (the sound probe, 2026-10-19): a bar is a whole number of frames (42 ms on Fastest), so\n * the tempo is the nearest that fits — `song.bpm` says which; a sound starts within 21 ms of its frame; 32 sound at\n * once, and a song that would need more is refused; notes that overlap add, with no limiter, so keep the volumes down.\n * `render` mixes a song into one clip when the script is built instead: every note exactly in time, one sound playing,\n * nothing to change while it plays. A program plays to its own player; a program for each player plays to each.\n */\nimport { game, sleep, frames, playWav, tone, noise, fm, pluck, mixdown, together, memory, type Clip, type Notes } from \"trigscript\";\n\n/** Seconds a frame lasts on Fastest, the speed a map is played at. */\nconst FRAME = 0.042;\n/** Sounds the game plays at once. */\nconst VOICES = 32;\n\n/** What plays a pattern's notes: each note a clip, made when the script is built. */\nexport interface Instrument {\n readonly kind: \"notes\";\n /**\n * Note `name` (\"C4\", \"F#5\") lasting `seconds`, release and all: a clip to play on its own, too. `rate`: samples a\n * second, for an instrument that makes its notes (render asks for 44 100).\n */\n note(name: string, seconds: number, rate?: number): Clip;\n}\n\n/** What plays a pattern's letters: a clip a letter. */\nexport interface Kit {\n readonly kind: \"kit\";\n readonly hits: Readonly>;\n}\n\nconst SEMITONE: Readonly> = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 };\n\n/** A note's number, 69 for A4: \"C4\" is 60, \"C#4\" 61, \"Db4\" 61. */\nfunction noteNumber(name: string): number {\n const m = /^([A-Ga-g])([#b]?)(-?\\d)$/.exec(name);\n if (!m) throw new Error(`A note is a name with its octave — \"C4\", \"F#5\", \"Bb2\" — not ${JSON.stringify(name)}.`);\n return 12 * (Number(m[3]) + 1) + SEMITONE[m[1].toLowerCase()] + (m[2] === \"#\" ? 1 : m[2] === \"b\" ? -1 : 0);\n}\n\n/** Notes kept as they are made, so a note played many times is the same clip (and the same file in the map). */\nfunction remembering(make: (name: string, seconds: number, rate?: number) => Clip): (name: string, seconds: number, rate?: number) => Clip {\n const made = new Map();\n return (name, seconds, rate) => {\n const key = `${name} ${seconds.toFixed(4)} ${rate ?? \"\"}`;\n let c = made.get(key);\n if (!c) { c = make(name, seconds, rate); made.set(key, c); }\n return c;\n };\n}\n\n/**\n * An instrument from a recording of one note, `root` (\"A3\"): every other note is it played faster or slower, as a\n * sampler does, cut to the note's length with a short release. Where the recording is shorter than a note, the note\n * is as long as the recording.\n */\nexport function sampler(clip: Clip, root: string = \"C4\", options: { readonly release?: number; readonly volume?: number } = {}): Instrument {\n const base = noteNumber(root), release = options.release ?? 0.05, volume = options.volume ?? 1;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds) => {\n const pitched = clip.pitch(noteNumber(name) - base).gain(volume);\n return pitched.trim(0, Math.min(pitched.seconds, seconds)).fade(0, Math.min(release, seconds / 2));\n }),\n });\n}\n\n/** An attack, a decay to the sustain level, and a release after the note's length, in seconds (sustain 0 … 1). */\nexport interface Shape { readonly attack?: number; readonly decay?: number; readonly sustain?: number; readonly release?: number }\n\n/** A shape's points for a note held `held` seconds, as levels 0 … 1: the attack from 0, the release to 0. */\nfunction shapePoints(shape: Required, held: number): [number, number][] {\n const { attack, decay, sustain, release } = shape;\n const h = Math.max(attack + decay, held);\n return [[0, 0], [attack, 1], [attack + decay, sustain], [h, sustain], [h + release, 0]];\n}\nconst full = (s: Shape | undefined, d: Required): Required => ({ attack: s?.attack ?? d.attack, decay: s?.decay ?? d.decay, sustain: s?.sustain ?? d.sustain, release: s?.release ?? d.release });\n\n/** A synth's filter: its type, the cutoff it rests at, its resonance, and how many octaves above it a note's envelope opens it. */\nexport interface SynthFilter {\n readonly type?: \"lowpass\" | \"highpass\" | \"bandpass\";\n readonly cutoff: number;\n readonly resonance?: number;\n /** Octaves above the cutoff at the envelope's top (0: the filter does not move). */\n readonly amount?: number;\n /** The filter's own attack, decay, sustain and release, as a note's loudness has. */\n readonly envelope?: Shape;\n}\n\nexport interface SynthOptions extends Shape {\n readonly wave?: \"sine\" | \"square\" | \"triangle\" | \"saw\";\n readonly volume?: number;\n /** Copies of each note (1 to 16), `detune` cents apart from lowest to highest, `width` 0 … 1 across left and right. */\n readonly voices?: number;\n readonly detune?: number;\n readonly width?: number;\n readonly filter?: SynthFilter;\n /** Saturation after the filter, dB of push (`Clip.drive`). */\n readonly drive?: number;\n /** Semitones each note slides up from (a negative number: from below) over `glideTime` seconds. */\n readonly glide?: number;\n readonly glideTime?: number;\n}\n\n/**\n * An instrument made of tones: `wave` (\"sine\", \"square\", \"triangle\", \"saw\") shaped by an attack, a decay to the\n * sustain level, and a release after the note's length — a stack of detuned copies with `voices`, through a filter\n * that opens and closes with each note with `filter`, saturated with `drive`.\n */\nexport function synth(options: SynthOptions = {}): Instrument {\n const wave = options.wave ?? \"square\", volume = options.volume ?? 0.2;\n const amp = full(options, { attack: 0.005, decay: 0.08, sustain: 0.7, release: 0.06 });\n const f = options.filter, fshape = full(f?.envelope, { attack: 0.005, decay: 0.25, sustain: 0.2, release: 0.2 });\n const voices = options.voices ?? 1, detune = options.detune ?? (voices > 1 ? 20 : 0), width = options.width ?? 0;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n const r = rate === undefined ? {} : { rate };\n const pitch = options.glide ? [[0, 440 * 2 ** ((noteNumber(name) + options.glide - 69) / 12)], [options.glideTime ?? 0.06, name]] as [number, number | string][] : name;\n let c = tone(pitch, length, { wave, volume, voices, detune, width, ...r });\n if (f) {\n const amount = f.amount ?? 0, base = f.cutoff;\n const cutoff = amount === 0 ? base : shapePoints(fshape, held).map(([t, level]) => [t, base * 2 ** (amount * level)] as [number, number]);\n c = c.filter(f.type ?? \"lowpass\", cutoff, { resonance: f.resonance ?? 0.1 });\n }\n if (options.drive) c = c.drive(options.drive);\n return c.envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/**\n * An FM instrument (`fm()`): `ratio` the modulator's pitch to the note's, `index` how bright a note starts, falling to\n * `index × brightness` over `bright` seconds — an electric piano (ratio 1), a bell (3.5), a bass (0.5).\n */\nexport function fmSynth(options: Shape & { readonly ratio?: number; readonly index?: number; readonly brightness?: number; readonly bright?: number; readonly feedback?: number; readonly volume?: number } = {}): Instrument {\n const amp = full(options, { attack: 0.002, decay: 1.2, sustain: 0.35, release: 0.3 });\n const ratio = options.ratio ?? 1, index = options.index ?? 3, after = index * (options.brightness ?? 0.2), bright = options.bright ?? 0.8, volume = options.volume ?? 0.25;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const held = Math.max(amp.attack + amp.decay, seconds - amp.release), length = held + amp.release;\n return fm(name, length, { ratio, index: [[0, index], [bright, after]], feedback: options.feedback ?? 0, volume, ...(rate === undefined ? {} : { rate }) }).envelope(...shapePoints(amp, held));\n }),\n });\n}\n\n/** A plucked-string instrument (`pluck()`): `brightness` 0 … 1, `decay` seconds; a note rings out its decay unless `cut`. */\nexport function strings(options: { readonly brightness?: number; readonly decay?: number; readonly volume?: number; readonly cut?: boolean } = {}): Instrument {\n const brightness = options.brightness ?? 0.55, decay = options.decay ?? 1.5, volume = options.volume ?? 0.3;\n return Object.freeze({\n kind: \"notes\" as const,\n note: remembering((name, seconds, rate) => {\n const length = options.cut ? seconds + 0.05 : Math.max(seconds + 0.05, Math.min(decay, 4));\n return pluck(name, length, { brightness, decay, volume, ...(rate === undefined ? {} : { rate }) }).fade(0, 0.05);\n }),\n });\n}\n\n/**\n * Instruments to start from, each a function that takes the same options as the instrument it is made with, over its\n * own: `music.presets.supersaw({ volume: 0.1 })`.\n */\nexport const presets = {\n /** Seven detuned saws through a filter that opens on each note: a trance lead, big chords. */\n supersaw: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 7, detune: 28, width: 0.7, volume: 0.16, attack: 0.005, decay: 0.25, sustain: 0.8, release: 0.18, filter: { cutoff: 1800, amount: 2, resonance: 0.15, envelope: { decay: 0.35, sustain: 0.35 } }, ...o }),\n /** A slow, wide pad: five saws, the filter low and slowly opening. Give its track a chorus and the reverb. */\n pad: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 5, detune: 18, width: 1, volume: 0.1, attack: 0.35, decay: 0.6, sustain: 0.85, release: 0.8, filter: { cutoff: 700, amount: 1.3, resonance: 0.1, envelope: { attack: 0.6, decay: 1.2, sustain: 0.5, release: 0.8 } }, ...o }),\n /** A round, punchy bass: two saws, the filter snapping shut after each note, a little drive. */\n bass: (o: SynthOptions = {}) => synth({ wave: \"saw\", voices: 2, detune: 8, volume: 0.32, attack: 0.002, decay: 0.12, sustain: 0.9, release: 0.04, filter: { cutoff: 180, amount: 2.6, resonance: 0.25, envelope: { decay: 0.18, sustain: 0.15, release: 0.05 } }, drive: 6, ...o }),\n /** A squelchy, resonant line (a 303's): one saw, the filter wide open on each note and shutting fast. */\n acid: (o: SynthOptions = {}) => synth({ wave: \"saw\", volume: 0.22, attack: 0.002, decay: 0.1, sustain: 0.8, release: 0.05, filter: { cutoff: 260, amount: 3.6, resonance: 0.82, envelope: { decay: 0.16, sustain: 0 } }, drive: 10, ...o }),\n /** A warm lead: two square waves a little apart, softened by a filter. */\n lead: (o: SynthOptions = {}) => synth({ wave: \"square\", voices: 2, detune: 12, width: 0.3, volume: 0.15, attack: 0.004, decay: 0.15, sustain: 0.7, release: 0.12, filter: { cutoff: 2200, amount: 1.2, resonance: 0.2, envelope: { decay: 0.25, sustain: 0.4 } }, ...o }),\n /** The old consoles' lead: a bare square wave. */\n chip: (o: SynthOptions = {}) => synth({ wave: \"square\", volume: 0.14, attack: 0.002, decay: 0.05, sustain: 0.8, release: 0.03, ...o }),\n /** An electric piano: FM at ratio 1, bright on the strike and mellowing. */\n keys: (o: Parameters[0] = {}) => fmSynth({ ratio: 1, index: 2.6, brightness: 0.25, bright: 0.6, volume: 0.22, decay: 1.4, sustain: 0.3, release: 0.35, ...o }),\n /** A bell: FM at ratio 3.5, long and shimmering. */\n bell: (o: Parameters[0] = {}) => fmSynth({ ratio: 3.5, index: 5, brightness: 0.08, bright: 1.6, volume: 0.16, decay: 2.5, sustain: 0, release: 1.2, ...o }),\n /** A plucked string, a little bright. */\n pluck: (o: Parameters[0] = {}) => strings({ brightness: 0.6, decay: 1.4, ...o }),\n};\n\n/**\n * A drum kit made from nothing, a letter a drum: k kick, s snare, c clap, h closed hat, o open hat, t tom, r rim, x crash. At\n * 44 100 Hz (`rate`), so the hats keep their top; `volume` 0 … 1 over the whole kit (1).\n */\nexport function drums(options: { readonly rate?: number; readonly volume?: number } = {}): Kit {\n const rate = options.rate ?? 44100, v = options.volume ?? 1;\n const metal = (seconds: number) => [263, 400, 421, 474, 587, 845].map((hz) => tone(hz * 2.6, seconds, { wave: \"square\", volume: 0.16, rate })).reduce((a, b) => a.mix(b));\n const kick = tone([[0, 170], [0.025, 85], [0.3, 46]], 0.42, { volume: 0.95, rate }).envelope([0, 1], [0.08, 0.85], [0.42, 0])\n .mix(noise(0.006, { volume: 0.35, rate }).filter(\"highpass\", 3000)).drive(5);\n const snare = tone([[0, 260], [0.04, 185]], 0.18, { wave: \"triangle\", volume: 0.6, rate }).envelope([0, 1], [0.15, 0])\n .mix(noise(0.28, { volume: 0.6, rate }).filter(\"highpass\", 1400).filter(\"peak\", 5000, { gain: 4 }).envelope([0, 1], [0.03, 0.55], [0.26, 0])).drive(4);\n const burst = (at: number) => ({ clip: noise(0.012, { volume: 0.6, rate }).envelope([0, 1], [0.012, 0.2]), at });\n const clap = mixdown([burst(0), burst(0.011), burst(0.023), { clip: noise(0.22, { volume: 0.5, rate }).envelope([0, 1], [0.22, 0]), at: 0.03 }], undefined, rate)\n .filter(\"bandpass\", 1300, { resonance: 0.35 }).gain(1.6);\n const hat = (seconds: number) => metal(seconds).mix(noise(seconds, { volume: 0.4, rate })).filter(\"highpass\", 7200).filter(\"highshelf\", 10000, { gain: 3 }).envelope([0, 1], [seconds * 0.25, 0.4], [seconds, 0]);\n const tom = tone([[0, 190], [0.2, 120]], 0.4, { volume: 0.8, rate }).envelope([0, 1], [0.4, 0]).mix(noise(0.01, { volume: 0.2, rate }).filter(\"bandpass\", 2500));\n const rim = tone(1700, 0.025, { wave: \"triangle\", volume: 0.5, rate }).mix(noise(0.012, { volume: 0.3, rate }).filter(\"highpass\", 4000)).envelope([0, 1], [0.025, 0]);\n const crash = metal(2).mix(noise(2, { volume: 0.5, rate })).filter(\"highpass\", 4500).filter(\"peak\", 7500, { gain: 3 }).envelope([0, 1], [0.08, 0.5], [2, 0]);\n return kit({ k: kick.gain(v), s: snare.gain(v), c: clap.gain(v), h: hat(0.07).gain(0.55 * v), o: hat(0.4).gain(0.5 * v), t: tom.gain(v), r: rim.gain(v), x: crash.gain(0.45 * v) });\n}\n\n/** A kit: a clip a letter, played by a pattern's letters (\"kh\" plays k and h together). */\nexport function kit(hits: Readonly>): Kit {\n for (const k of Object.keys(hits)) if (k.length !== 1 || k === \".\" || k === \"-\" || k === \"|\") throw new Error(`A kit's sounds are named by one letter each, not ${JSON.stringify(k)}.`);\n return Object.freeze({ kind: \"kit\" as const, hits: Object.freeze({ ...hits }) });\n}\n\n/* ── Patterns: what is written, and what writes it ── */\n\n/** A pattern: steps a word each, as one text or a text a bar. */\nexport type Pattern = string | readonly string[];\n\n/** A pattern's steps, the \"|\" left out. */\nconst wordsOf = (p: Pattern): string[] => (typeof p === \"string\" ? p : p.join(\" \")).split(/\\s+/).filter((w) => w !== \"\" && w !== \"|\");\n\n/** A step's sounds and how loud: \"C4+E4@0.6\" is C4 and E4 at 0.6; \"kh\" k and h at 1. */\nfunction velocityOf(word: string, step: number): { body: string; velocity: number } {\n const at = word.indexOf(\"@\");\n if (at < 0) return { body: word, velocity: 1 };\n const body = word.slice(0, at), v = Number(word.slice(at + 1));\n if (body === \"\" || body === \"-\" || body === \".\" || !(v >= 0 && v <= 4)) throw new Error(`Step ${step + 1}: \"@\" follows a note, a chord or a kit's letters, with how loud they play, 0 to 4 (1 as written): \"C4@0.6\", \"kh@1.3\" — not ${JSON.stringify(word)}.`);\n return { body, velocity: v };\n}\n\nconst NAMES = [\"C\", \"C#\", \"D\", \"D#\", \"E\", \"F\", \"F#\", \"G\", \"G#\", \"A\", \"A#\", \"B\"];\n/** A note's name from its number: 60 is \"C4\". Sharps, not flats. */\nfunction nameOf(n: number): string {\n const octave = Math.floor(n / 12) - 1;\n if (octave < -1 || octave > 9) throw new Error(`A note goes from C-1 to B9: this one would be ${NAMES[((n % 12) + 12) % 12]}${octave}.`);\n return NAMES[((n % 12) + 12) % 12] + octave;\n}\n\n/** Each note of a step moved by a function of its number; holds, rests, kit letters and \"@\" kept. */\nconst eachNote = (p: Pattern, move: (n: number) => number): string[] => wordsOf(p).map((w) => w.replace(/[A-Ga-g][#b]?-?\\d/g, (name) => nameOf(move(noteNumber(name)))));\n\n/** A pattern `semitones` up (or down: a negative number): `transpose(tune, 12)` is the tune an octave up. A text a bar stays a text a bar. */\nexport function transpose(p: Pattern, semitones: number): Pattern {\n if (!Number.isInteger(semitones)) throw new Error(`transpose() moves by whole semitones, not ${semitones}.`);\n if (semitones === 0) return p;\n return typeof p === \"string\" ? eachNote(p, (n) => n + semitones).join(\" \") : p.map((bar) => eachNote(bar, (n) => n + semitones).join(\" \"));\n}\n\n/** A pattern with each step `by` steps long — eighths written as sixteenths with `stretch(p, 2)`. A text a bar stays a text a bar. */\nexport function stretch(p: Pattern, by: number): Pattern {\n if (!Number.isInteger(by) || by < 1) throw new Error(`stretch() makes each step a whole number of steps long, 1 or more — not ${by}.`);\n const one = (text: string) => wordsOf(text).map((w) => [w, ...Array.from({ length: by - 1 }, () => (w.startsWith(\".\") ? \".\" : \"-\"))].join(\" \")).join(\" \");\n return typeof p === \"string\" ? one(p) : p.map(one);\n}\n\n/** A pattern `n` times over, as one text. */\nexport function repeat(p: Pattern, n: number): string {\n if (!Number.isInteger(n) || n < 1) throw new Error(`repeat() plays a pattern a whole number of times, 1 or more — not ${n}.`);\n return Array.from({ length: n }, () => wordsOf(p).join(\" \")).join(\" \");\n}\n\n/** A kit's bar: the steps (from 0) each letter plays on, `steps` steps long — `beat({ k: [0, 8], s: [4, 12] })`. */\nexport function beat(hits: Readonly>, steps: number = 16): string {\n for (const [k, at] of Object.entries(hits)) for (const s of at) if (!Number.isInteger(s) || s < 0 || s >= steps) throw new Error(`beat(): ${k} on step ${s}, of steps 0 to ${steps - 1}.`);\n return Array.from({ length: steps }, (_, i) => Object.keys(hits).filter((k) => hits[k].includes(i)).join(\"\") || \".\").join(\" \");\n}\n\n/** A chord's notes above its root, by what follows the root's name. */\nconst QUALITIES: Readonly> = {\n \"\": [0, 4, 7], m: [0, 3, 7], dim: [0, 3, 6], aug: [0, 4, 8], \"+\": [0, 4, 8], sus2: [0, 2, 7], sus4: [0, 5, 7], sus: [0, 5, 7], \"5\": [0, 7],\n \"6\": [0, 4, 7, 9], m6: [0, 3, 7, 9], \"7\": [0, 4, 7, 10], maj7: [0, 4, 7, 11], M7: [0, 4, 7, 11], m7: [0, 3, 7, 10], dim7: [0, 3, 6, 9],\n m7b5: [0, 3, 6, 10], \"7sus4\": [0, 5, 7, 10], \"9\": [0, 4, 7, 10, 14], maj9: [0, 4, 7, 11, 14], m9: [0, 3, 7, 10, 14], add9: [0, 4, 7, 14], madd9: [0, 3, 7, 14],\n};\n\n/** A chord's notes as numbers, lowest first. */\nfunction chordNumbers(symbol: string, octave: number, inversion: number): number[] {\n const m = /^([A-G][#b]?)([^/]*)(?:\\/([A-G][#b]?))?$/.exec(symbol);\n const steps = m ? QUALITIES[m[2]] : undefined;\n if (!m || !steps) throw new Error(`A chord is a root and what kind — \"Am\", \"C\", \"G7\", \"Fmaj7\", \"Dm7\", \"Esus4\", \"Bdim\", \"C/E\" — not ${JSON.stringify(symbol)}. The kinds: ${Object.keys(QUALITIES).map((q) => `\"${q}\"`).join(\", \")}.`);\n if (!Number.isInteger(inversion) || inversion < 0 || inversion >= steps.length) throw new Error(`${symbol} has ${steps.length} notes: its inversion is 0 to ${steps.length - 1}, not ${inversion}.`);\n const root = noteNumber(m[1] + octave);\n const notes = steps.map((s) => root + s);\n for (let k = 0; k < inversion; k++) notes.push(notes.shift()! + 12);\n if (m[3]) {\n // Over another bass: that note, below the rest.\n let bass = noteNumber(m[3] + octave);\n while (bass >= notes[0]) bass -= 12;\n notes.unshift(bass);\n }\n return notes;\n}\n\n/**\n * A chord's notes, as a step plays them: `chord(\"Am\")` is \"A4+C5+E5\", its root in octave 4 (`octave`); `inversion`\n * moves its lowest notes up an octave, one each — `chord(\"E\", 3, 1)` is \"G#3+B3+E4\". \"C/E\" is C over an E below it.\n */\nexport function chord(symbol: string, octave: number = 4, inversion: number = 0): string {\n return chordNumbers(symbol, octave, inversion).map(nameOf).join(\"+\");\n}\n\n/** Each chord's notes as numbers, lowest first: the ones written, or the symbol's at `octave`. */\nconst notesOf = (c: string, octave: number): number[] => (/\\d/.test(c) ? c.split(\"+\").map(noteNumber).sort((a, b) => a - b) : chordNumbers(c, octave, 0));\n\n/**\n * A chord played as `template` says: its steps name the chord's notes by number, 1 the lowest — \"1 3 2 3\", \"1+2+3 - - -\"\n * — past the last, round again an octave up (in a three-note chord, 4 is 1 an octave up); each ^ after a number takes it\n * an octave higher, each _ an octave lower (\"1__\" the root two octaves down, for a bass). \"-\", \".\" and \"@\" as in any\n * pattern. `notes` is a chord's notes (\"A3+C4+E4\") or its name (\"Am\", with its root in octave `octave`).\n */\nexport function over(notes: string, template: Pattern, octave: number = 4): string {\n const n = notesOf(notes, octave);\n return wordsOf(template).map((w, s) => {\n const { body, velocity } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return w;\n const named = body.split(\"+\").map((d) => {\n const m = /^(\\d+)([\\^_]*)$/.exec(d);\n if (!m || Number(m[1]) < 1) throw new Error(`Step ${s + 1} of a chord's template: its notes are numbers from 1, the lowest, with ^ or _ for an octave up or down — \"1\", \"3^\", \"1__\" — not ${JSON.stringify(d)}.`);\n const k = Number(m[1]) - 1, up = Math.floor(k / n.length) + (m[2].match(/\\^/g)?.length ?? 0) - (m[2].match(/_/g)?.length ?? 0);\n return nameOf(n[k % n.length] + 12 * up);\n });\n return named.join(\"+\") + (velocity === 1 ? \"\" : `@${velocity}`);\n }).join(\" \");\n}\n\n/**\n * A progression played as `template` says, a template a chord: `chords(\"Am Am E Am Dm C E Am\", \"1_ 1 2 3\")` — as\n * `over` plays one chord. Each chord's root in octave `octave` (4); `lead: true` turns each chord after the first to\n * the inversion nearest the one before (its notes move least), as a player's hands do. A text a chord.\n */\nexport function chords(progression: string | readonly string[], template: Pattern, options: { readonly octave?: number; readonly lead?: boolean } = {}): string[] {\n const octave = options.octave ?? 4, list = wordsOf(progression);\n let before: number[] | null = null;\n return list.map((c) => {\n let n = notesOf(c, octave);\n if (options.lead && before && !/\\d/.test(c)) {\n // Each inversion, an octave either side: the one whose notes are nearest those before.\n const prev: number[] = before, mean = prev.reduce((a, b) => a + b, 0) / prev.length;\n let best = n, cost = Infinity;\n for (let inv = 0; inv < n.length; inv++) for (const shift of [-12, 0, 12]) {\n const cand = chordNumbers(c, octave, inv).map((x) => x + shift);\n const d = cand.length === prev.length ? cand.reduce((s, x, i) => s + Math.abs(x - prev[i]), 0) : Math.abs(cand.reduce((a, b) => a + b, 0) / cand.length - mean) * cand.length;\n if (d < cost) { cost = d; best = cand; }\n }\n n = best;\n }\n before = n;\n return over(n.map(nameOf).join(\"+\"), template);\n });\n}\n\n/** Seconds `n` bars last at `bpm` as a song plays it (its bar a whole number of frames): how long to make a riser. */\nexport function bars(bpm: number, n: number = 1, beats: number = 4): number {\n return n * Math.max(1, Math.round((60 / bpm) * beats / FRAME)) * FRAME;\n}\n\n/**\n * What makes a part breathe with the beat, as a mix does under a kick: down to `1 − depth` on each beat (`beat`\n * seconds apart, from the clip's start) and back over `release` of a beat — a part's fx, or a section's:\n * `{ fx: music.pump(music.bars(143) / 4) }`.\n */\nexport function pump(beat: number, depth: number = 0.45, release: number = 0.55): (clip: Clip) => Clip {\n if (!(beat > 0) || !(depth >= 0 && depth <= 1) || !(release > 0 && release <= 1)) throw new Error(\"pump() takes seconds a beat, how deep (0 … 1) and how long it takes to come back, as a part of a beat (0 … 1).\");\n return (c) => {\n const points: [number, number][] = [];\n for (let t = 0; t < c.seconds; t += beat) points.push([t, 1 - depth], [Math.min(c.seconds, t + release * beat), 1], [Math.min(c.seconds, t + beat - 0.004), 1]);\n points.push([c.seconds, 1]);\n return c.envelope(...points);\n };\n}\n\n/* ── Sounds between sections ── */\n\n/** Noise rising for `seconds` through a filter opening, loudest at the end: what leads into a drop. */\nexport function riser(seconds: number, options: { readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.5, s = seconds;\n const swept = noise(s, { volume: v, rate }).filter(\"bandpass\", [[0, 350], [s * 0.6, 1800], [s, 9000]], { resonance: 0.55 });\n const lift = tone([[0, 220], [s, 880]], s, { wave: \"saw\", voices: 3, detune: 25, width: 0.8, volume: v * 0.25, rate }).filter(\"lowpass\", [[0, 500], [s, 6000]]);\n return swept.mix(lift).envelope([0, 0], [s * 0.5, 0.25], [s * 0.9, 0.85], [s, 1]).fade(0, 0.01);\n}\n\n/** A boom to land on: a falling sine under a burst of noise, ringing `seconds` (1.5). */\nexport function impact(options: { readonly seconds?: number; readonly volume?: number; readonly rate?: number } = {}): Clip {\n const rate = options.rate ?? 44100, v = options.volume ?? 0.8, s = options.seconds ?? 1.5;\n return tone([[0, 90], [0.4, 38], [s, 30]], s, { volume: v, rate }).envelope([0, 1], [0.15, 0.7], [s, 0])\n .mix(noise(s, { volume: v * 0.5, rate }).filter(\"lowpass\", [[0, 6000], [s, 300]]).envelope([0, 1], [0.05, 0.4], [s, 0])).drive(3);\n}\n\n/** How a track or a part is heard. */\nexport interface PartOptions {\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan?: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume?: number;\n /** What it goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb?: number;\n}\n\n/** What an instrument plays, step by step. */\nexport interface Track {\n readonly instrument: Instrument | Kit;\n readonly steps: readonly string[];\n /** Where it is heard, −1 left to 1 right; 0, the middle, when left out. */\n readonly pan: number;\n /** How loud, 1 as the instrument plays. */\n readonly volume: number;\n /** What the track goes through: its whole part in `render`, each note on its own in `play`. */\n readonly fx?: (clip: Clip) => Clip;\n /** How much of it goes to the song's reverb in `render`, 0 … 1 (0). */\n readonly reverb: number;\n}\n\n/** A part's options checked, and its defaults filled in. */\nfunction heard(options: PartOptions, what: string): { pan: number; volume: number; reverb: number; fx?: (clip: Clip) => Clip } {\n const pan = options.pan ?? 0, volume = options.volume ?? 1, reverb = options.reverb ?? 0;\n if (!(pan >= -1 && pan <= 1)) throw new Error(`A ${what}'s pan is −1 (left) to 1 (right), not ${pan}.`);\n if (!(volume >= 0)) throw new Error(`A ${what}'s volume is 0 or more, not ${volume}.`);\n if (!(reverb >= 0 && reverb <= 1)) throw new Error(`A ${what}'s reverb is 0 to 1, not ${reverb}.`);\n return { pan, volume, reverb, ...(options.fx ? { fx: options.fx } : {}) };\n}\n\n/**\n * A pattern for an instrument: steps a word each — a note or notes joined by +, \"-\" to hold, \".\" to rest, \"|\" for the\n * eye; \"@\" after a step's notes says how loud, \"C4@0.6\". `pan`: where it is heard, −1 left to 1 right (a panned track's\n * notes have two channels); `volume`: 1 as the instrument plays; `fx`: what the track goes through —\n * `(c) => c.delay(0.21).chorus()` — its whole part when the song is rendered (as a mixer's insert), each note on its own\n * when it is played note by note; `reverb`: how much goes to the song's reverb when it is rendered, 0 … 1.\n */\nexport function track(instrument: Instrument | Kit, pattern: Pattern, options: PartOptions = {}): Track {\n return Object.freeze({ instrument, steps: Object.freeze(wordsOf(pattern)), ...heard(options, \"track\") });\n}\n\n/** An instrument and how it is heard, which a song's sections give its notes: `arrange`. */\nexport interface Part extends Omit { readonly kind: \"part\" }\n\n/** An instrument as a part of a song made of sections (`arrange`), heard as `track` says. */\nexport function part(instrument: Instrument | Kit, options: PartOptions = {}): Part {\n return Object.freeze({ kind: \"part\" as const, instrument, ...heard(options, \"part\") });\n}\n\n/** How long a section lasts, as its effects need it for their points: seconds, its bars, and seconds a bar. */\nexport interface SectionTime { readonly seconds: number; readonly bars: number; readonly bar: number }\n\n/** What a section is and does, besides its notes. */\nexport interface SectionOptions {\n /** What `song.sections` calls it. */\n readonly name?: string;\n /**\n * What a part goes through in this section only, given how long the section is — a filter opening over a build:\n * `{ pad: (c, t) => c.filter(\"lowpass\", [[0, 400], [t.seconds, 8000]]) }`. Its notes from this section, tails and\n * all, before the part's own `fx`. Mixed by `render` only: a song with one is not played note by note.\n */\n readonly fx?: Readonly Clip>>;\n /** How loud a part is in this section only, 1 as it is. */\n readonly volume?: Readonly>;\n}\n\n/** Bars of a song: what each part plays in them. */\nexport interface Section {\n readonly kind: \"section\";\n readonly name: string;\n readonly bars: number;\n readonly parts: Readonly>;\n readonly fx: Readonly Clip>>;\n readonly volume: Readonly>;\n /** This section with some parts written again — null: that part rests — and these options over its own. */\n with(parts: Readonly>, options?: SectionOptions): Section;\n}\n\n/**\n * `bars` bars of a song (`arrange`), and what each part plays in them, by the part's name: `{ lead: tune, drums: beat }`.\n * A pattern shorter than the section plays again until it is filled, so a bar of drums fills eight; a part not named\n * rests. `options`: its name, a part's effects or volume for this section only.\n */\nexport function section(bars: number, parts: Readonly>, options: SectionOptions = {}): Section {\n if (!Number.isInteger(bars) || bars < 1) throw new Error(`A section is a whole number of bars, 1 or more — not ${bars}.`);\n for (const [k, v] of Object.entries(options.volume ?? {})) if (!(v >= 0)) throw new Error(`A section's volume for ${k} is 0 or more, not ${v}.`);\n const made: Section = Object.freeze({\n kind: \"section\" as const, name: options.name ?? \"\", bars,\n parts: Object.freeze({ ...parts }), fx: Object.freeze({ ...(options.fx ?? {}) }), volume: Object.freeze({ ...(options.volume ?? {}) }),\n with(changes: Readonly>, more: SectionOptions = {}): Section {\n const next: Record = { ...parts };\n for (const [k, p] of Object.entries(changes)) { if (p === null) delete next[k]; else next[k] = p; }\n return section(bars, next, { name: more.name ?? made.name, fx: { ...made.fx, ...(more.fx ?? {}) }, volume: { ...made.volume, ...(more.volume ?? {}) } });\n },\n });\n return made;\n}\n\n/** A song's tempo and grid. */\nexport interface SongOptions {\n readonly bpm: number;\n /** Steps a beat (4: sixteenths). */\n readonly steps?: number;\n /** Beats a bar (4). */\n readonly beats?: number;\n /** How late every second step starts, as a part of a step: 0 straight (as it is when left out), 1/3 a triplet's lilt. */\n readonly swing?: number;\n}\n\n/** Where a song's section is: from bar `bar` (0 the first) for `bars`, from frame `frame`. */\nexport interface SongSection { readonly name: string; readonly bar: number; readonly bars: number; readonly frame: number }\n\n/** Steps of a track that belong to one section, and what that section does to them. */\ninterface Piece { readonly from: number; readonly to: number; readonly volume: number; readonly fx?: (clip: Clip, time: SectionTime) => Clip; readonly time: SectionTime }\nconst piecesOf = new WeakMap();\n/** A song of sections' parts, by name, and the track each became: what `stems` cuts. */\nconst partsOf = new WeakMap();\n\n/**\n * A song made of sections, one after another: `parts` names each instrument and how it is heard; each section says\n * what the parts play in it. A section can be written once and played again — `arrange(o, parts, intro, verse, verse,\n * chorus)` — or played again changed: `verse.with({ lead: transpose(tune, 12) })`.\n */\nexport function arrange(options: SongOptions, parts: Readonly>, ...sections: readonly Section[]): Song {\n const { perBar, barFrames } = grid(options);\n if (sections.length === 0) throw new Error(\"arrange() takes the song's sections, one at least.\");\n const names = Object.keys(parts);\n for (const s of sections) for (const k of [...Object.keys(s.parts), ...Object.keys(s.fx), ...Object.keys(s.volume)]) {\n if (!(k in parts)) throw new Error(`A section${s.name ? ` (${s.name})` : \"\"} names ${JSON.stringify(k)}, which is no part of the song: its parts are ${names.join(\", \")}.`);\n }\n const tracks = names.map((k) => {\n const steps: string[] = [], pieces: Piece[] = [];\n for (const s of sections) {\n const length = s.bars * perBar, from = steps.length, p = s.parts[k];\n if (p === undefined) for (let i = 0; i < length; i++) steps.push(\".\");\n else {\n const words = wordsOf(p);\n if (words.length === 0 || length % words.length !== 0) throw new Error(`${k}${s.name ? ` in ${s.name}` : \"\"}: a pattern of ${words.length} steps does not fill ${s.bars} bar${s.bars === 1 ? \"\" : \"s\"} of ${perBar} steps evenly.`);\n for (let i = 0; i < length; i++) steps.push(words[i % words.length]);\n }\n const fx = k in s.fx ? s.fx[k] : undefined, volume = s.volume[k] ?? 1;\n if (fx || volume !== 1) pieces.push({ from, to: from + length, volume, ...(fx ? { fx } : {}), time: { seconds: s.bars * barFrames * FRAME, bars: s.bars, bar: barFrames * FRAME } });\n }\n const t = track(parts[k].instrument, steps, parts[k]);\n if (pieces.length) piecesOf.set(t, pieces);\n return t;\n });\n let bar = 0;\n const placedSections = sections.map((s) => { const at = { name: s.name, bar, bars: s.bars, frame: bar * barFrames }; bar += s.bars; return Object.freeze(at); });\n const made = compose(options, tracks, placedSections);\n partsOf.set(made, names.map((k, i) => ({ name: k, track: tracks[i] })));\n return made;\n}\n\n/** A clip as a track plays it note by note: through its fx, at its volume and its pan, made once a clip a track. */\nfunction placed(t: Track, made: Map, c: Clip): Clip {\n if (t.pan === 0 && t.volume === 1 && !t.fx) return c;\n let p = made.get(c);\n if (!p) {\n p = t.fx ? t.fx(c) : c;\n if (t.volume !== 1) p = p.gain(t.volume);\n if (t.pan !== 0) p = p.pan(t.pan);\n made.set(c, p);\n }\n return p;\n}\n\n/** What a song's notes are, kept for render, which makes them again at its own rate; `piece`, the section whose effects it goes through. */\ninterface Score { readonly at: number; readonly track: Track; readonly made: Map; readonly velocity: number; readonly piece?: Piece; readonly name?: string; readonly seconds?: number; readonly hit?: Clip }\nconst scores = new WeakMap();\n\n/** A song, when the script is built: the tables `play` reads, and what it takes. */\nexport interface Song {\n /**\n * Frames a step lasts: a bar is a whole number of frames, a step need not be (2.5 for sixteenths at 143 beats a\n * minute). Played note by note, a note starts on the frame nearest its time — within the 21 ms the game's own\n * mixer starts a sound in anyway; rendered, exactly on it.\n */\n readonly stepFrames: number;\n /** Frames a bar lasts, a whole number. */\n readonly barFrames: number;\n /** Beats a bar. */\n readonly beats: number;\n /** The tempo it plays at: the nearest to the one asked for whose bar is a whole number of frames. */\n readonly bpm: number;\n /** Steps, the longest track's. */\n readonly steps: number;\n /** Its sections, made by `arrange`, in the order they play (none for a song of tracks). */\n readonly sections: readonly SongSection[];\n /** Frames it lasts, its last note's release included. */\n readonly frames: number;\n readonly seconds: number;\n /** Each clip the song plays, once. */\n readonly clips: readonly Clip[];\n /** Each note in the order they start: the frame it starts on, from 0, and which of `clips` it is. */\n readonly at: readonly number[];\n readonly which: readonly number[];\n /** The most notes sounding at once: `play` refuses a song of more than 32 (`render` has no limit). */\n readonly voices: number;\n}\n\n/** A song's grid: steps a bar, and frames a bar and a step. */\nfunction grid(options: SongOptions): { perBar: number; barFrames: number; stepFrames: number } {\n const perBeat = options.steps ?? 4, beats = options.beats ?? 4, swing = options.swing ?? 0;\n if (!(options.bpm > 0) || !(perBeat > 0) || !(beats > 0)) throw new Error(\"A song takes a tempo, { bpm: 120 }, steps a beat (4 by default) and beats a bar (4).\");\n if (!(swing >= 0 && swing < 1)) throw new Error(`A song's swing is 0 (straight) to under 1 (a step late), not ${swing}.`);\n const barFrames = Math.max(1, Math.round((60 / options.bpm) * beats / FRAME));\n return { perBar: perBeat * beats, barFrames, stepFrames: barFrames / (beats * perBeat) };\n}\n\n/**\n * Tracks played together from their first step: `bpm` beats a minute, `steps` steps a beat (4 by default), `swing`\n * every second step late. Played note by note (`play`), a song is refused when more than 32 notes would sound at once;\n * rendered, it has no limit. A song of sections: `arrange`.\n */\nexport function song(options: SongOptions, ...tracks: readonly Track[]): Song {\n return compose(options, tracks, []);\n}\n\nfunction compose(options: SongOptions, tracks: readonly Track[], sections: readonly SongSection[]): Song {\n const { barFrames, stepFrames } = grid(options), beats = options.beats ?? 4, swing = options.swing ?? 0;\n const stepSeconds = stepFrames * FRAME;\n // Where step s starts, in frames: every second step `swing` of a step late.\n const time = (s: number) => (s + (s % 2 === 1 ? swing : 0)) * stepFrames;\n const score: Score[] = [];\n let steps = 0, effects = false;\n for (const t of tracks) {\n const ws = t.steps, made = new Map(), pieces = piecesOf.get(t) ?? [];\n if (pieces.some((p) => p.fx)) effects = true;\n steps = Math.max(steps, ws.length);\n let k = 0;\n ws.forEach((w, s) => {\n while (k < pieces.length && pieces[k].to <= s) k++;\n const piece = k < pieces.length && pieces[k].from <= s ? pieces[k] : undefined;\n const { body, velocity: v } = velocityOf(w, s);\n if (body === \"-\" || body === \".\") return;\n const velocity = v * (piece?.volume ?? 1), into = piece ? { piece } : {};\n if (t.instrument.kind === \"kit\") {\n for (const letter of body) {\n const hit = t.instrument.hits[letter];\n if (!hit) throw new Error(`Step ${s + 1}: the kit has no ${JSON.stringify(letter)} (it has ${Object.keys(t.instrument.hits).join(\", \")}).`);\n score.push({ at: time(s), track: t, made, velocity, ...into, hit });\n }\n return;\n }\n // A note lasts its step and every \"-\" after it.\n let length = 1;\n while (s + length < ws.length && ws[s + length] === \"-\") length++;\n const seconds = swing === 0 ? length * stepSeconds : (time(s + length) - time(s)) * FRAME;\n for (const name of body.split(\"+\")) {\n noteNumber(name);\n score.push({ at: time(s), track: t, made, velocity, ...into, name, seconds });\n }\n });\n }\n score.sort((a, b) => a.at - b.at);\n const base = (n: Score) => n.hit ?? (n.track.instrument as Instrument).note(n.name!, n.seconds!);\n // The tables `play` reads, made when they are first read: a song only rendered never makes its notes for the game.\n let table: { clips: readonly Clip[]; at: readonly number[]; which: readonly number[]; voices: number; peakStep: number; frames: number } | null = null;\n const tables = () => {\n if (table) return table;\n const notes = score.map((n) => ({ at: Math.round(n.at), clip: placed(n.track, n.made, n.velocity === 1 ? base(n) : base(n).gain(n.velocity)) }));\n // How many sound at once: at each note's start, those started before it and not yet over.\n let voices = 0, peakStep = 0;\n notes.forEach((n, k) => {\n let sounding = 0;\n for (let j = 0; j <= k; j++) if (notes[j].at + notes[j].clip.seconds / FRAME > n.at) sounding++;\n if (sounding > voices) { voices = sounding; peakStep = Math.round(n.at / stepFrames) + 1; }\n });\n const clips: Clip[] = [];\n const which = notes.map((n) => { let k = clips.indexOf(n.clip); if (k < 0) { clips.push(n.clip); k = clips.length - 1; } return k; });\n const frames = Math.max(Math.round(steps * stepFrames), ...notes.map((n) => n.at + Math.ceil(n.clip.seconds / FRAME)));\n table = { clips: Object.freeze(clips), at: Object.freeze(notes.map((n) => n.at)), which: Object.freeze(which), voices, peakStep, frames };\n return table;\n };\n // What the game plays note by note: refused, when it is read, if more than 32 would sound at once (render has no limit).\n const playable = () => {\n if (effects) throw new Error(\"A section of this song puts a part through effects of its own, which only music.render mixes: render the song, or give the part those effects for the whole song.\");\n const t = tables();\n if (t.voices > VOICES) throw new Error(`At step ${t.peakStep}, ${t.voices} notes would sound at once; the game plays ${VOICES} note by note. Shorten notes or thin the chords — or music.render the song, one sound however many it mixes.`);\n return t;\n };\n const made: Song = Object.freeze({\n stepFrames, barFrames, beats, bpm: (60 * beats) / (barFrames * FRAME), steps, sections: Object.freeze([...sections]),\n get frames() { return tables().frames; },\n get seconds() { return tables().frames * FRAME; },\n get clips() { return playable().clips; },\n get at() { return playable().at; },\n get which() { return playable().which; },\n get voices() { return tables().voices; },\n });\n scores.set(made, score);\n return made;\n}\n\n/** The General MIDI drums, by number, as `drums()` has them: kicks k, snares s, the clap c, hats h and o, toms t, the rim r, crashes x. */\nconst GM_DRUMS: Readonly> = {\n 35: \"k\", 36: \"k\", 37: \"r\", 38: \"s\", 40: \"s\", 39: \"c\", 42: \"h\", 44: \"h\", 51: \"h\", 53: \"h\", 59: \"h\", 46: \"o\",\n 41: \"t\", 43: \"t\", 45: \"t\", 47: \"t\", 48: \"t\", 50: \"t\", 49: \"x\", 52: \"x\", 55: \"x\", 57: \"x\",\n};\n\n/** How imported notes are made a song: its grid, and which kit letter a drum's number plays. */\nexport interface MidiOptions extends Omit {\n /** Beats a minute: the file's own when left out. */\n readonly bpm?: number;\n /** A drum's MIDI number to a kit's letter, over General MIDI's (36 \"k\", 38 \"s\", 42 \"h\", 46 \"o\", 49 \"x\", …). */\n readonly drums?: Readonly>;\n}\n\n/**\n * A song of imported notes (`import tune from \"./tune.notes\"`): each track `parts` names — by its name, or its place\n * in `tune.tracks` from 0 — played by an instrument, a kit (the drums by their General MIDI numbers) or a `part`;\n * the tracks not named are left out. Each note starts on the step nearest its time and lasts the steps nearest its\n * length (`steps` a beat, 4: sixteenths); how hard it was struck is how loud it plays, 100 as written. A track whose\n * notes overlap becomes several, so the song can hold them. At the file's first tempo unless `bpm` says.\n */\nexport function midi(notes: Notes, parts: Readonly>, options: MidiOptions = {}): Song {\n const perBeat = options.steps ?? 4, map = { ...GM_DRUMS, ...(options.drums ?? {}) };\n const names = notes.tracks.map((t) => t.name);\n const tracks: Track[] = [];\n for (const [key, given] of Object.entries(parts)) {\n const index = names.indexOf(key) >= 0 ? names.indexOf(key) : /^\\d+$/.test(key) ? Number(key) : -1;\n const source = notes.tracks[index];\n if (!source) throw new Error(`The notes have no track ${JSON.stringify(key)}: their tracks are ${names.map((n) => JSON.stringify(n)).join(\", \")} (or 0 to ${names.length - 1}).`);\n const instrument = given.kind === \"part\" ? given.instrument : given, how = given.kind === \"part\" ? given : {};\n const kit = instrument.kind === \"kit\" ? instrument : null;\n // Voices: steps that each hold one chord at a time; a note joins a chord struck with it, or the first voice free.\n type Word = { sounds: string[]; velocity: number; length: number };\n const voices: { words: Map; end: number; last: number }[] = [];\n const placed = source.notes.map((n) => {\n const from = Math.round(n.at * perBeat);\n return { from, length: kit ? 1 : Math.max(1, Math.round((n.at + n.beats) * perBeat) - from), sound: kit ? map[n.pitch] : nameOf(n.pitch), velocity: Math.max(0.05, Math.round((n.velocity / 100) * 20) / 20) };\n }).filter((n) => n.sound !== undefined && (!kit || n.sound in kit.hits)).sort((a, b) => a.from - b.from || b.length - a.length);\n for (const n of placed) {\n let voice = voices.find((v) => { const w = v.words.get(v.last); return v.last === n.from && w !== undefined && w.length === n.length && w.velocity === n.velocity; });\n if (voice) { const w = voice.words.get(n.from)!; if (!w.sounds.includes(n.sound)) w.sounds.push(n.sound); continue; }\n voice = voices.find((v) => v.end <= n.from);\n if (!voice) { voice = { words: new Map(), end: 0, last: -1 }; voices.push(voice); }\n voice.words.set(n.from, { sounds: [n.sound], velocity: n.velocity, length: n.length });\n voice.end = n.from + n.length;\n voice.last = n.from;\n }\n for (const v of voices) {\n const steps: string[] = Array.from({ length: v.end }, () => \".\");\n for (const [at, w] of v.words) {\n steps[at] = (kit ? w.sounds.join(\"\") : w.sounds.join(\"+\")) + (w.velocity === 1 ? \"\" : `@${w.velocity}`);\n if (!kit) for (let k = 1; k < w.length; k++) steps[at + k] = \"-\";\n }\n tracks.push(track(instrument, steps, how));\n }\n }\n return compose({ ...options, bpm: options.bpm ?? notes.bpm, beats: options.beats ?? notes.beats }, tracks, []);\n}\n\n/** The song's reverb in `render`, as `Clip.reverb` takes it, the dry part left out. */\nexport interface ReverbOptions { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly width?: number }\n\n/**\n * A song mixed into one clip when the script is built: every note exactly in time, one sound playing. Mixed as a\n * mixing desk does: each track's notes made again at `rate` (44 100 when left out: what the ear hears up to 20 kHz; a\n * note played in the game note by note is made at 22 050, to keep the map small) into its own part, through a\n * section's effects where it has them, then its `fx`, at its volume, to its side; what each track sends (its `reverb`)\n * into one reverb shared by them all (`reverb`, its room); the whole through `master` —\n * `(mix) => mix.compress({ threshold: -14, ratio: 2 })` — and last a limiter (`Clip.limit`) so it never goes over\n * −1.5 dB however the parts add, between the samples too: `limit: false` leaves it as mixed, `{ ceiling, release }` sets it. A long clip a\n * program plays goes in the map as Ogg Vorbis, two channels when it has two.\n */\nexport function render(s: Song, options: { readonly rate?: number; readonly limit?: boolean | { readonly ceiling?: number; readonly release?: number }; readonly reverb?: ReverbOptions; readonly master?: (mix: Clip) => Clip } = {}): Clip {\n const rate = options.rate ?? 44100, score = scores.get(s), least = s.steps * s.stepFrames * FRAME;\n let mixed: Clip;\n if (!score) {\n // (A song from elsewhere, without its score: its own clips, at their rate.)\n mixed = mixdown(s.at.map((at, k) => ({ clip: s.clips[s.which[k]], at: at * FRAME })), s.seconds, rate);\n } else {\n const parts: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n const byTrack = new Map();\n for (const n of score) { const list = byTrack.get(n.track); if (list) list.push(n); else byTrack.set(n.track, [n]); }\n for (const [t, notes] of byTrack) {\n const clipOf = (n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // The notes as they are, and each section's that has effects of its own mixed apart and through them.\n const entries: { clip: Clip; at: number }[] = [], apart = new Map();\n for (const n of notes) {\n if (n.piece?.fx) { const list = apart.get(n.piece); if (list) list.push(n); else apart.set(n.piece, [n]); }\n else entries.push({ clip: clipOf(n), at: n.at * FRAME });\n }\n for (const [p, list] of apart) {\n const from = p.from * s.stepFrames;\n const sub = mixdown(list.map((n) => ({ clip: clipOf(n), at: (n.at - from) * FRAME })), p.time.seconds, rate);\n entries.push({ clip: p.fx!(sub, p.time), at: from * FRAME });\n }\n let part = mixdown(entries, least, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n parts.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (sends.length) {\n const r = options.reverb ?? {};\n parts.push({ clip: mixdown(sends, least, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n }\n mixed = mixdown(parts, least, rate);\n }\n if (options.master) mixed = options.master(mixed);\n const limit = options.limit ?? true;\n return limit === false ? mixed : mixed.limit(limit === true ? {} : limit);\n}\n\n/* ── Music that follows the game: a song cut into pieces, played piece by piece ── */\n\n/** How a song of sections is cut for the game to play piece by piece (`stems`). */\nexport interface StemsOptions {\n /**\n * Which parts each layer holds, by name — `{ drums: [\"kit\", \"hats\"], bass: [\"bass\"], melody: [\"lead\", \"saw\"] }` — a\n * layer the game can turn on and off where a piece begins. A part in no layer is left out.\n */\n readonly layers: Readonly>;\n /**\n * Beats a piece: where the game can go to another section or turn a layer on or off (a bar's beats when left out).\n * Two in a bar of four halves the wait for a change, for about half as much map again and more sounds at once.\n */\n readonly every?: number;\n /** Samples a second (44 100). */\n readonly rate?: number;\n /** The reverb's room, as `render` takes it. */\n readonly reverb?: ReverbOptions;\n /** What each piece's clip goes through last — an EQ, not a compressor: each is on its own, the game adds them up. */\n readonly master?: (clip: Clip) => Clip;\n /** The loudest the pieces may add up to as the song plays through, every layer on (0.84, −1.5 dB). */\n readonly peak?: number;\n /**\n * Seconds a piece's clip rings on after its notes end (2), its echoes and reverb faded out over the last of them: the\n * pieces after it cover the rest, and a shorter clip is a smaller map. A layer's last piece keeps all of its tail.\n */\n readonly tail?: number;\n /** The Ogg Vorbis quality of the pieces' clips (4: a little smaller than a song's 5, as many play at once). */\n readonly quality?: number;\n /**\n * Sounds to play on the next beat when the game asks (`band.sting`): a crash for a cleared line, a hit for a boss.\n * Turned down with the pieces, which leave room for one on any beat.\n */\n readonly stingers?: Readonly>;\n}\n\n/** A song cut into pieces for the game to play piece by piece, with `conduct` and a `Band`: made when the script is built. */\nexport interface Stems {\n readonly kind: \"stems\";\n /** Frames a bar lasts. */\n readonly barFrames: number;\n readonly bpm: number;\n /** Beats a bar, and beats a piece. */\n readonly beats: number;\n readonly every: number;\n /** Frames each beat of a piece lasts, whole frames (beats of a bar not a whole number of frames take turns). */\n readonly waits: readonly number[];\n /** The layers' names, in the order `band.on` has them. */\n readonly layers: readonly string[];\n /** The sections, in the order the song plays them: their names and how many bars. */\n readonly sections: readonly SongSection[];\n /**\n * Section `s`'s piece `p` in layer `l`: its notes, their tails and echoes and reverb with them, or null where it plays\n * none. A section of `bars` bars has bars × beats / every pieces.\n */\n readonly pieces: readonly (readonly (readonly (Clip | null)[])[])[];\n readonly stingers: readonly Clip[];\n /** A section's place in `sections`, by its name: `band.go(stems.section(\"drop\"))`. */\n section(name: string): number;\n /** A layer's place in `layers`, by its name: `band.mute(stems.layer(\"drums\"))`. */\n layer(name: string): number;\n /** A stinger's place, by its name: `band.sting(stems.stinger(\"crash\"))`. */\n stinger(name: string): number;\n /** The most clips sounding at once as the song plays through, every layer on: of the game's 32, what is left for the rest. */\n readonly voices: number;\n /** The pieces added up as the game plays the song through, every layer on, each on its frame: to hear it, or compare. */\n readonly playthrough: Clip;\n}\n\n/**\n * A song of sections (`arrange`) cut into pieces — a bar each, or `every` beats — a clip each piece of each layer, for\n * music that follows the game: where each piece begins the program says which section plays and which layers (`Band`,\n * `conduct`), and a stinger plays on the next beat. A piece's clip holds the notes that start in it, with their tails,\n * echoes and reverb, so it overlaps the next and nothing is cut off, whichever comes after it; a piece the same as\n * another is the same clip, one file in the map. Each layer is mixed as `render` mixes the song, but with no master\n * and no limiter between the parts — the game adds the clips up — so all of them are turned down together until the\n * song played through stays under `peak`.\n */\nexport function stems(s: Song, options: StemsOptions): Stems {\n const parts = partsOf.get(s), score = scores.get(s);\n if (!parts || !score) throw new Error(\"stems() cuts a song made of sections, by music.arrange.\");\n const rate = options.rate ?? 44100, layers = Object.keys(options.layers), r = options.reverb ?? {}, tail = options.tail ?? 2;\n if (!(tail > 0)) throw new Error(`stems()' tail is seconds after a piece, more than 0, not ${tail}.`);\n if (layers.length === 0 || layers.length > 8) throw new Error(`stems() takes one to eight layers, not ${layers.length}.`);\n const beats = s.beats, every = options.every ?? beats, piece = (s.barFrames * every) / beats;\n if (!Number.isInteger(every) || every < 1 || every > beats || beats % every !== 0) throw new Error(`stems() cuts every few beats of a bar's ${beats}, a number that divides it — not every ${every}.`);\n if (!Number.isInteger(piece)) throw new Error(`A piece of ${every} beats would be ${piece.toFixed(2)} frames: the game plays a sound on a frame. Make it a bar (every: ${beats}), or a tempo whose bar divides into whole frames.`);\n const perBar = beats / every;\n const layerOf = new Map();\n layers.forEach((name, l) => {\n for (const p of options.layers[name]) {\n const found = parts.find((x) => x.name === p);\n if (!found) throw new Error(`The layer ${name} names ${JSON.stringify(p)}, which is no part of the song: its parts are ${parts.map((x) => x.name).join(\", \")}.`);\n if (layerOf.has(found.track)) throw new Error(`${p} is in two layers: a part is in one at most.`);\n layerOf.set(found.track, l);\n }\n });\n const pieceSeconds = piece * FRAME, total = s.sections.reduce((n, x) => n + x.bars, 0) * perBar;\n // The notes of each piece of each layer, by part.\n const cells: Map[][] = Array.from({ length: total }, () => layers.map(() => new Map()));\n for (const n of score) {\n const l = layerOf.get(n.track);\n if (l === undefined) continue;\n const g = Math.min(total - 1, Math.floor((n.at + 1e-6) / piece));\n const m = cells[g][l], list = m.get(n.track);\n if (list) list.push(n); else m.set(n.track, [n]);\n }\n const clipOf = (t: Track, n: Score) => { const c = n.hit ?? (t.instrument as Instrument).note(n.name!, n.seconds!, rate); return n.velocity === 1 ? c : c.gain(n.velocity); };\n // Each layer's last piece with notes: it rings out whole, as the song ends.\n const last = layers.map((_, l) => { let g = total - 1; while (g > 0 && cells[g][l].size === 0) g--; return g; });\n // A piece of a layer, mixed: each part's notes from where it begins, through what the part goes through, and the reverb.\n const raw: (Clip | null)[][] = cells.map((byLayer, g) => byLayer.map((m, l) => {\n const out: { clip: Clip; at: number }[] = [], sends: { clip: Clip; at: number }[] = [];\n // Where the piece's last note ends, its release and all: a riser four bars long is kept whole.\n let end = pieceSeconds;\n for (const [t, notes] of m) for (const n of notes) end = Math.max(end, (n.at - g * piece) * FRAME + clipOf(t, n).seconds);\n for (const [t, notes] of m) {\n const plain = notes.filter((n) => !n.piece?.fx), apart = notes.filter((n) => n.piece?.fx);\n const entries: { clip: Clip; at: number }[] = plain.map((n) => ({ clip: clipOf(t, n), at: (n.at - g * piece) * FRAME }));\n if (apart.length) {\n // A section's effects take the whole section: this piece's notes where they fall in it, then the piece cut out.\n const p = apart[0].piece!, from = p.from * s.stepFrames, into = (g * piece - from) * FRAME;\n const whole = p.fx!(mixdown(apart.map((n) => ({ clip: clipOf(t, n), at: (n.at - from) * FRAME })), undefined, rate), p.time);\n entries.push({ clip: whole.trim(into, whole.seconds), at: 0 });\n }\n let part = mixdown(entries, undefined, rate);\n if (t.fx) part = t.fx(part);\n if (t.volume !== 1) part = part.gain(t.volume);\n if (t.pan !== 0) part = part.pan(t.pan);\n out.push({ clip: part, at: 0 });\n if (t.reverb > 0) sends.push({ clip: part.gain(t.reverb), at: 0 });\n }\n if (out.length === 0) return null;\n if (sends.length) out.push({ clip: mixdown(sends, undefined, rate).reverb({ decay: r.decay ?? 0.7, damping: r.damping ?? 0.45, predelay: r.predelay ?? 0.02, width: r.width ?? 1, mix: 1, dry: 0 }), at: 0 });\n let mixed = mixdown(out, undefined, rate);\n if (options.master) mixed = options.master(mixed);\n // What rings on after the piece's notes, kept for `tail` seconds and faded over the last of them — the pieces\n // after cover the rest — but all of it after the layer's last piece.\n const keep = end + tail;\n return g < last[l] && mixed.seconds > keep ? mixed.trim(0, keep).fade(0, Math.min(1, tail / 2)) : mixed;\n }));\n // All of them turned down together, as the song plays through.\n const placed: { clip: Clip; at: number; g: number; l: number }[] = [];\n raw.forEach((byLayer, g) => byLayer.forEach((c, l) => { if (c) placed.push({ clip: c, at: g * pieceSeconds, g, l }); }));\n if (placed.length === 0) throw new Error(\"stems(): the layers play no notes.\");\n // The stingers may play on any beat: turned down with the pieces, and room left for them over the loudest.\n const waits = Array.from({ length: every }, (_, j) => Math.round(((j + 1) * piece) / every) - Math.round((j * piece) / every));\n const stingerNames = Object.keys(options.stingers ?? {});\n const lines: number[] = [];\n for (let g = 0; g < total; g++) { let at = g * piece; for (const w of waits) { lines.push(at * FRAME); at += w; } }\n const fitted = together(placed, { peak: options.peak ?? 0.84, over: stingerNames.map((k) => ({ clip: options.stingers![k], at: lines })) });\n const grid: (Clip | null)[][] = raw.map((byLayer) => byLayer.map(() => null));\n const quality = options.quality ?? 4;\n placed.forEach((p, k) => { grid[p.g][p.l] = fitted[k].ogg(quality); });\n const stingers = stingerNames.map((_, k) => fitted[placed.length + k]);\n let start = 0;\n const pieces = s.sections.map((sec) => { const n = sec.bars * perBar, rows = grid.slice(start, start + n).map((row) => Object.freeze(row)); start += n; return Object.freeze(rows); });\n const lookup = (what: string, list: readonly string[], name: string) => {\n const k = list.indexOf(name);\n if (k < 0) throw new Error(`There is no ${what} ${JSON.stringify(name)}: there are ${list.map((x) => JSON.stringify(x)).join(\", \") || \"none\"}.`);\n return k;\n };\n let voices = -1;\n return Object.freeze({\n kind: \"stems\" as const, barFrames: s.barFrames, bpm: s.bpm, beats, every, waits: Object.freeze(waits),\n layers: Object.freeze(layers), sections: s.sections, pieces: Object.freeze(pieces), stingers: Object.freeze(stingers),\n section: (name: string) => lookup(\"section\", s.sections.map((x) => x.name), name),\n layer: (name: string) => lookup(\"layer\", layers, name),\n stinger: (name: string) => lookup(\"stinger\", stingerNames, name),\n get playthrough() {\n return mixdown(placed.map((p) => ({ clip: grid[p.g][p.l]!, at: p.at })));\n },\n get voices() {\n if (voices < 0) {\n voices = 0;\n for (const p of placed) { let n = 0; for (const q of placed) if (q.at <= p.at && q.at + q.clip.seconds > p.at) n++; voices = Math.max(voices, n); }\n }\n return voices;\n },\n });\n}\n\n/**\n * The music as the game steers it: which section plays, which piece of it, which layers. Make one inside the program\n * that steers the music and hand it to `conduct`, which plays a piece where each begins as it says:\n *\n * import { music, Band } from \"trigscript/music\";\n * const band = new Band();\n * start(music.conduct(stems, band));\n * band.go(stems.section(\"drop\")); // where the next piece begins\n * band.mute(stems.layer(\"drums\"));\n * band.sting(stems.stinger(\"crash\")); // on the next beat\n *\n * With nothing said, the song plays through its sections in order and starts again.\n */\nexport class Band {\n /** The section playing, its place in `stems.sections`; the piece of it (a bar, unless `stems.every` says), from 0; the beat of the piece. */\n section = 0;\n piece = 0;\n beat = 0;\n /** Pieces played since it started. */\n count = 0;\n /** The game's frame the beat playing began on: how far into the beat it is, for what moves with the music. */\n at = 0;\n /** The section to go to, and whether where the next piece begins (`go`) or when this section ends (`queue`); −1 for none. */\n next = -1;\n now = false;\n /** Whether a section plays again when it ends, instead of going on to the next. */\n loop = false;\n /** Which layers play, by their place in `stems.layers`. */\n on = [true, true, true, true, true, true, true, true];\n /** A stinger to play on the next beat, by its place; −1 for none. */\n stung = -1;\n /** Whether it plays: false stops it where the next piece would begin (what sounds rings out). */\n playing = true;\n\n /** Go to a section where the next piece begins, from its first piece. */\n go(section: number) {\n this.next = section;\n this.now = true;\n }\n /** Go to a section when the one playing ends. */\n queue(section: number) {\n this.next = section;\n this.now = false;\n }\n /** Turn a layer off, or on, from where the next piece begins. */\n mute(layer: number) {\n this.on[layer] = false;\n }\n unmute(layer: number) {\n this.on[layer] = true;\n }\n /** Play a stinger on the next beat. */\n sting(stinger: number) {\n this.stung = stinger;\n }\n /** Stop where the next piece would begin. */\n stop() {\n this.playing = false;\n }\n}\n\n/** Play `stems` as `band` says, beat by beat, until it stops: `start(music.conduct(stems, band))` beside the program. */\nexport const conduct = game(async (s: Stems, band: Band) => {\n band.playing = true;\n band.beat = 0;\n while (band.playing || band.beat > 0) {\n band.at = memory.game.frame;\n if (band.beat === 0) {\n // Where a piece begins: where to be, then the piece's clip of each layer that is on. (Which clip, by test: a\n // clip is no value a program holds.)\n if (band.next >= 0 && (band.now || band.piece === 0)) {\n band.section = band.next;\n band.piece = 0;\n band.next = -1;\n }\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) {\n for (let p = 0; p < s.pieces[k].length; p++) if (band.piece === p) {\n for (let l = 0; l < s.layers.length; l++) if (s.pieces[k][p][l]) if (band.on[l]) playWav(s.pieces[k][p][l]!, 0);\n }\n }\n }\n for (let j = 0; j < s.stingers.length; j++) if (band.stung === j) playWav(s.stingers[j], 0);\n band.stung = -1;\n for (let j = 0; j < s.every; j++) if (band.beat === j) await sleep(frames(s.waits[j]));\n band.beat++;\n if (band.beat >= s.every) {\n band.beat = 0;\n band.count++;\n band.piece++;\n // A section ended: again, or on to the next (after the last, the first).\n for (let k = 0; k < s.sections.length; k++) if (band.section === k) if (band.piece >= s.pieces[k].length) {\n band.piece = 0;\n if (!band.loop) band.section = k + 1 < s.sections.length ? k + 1 : 0;\n }\n if (!band.playing) band.beat = 0;\n }\n }\n});\n\n/**\n * Play a song, note by note, inside an async program: \\`await music.play(song)\\` resolves at its end, or\n * \\`start(music.play(song))\\` plays it beside the program.\n */\nexport const play = game(async (s: Song) => {\n const at = s.at;\n const which = s.which;\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n // Which clip, by its number: a test a clip (a loop over a length known when the script is built is unrolled, and\n // the clips stay the song's, known when it is built — a clip is no value a program holds).\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n if (s.frames > now) await sleep(frames(s.frames - now));\n});\n\n/** Play a song again and again; start it beside a program: \\`start(music.loop(song))\\`. */\nexport const loop = game(async (s: Song) => {\n // (\\`play\\` written out again, so that the wait at the end of each turn is where the loop shows it — at least a frame,\n // so that a song with nothing left at its end does not turn round within one frame for ever.)\n const at = s.at;\n const which = s.which;\n while (true) {\n let i = 0;\n let now = 0;\n while (i < at.length) {\n if (at[i] > now) {\n await sleep(frames(at[i] - now));\n now = at[i];\n }\n const k = which[i];\n for (let j = 0; j < s.clips.length; j++) if (k === j) playWav(s.clips[j], 0);\n i++;\n }\n await sleep(frames(Math.max(s.frames - now, 1)));\n }\n});\n\n/** Everything above as one name: import { music } from \"trigscript/music\"; music.play(song). */\nexport const music = {\n sampler, synth, fmSynth, strings, presets, drums, kit, track, song, render, play, loop,\n part, section, arrange, transpose, stretch, repeat, beat, chord, over, chords, bars, pump, riser, impact, midi, stems, conduct,\n};\n", + "random": "/**\n * Chance: the standard library's \"trigscript/random\" (TrigScript 4).\n *\n * import { chance, range, pick, shuffle, weighted, Random } from \"trigscript/random\";\n *\n * if (chance(25)) dropLoot(); // one time in four\n * const lane = range(1, 3); // 1, 2 or 3\n * const boss = pick(BOSSES); // any one of a list\n * shuffle(order); // an array of the program, in place\n * const tier = weighted([70, 25, 5]); // 0 most of the time, 2 rarely\n *\n * const rng = new Random(seed); // the same numbers every time for the same seed\n * const x = rng.range(0, 63);\n *\n * The functions use the game's own random numbers, which every player's game draws alike, so nothing goes out of step\n * in a game of several players. `Random` is a generator of your own (xorshift), seeded: the same seed gives the same\n * numbers, game after game — a level made from a seed, a save code that holds one.\n */\nimport { random } from \"trigscript\";\n\n/** True `percent` times in a hundred (25: one time in four). */\nexport function chance(percent: number): boolean {\n return random(100) < percent;\n}\n\n/** A whole number from `min` to `max`, both included. */\nexport function range(min: number, max: number): number {\n return min + random(max - min + 1);\n}\n\n/** The total of `count` dice of `sides` sides each (3, 6: from 3 to 18). */\nexport function dice(count: number, sides: number): number {\n let total = 0;\n let i = 0;\n while (i < count) { total += 1 + random(sides); i++; }\n return total;\n}\n\n/** One of a list, any one alike; a list the script has, or an array of the program (the first's 0 when it is empty). */\nexport function pick(items: readonly T[]): T {\n return items[random(items.length)];\n}\n\n/** The place of one of the weights, each as likely as its share of their total: [70, 25, 5] gives 0 most of the time. */\nexport function weighted(weights: readonly number[]): number {\n let total = 0;\n for (const w of weights) total += w;\n let roll = random(total);\n let i = 0;\n while (i < weights.length) {\n if (roll < weights[i]) return i;\n roll -= weights[i];\n i++;\n }\n return weights.length - 1;\n}\n\n/** An array of the program in a random order, in place (Fisher–Yates: every order alike). */\nexport function shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = random(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n}\n\n/**\n * A generator of random numbers of your own, seeded: the same seed gives the same numbers, in the game and when the\n * script is tested. Make it in the program (`const rng = new Random(1234)`).\n */\nexport class Random {\n state: number;\n\n /** Seeded with any whole number (0 is taken as 1). */\n constructor(seed: number) {\n this.state = seed === 0 ? 1 : seed;\n }\n\n /** The next number, from 0 to 2 147 483 647. */\n next(): number {\n let x = this.state;\n x ^= x << 13;\n x ^= x >>> 17;\n x ^= x << 5;\n this.state = x;\n return x & 0x7fffffff;\n }\n\n /** A whole number from 0 to n − 1. */\n int(n: number): number {\n return this.next() % n;\n }\n\n /** A whole number from `min` to `max`, both included. */\n range(min: number, max: number): number {\n return min + this.int(max - min + 1);\n }\n\n /** True `percent` times in a hundred. */\n chance(percent: number): boolean {\n return this.int(100) < percent;\n }\n\n /** One of a list, any one alike. */\n pick(items: readonly T[]): T {\n return items[this.int(items.length)];\n }\n\n /** An array of the program in a random order, in place. */\n shuffle(items: T[]) {\n let i = items.length - 1;\n while (i > 0) {\n const j = this.int(i + 1);\n const held = items[i];\n items[i] = items[j];\n items[j] = held;\n i--;\n }\n }\n}\n", + "screen": "/**\n * Pixel screens: the standard library's \"trigscript/screen\" (TrigScript 4).\n *\n * import { Screen, ScreenView, sprite } from \"trigscript/screen\";\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n *\n * program(async () => {\n * const screen = new Screen(4, 4, 24, 16); // 24 × 16 pixels, its corner on tile (4, 4)\n * let x = 10;\n * while (true) {\n * screen.clear();\n * screen.rect(0, 15, 24, 1, 5); // the ground, in colour 5\n * screen.blit(SHIP, x, 12);\n * screen.number(score, 1, 1, 2); // digits 3 × 5 pixels, in colour 2\n * screen.present(); // on the map, and so on the minimap (screen.reveal(P1) once first)\n * await nextFrame();\n * }\n * });\n *\n * A screen is a grid of pixels, each 0 (empty) or a colour from 1 to 7; drawing changes the grid, and `present()`\n * shows it: a pixel is a unit on the map, owned by the player whose colour it is, so the minimap shows the picture —\n * neighbouring pixels of one colour join into a solid block there. Only the pixels that changed since the last\n * `present()` are made or taken away, and only the pixels drawn since are looked at, so a still picture costs nothing to\n * show. Draw through the methods (`set`, `clear`, `rect`, …): a pixel written into `pixels` directly is not seen. The same grid can be shown in a\n * player's text lines too, as squares (`ScreenView` in a `ui.lines` view): one game, two screens, as the Tetris probe has it.\n *\n * A player sees the pixels only where they have vision: `screen.reveal(P1)` once gives them Map Revealers over it.\n *\n * Two looks (the constructor's last argument): \"dots\", Overlords, which fly — the minimap draws each as a dot with a gap\n * round it, the screen may be over Space; and \"solid\", Pylons, which the minimap draws as their whole two-tile\n * footprint, so pixels side by side are one block — on buildable ground (probes/lib/screen-map.mts --ground).\n *\n * What the map needs, found playing the unit-pixel probe in StarCraft: Remastered (2026-10-16): the owners of the\n * pixels (P3, P4, P5, P1, P6, P7, P8 below; their colours are theirs on the map, so give them the colours of TEXT_COLORS) must not\n * be computers — a computer moves its own units — and Remastered will not start a game without a computer, so one slot\n * (P2 here) is a computer that owns no pixel. A pixel is an Overlord, which sits on any ground and on nothing else; keep\n * the screen's tiles clear of buildings. A Pylon needs buildable ground, and none of the map's own units in the way. About 80 pixels made and taken away in a frame did not slow the game.\n */\nimport { centerLocation, createUnit, removeUnitAt, reserveLocation, textColors, units, P1, P3, P4, P5, P6, P7, P8, type Player } from \"trigscript\";\n\n/**\n * Whose units show colours 1 to 7 on the map, in the order of TEXT_COLORS: give each player that colour in the map\n * (cyan, yellow, purple, green, red, blue, orange). Green is P1's — your own units are green on the minimap anyway.\n * Not computers.\n */\nexport const OWNERS: readonly Player[] = [P3, P4, P5, P1, P6, P7, P8];\n/** The same colours in the text lines (`ScreenView`), 0 an empty square: cyan, yellow, purple, green, red, blue, orange. */\nexport const TEXT_COLORS: readonly number[] = [0, textColors.cyan, textColors.yellow, textColors.purple, textColors.green, textColors.red, textColors.blue, textColors.orange];\n\n/** Where each pixel's unit is made: a location the map does not use, taken when the script is built. */\nconst SPOT = reserveLocation(\"screen: where each pixel is made\");\n\n/** A picture known when the script is built, to `blit` onto a screen: its size, and its pixels a row at a time (0 is see-through). */\nexport interface Sprite { readonly width: number; readonly height: number; readonly cells: readonly number[] }\n\n/**\n * A sprite from rows of text, when the script is built: \"1\" … \"7\" a pixel of that colour, anything else see-through.\n *\n * const SHIP = sprite([\".1.\", \"111\", \"1.1\"]);\n */\nexport function sprite(rows: readonly string[]): Sprite {\n const width = Math.max(0, ...rows.map((r) => r.length));\n const cells: number[] = [];\n for (const row of rows) for (let x = 0; x < width; x++) { const c = Number(row[x]); cells.push(c >= 1 && c <= 7 ? c : 0); }\n return { width, height: rows.length, cells };\n}\n\n/** The digits 0 to 9, three pixels wide and five high, for `number()`: each fifteen pixels, a row at a time. */\nconst DIGITS: readonly number[] = [\n \"111101101101111\", \"010110010010111\", \"111001111100111\", \"111001111001111\", \"101101111001001\",\n \"111100111001111\", \"111100111101111\", \"111001001010010\", \"111101111101111\", \"111101111001111\",\n].flatMap((d) => d.split(\"\").map(Number));\n\n/** The most pixels a screen has: 32 × 32 at two tiles a pixel covers a 64 × 64 map, 64 × 32 half of a 128 × 128 one. */\nexport const MAX_PIXELS = 2048;\n\nexport class Screen {\n /** The picture being drawn: a colour a pixel, a row at a time (the first width × height of them). */\n pixels: number[] = new Array(MAX_PIXELS).fill(0);\n /** What the map shows now: the picture as it was at the last `present()`. */\n shown: number[] = new Array(MAX_PIXELS).fill(0);\n /** The pixels drawn a colour they were not since the last `present()`, in order: each one's place and its x and y. */\n drawnAt: number[] = new Array(MAX_PIXELS).fill(0);\n drawnX: number[] = new Array(MAX_PIXELS).fill(0);\n drawnY: number[] = new Array(MAX_PIXELS).fill(0);\n drawn = 0;\n /** More drawn than the list holds: `present()` looks at every pixel instead. */\n overflow = false;\n x: number;\n y: number;\n width: number;\n height: number;\n cell: number;\n /** The unit a pixel is: an Overlord (\"dots\"), or a Pylon (\"solid\"). */\n unit = 0;\n\n /**\n * A screen `width` × `height` pixels (MAX_PIXELS at most), its top left corner on tile (x, y) of the map, a pixel two\n * tiles across.\n * @param look \"dots\" (the default): a pixel is an Overlord, which the minimap draws as a dot, a gap round it — it flies,\n * so the screen may be over anything, Space too. \"solid\": a pixel is a Pylon, which the minimap draws as its whole\n * footprint, two tiles square, so pixels side by side are one block — it needs buildable ground under the screen,\n * and gives its owner Protoss supply. (Overlords a tile apart do not fit: the game will not place most of them.)\n */\n constructor(x: number, y: number, width: number, height: number, look: \"dots\" | \"solid\" = \"dots\") {\n this.x = x;\n this.y = y;\n this.width = width;\n this.height = height;\n this.cell = 64;\n this.unit = look === \"solid\" ? units.ProtossPylon : units.ZergOverlord;\n }\n\n /** Every pixel the colour (0, empty, unless it says). */\n clear(color: number = 0) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.put(i, x, y, color); i++; x++; }\n y++;\n }\n }\n\n /** One pixel; nothing happens outside the screen. */\n set(x: number, y: number, color: number) {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) this.put(y * this.width + x, x, y, color);\n }\n\n /** A pixel written, and listed for `present()` when its colour changed. */\n private put(i: number, x: number, y: number, color: number) {\n if (this.pixels[i] === color) return;\n this.pixels[i] = color;\n if (this.drawn < MAX_PIXELS) {\n this.drawnAt[this.drawn] = i;\n this.drawnX[this.drawn] = x;\n this.drawnY[this.drawn] = y;\n this.drawn++;\n } else this.overflow = true;\n }\n\n /** The colour of a pixel; 0 outside the screen. */\n get(x: number, y: number): number {\n if (x >= 0 && y >= 0 && x < this.width && y < this.height) return this.pixels[y * this.width + x];\n return 0;\n }\n\n /** A filled rectangle, its top left at (x, y); what is outside the screen is left out. */\n rect(x: number, y: number, width: number, height: number, color: number) {\n let j = 0;\n while (j < height) {\n let i = 0;\n while (i < width) { this.set(x + i, y + j, color); i++; }\n j++;\n }\n }\n\n /** A line from (x0, y0) to (x1, y1), both ends drawn. */\n line(x0: number, y0: number, x1: number, y1: number, color: number) {\n const dx = Math.abs(x1 - x0);\n const dy = -Math.abs(y1 - y0);\n const sx = x0 < x1 ? 1 : -1;\n const sy = y0 < y1 ? 1 : -1;\n let err = dx + dy;\n let x = x0;\n let y = y0;\n while (true) {\n this.set(x, y, color);\n if (x === x1 && y === y1) break;\n const e2 = 2 * err;\n if (e2 >= dy) { err += dy; x += sx; }\n if (e2 <= dx) { err += dx; y += sy; }\n }\n }\n\n /** A sprite with its top left at (x, y): its see-through pixels leave what is under them. */\n blit(s: Sprite, x: number, y: number) {\n let k = 0;\n let j = 0;\n while (j < s.height) {\n let i = 0;\n while (i < s.width) {\n const c = s.cells[k];\n if (c !== 0) this.set(x + i, y + j, c);\n k++;\n i++;\n }\n j++;\n }\n }\n\n /** A whole number's digits, 3 × 5 pixels each with a pixel between, its top left at (x, y): a score on the minimap. */\n number(n: number, x: number, y: number, color: number) {\n let rest = n < 0 ? -n : n;\n let count = 1;\n let probe = rest;\n while (probe >= 10) { probe = Math.floor(probe / 10); count++; }\n let place = x + (count - 1) * 4;\n while (true) {\n const d = rest % 10;\n let k = 0;\n while (k < 15) {\n if (DIGITS[d * 15 + k] === 1) this.set(place + (k % 3), y + Math.floor(k / 3), color);\n k++;\n }\n rest = Math.floor(rest / 10);\n place -= 4;\n if (rest === 0) break;\n }\n }\n\n /**\n * Let a player see the screen: Map Revealers of theirs over it, for the rest of the game. Without them its pixels,\n * owned by other players, are hidden by the fog of war — on the map and on the minimap. Once, before the first\n * `present()`.\n * @param player Who sees it: P1 … P8, known when the script is built.\n */\n reveal(player: Player) {\n // A Map Revealer sees ten tiles round; one every six tiles leaves no gap.\n const right = this.x * 32 + this.width * this.cell;\n const bottom = this.y * 32 + this.height * this.cell;\n let py = this.y * 32 + 96;\n while (py < bottom + 96) {\n let px = this.x * 32 + 96;\n while (px < right + 96) {\n centerLocation(SPOT, px, py, 32, 32);\n createUnit(player, units.MapRevealer, 1, SPOT);\n px += 192;\n }\n py += 192;\n }\n }\n\n /**\n * The picture on the map: each pixel that changed since the last time taken away and made again in its colour. Only\n * the pixels drawn since are looked at (one drawn and drawn back is passed over), unless more were drawn than the list\n * holds.\n */\n present() {\n if (this.overflow) {\n let i = 0;\n let y = 0;\n while (y < this.height) {\n let x = 0;\n while (x < this.width) { this.show(i, x, y); i++; x++; }\n y++;\n }\n this.overflow = false;\n } else {\n for (let k = 0; k < this.drawn; k++) this.show(this.drawnAt[k], this.drawnX[k], this.drawnY[k]);\n }\n this.drawn = 0;\n }\n\n /** One pixel on the map as it is in the picture, when it is not already. */\n private show(i: number, x: number, y: number) {\n const now = this.pixels[i];\n const was = this.shown[i];\n if (now === was) return;\n const half = this.cell >> 1;\n centerLocation(SPOT, this.x * 32 + x * this.cell + half, this.y * 32 + y * this.cell + half, 32, 32);\n OWNERS.forEach((owner, k) => { if (was === k + 1) removeUnitAt(owner, this.unit, 1, SPOT); });\n OWNERS.forEach((owner, k) => { if (now === k + 1) createUnit(owner, this.unit, 1, SPOT); });\n this.shown[i] = now;\n }\n}\n\n/**\n * A screen in the text lines, a square a pixel — filled in its colour, or empty — a line a row. For a `ui.lines` view:\n * ``, the screen's size known when the script is built. Eleven lines\n * show at once, sixty squares across.\n */\nexport function ScreenView({ screen, width, height }: { screen: Screen; width: number; height: number }) {\n return <>{UP_TO_60.map((y) => y < height && {UP_TO_60.map((x) => x < width && )})};\n}\n\n/** 0 … 59: the places a view's rows and columns are counted over, the ones past its size left out when the script is built. */\nconst UP_TO_60: readonly number[] = Array.from({ length: 60 }, (_, i) => i);\n", + "signals": "/**\n * Events of your own: the standard library's \"trigscript/signals\" (TrigScript 4).\n *\n * import { Signal } from \"trigscript/signals\";\n *\n * const waveCleared = new Signal();\n *\n * program(async () => {\n * waveCleared.on((wave) => setResources(P1, \"add\", 100 * wave, \"ore\"));\n * waveCleared.once((wave) => print(`First wave down!`));\n * const id = waveCleared.on((wave) => print(`Wave ${wave} cleared`));\n * …\n * waveCleared.emit(3); // every handler, in the order they were added\n * waveCleared.off(id);\n * });\n *\n * A signal lets one part of a map say that something happened without knowing who listens: the shop, the score and the\n * music each add a handler, and the wave code only emits. What it carries is one value of the type it is made with —\n * a number, a boolean, a text or a unit (`new Signal()`). Its handlers are functions kept as values: they may use\n * what the block they were written in has, and they run to their end, one after another, within the frame.\n *\n * Make a signal inside the program that uses it (`const s = new Signal()` at its top): a signal is variables\n * of a program, as anything a program writes is.\n */\n\nexport class Signal {\n /** The handlers, by the number `on` gave; one taken off is undefined, and its place is given to the next one added. */\n handlers: (((value: T) => void) | undefined)[] = [];\n /** Which handlers go once they have run once. */\n onceOnly: boolean[] = [];\n\n /**\n * Add a handler: it runs at every `emit`, handed the value.\n * @returns Its number, for `off`.\n */\n on(handler: (value: T) => void): number {\n return this.add(handler, false);\n }\n\n /** Add a handler that runs at the next `emit` only. @returns Its number, for `off` before then. */\n once(handler: (value: T) => void): number {\n return this.add(handler, true);\n }\n\n /** Take a handler off, by the number `on` or `once` gave. */\n off(id: number) {\n if (id >= 0 && id < this.handlers.length) this.handlers[id] = undefined;\n }\n\n /** Run every handler, in the order they were added, each handed `value`. */\n emit(value: T) {\n let i = 0;\n while (i < this.handlers.length) {\n const h = this.handlers[i];\n if (h) {\n if (this.onceOnly[i]) this.handlers[i] = undefined;\n h(value);\n }\n i++;\n }\n }\n\n /** How many handlers it has. */\n count(): number {\n let n = 0;\n for (const h of this.handlers) if (h) n++;\n return n;\n }\n\n /** Take every handler off. */\n clear() {\n let i = 0;\n while (i < this.handlers.length) { this.handlers[i] = undefined; i++; }\n }\n\n private add(handler: (value: T) => void, once: boolean): number {\n // A place one was taken off from, or a new one at the end.\n let i = 0;\n while (i < this.handlers.length) {\n if (!this.handlers[i]) {\n this.handlers[i] = handler;\n this.onceOnly[i] = once;\n return i;\n }\n i++;\n }\n this.handlers.push(handler);\n this.onceOnly.push(once);\n return this.handlers.length - 1;\n }\n}\n", + "timers": "/**\n * Time in a program: the standard library's \"trigscript/timers\" (TrigScript 4).\n *\n * import { every, after, Cooldown, Countdown, SECOND } from \"trigscript/timers\";\n *\n * program(async () => {\n * const spawner = every(10 * SECOND, (n) => spawnWave(n)); // every ten seconds, the wave's number handed over\n * after(3 * SECOND, () => print(\"Go!\"));\n * const shot = new Cooldown(12);\n * while (true) {\n * if (keyPressed(P1, \"Space\") && shot.use()) fire();\n * if (boss.hp <= 0) spawner.cancel();\n * await nextFrame();\n * }\n * });\n *\n * for await (const b of over(bullets, { per: 50 })) b.x += b.dx; // fifty a frame, on to the next frame after each fifty\n *\n * Time is counted in frames of the game, 24 a second on Fastest (`SECOND`). `every` and `after` run their function in a\n * task beside the program, so the program goes on at once; the Task they give back stops it (`cancel()`) and says\n * whether it still runs (`running`). Their function is kept for the task, with what it uses of the block it was written\n * in — written in a loop, each turn's is its own, as JavaScript has it — and it runs to its end within the frame (it\n * does not wait). Sixteen of one `every` or `after` written once can run at a time. `Cooldown`, `Countdown` and\n * `Stopwatch` read the game's frame counter: no task, nothing to stop.\n */\nimport { game, start, sleep, frames, nextFrame, memory, type Task } from \"trigscript\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nexport const SECOND = 24;\n\n/** The frames of game time since the game began. */\nexport function now(): number {\n return memory.game.frame;\n}\n\nconst repeating = game(async (period: number, fn: (count: number) => void, times: number) => {\n let count = 0;\n while (times <= 0 || count < times) {\n await sleep(frames(period < 1 ? 1 : period));\n count++;\n fn(count);\n }\n});\n\nconst later = game(async (delay: number, fn: () => void) => {\n await sleep(frames(delay < 1 ? 1 : delay));\n fn();\n});\n\n/**\n * Run `fn` every `period` frames, from `period` frames from now: forever, or `times` times. It is handed how many times\n * it has run, 1 the first time.\n * @param period Frames between one run and the next (24 a second).\n * @param fn What to run; it runs to its end within the frame.\n * @param times How many times, or 0 (the default) for as long as the game lasts.\n * @returns The task: `cancel()` stops it.\n */\nexport const every = game((period: number, fn: (count: number) => void, times: number = 0): Task => start(repeating(period, fn, times)));\n\n/**\n * Run `fn` once, `delay` frames from now.\n * @param delay Frames from now (24 a second).\n * @param fn What to run; it runs to its end within the frame.\n * @returns The task: `cancel()` before then and it never runs.\n */\nexport const after = game((delay: number, fn: () => void): Task => start(later(delay, fn)));\n\n/**\n * The elements of `list`, `per` of them a frame: a loop spread over frames, for work that need not be done in one —\n * a thousand bullets moved, a map's cells looked at. What a frame costs stays what `per` turns cost.\n *\n * for await (const b of over(bullets, { per: 50 })) b.x += b.dx;\n *\n * It reads the list's length as it goes, as `for…of` does: what is pushed meanwhile is reached too, and an element\n * removed before it is reached moves the rest up, so one is passed over, as with an index. Each element is the list's\n * own: what is written to it stays. The loop goes on to the next frame only between elements, never after the last.\n * @param list The array to walk.\n * @param options `per`: how many elements a frame (1 at least).\n */\nexport async function* over(list: T[], options: { per: number }) {\n const per = options.per < 1 ? 1 : options.per;\n let n = 0;\n for (let i = 0; i < list.length; i++) {\n yield list[i];\n n++;\n if (n >= per && i + 1 < list.length) {\n n = 0;\n await nextFrame();\n }\n }\n}\n\n/**\n * 0, 1, … `count` − 1, `per` of them a frame: `over` for a count rather than an array.\n *\n * for await (const y of spread(96, { per: 8 })) drawRow(y); // twelve frames for 96 rows\n * @param count How many numbers, from 0.\n * @param options `per`: how many a frame (1 at least).\n */\nexport async function* spread(count: number, options: { per: number }) {\n const per = options.per < 1 ? 1 : options.per;\n let n = 0;\n for (let i = 0; i < count; i++) {\n yield i;\n n++;\n if (n >= per && i + 1 < count) {\n n = 0;\n await nextFrame();\n }\n }\n}\n\n/**\n * Something that can be done once every so many frames: a weapon's reload, a spell's cooldown.\n *\n * const blink = new Cooldown(5 * SECOND);\n * if (keyPressed(P1, \"B\") && blink.use()) teleport();\n */\nexport class Cooldown {\n period: number;\n until = 0;\n\n /** Ready at once, then once every `period` frames. */\n constructor(period: number) {\n this.period = period;\n }\n\n /** Whether it is ready now. */\n ready(): boolean {\n return now() >= this.until;\n }\n\n /** If it is ready, use it — it is ready again `period` frames from now — and say true; else false. */\n use(): boolean {\n if (now() < this.until) return false;\n this.until = now() + this.period;\n return true;\n }\n\n /** Frames until it is ready again: 0 when it is. */\n left(): number {\n return this.until > now() ? this.until - now() : 0;\n }\n\n /** Ready again at once. */\n reset() {\n this.until = 0;\n }\n}\n\n/**\n * A length of time that runs down while the game runs: a round's clock, a bomb's fuse.\n *\n * const round = new Countdown(90 * SECOND);\n * ui.lines(P1, () => {clock(round.left())});\n * await until(() => round.done());\n */\nexport class Countdown {\n end = 0;\n\n /** Running, done `length` frames from now (0: done already). */\n constructor(length: number = 0) {\n this.end = now() + length;\n }\n\n /** Start again, done `length` frames from now. */\n restart(length: number) {\n this.end = now() + length;\n }\n\n /** Frames left: 0 once it is done. */\n left(): number {\n return this.end > now() ? this.end - now() : 0;\n }\n\n /** Whether it has run out. */\n done(): boolean {\n return now() >= this.end;\n }\n\n /** More time, or less (a number below zero). */\n add(frames: number) {\n this.end += frames;\n }\n}\n\n/** How long since it was made, or last reset: a lap's time, a survival score. */\nexport class Stopwatch {\n from = 0;\n\n constructor() {\n this.from = now();\n }\n\n /** Frames since it started. */\n elapsed(): number {\n return now() - this.from;\n }\n\n /** From 0 again. */\n reset() {\n this.from = now();\n }\n}\n", + "ui": "/**\n * A kit for the game's text lines: the standard library's \"trigscript/ui\" (TrigScript 4).\n *\n * import { Meter, Panel, MenuList, DialogBox, choose, say, clock } from \"trigscript/ui\";\n *\n * const ITEMS = [\"Marine (50)\", \"Medic (75)\", \"Leave\"];\n * const shop = { selected: 0, open: false };\n * const talk = { speaker: \"\", text: \"\", open: false };\n *\n * program(async () => {\n * ui.lines(P1, () => (\n * <>\n * \n * \n * \n * {shop.open && }\n * \n * \n * ));\n * await say(P1, talk, \"Raynor\", \"Hold the bridge until dawn.\");\n * const pick = await choose(P1, ITEMS.length, shop);\n * });\n *\n * The text lines are one player's: a view is shown to that player only, rebuilt when what it reads changes (a function\n * it calls included) and shown again once a second as it was, so a menu, a dialog and a meter cost nothing while nothing\n * changes. Give a player one `ui.lines` and\n * put everything in it, as the example does: the components are pieces of one view, and the state they show is a\n * record of yours (`shop`, `talk`) that the functions here change.\n *\n * What the text lines can show, played in StarCraft: Remastered (the UI spike, 2026-10-16): eleven lines; colour codes\n * take no width; ■ □ ● are one width; after grey on a line every later colour code is ignored, so grey goes last.\n */\nimport { keyPressed, nextFrame, sleep, frames, game, textColors, type Player, type TextColor } from \"trigscript\";\n\n/** Frames a second of game time on Fastest, the speed a map is played at. */\nconst PER_SECOND = 24;\n\n/** \"1:05\": frames of game time as minutes and seconds (24 frames a second, on Fastest). */\nexport function clock(frames: number): string {\n const seconds = Math.floor(frames / PER_SECOND);\n return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, \"0\")}`;\n}\n\n/** \"12,345\": a whole number with its thousands marked. */\nexport function thousands(n: number): string {\n let rest = n < 0 ? -n : n;\n let out = \"\";\n while (rest >= 1000) {\n out = `,${String(rest % 1000).padStart(3, \"0\")}${out}`;\n rest = Math.floor(rest / 1000);\n }\n return `${n < 0 ? \"-\" : \"\"}${rest}${out}`;\n}\n\n/**\n * A labelled bar on a line of its own — \"Base ■■■■■■□□□□ 900/1500\" — green above half, yellow above a quarter, red below.\n * @param label What the bar is of.\n * @param value How much there is.\n * @param max How much there can be.\n * @param width How many squares the bar is (10 unless it says).\n */\nexport function Meter({ label, value, max, width }: { label: string; value: number; max: number; width?: number }) {\n return (\n \n {label} max ? textColors.green : value * 4 > max ? textColors.yellow : textColors.red} /> {value}/{max}\n \n );\n}\n\n/**\n * A title over what is inside it: \"-- Wave 3 --\" in its colour (yellow unless it says), then the lines inside.\n * @param title The title.\n * @param color The title's colour.\n */\nexport function Panel({ title, children, color }: { title: string; children?: Children; color?: TextColor }) {\n return (\n <>\n -- {title} --\n {children}\n \n );\n}\n\n/**\n * A menu's lines: each item on a line of its own, the one at `selected` marked \"> \" and in its colour (cyan unless it\n * says). `items` is a list the script has, or an array of texts of the program.\n */\nexport function MenuList({ items, selected, color }: { items: readonly string[]; selected: number; color?: TextColor }) {\n return (\n <>\n {items.map((item, i) => (\n \n {i === selected ? \"> \" : \" \"}\n {item}\n \n ))}\n \n );\n}\n\n/**\n * Let a player choose one of `count` things: the menu opens (`menu.open`), W and S (or Up and Down) move\n * `menu.selected` (round from the last to the first), and Space chooses — the place chosen, once it is. (Not Enter:\n * in a game of several players Enter opens the chat.) Show it with `MenuList`, in the\n * player's `ui.lines`: `{shop.open && }`.\n * @param player Who chooses: P1 … P8, known when the script is built.\n * @param count How many things there are to choose from.\n * @param menu The record a view shows: which is marked, and whether the menu is open.\n */\nexport const choose = game(async (player: Player, count: number, menu: { selected: number; open: boolean }): Promise => {\n menu.open = true;\n while (true) {\n if (keyPressed(player, \"W\") || keyPressed(player, \"Up\")) menu.selected = (menu.selected + count - 1) % count;\n if (keyPressed(player, \"S\") || keyPressed(player, \"Down\")) menu.selected = (menu.selected + 1) % count;\n if (keyPressed(player, \"Space\")) break;\n await nextFrame();\n }\n menu.open = false;\n return menu.selected;\n});\n\n/**\n * A text put into `into.text` a character at a time, one every `every` frames (2 unless it says): a view that shows\n * `into.text` shows it being typed. Done when the whole text is there.\n */\nexport const typewriter = game(async (into: { text: string }, text: string, every: number = 2) => {\n let n = 0;\n while (n < text.length) {\n n++;\n into.text = text.slice(0, n);\n await sleep(frames(every));\n }\n});\n\n/**\n * Someone says something to a player: the dialog opens (`box.open`), the speaker's name and the words typed in, and it\n * closes when the player presses Space. Show it with `DialogBox`.\n * @param player Who reads it: P1 … P8, known when the script is built.\n * @param box The record a view shows.\n * @param speaker Who speaks.\n * @param text What they say.\n */\nexport const say = game(async (player: Player, box: { speaker: string; text: string; open: boolean }, speaker: string, text: string) => {\n box.open = true;\n box.speaker = speaker;\n box.text = \"\";\n await typewriter(box, text);\n while (!keyPressed(player, \"Space\")) await nextFrame();\n box.open = false;\n});\n\n/** A dialog's lines while it is open: the speaker in yellow, the words, and what to press (Space). Nothing while it is closed. */\nexport function DialogBox({ box }: { box: { speaker: string; text: string; open: boolean } }) {\n return box.open ? (\n <>\n {box.speaker}\n {box.text}\n (Space)\n \n ) : null;\n}\n\n/** A line at the end of a log that keeps the last `keep` (6 unless it says), the oldest taken off the front. */\nexport const log = game((lines: string[], text: string, keep: number = 6) => {\n lines.push(text);\n while (lines.length > keep) lines.shift();\n});\n\n/** A log's lines, oldest first, each in its colour (white unless it says). */\nexport function LogLines({ lines, color }: { lines: readonly string[]; color?: TextColor }) {\n return <>{lines.map((text) => {text})};\n}\n" +}; +export const STDLIB_EXTENSIONS: Readonly> = {"cutscene":".ts","fog":".ts","math":".ts","music":".ts","random":".ts","screen":".tsx","signals":".ts","timers":".ts","ui":".tsx"}; diff --git a/compiler/generated/trigscriptPy.ts b/compiler/generated/trigscriptPy.ts deleted file mode 100644 index 8f06e84..0000000 --- a/compiler/generated/trigscriptPy.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Generated by scripts/embed-python.mts from python/trigscript.py — do not edit. -export const TRIGSCRIPT_PY: string = "\"\"\"\n[trigscript]\nir : /work/files/trigscript.json\n\nTrigScript's programs: the euddraft plugin that lowers the compiler's IR — data, never\ncode — into eudplib. The IR file is what the plugin's compiler wrote (docs/ir.md);\nthis module is handed to the eudplib library plugin as a source with every build, so the\ntwo halves of the IR version are always the same build.\n\nA program is a coroutine the game runs every frame. Its body is lowered to straight-line\neudplib triggers with jumps between labels: `if` / `while` / `switch` become conditional\njumps, and `sleep` stores the label to resume at in a state variable, sets a frame counter,\nand leaves the frame. The frame's entry counts the wait down, then jumps to the stored\nlabel. A program runs for the players it is owned by, as a trigger would: one owner runs\nit as that player while that player is in the game; several owners, All Players or a force\nrun it once each frame for every such player who is in the game, CurrentPlayer set, its\nvariables and its state as 12-slot arrays indexed by the player.\n\nText is written out in the IR: an action's `text` or `wav` is the string itself, which\neudplib adds to the built map's string table, or a number when the script named an index\nof the map's own. The map the user edits never holds a program's strings.\n\nA read is a value of the game taken when the expression is evaluated. What a condition compares\nis read from the game's tables where the table is that value (a player's deaths, kills, ore, gas)\nand otherwise found by asking the condition itself — \"at least 2^31? at least that plus 2^30? …\" —\nso a read means what the condition means, for a force's minerals or the units at a location alike.\nPlayer facts are bytes of the player tables. Text with values in it is printed through eudplib's\nstring buffer, for the player it is for and nobody else.\n\nA unit of the game is a pointer into the game's unit table — 1700 slots of 336 bytes — with the slot's\nuniqueness byte kept beside it: the game gives a dead unit's slot to the next unit made, so a unit kept\nin a variable is checked before every use (a sprite, an order other than \"die\", the same uniqueness\nbyte) and reads 0, and takes no write, once it is gone. A loop over units and a pick walk the whole\ntable with conditions whose address is moved on a slot at a time, as eudplib's own EUDLoopUnit2 does;\na dying unit is passed over. What a unit can be asked and told is what Magenta's probe maps saw\nworking in Remastered: no position, cloak or speed writes. The game's tables (`stats()`) are plain\ncells at addresses the IR carries; a speed is four flingy records, a colour two bytes, a name a string.\n\nWhat the players do — keys, clicks, the mouse, what they type — happens on one computer, and two\nplugins the build adds beside this one bring it to all of them in step. chatEvent (before this\nplugin) finds the line the local player typed; this plugin matches it against the programs'\npatterns, there and then, into a number for the pattern and up to three values; MSQC (after this\nplugin) sends those, the keys and the clicks to every computer as the player they came from, and\nkeeps each player's mouse in a location. They land in arrays registered by name — which is how the\nother two plugins' settings reach them — a cell per player, fresh every frame: an input reads 1 on\nthe frame it arrives. The IR's `input` lists what is asked for; the editor wrote the settings from it.\n\nNumbers keep one contract with the simulator: 32 bits, a `number` signed and a `u32` not, wrapping at\neither end. + - * and the bitwise operators are the same bits whichever way they are read; what reads\nthem one way or the other - a comparison, a division, a shift right, min and max, a printed number -\nsays which in the IR (`unsigned`), so nothing here works a type out. Where the game takes nothing below\nzero the compiler has already written max(v, 0), and a store keeps stopping at the top of what it holds.\nIn here a number known when the map is built is a Python int holding the 32 bits, 0 to 2^32 - 1.\n\nA text is kept one of two ways, a variable at a time (the IR says which). One that only ever holds texts written in\nthe script is the text's id in the built map's string table: a number. One that is made while the map is played is\nthree cells - where its bytes are (UTF-8, ended by a 0), the block of the heap it owns (0: none, the bytes are a\nstring of the table) and its length in characters. A made text is written into one scratch buffer, measured, and\ncopied into a block of just that size, whose first cell says which size; what holds the text owns the block, so\nassigning copies it, a value that was just made is moved, and whatever a variable held goes back to the heap once\nits new value is worked out. compiler/simulateIr.ts takes and gives blocks in the same order, so both run out at\nthe same text. A made text in an action's field goes over a string the build keeps for that kind of field, on the\ncomputer of the player the action is for and nowhere else: the game reads such a string again whenever it draws.\n\"\"\"\nimport json\n\nfrom eudplib import *\nfrom eudplib.core.mapdata.stringmap import ForceAddString\nfrom eudplib.memio.rwcommon import br1, br2, bw1\n\nIR_VERSION = 14\nFRAMES_PER_SECOND = 24\n# Where the game keeps what a read reads (1.16.1 addresses, which Remastered emulates). The player\n# tables are the ones Magenta's probes 5 and 8 read in the game.\nDEATHS_TABLE = 0x58A364\nKILLS_TABLE = 0x5878A4\nORE_TABLE = 0x57F0F0\nGAS_TABLE = 0x57F120\nPLAYER_BYTES = {\"slot\": 0x57F1B4, \"race\": 0x57F1C0}\n# Per race (144 bytes apart: Zerg, Terran, Protoss), a dword per player, in half supplies.\nSUPPLY_TABLES = {\"provided\": 0x582144, \"used\": 0x582174, \"max\": 0x5821A4}\n# The unit table, and a unit's dwords the scans test (as EPD offsets from the unit's own).\nUNIT_TABLE = 0x59CCA8\nUNIT_SIZE = 336\nUNIT_SLOTS = 1700\nUNIT_END = UNIT_TABLE + UNIT_SIZE * UNIT_SLOTS\nOFF_SPRITE, OFF_POS, OFF_OWNER_ORDER, OFF_TYPE, OFF_UID = 0x0C // 4, 0x28 // 4, 0x4C // 4, 0x64 // 4, 0xA4 // 4\n# units.dat: max hit points (dword, 256 to a point), max shields (word), the group flags a trigger's\n# Men / Buildings / Factories go by (byte), the flingy a type moves as (byte).\nUNITS_MAX_HP = 0x662350\nUNITS_MAX_SHIELDS = 0x660E00\nUNITS_GROUP = 0x6637A0\nUNITS_FLINGY = 0x6644F8\nGROUP_BITS = {230: 0x08, 231: 0x10, 232: 0x20}\n# flingy.dat, by flingy id: movement control (byte), top speed (dword), acceleration (word), halt distance (dword).\nFLINGY_CONTROL, FLINGY_SPEED, FLINGY_ACCELERATION, FLINGY_HALT = 0x6C9858, 0x6C9EF8, 0x6C9C78, 0x6C9930\nMINIMAP_COLOR_OFFSET = 0x581DD6 - 0x581D76\n# The location table, and the one an order borrows for the length of one action (put back after).\nMRGN = 0x58DC60\nSCRATCH_LOCATION = 255\nUNIT_TIMERS = {\"stim\": \"stimTimer\", \"ensnare\": \"ensnareTimer\", \"plague\": \"plagueTimer\", \"lockdown\": \"lockdownTimer\", \"maelstrom\": \"maelstromTimer\", \"irradiate\": \"irradiateTimer\", \"stasis\": \"stasisTimer\"}\nSTATUS_INVINCIBLE, STATUS_HALLUCINATION, STATUS_BURROWED, STATUS_CLOAKED = 0x04000000, 0x40000000, 0x00000010, 0x00000300\nCOND_COMMAND, COND_BRING, COND_ACCUMULATE, COND_KILL, COND_OPPONENTS, COND_DEATHS = 2, 3, 4, 5, 14, 15\nCURRENT_PLAYER = 13\n# The state of a program whose body ended: nothing resumes it.\nDONE = 0xFFFFFFFF\nU32 = 0xFFFFFFFF\nSIGN = 0x80000000\n\nMINUS_SIGN = Db(b\"-\\0\\0\\0\")\nNO_SIGN = Db(b\"\\0\\0\\0\\0\")\n\nwith open(settings[\"ir\"], encoding=\"utf-8\") as _f:\n IR = json.load(_f)\nif IR.get(\"version\") != IR_VERSION:\n raise RuntimeError(\"trigscript: the IR is version %r; this plugin reads version %d\" % (IR.get(\"version\"), IR_VERSION))\n\n\ndef where(node):\n \"\"\"\" at main.ts:12:5\" for a node with a position — what the editor parses back into a marker.\"\"\"\n at = node.get(\"at\") if isinstance(node, dict) else None\n if not at:\n return \"\"\n if at.get(\"file\"):\n return \" at %s:%s:%s\" % (at.get(\"file\"), at.get(\"line\"), at.get(\"column\"))\n return \" at %s:%s\" % (at.get(\"line\"), at.get(\"column\"))\n\n\nclass Fail(RuntimeError):\n pass\n\n\nclass Leave(Exception):\n \"\"\"Raised through the lowering when a statement ends the straight line (break / continue / return).\"\"\"\n\n\nclass Storage:\n \"\"\"A variable's cell: plain, or a 12-slot row of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n self.store = EUDArray([0] * 12) if self.rowed else EUDVariable(0) # initial: the variable's own cell\n\n def get(self):\n return self.store[self.player_of()] if self.rowed else self.store\n\n def set(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n if self.rowed:\n self.store[self.player_of()] = value\n else:\n self.store << value\n\n\nclass ArrayStorage:\n \"\"\"An array's cells: `length` of them, twelve rows of that in a per-player program, or - `values` - a list\n the script computed when it was built, which is in the map as it loads and which nothing writes. An index\n past either end (read from 0 up, so one below zero too) reads 0 and stores nothing.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.bits = decl.get(\"bits\")\n values = decl.get(\"values\")\n self.rowed = per_player and not decl.get(\"shared\") and values is None\n self.player_of = player_of\n if decl.get(\"texts\") is not None:\n # A list of texts the script has: a cell is the text's id in the built map's table.\n values = [text_id(t) for t in decl[\"texts\"]]\n self.store = EUDArray([int(v) & U32 for v in values] if values is not None else [0] * (self.length * (12 if self.rowed else 1))) # initial: a table's values, or cells declareArray sets\n\n def at(self, index):\n \"\"\"The cell's place among the store's: the player's row first.\"\"\"\n if not self.rowed:\n return index\n p = self.player_of()\n row = p * self.length if isinstance(p, int) else f_mul(p, self.length)\n return row + index\n\n def get(self, index):\n if isinstance(index, int):\n return self.store[self.at(index)] if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.store[self.at(index)]\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n if isinstance(index, int):\n if index < self.length:\n self.store[self.at(index)] = value\n return\n if EUDIf()(index <= self.length - 1):\n self.store[self.at(index)] = value\n EUDEndIf()\n\n def fill(self, value):\n if self.length <= 16:\n for i in range(self.length):\n self.set(i, value)\n return\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\nclass SliceStorage:\n \"\"\"A window on another array's cells - a row of a grid: cell i is cell `offset + i` of the array it is a window\n on, `offset` a variable of the program. Past its own end it reads 0 and stores nothing, so a row never reaches\n into the next; what it is a window on keeps its own ends (and its own width, and its row a player).\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.lowering = lowering\n\n def of(self):\n return self.lowering.array(self.decl[\"slice\"][\"of\"], self.decl)\n\n def offset(self):\n return self.lowering.var(self.decl[\"slice\"][\"offset\"], self.decl).get()\n\n def get(self, index):\n if isinstance(index, int):\n return self.of().get(self.offset() + index) if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.of().get(self.offset() + index)\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if isinstance(index, int):\n if index < self.length:\n self.of().set(self.offset() + index, value)\n return\n if EUDIf()(index <= self.length - 1):\n self.of().set(self.offset() + index, value)\n EUDEndIf()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\n# The heap the programs' growing arrays share. A block is a power of two of cells, four at least; one given back\n# waits in its size's list (its first cell links the next) for whoever wants that size next; new ground is taken\n# from the bottom up. Cell 0 is never handed out: 0 is \"no block\". compiler/simulateIr.ts counts the same way, so\n# both run out at the same push. (Recursion's stack is an array of its own, below: how deep a function may go is\n# then the same whatever the arrays hold.)\nHEAP_CELLS = min(1 << 20, max(1024, int(IR.get(\"heap\", 16384)))) # the map's script settings, or the default\nHEAP_SMALLEST = 4\nHEAP_ROOMS = []\nwhile HEAP_SMALLEST << len(HEAP_ROOMS) <= HEAP_CELLS:\n HEAP_ROOMS.append(HEAP_SMALLEST << len(HEAP_ROOMS))\n_HEAP = {}\n\n\ndef heap():\n \"\"\"The heap's cells and its functions, made when the first growing array is met.\"\"\"\n if _HEAP:\n return _HEAP\n cells = EUDArray(HEAP_CELLS)\n rooms = EUDArray(HEAP_ROOMS + [0xFFFFFFFF]) # initial: the sizes, never written\n free = EUDArray(len(HEAP_ROOMS)) # initial: no block waits\n top = EUDVariable(1) # initial: the heap's own state, for the whole game\n said = EUDVariable(0) # initial: the heap's own state\n\n @EUDFunc\n def take(k):\n \"\"\"A block of size class k: its place among the cells, or 0 when there is none.\"\"\"\n at = EUDVariable()\n at << free[k]\n if EUDIf()(at >= 1):\n free[k] = cells[at]\n EUDReturn(at)\n EUDEndIf()\n end = top + rooms[k]\n if EUDIf()(end <= HEAP_CELLS):\n at << top\n top << end\n EUDReturn(at)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def give(at, k):\n cells[at] = free[k]\n free[k] = at\n\n @EUDFunc\n def grow(ptr, length, room, k, need):\n \"\"\"Room for `need` cells: a larger block, the cells copied over, the old block given back. Returns the\n handle as it is now and whether there is room; when the heap has no block left the handle is unchanged.\"\"\"\n if EUDIf()(need <= room):\n EUDReturn(ptr, room, k, 1)\n EUDEndIf()\n nk = EUDVariable()\n nk << 0\n if EUDIf()(ptr >= 1):\n nk << k + 1\n EUDEndIf()\n if EUDWhile()(rooms[nk] < need):\n nk += 1\n EUDEndWhile()\n if EUDIf()(nk >= len(HEAP_ROOMS)):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n block = take(nk)\n if EUDIf()(block == 0):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n if EUDIf()(ptr >= 1):\n if EUDIf()(length >= 1):\n # An EUDArray's own value is where it is, as an EPD already.\n f_repmovsd_epd(cells + block, cells + ptr, length)\n EUDEndIf()\n give(ptr, k)\n EUDEndIf()\n EUDReturn(block, rooms[nk], nk, 1)\n\n @EUDFunc\n def push(ptr, length, room, k, value):\n \"\"\"One more cell at the end; the handle as it is afterwards. Nothing is pushed when the heap is full.\"\"\"\n ptr, room, k, ok = grow(ptr, length, room, k, length + 1)\n if EUDIf()(ok >= 1):\n cells[ptr + length] = value\n length += 1\n if EUDElse()():\n if EUDIf()(said == 0):\n said << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - an array could not grow (the programs' arrays share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDEndIf()\n EUDReturn(ptr, length, room, k)\n\n _HEAP.update(cells=cells, rooms=rooms, take=take, give=give, grow=grow, push=push)\n return _HEAP\n\n\n# The stack of the functions that call themselves. A function's variables are cells of the program, one of each, so\n# a call that may come back into the function it is in (the IR's `saves`) puts what that function holds here first\n# and takes it back after, with where the function returns to. One stack serves every program and every player:\n# such a function never sleeps, so the stack is empty whenever a frame ends. It is as many frames of the largest\n# frame as the map's script settings allow calls deep (the IR file's `stack`), and the depth is what is counted -\n# compiler/simulateIr.ts counts the same - so a program stops at the same call in both.\nSTACK_DEPTH = min(1 << 16, max(16, int(IR.get(\"stack\", 1024))))\nSTACK_CELLS_MAX = 1 << 20\nHANDLE = (\"ptr\", \"len\", \"room\", \"k\")\n_STACK = {}\n\n\ndef frame_cells(saves, kinds):\n return 1 + 4 * len(saves.get(\"arrays\", [])) + sum(3 if kinds.get(v) in (\"unit\", \"text\") else 1 for v in saves.get(\"vars\", []))\n\n\ndef largest_frame():\n most = [0]\n\n def walk(node, kinds):\n if isinstance(node, list):\n for x in node:\n walk(x, kinds)\n elif isinstance(node, dict):\n if isinstance(node.get(\"saves\"), dict):\n most[0] = max(most[0], frame_cells(node[\"saves\"], kinds))\n for v in node.values():\n if isinstance(v, (dict, list)):\n walk(v, kinds)\n\n def kinds_of(node, into):\n if isinstance(node, list):\n for x in node:\n kinds_of(x, into)\n elif isinstance(node, dict):\n if isinstance(node.get(\"id\"), str) and node.get(\"kind\") in (\"number\", \"boolean\", \"unit\", \"text\") and \"shared\" in node:\n # A text kept as its id is one cell, as a number is; one that was made is three.\n into[node[\"id\"]] = \"number\" if node[\"kind\"] == \"text\" and node.get(\"text\") == \"id\" else node[\"kind\"]\n for v in node.values():\n if isinstance(v, (dict, list)):\n kinds_of(v, into)\n return into\n\n for program in IR.get(\"programs\", []):\n walk(program.get(\"functions\", []), kinds_of(program, {}))\n return most[0]\n\n\ndef stack():\n \"\"\"The stack's cells, where the next frame goes (an EPD, so a cell is one write) and how deep it is.\"\"\"\n if _STACK:\n return _STACK\n frame = largest_frame()\n if frame * STACK_DEPTH > STACK_CELLS_MAX:\n raise Fail(\"trigscript: the stack would be %d cells - %d calls deep, %d cells a call - and %d is the most: lower the recursion depth in the script's Settings, or keep fewer variables in the function that calls itself\" % (frame * STACK_DEPTH, STACK_DEPTH, frame, STACK_CELLS_MAX))\n cells = EUDArray(max(1, frame) * STACK_DEPTH)\n base = cells # an EUDArray's own value is where it is, as an EPD already\n at = EUDVariable(base) # initial: the stack's own state; it is back here whenever a frame ends\n depth = EUDVariable(0) # initial: the stack's own state; nothing is on it between frames\n _STACK.update(cells=cells, base=base, at=at, depth=depth)\n return _STACK\n\n\nclass ListStorage:\n \"\"\"An array that grows: a handle - where its block is among the heap's cells (0: none yet), how many cells are\n in use, how many the block has room for, and the block's size class - a cell each, or a row of twelve each in\n a per-player program. Reads and stores are bounded by the cells in use; a store at exactly the length is a push.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no block\n self.handle = {name: make() for name in (\"ptr\", \"len\", \"room\", \"k\")}\n self.heap = heap()\n\n def field(self, name):\n cell = self.handle[name]\n return cell[self.player_of()] if self.rowed else cell\n\n def put(self, name, value):\n if self.rowed:\n self.handle[name][self.player_of()] = value\n else:\n self.handle[name] << value\n\n def length(self):\n return self.field(\"len\")\n\n def get(self, index):\n out = fresh(0)\n if EUDIf()(as_var(index) < as_var(self.field(\"len\"))):\n out << self.heap[\"cells\"][self.field(\"ptr\") + index]\n EUDEndIf()\n return out\n\n def push(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n ptr, length, room, k = self.heap[\"push\"](self.field(\"ptr\"), self.field(\"len\"), self.field(\"room\"), self.field(\"k\"), value)\n for name, v in ((\"ptr\", ptr), (\"len\", length), (\"room\", room), (\"k\", k)):\n self.put(name, v)\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n i, v, n = as_var(index), fresh(value), as_var(self.field(\"len\"))\n if EUDIf()(i < n):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n if EUDElseIf()(i == n):\n self.push(v) # xs[xs.length] = v, as JavaScript has it\n EUDEndIf()\n\n def pop(self):\n out = fresh(0)\n n = fresh(self.field(\"len\"))\n if EUDIf()(n >= 1):\n n -= 1\n self.put(\"len\", n)\n out << self.heap[\"cells\"][self.field(\"ptr\") + n]\n EUDEndIf()\n return out\n\n def set_length(self, value):\n v = as_var(value)\n if EUDIf()(v < as_var(self.field(\"len\"))):\n self.put(\"len\", v)\n EUDEndIf()\n\n def declare(self, values):\n \"\"\"Declared (again): the block it held goes back, and it starts over with these values.\"\"\"\n ptr = fresh(self.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n self.heap[\"give\"](ptr, self.field(\"k\"))\n EUDEndIf()\n for name in (\"ptr\", \"len\", \"room\", \"k\"):\n self.put(name, 0)\n for v in values:\n self.push(v)\n\n def declare_filled(self, value, count):\n \"\"\"The same with one value `count` times over: a loop, where a push a cell would be a push a cell in the map.\"\"\"\n if count <= 8:\n self.declare([value] * count)\n return\n self.declare([])\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= count - 1):\n self.push(v)\n i += 1\n EUDEndWhile()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i < as_var(self.field(\"len\"))):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n i += 1\n EUDEndWhile()\n\n\nclass InnerListStorage(ListStorage):\n \"\"\"An array that grows inside another: its handle is cell `index` of four arrays the outer one keeps (where the\n block is, how many cells are in use, its room, its size class), `index` a variable of the program. Everything\n else is a growing array's, which only ever asks for its handle's fields and puts them back.\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = False\n self.lowering = lowering\n self.heap = heap()\n\n def at(self):\n return self.lowering.var(self.decl[\"through\"][\"index\"], self.decl).get()\n\n def field(self, name):\n return self.lowering.array(self.decl[\"through\"][name], self.decl).get(self.at())\n\n def put(self, name, value):\n self.lowering.array(self.decl[\"through\"][name], self.decl).set(self.at(), value)\n\n\n# ── texts ──\nTEXT_BYTES = 1023 # the most a made text holds; compiler/ir.ts has the same number\nTEXT_FIELD_BYTES = 255 # the room of a string kept for an action's field or a unit type's name\nUNIT_MAP_STRING = 0x660260 # units.dat: the id of the name a map gives a unit type, a word each\n_TEXT = {}\n_SLOTS = {}\n\n\ndef texts():\n \"\"\"The texts' part of the run time, made when the first text that is made is met.\"\"\"\n if _TEXT:\n return _TEXT\n h = heap()\n cells, rooms, take, give = h[\"cells\"], h[\"rooms\"], h[\"take\"], h[\"give\"]\n # A text being made. It may run one part past its most before it is cut, and a part is at most that long again.\n scratch = Db(TEXT_BYTES + 1 + TEXT_BYTES + 1 + 8)\n empty = Db(4)\n said_cut = EUDVariable(0) # initial: said once a game\n said_full = EUDVariable(0) # initial: said once a game\n said_field = EUDVariable(0) # initial: said once a game\n\n @EUDFunc\n def count(addr):\n \"\"\"How many characters the text at addr is: every byte but those that continue a character (10xxxxxx).\"\"\"\n n = EUDVariable()\n n << 0\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n n += 1\n EUDEndIf()\n EUDEndInfLoop()\n EUDReturn(n)\n\n @EUDFunc\n def append(pos, src):\n \"\"\"The text at src written at pos, ended by a 0; where that 0 is. A text of the table may be any length, so no\n more of it than leaves the scratch whole: what is past a text's most is cut by make() anyway.\"\"\"\n br1.seekoffset(src)\n bw1.seekoffset(pos)\n if EUDWhile()(pos <= scratch + TEXT_BYTES + TEXT_BYTES):\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n bw1.writebyte(b)\n pos += 1\n EUDEndWhile()\n bw1.writebyte(0)\n EUDReturn(pos)\n\n @EUDFunc\n def make(end):\n \"\"\"The text in the scratch, which ends at `end`, into a block of its own: where it is, the block, its\n length in characters. Past what a text holds it is cut, never inside a character.\"\"\"\n if EUDIf()(end >= scratch + TEXT_BYTES + 1):\n end << scratch + TEXT_BYTES\n if EUDWhile()(f_bread(end).ExactlyX(0x80, 0xC0)):\n end -= 1\n EUDEndWhile()\n f_bwrite(end, 0)\n if EUDIf()(said_cut == 0):\n said_cut << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - one that is made holds %d bytes.\" % TEXT_BYTES)\n EUDEndIf()\n EUDEndIf()\n need = f_div(end - scratch, 4)[0] + 2\n k = EUDVariable()\n k << 0\n if EUDWhile()(rooms[k] < need):\n k += 1\n EUDEndWhile()\n block = take(k)\n if EUDIf()(block == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be made (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n cells[block] = k\n f_repmovsd_epd(cells + block + 1, EPD(scratch), need - 1)\n addr = f_mul(cells + block + 1, 4) + 0x58A364\n EUDReturn(addr, block, count(addr))\n\n @EUDFunc\n def copy(addr, block, length):\n \"\"\"A text as something to keep: itself when it owns no block, else a block of the same size with the same cells.\"\"\"\n if EUDIf()(block == 0):\n EUDReturn(addr, 0, length)\n EUDEndIf()\n k = cells[block]\n mine = take(k)\n if EUDIf()(mine == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be copied (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n f_repmovsd_epd(cells + mine, cells + block, rooms[k])\n EUDReturn(f_mul(cells + mine + 1, 4) + 0x58A364, mine, length)\n\n @EUDFunc\n def release(block):\n if EUDIf()(block >= 1):\n give(block, cells[block])\n EUDEndIf()\n\n @EUDFunc\n def skip(addr, n):\n \"\"\"Where character n of the text at addr starts; its end when it has fewer.\"\"\"\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n EUDBreakIf(n == 0)\n n -= 1\n EUDEndIf()\n addr += 1\n EUDEndInfLoop()\n EUDReturn(addr)\n\n @EUDFunc\n def size(addr):\n \"\"\"The bytes of the character that starts at addr; 0 at the text's end.\"\"\"\n b = f_bread(addr)\n n = EUDVariable()\n n << 1\n if EUDIf()(b == 0):\n n << 0\n if EUDElseIf()(b >= 0xF0):\n n << 4\n if EUDElseIf()(b >= 0xE0):\n n << 3\n if EUDElseIf()(b >= 0xC0):\n n << 2\n EUDEndIf()\n EUDReturn(n)\n\n @EUDFunc\n def slice_(addr, start, end):\n \"\"\"Characters start … end - 1 of the text at addr, made.\"\"\"\n a = skip(addr, start)\n z = EUDVariable()\n z << a\n if EUDIf()(end >= start + 1):\n z << skip(a, end - start)\n EUDEndIf()\n # A text of the table may be longer than one that is made: no more of it than the scratch holds, and make() cuts.\n n = z - a\n if EUDIf()(n >= TEXT_BYTES + 2):\n n << TEXT_BYTES + 1\n EUDEndIf()\n f_memcpy(scratch, a, n)\n f_bwrite(scratch + n, 0)\n EUDReturn(*make(scratch + n))\n\n @EUDFunc\n def pad(addr, length, width, fill, at_start):\n \"\"\"The text with `fill` over and over before or after it until it is `width` characters; as it is when it is that long, or `fill` is empty.\"\"\"\n pos = EUDVariable()\n pos << scratch\n need = EUDVariable()\n need << 0\n if EUDIf()([width >= length + 1, width <= 0x7FFFFFFF, f_bread(fill) >= 1]):\n need << width - length\n EUDEndIf()\n if EUDIf()(at_start == 0):\n pos << append(pos, addr)\n EUDEndIf()\n src = EUDVariable()\n src << fill\n if EUDWhile()([need >= 1, pos <= scratch + TEXT_BYTES]):\n n = size(src)\n if EUDIf()(n == 0):\n src << fill\n n << size(src)\n EUDEndIf()\n f_memcpy(pos, src, n)\n pos += n\n src += n\n need -= 1\n EUDEndWhile()\n f_bwrite(pos, 0)\n if EUDIf()([at_start >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def repeat(addr, times):\n pos = EUDVariable()\n pos << scratch\n f_bwrite(pos, 0)\n if EUDIf()([times <= 0x7FFFFFFF, f_bread(addr) >= 1]):\n if EUDWhile()([times >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n times -= 1\n EUDEndWhile()\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def starts(addr, find):\n \"\"\"Whether the text at addr starts with the one at find.\"\"\"\n br1.seekoffset(addr)\n br2.seekoffset(find)\n if EUDInfLoop()():\n want = br2.readbyte()\n if EUDIf()(want == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDBreakIfNot(br1.readbyte() == want)\n EUDEndInfLoop()\n EUDReturn(0)\n\n @EUDFunc\n def find_from(addr, find, start):\n \"\"\"The place, in characters, of the first match at or after character `start`; 0xFFFFFFFF when there is none.\"\"\"\n if EUDIf()(start >= count(addr) + 1):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, start)\n place = EUDVariable()\n place << start\n if EUDInfLoop()():\n if EUDIf()(starts(at, find) >= 1):\n EUDReturn(place)\n EUDEndIf()\n n = size(at)\n EUDBreakIf(n == 0)\n at += n\n place += 1\n EUDEndInfLoop()\n EUDReturn(0xFFFFFFFF)\n\n @EUDFunc\n def ends(addr, find):\n a, b = f_strlen(addr), f_strlen(find)\n if EUDIf()(b >= a + 1):\n EUDReturn(0)\n EUDEndIf()\n if EUDIf()(f_strcmp(addr + (a - b), find) == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def code(addr, index):\n \"\"\"The number of character `index`; 0xFFFFFFFF past either end.\"\"\"\n if EUDIf()(index >= 0x80000000):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, index)\n n = size(at)\n if EUDIf()(n == 0):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n br1.seekoffset(at)\n v = EUDVariable()\n v << br1.readbyte()\n if EUDIf()(n == 2):\n v << (v & 0x1F)\n if EUDElseIf()(n == 3):\n v << (v & 0x0F)\n if EUDElseIf()(n == 4):\n v << (v & 0x07)\n EUDEndIf()\n if EUDWhile()(n >= 2):\n v << f_mul(v, 64) + (br1.readbyte() & 0x3F)\n n -= 1\n EUDEndWhile()\n EUDReturn(v)\n\n @EUDFunc\n def show(dst, src):\n \"\"\"At most TEXT_FIELD_BYTES bytes of the text at src over the string at dst, never half a character.\"\"\"\n used = EUDVariable()\n used << 0\n if EUDInfLoop()():\n n = size(src)\n EUDBreakIf(n == 0)\n if EUDIf()(used + n >= TEXT_FIELD_BYTES + 1):\n if EUDIf()(said_field == 0):\n said_field << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - an action's text and a unit's name show %d bytes of one that is made.\" % TEXT_FIELD_BYTES)\n EUDEndIf()\n EUDBreak()\n EUDEndIf()\n f_memcpy(dst + used, src, n)\n used += n\n src += n\n EUDEndInfLoop()\n f_bwrite(dst + used, 0)\n\n @EUDFunc\n def address(id_):\n \"\"\"Where the text of the table with this id is; an empty one for id 0.\"\"\"\n if EUDIf()(id_ == 0):\n EUDReturn(empty)\n EUDEndIf()\n EUDReturn(GetMapStringAddr(id_))\n\n _TEXT.update(scratch=scratch, empty=empty, append=append, count=count, make=make, copy=copy, release=release, slice=slice_, pad=pad, repeat=repeat,\n starts=starts, ends=ends, find=find_from, code=code, show=show, size=size, address=address)\n return _TEXT\n\n\ndef slot(kind):\n \"\"\"The string of the built map's table kept for one kind of field, made the first time one is asked for: its\n room in bytes that nothing else shares, which is what ForceAddString is for.\"\"\"\n if kind not in _SLOTS:\n text = \"(TrigScript: %s)\" % kind\n _SLOTS[kind] = ForceAddString(text + \" \" * (TEXT_FIELD_BYTES - len(text)))\n return _SLOTS[kind]\n\n\n# The actions whose text the game shows from a string it reads again whenever it draws (played 2026-09-19), by the\n# kind of field: a player has one of each at a time, so one string a kind is enough.\nSLOT_OF_ACTION = {12: \"objectives\", 7: \"transmission\"}\nfor _t in (17, 18, 19, 20, 21, 33, 34, 35, 36, 37, 40):\n SLOT_OF_ACTION[_t] = \"leaderboard\"\n\n\ndef text_id(text):\n \"\"\"The id of a text written in the script; 0 for the empty one, which the table does not hold.\"\"\"\n return EncodeString(text) if text else 0\n\n\nclass TextVal:\n \"\"\"A text as the lowering holds it: where it is, the block it owns (the int 0: none), and its length in\n characters - an int, a variable, or None when nobody has counted yet. `taken`: the block is the value's own, so\n what receives the value keeps the block or gives it back; a variable's is only looked at.\"\"\"\n\n def __init__(self, addr, block, length, taken):\n self.addr, self.block, self._length, self.taken = addr, block, length, taken\n\n def length(self):\n if self._length is None:\n self._length = texts()[\"count\"](self.addr)\n return self._length\n\n\nclass TextStorage:\n \"\"\"A text variable that is made: three cells, or three rows of twelve in a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no text yet, and no block\n self.addr, self.block, self.len = make(), make(), make()\n\n def cell(self, cell):\n return cell[self.player_of()] if self.rowed else cell\n\n def get(self):\n # A variable that was never given a text is an empty one: its address is 0 until then.\n addr = fresh(self.cell(self.addr))\n if EUDIf()(addr == 0):\n addr << texts()[\"empty\"]\n EUDEndIf()\n return TextVal(addr, self.cell(self.block), self.cell(self.len), False)\n\n def write(self, addr, block, length):\n for cell, value in ((self.addr, addr), (self.block, block), (self.len, length)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n def take_out(self):\n \"\"\"The text, and its block with it: the variable holds no block from here on.\"\"\"\n v = self.get()\n out = TextVal(v.addr, fresh(v.block), fresh(v.length()), True)\n self.write(out.addr, 0, out.length())\n return out\n\n\nclass UnitRef:\n \"\"\"A unit of the game as the lowering holds it: the pointer, its EPD, and the slot's uniqueness byte\n (as it sits in its dword, masked 0xFF00). `uid` None is the unit of a loop's turn, there by\n construction; a pointer that is the int 0 is no unit at all.\"\"\"\n\n def __init__(self, ptr, epd, uid=None):\n self.ptr, self.epd, self.uid = ptr, epd, uid\n\n @property\n def none(self):\n return isinstance(self.ptr, int) and self.ptr == 0\n\n\nNO_UNIT = UnitRef(0, 0, 0)\n\n\nclass UnitStorage:\n \"\"\"A unit variable: three cells, or three 12-slot rows of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no unit\n self.ptr, self.epd, self.uid = make(), make(), make()\n\n def get(self):\n if self.rowed:\n p = self.player_of()\n return UnitRef(self.ptr[p], self.epd[p], self.uid[p])\n return UnitRef(self.ptr, self.epd, self.uid)\n\n def set(self, ref):\n uid = ref.uid\n if uid is None:\n # The unit of a loop's turn is there now: this is when its slot's uniqueness byte is taken.\n uid = f_maskread_epd(ref.epd + OFF_UID, 0xFF00)\n for cell, value in ((self.ptr, ref.ptr), (self.epd, ref.epd), (self.uid, uid)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n\nclass LoopUnit:\n \"\"\"The variable of a loop over units: the scan's own pointer, never kept past the loop.\"\"\"\n\n def __init__(self, ref):\n self.ref = ref\n\n def get(self):\n return self.ref\n\n def set(self, ref):\n raise Fail(\"trigscript: the unit of a loop's turn cannot be assigned\")\n\n\ndef saturate(value, bits):\n top = (1 << bits) - 1\n if isinstance(value, int):\n return min(value, top)\n v = EUDVariable()\n v << value\n if EUDIf()(v >= top + 1):\n v << top\n EUDEndIf()\n return v\n\n\ndef as_var(value):\n if isinstance(value, int):\n return EUDVariable(value & U32) # initial: a constant, never written\n return value\n\n\ndef fresh(value=0):\n \"\"\"A temporary that starts from `value` every time the code runs. `EUDVariable(n)` is not\n that: n is the cell's value when the map loads, so a temporary built that way and then\n written keeps what the last run left in it — `ticks + 1` went 1, 2, 4, 8 (found in the\n first played probe, 2026-09-18). Anything the lowering writes to starts here.\"\"\"\n v = EUDVariable()\n v << value\n return v\n\n\nclass Lowering:\n def __init__(self, program):\n self.p = program\n self.per_player = bool(program.get(\"perPlayer\"))\n self.owner = int(program.get(\"owner\", 0))\n self.slots = owner_slots(program)\n self.player = None\n self.vars = {}\n self.arrays = {a[\"id\"]: SliceStorage(a, self) if a.get(\"slice\") else InnerListStorage(a, self) if a.get(\"through\") else (ListStorage if a.get(\"dynamic\") else ArrayStorage)(a, self.per_player, self.player_of) for a in program.get(\"arrays\", [])}\n self.state = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.wait = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.resumes = [] # (index, Forward) for every sleep\n self.frame_end = None\n self.latches = {}\n # The functions that are called, by id, and each one's EUDFunc once something has called it.\n self.functions = {f[\"id\"]: f for f in program.get(\"functions\", [])}\n self.made = {}\n self.in_function = 0\n # The function being lowered, when it is one that calls itself: whose return address a frame keeps.\n self.within = []\n\n # ── storage ──\n def player_of(self):\n if self.player is None:\n raise Fail(\"trigscript: a per-player variable outside the player loop\")\n return self.player\n\n def declare(self, decl):\n if decl.get(\"kind\") == \"unit\":\n s = UnitStorage(decl, self.per_player, self.player_of)\n elif decl.get(\"kind\") == \"text\" and decl.get(\"text\") != \"id\":\n s = TextStorage(decl, self.per_player, self.player_of)\n else:\n s = Storage(decl, self.per_player, self.player_of)\n self.vars[decl[\"id\"]] = s\n return s\n\n def array(self, id_, node=None):\n a = self.arrays.get(id_)\n if a is None:\n raise Fail(\"trigscript: unknown array %r%s\" % (id_, where(node)))\n return a\n\n def var(self, id_, node=None):\n s = self.vars.get(id_)\n if s is None:\n raise Fail(\"trigscript: unknown variable %r%s\" % (id_, where(node)))\n return s\n\n def get_state(self):\n return self.state[self.player] if self.per_player else self.state\n\n def set_state(self, value):\n if self.per_player:\n self.state[self.player] = value\n else:\n self.state << value\n\n def get_wait(self):\n return self.wait[self.player] if self.per_player else self.wait\n\n def set_wait(self, value):\n if self.per_player:\n self.wait[self.player] = value\n else:\n self.wait << value\n\n # ── numbers: an int (the 32 bits, 0 … 2^32 - 1) or an EUDVariable ──\n def num(self, e):\n k = e[\"kind\"]\n if k in (\"textLength\", \"textIndexOf\", \"textCode\"):\n return self.text_number(e)\n if k == \"input\":\n return INPUT.read(e[\"input\"], self, e)\n if k == \"const\":\n return int(e[\"value\"]) & U32\n if k == \"var\":\n return self.var(e[\"id\"], e).get()\n if k == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"length\":\n a = self.array(e[\"array\"], e)\n return a.length() if isinstance(a, ListStorage) else a.length\n if k == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if k == \"cast\":\n return self.num(e[\"expr\"])\n if k == \"unary\":\n x = self.num(e[\"expr\"])\n return (-x) & U32 if isinstance(x, int) else 0 - x\n if k == \"binary\":\n return self.binary(e)\n if k == \"read\":\n return self.read(e)\n if k == \"randomInt\":\n # 0 … n - 1; an n of 0 gives 0, as a 0 divisor does.\n n = self.num(e[\"bound\"])\n if isinstance(n, int):\n if n <= 1:\n return 0\n return f_div(f_dwrand(), n)[1]\n out = fresh(0)\n if EUDIf()(n >= 1):\n out << f_div(f_dwrand(), n)[1]\n EUDEndIf()\n return out\n if k == \"unitField\":\n return self.unit_field(e)\n if k == \"unitPart\":\n return self.unit_part(e)\n if k == \"tableRead\":\n return self.table_read(e[\"cell\"], e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.num(e[\"whenTrue\"])\n if EUDElse()():\n t << self.num(e[\"whenFalse\"])\n EUDEndIf()\n return t\n if k == \"intrinsic\" and e[\"name\"] == \"abs\":\n x = self.num(e[\"args\"][0])\n if isinstance(x, int):\n return abs(signed(x)) & U32\n t = fresh(x)\n if EUDIf()(t >= SIGN):\n t << 0 - t\n EUDEndIf()\n return t\n if k == \"intrinsic\":\n a, b = [self.num(x) for x in e[\"args\"]]\n name = e[\"name\"]\n unsigned = bool(e.get(\"unsigned\"))\n if isinstance(a, int) and isinstance(b, int):\n key = (lambda v: v) if unsigned else signed\n return min(a, b, key=key) if name == \"min\" else max(a, b, key=key)\n t = EUDVariable()\n if EUDIf()(self.ordered(a, \"<=\" if name == \"min\" else \">=\", b, unsigned)):\n t << a\n if EUDElse()():\n t << b\n EUDEndIf()\n return t\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown expression %r%s\" % (k, where(e)))\n\n def binary(self, e):\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op = e[\"op\"]\n if op in BITWISE:\n return self.bitwise(op, a, b)\n both = isinstance(a, int) and isinstance(b, int)\n if op == \"+\":\n return (a + b) & U32 if both else a + b\n if op == \"-\":\n return (a - b) & U32 if both else a - b\n if op == \"*\":\n return (a * b) & U32 if both else f_mul(as_var(a), as_var(b))\n # / and %: towards zero, the remainder with the dividend's sign, unless both sides are u32s. A divisor of 0 gives 0.\n unsigned = bool(e.get(\"unsigned\"))\n pick = (lambda q, r: q) if op == \"/\" else (lambda q, r: r)\n if isinstance(b, int):\n if b == 0:\n raise Fail(\"trigscript: division by zero%s\" % where(e))\n if both:\n if unsigned:\n return pick(a // b, a % b)\n x, y = signed(a), signed(b)\n q = abs(x) // abs(y) * (-1 if (x < 0) != (y < 0) else 1)\n return pick(q, x - q * y) & U32\n return pick(*(f_div(a, b) if unsigned else f_div_towards_zero(fresh(a), signed(b))))\n out = fresh(0)\n if EUDIf()(b >= 1):\n q, r = f_div(as_var(a), b) if unsigned else f_div_towards_zero(fresh(a), fresh(b))\n out << pick(q, r)\n EUDEndIf()\n return out\n\n def ordered(self, a, op, b, unsigned):\n \"\"\"`a op b` as one condition, the two read as u32s (`unsigned` true), as signed numbers (false), or one of\n each (\"left\" / \"right\" names the u32) - exactly: a number below zero is smaller than any u32. At least one\n side is a variable. A signed order is the unsigned one with the top bit of both sides flipped.\"\"\"\n if unsigned in (\"left\", \"right\"):\n # s is the signed side, u the u32, and the comparison is turned to read s op u.\n s_, u_ = (b, a) if unsigned == \"left\" else (a, b)\n if unsigned == \"left\":\n op = FLIPPED[op]\n if isinstance(s_, int):\n if s_ >= SIGN:\n return always(op in (\"<\", \"<=\", \"!=\"))\n return self.ordered(s_, op, u_, True)\n sv = as_var(s_)\n if op in (\"<\", \"<=\", \"!=\"):\n return EUDOr(sv >= SIGN, relation(sv, op, u_))\n return EUDAnd(sv <= SIGN - 1, relation(sv, op, u_))\n if isinstance(a, int):\n a, b, op = b, a, FLIPPED[op]\n if unsigned or op in (\"==\", \"!=\"):\n return relation(as_var(a), op, b)\n return relation(a + SIGN, op, (b + SIGN) & U32 if isinstance(b, int) else b + SIGN)\n\n @staticmethod\n def bitwise(op, a, b):\n \"\"\"& | ^ << >> >>> over 32 bits. `>>` keeps the sign of what it shifts and `>>>` does not; a shift by 32 or\n more leaves nothing but that sign.\"\"\"\n if op in (\"<<\", \">>\", \">>>\"):\n if isinstance(a, int) and isinstance(b, int):\n if op == \"<<\":\n return 0 if b >= 32 else (a << b) & U32\n if op == \">>>\":\n return 0 if b >= 32 else a >> b\n return (signed(a) >> min(b, 31)) & U32\n if isinstance(b, int) and b == 0:\n return a\n if isinstance(b, int) and b >= 32 and op != \">>\":\n return 0\n if op == \"<<\":\n return f_bitlshift(fresh(a), b)\n if op == \">>>\":\n return f_bitrshift(fresh(a), b)\n # The sign kept: a number below zero is shifted as its complement, which has zeros where it has ones.\n x, below = fresh(a), fresh(0)\n if EUDIf()(x >= SIGN):\n below << 1\n x << ~x\n EUDEndIf()\n r = fresh(f_bitrshift(x, b)) if not (isinstance(b, int) and b >= 32) else fresh(0)\n if EUDIf()(below >= 1):\n r << ~r\n EUDEndIf()\n return r\n if isinstance(a, int) and isinstance(b, int):\n return {\"&\": a & b, \"|\": a | b, \"^\": a ^ b}[op]\n # Copies: eudplib computes in place into an operand nothing else refers to, and ours may be a variable's own cell.\n x, y = fresh(a), fresh(b)\n return x & y if op == \"&\" else x | y if op == \"|\" else x ^ y\n\n # ── reads: a value of the game ──\n def current(self):\n \"\"\"The player the program is running as: a slot, or the player loop's variable.\"\"\"\n return self.player if self.per_player else self.slots[0]\n\n def one_player(self, p, node):\n if p == CURRENT_PLAYER:\n return self.current()\n if 0 <= p < 12:\n return p\n raise Fail(\"trigscript: a read takes one player%s\" % where(node))\n\n def read(self, e):\n r = e[\"read\"]\n source = r.get(\"source\")\n if source == \"condition\":\n return self.read_condition(r[\"record\"], e)\n if source == \"player\" and r.get(\"fact\") == \"left\":\n return self.read_left(r, e)\n if source == \"player\":\n base = PLAYER_BYTES.get(r.get(\"fact\"))\n if base is None:\n raise Fail(\"trigscript: unknown player fact %r%s\" % (r.get(\"fact\"), where(e)))\n value = f_bread(base + self.one_player(r[\"player\"], e))\n if r.get(\"fact\") == \"slot\":\n # A computer of a Use Map Settings game keeps the map's own number, 5; a melee computer is 1\n # (the slice 2 probe read 5 for one). Both are \"a computer\": one number for the script.\n kind = fresh(value)\n if EUDIf()(kind == 5):\n kind << 1\n EUDEndIf()\n return kind\n return value\n if source == \"supply\":\n return self.read_supply(r, e)\n raise Fail(\"trigscript: unknown read %r%s\" % (source, where(e)))\n\n def read_left(self, r, e):\n \"\"\"1 once a player who was in the map's settings as a human or a computer is gone. Asked the way\n eudplib's f_playerexist asks (the player's trigger list), which the player loop already relies on.\n The byte table at 0x581D62 read 0 for players who were there, as it should, but nobody has seen it\n turn 1 in a game — a computer never leaves — so it is not what this rests on.\"\"\"\n p = self.one_player(r[\"player\"], e)\n if isinstance(p, int) and (p >= 8 or GetPlayerInfo(p).typestr not in (\"Human\", \"Computer\")):\n return 0\n gone = fresh(1)\n if EUDIf()(f_playerexist(p)):\n gone << 0\n EUDEndIf()\n return gone\n\n def read_supply(self, r, e):\n base = SUPPLY_TABLES.get(r.get(\"of\"))\n if base is None:\n raise Fail(\"trigscript: unknown supply %r%s\" % (r.get(\"of\"), where(e)))\n p = self.one_player(r[\"player\"], e)\n half = fresh(0)\n if r.get(\"race\") is None:\n # The race the player plays; a slot with none (neutral, empty) has no supply.\n race = f_bread(PLAYER_BYTES[\"race\"] + p)\n if EUDIf()(race <= 2):\n half << f_dwread_epd(EPD(base) + race * 36 + p)\n EUDEndIf()\n else:\n half << f_dwread_epd(EPD(base) + int(r[\"race\"]) * 36 + p)\n # As the top bar shows it: half a supply in use counts as one.\n if r[\"of\"] == \"used\":\n half += 1\n return half // 2\n\n def read_condition(self, rec, e):\n t, player, unit = rec[\"type\"], rec[\"player\"], rec[\"unitId\"]\n one = player == CURRENT_PLAYER or not 12 <= player <= 26 # not a group; beyond 26 is an EUD offset, read as it stands\n table = None\n if one and t in (COND_DEATHS, COND_KILL) and not 229 <= unit <= 232:\n table = (DEATHS_TABLE if t == COND_DEATHS else KILLS_TABLE, unit * 12)\n elif one and t == COND_ACCUMULATE and rec[\"resource\"] in (0, 1):\n table = (ORE_TABLE if rec[\"resource\"] == 0 else GAS_TABLE, 0)\n if table is not None:\n p = self.current() if player == CURRENT_PLAYER else player\n return f_dwread_epd(EPD(table[0]) + table[1] + p)\n # No table holds it (a group's sum, units counted at a location, a score, a clock): ask the\n # condition, a bit at a time from the top. A unit count stays under 2^12, a number of players under 2^4.\n bits = 12 if t in (COND_BRING, COND_COMMAND) else 4 if t == COND_OPPONENTS else 32\n found = fresh(0)\n for bit in reversed(range(bits)):\n probe = found + (1 << bit)\n if EUDIf()(Condition(rec[\"location\"], player, probe, unit, 0, t, rec[\"resource\"], rec[\"flags\"], eudx=rec.get(\"mask\", 0) or 0)):\n found << probe\n EUDEndIf()\n return found\n\n # ── booleans: an eudplib condition ──\n def cond(self, e):\n k = e[\"kind\"]\n if k == \"const\":\n return always(e[\"value\"])\n if k == \"cond\":\n return condition(e[\"record\"])\n if k == \"var\":\n return as_var(self.var(e[\"id\"], e).get()) >= 1\n if k == \"element\":\n return as_var(self.array(e[\"array\"], e).get(self.num(e[\"index\"]))) >= 1\n if k == \"pop\":\n return as_var(self.array(e[\"array\"], e).pop()) >= 1\n if k == \"test\":\n return as_var(self.num(e[\"expr\"])) >= 1\n if k == \"compare\":\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op, unsigned = e[\"op\"], e.get(\"unsigned\", False)\n if isinstance(a, int) and isinstance(b, int):\n x = a if unsigned in (True, \"left\") else signed(a)\n y = b if unsigned in (True, \"right\") else signed(b)\n return always(compare(x, op, y))\n # One comparison, built once: a comparison between variables writes into its own\n # condition, and one that is built and dropped is an orphan eudplib refuses.\n return self.ordered(a, op, b, unsigned)\n if k == \"and\":\n return EUDAnd(*[self.cond(c) for c in e[\"items\"]])\n if k == \"or\":\n return EUDOr(*[self.cond(c) for c in e[\"items\"]])\n if k == \"not\":\n return EUDNot(self.cond(e[\"expr\"]))\n if k == \"random\":\n return (f_rand() & 1) >= 1\n if k in (\"unitAlive\", \"unitFlag\", \"unitSame\"):\n return as_var(self.truth(e)) >= 1\n if k == \"edge\":\n return self.edge(e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.truth(e[\"whenTrue\"])\n if EUDElse()():\n t << self.truth(e[\"whenFalse\"])\n EUDEndIf()\n return t >= 1\n if k == \"call\":\n return as_var(self.call(e[\"call\"])) >= 1\n if k in (\"textCompare\", \"textTest\"):\n return as_var(self.text_truth(e)) >= 1\n raise Fail(\"trigscript: unknown condition %r%s\" % (k, where(e)))\n\n def truth(self, e):\n \"\"\"A boolean expression as 0 / 1.\"\"\"\n if e[\"kind\"] == \"const\":\n return 1 if e[\"value\"] else 0\n if e[\"kind\"] == \"var\":\n return self.var(e[\"id\"], e).get()\n if e[\"kind\"] == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if e[\"kind\"] == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if e[\"kind\"] == \"unitAlive\":\n ref = self.unit(e[\"unit\"])\n t = fresh(0)\n if not ref.none:\n self.when_alive(ref, lambda: t << 1)\n return t\n if e[\"kind\"] == \"unitFlag\":\n return self.unit_flag(e)\n if e[\"kind\"] == \"unitSame\":\n a, b = self.unit(e[\"left\"]), self.unit(e[\"right\"])\n t = fresh(0)\n if not a.none and not b.none:\n ap, bp = as_var(a.ptr), as_var(b.ptr)\n if EUDIf()([ap >= 1, ap == bp]):\n t << 1\n EUDEndIf()\n return t\n t = fresh(0)\n if EUDIf()(self.cond(e)):\n t << 1\n EUDEndIf()\n return t\n\n def edge(self, e):\n \"\"\"rose(c): true on the frame c becomes true; once(c): true the first time it holds.\"\"\"\n key = id(e)\n if key not in self.latches:\n self.latches[key] = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: the latch\n latch = self.latches[key]\n\n def get():\n return latch[self.player] if self.per_player else latch\n\n def put(v):\n if self.per_player:\n latch[self.player] = v\n else:\n latch << v\n\n fired = fresh(0)\n held = self.truth(e[\"cond\"])\n if EUDIf()(as_var(held) >= 1):\n if EUDIf()(as_var(get()) == 0):\n fired << 1\n put(1)\n EUDEndIf()\n if EUDElse()():\n if e[\"edge\"] == \"rose\":\n put(0)\n EUDEndIf()\n return fired >= 1\n\n # ── units: a pointer into the game's unit table, checked before use ──\n def unit(self, e):\n k = e[\"kind\"]\n if k == \"unitNull\":\n return NO_UNIT\n if k == \"unitVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"pick\":\n return self.pick(e)\n if k == \"unitAt\":\n # A unit kept as three numbers of the program (a cell each of an array of units): re-checked like any kept unit.\n ptr = self.num(e[\"ptr\"])\n if isinstance(ptr, int) and ptr == 0:\n return NO_UNIT\n return UnitRef(fresh(ptr), fresh(self.num(e[\"epd\"])), fresh(self.num(e[\"uid\"])))\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown unit expression %r%s\" % (k, where(e)))\n\n def unit_part(self, e):\n \"\"\"One of the three numbers a unit is kept as. The unit of a loop's turn has no uniqueness byte taken yet: it is read here.\"\"\"\n ref = self.unit(e[\"unit\"])\n if ref.none:\n return 0\n part = e[\"part\"]\n if part == \"ptr\":\n return ref.ptr\n if part == \"epd\":\n return ref.epd\n return f_maskread_epd(ref.epd + OFF_UID, 0xFF00) if ref.uid is None else ref.uid\n\n def when_alive(self, ref, body):\n \"\"\"`body()` when the unit is still the one that was kept: the slot has a sprite, its order is not\n \"die\", and its uniqueness byte is the one taken with the pointer.\"\"\"\n if ref.none:\n return\n if ref.uid is None:\n body()\n return\n ptr, epd = as_var(ref.ptr), as_var(ref.epd)\n if EUDIf()([ptr >= 1, MemoryEPD(epd + OFF_SPRITE, AtLeast, 1), MemoryXEPD(epd + OFF_OWNER_ORDER, AtLeast, 0x100, 0xFF00), MemoryXEPD(epd + OFF_UID, Exactly, ref.uid, 0xFF00)]):\n body()\n EUDEndIf()\n\n @staticmethod\n def cunit(ref):\n return CUnit(ref.epd, ptr=ref.ptr)\n\n def unit_field(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n self.when_alive(ref, lambda: out << self.field_of(self.cunit(ref), e[\"field\"], e))\n return out\n\n def field_of(self, cu, field, node):\n \"\"\"A unit's number, in the script's units: whole points for hit points (as the game shows them,\n a started point counting), shields and energy.\"\"\"\n if field == \"hp\":\n return f_div(cu.hp + 255, 256)[0]\n if field == \"maxHp\":\n return f_div(f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType), 256)[0]\n if field == \"shields\":\n return f_div(cu.shield, 256)[0]\n if field == \"maxShields\":\n return f_wread(UNITS_MAX_SHIELDS + cu.unitType * 2)\n if field == \"energy\":\n return f_div(cu.energy, 256)[0]\n if field in UNIT_TIMERS:\n return getattr(cu, UNIT_TIMERS[field])\n name = {\"owner\": \"owner\", \"type\": \"unitType\", \"x\": \"posX\", \"y\": \"posY\", \"kills\": \"killCount\", \"orderId\": \"orderID\", \"cooldown\": \"groundWeaponCooldown\", \"resources\": \"resourceAmount\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: unknown unit field %r%s\" % (field, where(node)))\n return getattr(cu, name)\n\n def unit_flag(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n\n def body():\n cu = self.cunit(ref)\n flag = e[\"flag\"]\n if flag == \"underAttack\":\n held = cu.attackNotifyTimer >= 1\n else:\n mask = {\"hallucinated\": STATUS_HALLUCINATION, \"cloaked\": STATUS_CLOAKED, \"burrowed\": STATUS_BURROWED, \"invincible\": STATUS_INVINCIBLE}.get(flag)\n if mask is None:\n raise Fail(\"trigscript: unknown unit flag %r%s\" % (flag, where(e)))\n held = cu.check_status_flag(mask)\n if EUDIf()(held):\n out << 1\n EUDEndIf()\n\n self.when_alive(ref, body)\n return out\n\n def location_bounds(self, number):\n base = EPD(MRGN + (int(number) - 1) * 20)\n return [f_dwread_epd(base + i) for i in range(4)]\n\n def scan(self, flt, node, body):\n \"\"\"The game's unit table, slot by slot: `body(ref, next_, exit_)` for every unit on the map the\n filter matches. Every test is one condition whose address moves on with the slot, so a slot\n that does not match costs a trigger or two.\"\"\"\n ptr, epd = EUDVariable(), EUDVariable()\n empty = MemoryEPD(0, Exactly, 0)\n dying = MemoryXEPD(0, Exactly, 0, 0xFF00)\n moving = [(empty, OFF_SPRITE), (dying, OFF_OWNER_ORDER)]\n match, values = [], []\n owner = flt.get(\"owner\")\n if owner is not None:\n who = self.current() if owner == CURRENT_PLAYER else int(owner)\n c = MemoryXEPD(0, Exactly, who if isinstance(who, int) else 0, 0xFF)\n match.append(c)\n moving.append((c, OFF_OWNER_ORDER))\n if not isinstance(who, int):\n values.append((c, who))\n kind = flt.get(\"type\")\n if kind is not None and kind < 228:\n c = MemoryXEPD(0, Exactly, int(kind), 0xFFFF)\n match.append(c)\n moving.append((c, OFF_TYPE))\n elif kind is not None and kind not in GROUP_BITS:\n raise Fail(\"trigscript: unknown unit type %r%s\" % (kind, where(node)))\n if flt.get(\"at\"):\n left, top, right, bottom = self.location_bounds(flt[\"at\"])\n # A location reaching past the map's edge starts below zero, which a unit's position never is.\n for v in (left, top):\n if EUDIf()(v >= 0x80000000):\n v << 0\n EUDEndIf()\n for comparison, mask, value in ((AtLeast, 0xFFFF, left), (AtMost, 0xFFFF, right), (AtLeast, 0xFFFF0000, f_bitlshift(top, 16)), (AtMost, 0xFFFF0000, f_bitlshift(bottom, 16))):\n c = MemoryXEPD(0, comparison, 0, mask)\n match.append(c)\n moving.append((c, OFF_POS))\n values.append((c, value))\n DoActions([ptr.SetNumber(UNIT_TABLE), epd.SetNumber(EPD(UNIT_TABLE))] + [SetMemory(c + 4, SetTo, EPD(UNIT_TABLE) + off) for c, off in moving])\n for c, value in values:\n f_dwwrite_epd(EPD(c + 8), value)\n head, next_, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n EUDJumpIf(ptr >= UNIT_END, exit_)\n EUDJumpIf(empty, next_)\n EUDJumpIf(dying, next_)\n if match:\n matched = Forward()\n EUDJumpIf(match, matched)\n EUDJump(next_)\n matched << NextTrigger()\n if kind in GROUP_BITS:\n group = fresh(f_bread(UNITS_GROUP + f_maskread_epd(epd + OFF_TYPE, 0xFFFF)))\n EUDJumpIf((group & GROUP_BITS[kind]) == 0, next_)\n body(UnitRef(ptr, epd, None), next_, exit_)\n next_ << NextTrigger()\n DoActions([SetMemory(c + 4, Add, UNIT_SIZE // 4) for c, _ in moving] + [ptr.AddNumber(UNIT_SIZE), epd.AddNumber(UNIT_SIZE // 4)])\n EUDJump(head)\n exit_ << NextTrigger()\n\n def pick(self, e):\n \"\"\"One unit among the matching: the first, the nearest to a location's centre, or one at random.\"\"\"\n by, flt = e[\"by\"], e.get(\"filter\", {})\n found_ptr, found_epd = fresh(0), fresh(0)\n\n def take(ref):\n found_ptr << ref.ptr\n found_epd << ref.epd\n\n if by == \"first\":\n def body(ref, next_, exit_):\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n elif by == \"nearest\":\n if e.get(\"mouse\") is not None:\n # A player's mouse, and nothing farther from it than `within`.\n cx = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"x\", \"player\": e[\"mouse\"]}, self, e))\n cy = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"y\", \"player\": e[\"mouse\"]}, self, e))\n least = fresh(int(e.get(\"within\", 48)) + 1)\n else:\n left, top, right, bottom = self.location_bounds(e[\"near\"])\n cx, cy = f_div(left + right, 2)[0], f_div(top + bottom, 2)[0]\n least = fresh(U32)\n\n def body(ref, next_, exit_):\n cu = self.cunit(ref)\n # |dx| + |dy| is enough to say which is nearest, and never overflows.\n d = distance(cu.posX, cx) + distance(cu.posY, cy)\n if EUDIf()(d < least):\n least << d\n take(ref)\n EUDEndIf()\n self.scan(flt, e, body)\n elif by == \"random\":\n # Count the matching, draw one, take the drawn one on a second pass.\n count = fresh(0)\n self.scan(flt, e, lambda ref, next_, exit_: count.__iadd__(1))\n none = Forward()\n EUDJumpIf(count == 0, none)\n drawn = f_div(f_dwrand(), count)[1]\n i = fresh(0)\n\n def body(ref, next_, exit_):\n hit = Forward()\n EUDJumpIf(i == drawn, hit)\n i.__iadd__(1)\n EUDJump(next_)\n hit << NextTrigger()\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n none << NextTrigger()\n else:\n raise Fail(\"trigscript: unknown pick %r%s\" % (by, where(e)))\n uid = fresh(0)\n if EUDIf()(found_ptr >= 1):\n uid << f_maskread_epd(found_epd + OFF_UID, 0xFF00)\n EUDEndIf()\n return UnitRef(found_ptr, found_epd, uid)\n\n def unit_loop(self, st, ctx):\n def body(ref, next_, exit_):\n self.vars[st[\"decl\"][\"id\"]] = LoopUnit(ref)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": next_}))\n self.scan(st.get(\"filter\", {}), st, body)\n\n def unit_write(self, st):\n ref = self.unit(st[\"unit\"])\n field = st[\"field\"]\n if field == \"invincible\":\n on = self.truth(st[\"value\"])\n\n def flag():\n cu = self.cunit(ref)\n if isinstance(on, int):\n cu.set_invincible() if on else cu.clear_invincible()\n return\n if EUDIf()(as_var(on) >= 1):\n cu.set_invincible()\n if EUDElse()():\n cu.clear_invincible()\n EUDEndIf()\n self.when_alive(ref, flag)\n return\n value = self.num(st[\"value\"])\n\n def write():\n cu = self.cunit(ref)\n if field == \"hp\":\n # Hit points at 0 are a dead unit, so that is what the write makes of it.\n if isinstance(value, int):\n if value == 0:\n cu.die()\n else:\n cu.hp = min(value, 0xFFFFFF) * 256\n return\n if EUDIf()(value == 0):\n cu.die()\n if EUDElse()():\n cu.hp = f_mul(saturate(value, 24), 256)\n EUDEndIf()\n elif field == \"shields\":\n cu.shield = points(value, 24)\n elif field == \"energy\":\n cu.energy = points(value, 8)\n elif field == \"kills\":\n cu.killCount = saturate(value, 8)\n elif field == \"resources\":\n cu.resourceAmount = saturate(value, 16)\n elif field == \"cooldown\":\n frames = saturate(value, 8)\n cu.groundWeaponCooldown = frames\n cu.airWeaponCooldown = frames\n cu.spellCooldown = frames\n elif field in UNIT_TIMERS:\n setattr(cu, UNIT_TIMERS[field], saturate(value, 8))\n else:\n raise Fail(\"trigscript: a unit's %s takes no write%s\" % (field, where(st)))\n self.when_alive(ref, write)\n\n def unit_do(self, st):\n ref = self.unit(st[\"unit\"])\n verb = st[\"verb\"]\n do = verb[\"do\"]\n amount = self.num(verb[\"amount\"]) if do in (\"damage\", \"heal\") else 0\n\n def act():\n cu = self.cunit(ref)\n if do == \"kill\":\n cu.die()\n elif do == \"remove\":\n cu.remove()\n elif do == \"give\":\n to = int(verb[\"to\"])\n cu.cgive(self.current() if to == CURRENT_PLAYER else to)\n elif do == \"order\":\n self.order(cu, verb, st)\n elif do == \"locate\":\n self.locate(cu, int(verb[\"location\"]))\n elif do in (\"damage\", \"heal\"):\n self.adjust(cu, do, amount, bool(verb.get(\"percent\")))\n else:\n raise Fail(\"trigscript: unknown unit verb %r%s\" % (do, where(st)))\n self.when_alive(ref, act)\n\n def order(self, cu, verb, node):\n \"\"\"The game's own Order, reaching this unit alone: a location is made a small box around the unit\n for the length of the action (the game did nothing with a box of no size), then put back.\"\"\"\n kind = {\"move\": Move, \"patrol\": Patrol, \"attack\": Attack}.get(verb[\"order\"])\n if kind is None:\n raise Fail(\"trigscript: unknown order %r%s\" % (verb[\"order\"], where(node)))\n base = EPD(MRGN + (SCRATCH_LOCATION - 1) * 20)\n kept = [f_dwread_epd(base + i) for i in range(5)]\n x, y = cu.posX, cu.posY\n for i, v in enumerate((x - 2, y - 2, x + 2, y + 2, 0)):\n f_dwwrite_epd(base + i, v)\n DoActions(Order(cu.unitType, cu.owner, SCRATCH_LOCATION, kind, int(verb[\"target\"])))\n for i, v in enumerate(kept):\n f_dwwrite_epd(base + i, v)\n\n def locate(self, cu, number):\n \"\"\"A location centred on the unit, its size kept.\"\"\"\n base = EPD(MRGN + (number - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n x, y = cu.posX - f_div(width, 2)[0], cu.posY - f_div(height, 2)[0]\n for i, v in enumerate((x, y, x + width, y + height)):\n f_dwwrite_epd(base + i, v)\n\n def center_location(self, st):\n \"\"\"A location centred on a point, its size kept.\"\"\"\n x, y = self.num(st[\"x\"]), self.num(st[\"y\"])\n base = EPD(MRGN + (int(st[\"location\"]) - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n nx, ny = x - f_div(width, 2)[0], y - f_div(height, 2)[0]\n for i, v in enumerate((nx, ny, nx + width, ny + height)):\n f_dwwrite_epd(base + i, v)\n\n def adjust(self, cu, do, amount, percent):\n \"\"\"Hit points down — at 0 the unit dies — or up to the type's maximum, in the game's own units (256 to a point).\"\"\"\n now = fresh(cu.hp)\n top = f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType)\n if percent:\n step = f_div(f_mul(top, as_var(amount)), 100)[0]\n else:\n step = amount * 256 if isinstance(amount, int) else f_mul(saturate(amount, 24), 256)\n if do == \"damage\":\n if EUDIf()(now <= step):\n cu.die()\n if EUDElse()():\n cu.hp = now - step\n EUDEndIf()\n return\n new = fresh(now + step)\n if EUDIf()(new >= top):\n new << top\n EUDEndIf()\n cu.hp = new\n\n # ── the game's tables ──\n def cell_address(self, c):\n index = c[\"index\"]\n if c.get(\"player\") and index == CURRENT_PLAYER:\n index = self.current()\n base = int(c[\"base\"]) + int(c.get(\"key\") or 0)\n return base + index * int(c[\"stride\"])\n\n def table_read(self, c, node):\n if c.get(\"special\"):\n raise Fail(\"trigscript: %s cannot be read%s\" % (c.get(\"name\"), where(node)))\n addr = self.cell_address(c)\n width = c[\"width\"]\n if width == \"bit\":\n v = fresh(0)\n if EUDIf()(MemoryX(addr, AtLeast, 1, 1 << int(c[\"bit\"]))):\n v << 1\n EUDEndIf()\n return v\n v = f_dwread(addr) if width == 4 else f_wread(addr) if width == 2 else f_bread(addr)\n scale = int(c.get(\"scale\") or 1)\n return f_div(v, scale)[0] if scale > 1 else v\n\n def table_write(self, st):\n c = st[\"cell\"]\n addr = self.cell_address(c)\n special = c.get(\"special\")\n if special == \"name\":\n if self.has_id(st[\"value\"]):\n f_wwrite(addr, self.text_id_of(st[\"value\"]))\n return\n # A name that was made: over the string kept for this unit type, on every computer - a name is nobody's in particular.\n made = self.text(st[\"value\"])\n sid = slot(\"the name of unit type %d\" % int(c[\"index\"]))\n texts()[\"show\"](GetMapStringAddr(sid), made.addr)\n self.used(made)\n f_wwrite(addr, sid)\n return\n width = c[\"width\"]\n if st.get(\"boolean\"):\n value = self.truth(st[\"value\"])\n else:\n value = self.num(st[\"value\"])\n scale = int(c.get(\"scale\") or 1)\n if scale > 1 and not st.get(\"scaled\"):\n value = value * scale if isinstance(value, int) else f_mul(value, scale)\n top = 1 if width == \"bit\" else (1 << (8 * width)) - 1\n if isinstance(value, int):\n value = min(value, top)\n elif width != 4 and width != \"bit\":\n value = saturate(value, 8 * width)\n if special == \"speed\":\n self.write_speed(int(c[\"index\"]), value)\n elif special == \"color\":\n f_bwrite(addr, value)\n f_bwrite(addr + MINIMAP_COLOR_OFFSET, value)\n elif width == \"bit\":\n mask = 1 << int(c[\"bit\"])\n if isinstance(value, int):\n DoActions(SetMemoryX(addr, SetTo, mask if value else 0, mask))\n else:\n if EUDIf()(value >= 1):\n DoActions(SetMemoryX(addr, SetTo, mask, mask))\n if EUDElse()():\n DoActions(SetMemoryX(addr, SetTo, 0, mask))\n EUDEndIf()\n elif width == 4:\n f_dwwrite(addr, value)\n elif width == 2:\n f_wwrite(addr, value)\n else:\n f_bwrite(addr, value)\n\n def write_speed(self, unit, speed):\n \"\"\"A unit type's top speed is its flingy's: the flingy is switched to table control and given the\n speed, with acceleration about a seventeenth of it and the braking distance v² / 2a to match\n (the Vulture's own figures), as Magenta writes it. Units made afterwards move at the new speed.\"\"\"\n flingy = f_bread(UNITS_FLINGY + unit)\n if isinstance(speed, int):\n acceleration = max(1, round(speed / 17))\n halt = max(1, round(speed * speed / (2 * acceleration)))\n else:\n acceleration = fresh(f_div(speed, 17)[0])\n if EUDIf()(acceleration == 0):\n acceleration << 1\n EUDEndIf()\n halt = fresh(f_div(f_mul(speed, speed), acceleration * 2)[0])\n if EUDIf()(halt == 0):\n halt << 1\n EUDEndIf()\n acceleration = saturate(acceleration, 16)\n f_bwrite(FLINGY_CONTROL + flingy, 0)\n f_dwwrite(FLINGY_SPEED + flingy * 4, speed)\n f_wwrite(FLINGY_ACCELERATION + flingy * 2, acceleration)\n f_dwwrite(FLINGY_HALT + flingy * 4, halt)\n\n # ── statements ──\n def block(self, statements, ctx):\n for st in statements:\n self.statement(st, ctx)\n\n def statement(self, st, ctx):\n k = st[\"kind\"]\n if k == \"declare\":\n s = self.declare(st[\"decl\"])\n if not st.get(\"failed\"):\n self.put(s, st[\"init\"], st[\"decl\"][\"kind\"])\n elif k == \"assignText\":\n self.put(self.var(st[\"target\"], st), st[\"value\"], \"text\")\n elif k == \"storeText\":\n self.store_text(st)\n elif k == \"releaseText\":\n block, i = self.array(st[\"block\"], st), fresh(self.num(st[\"index\"]))\n texts()[\"release\"](as_var(block.get(i)))\n block.set(i, 0)\n elif k == \"textLoop\":\n self.text_loop(st, ctx)\n elif k == \"assignUnit\":\n self.var(st[\"target\"], st).set(self.unit(st[\"value\"]))\n elif k == \"unitLoop\":\n self.unit_loop(st, ctx)\n elif k == \"unitWrite\":\n self.unit_write(st)\n elif k == \"unitDo\":\n self.unit_do(st)\n elif k == \"tableWrite\":\n self.table_write(st)\n elif k == \"assign\":\n self.var(st[\"target\"], st).set(self.num(st[\"value\"]))\n elif k == \"assignBool\":\n self.var(st[\"target\"], st).set(self.truth(st[\"value\"]))\n elif k == \"declareArray\":\n a = self.array(st[\"array\"], st)\n cell = self.num if a.decl[\"kind\"] == \"number\" else self.truth\n if isinstance(a, ListStorage):\n if st.get(\"fill\") is not None:\n v = cell(st[\"fill\"])\n a.declare_filled(v if isinstance(v, int) else fresh(v), int(a.decl[\"length\"]))\n else:\n values = [cell(v) for v in st.get(\"init\", [])]\n a.declare([v if isinstance(v, int) else fresh(v) for v in values])\n elif st.get(\"fill\") is not None:\n a.fill(cell(st[\"fill\"]))\n else:\n # Every value first, then the stores: [b, a] of two cells of the array itself is a swap.\n values = [cell(v) for v in st.get(\"init\", [])]\n values = [v if isinstance(v, int) else fresh(v) for v in values]\n for i, v in enumerate(values):\n a.set(i, v)\n elif k == \"push\":\n a = self.array(st[\"array\"], st)\n a.push((self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"]))\n elif k == \"pop\":\n self.array(st[\"array\"], st).pop()\n elif k == \"setLength\":\n self.array(st[\"array\"], st).set_length(self.num(st[\"value\"]))\n elif k == \"store\":\n a = self.array(st[\"array\"], st)\n value = (self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"])\n a.set(self.num(st[\"index\"]), value)\n elif k == \"if\":\n self.if_(st, ctx)\n elif k == \"while\":\n self.while_(st, ctx)\n elif k == \"do\":\n self.do_(st, ctx)\n elif k == \"for\":\n self.for_(st, ctx)\n elif k == \"unrolled\":\n self.unrolled(st, ctx)\n elif k == \"switch\":\n self.switch(st, ctx)\n elif k == \"break\":\n if \"break\" not in ctx:\n raise Fail(\"trigscript: break outside a loop%s\" % where(st))\n EUDJump(ctx[\"break\"])\n raise Leave()\n elif k == \"continue\":\n if \"continue\" not in ctx:\n raise Fail(\"trigscript: continue outside a loop%s\" % where(st))\n EUDJump(ctx[\"continue\"])\n raise Leave()\n elif k == \"return\":\n fn = ctx.get(\"fn\")\n if fn is None:\n raise Fail(\"trigscript: return outside a function%s\" % where(st))\n if st.get(\"value\") is not None and fn[\"result\"] is not None:\n self.put(fn[\"result\"], st[\"value\"], fn[\"kind\"])\n EUDJump(fn[\"end\"])\n raise Leave()\n elif k == \"sleep\":\n self.sleep(st)\n elif k == \"action\":\n self.action(st)\n elif k == \"print\":\n self.print_(st)\n elif k == \"centerLocation\":\n self.center_location(st)\n elif k == \"call\":\n self.call(st[\"call\"])\n elif k == \"block\":\n self.block(st[\"body\"], ctx)\n elif k == \"remark\":\n pass\n else:\n raise Fail(\"trigscript: unknown statement %r%s\" % (k, where(st)))\n\n def straight(self, statements, ctx):\n \"\"\"A statement list whose end may not be reached (a break inside): Leave stops it. True when the end was reached.\"\"\"\n try:\n self.block(statements, ctx)\n return True\n except Leave:\n return False\n\n def if_(self, st, ctx):\n else_l, end = Forward(), Forward()\n EUDJumpIfNot(self.cond(st[\"cond\"]), else_l)\n if self.straight(st[\"then\"], ctx):\n EUDJump(end)\n else_l << NextTrigger()\n if st.get(\"else\"):\n self.straight(st[\"else\"], ctx)\n end << NextTrigger()\n\n def while_(self, st, ctx):\n head, exit_ = Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n if self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": head})):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def do_(self, st, ctx):\n body, check, exit_ = Forward(), Forward(), Forward()\n body << NextTrigger()\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": check}))\n check << NextTrigger()\n EUDJumpIf(self.cond(st[\"cond\"]), body)\n exit_ << NextTrigger()\n\n def for_(self, st, ctx):\n head, update, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": update}))\n update << NextTrigger()\n if self.straight(st[\"update\"], ctx):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def unrolled(self, st, ctx):\n exit_ = Forward()\n for statements in st[\"iterations\"]:\n nxt = Forward()\n self.straight(statements, dict(ctx, **{\"break\": exit_, \"continue\": nxt}))\n nxt << NextTrigger()\n exit_ << NextTrigger()\n\n def switch(self, st, ctx):\n v = as_var(self.num(st[\"value\"]))\n exit_ = Forward()\n labels = [Forward() for _ in st[\"cases\"]]\n default = None\n for c, label in zip(st[\"cases\"], labels):\n if c[\"value\"] is None:\n default = label\n continue\n n = c[\"value\"]\n if n != n or n < -SIGN or n > U32: # NaN, or a value the variable never holds\n continue\n EUDJumpIf(v == int(n) & U32, label)\n EUDJump(default if default is not None else exit_)\n for c, label in zip(st[\"cases\"], labels):\n label << NextTrigger()\n self.straight(c[\"body\"], dict(ctx, **{\"break\": exit_}))\n exit_ << NextTrigger()\n\n def sleep(self, st):\n if self.in_function:\n raise Fail(\"trigscript: sleep inside a function that is called%s\" % where(st))\n if st.get(\"cycles\") is not None:\n frames = int(st[\"cycles\"])\n else:\n frames = max(1, int(round(float(st.get(\"ms\", 0)) * FRAMES_PER_SECOND / 1000)))\n index = len(self.resumes) + 1\n resume = Forward()\n self.resumes.append((index, resume))\n self.set_state(index)\n # The frame after this one is one frame later: sleep(frames(1)) goes on in the next frame, as the\n # simulator has it. (Until 3.4 the wait was one frame longer, so a loop sleeping a frame ran every other.)\n self.set_wait(frames - 1)\n EUDJump(self.frame_end)\n resume << NextTrigger()\n\n def action(self, st):\n r = st[\"record\"]\n fields = dict(locid1=r[\"location\"], strid=string_of(r[\"text\"]), wavid=string_of(r[\"wav\"]), time=r[\"time\"], player1=r[\"player\"], player2=r[\"target\"], unitid=r[\"unitId\"], acttype=r[\"type\"], amount=r[\"modifier\"], flags=r[\"flags\"])\n shown = None\n if st.get(\"text\") is not None:\n if self.has_id(st[\"text\"]):\n fields[\"strid\"] = self.text_id_of(st[\"text\"])\n else:\n kind = SLOT_OF_ACTION.get(int(r[\"type\"]))\n if kind is None:\n raise Fail(\"trigscript: this action's text cannot be one that is made%s\" % where(st))\n # Over the string kept for this kind of field - on the computer of the player the action is for, which is\n # the one the program is running as: the game reads the string again whenever it draws, and another\n # player's text must not be there when it does. Nothing of the script can read the string back.\n shown = self.text(st[\"text\"])\n if EUDIf()(IsUserCP()):\n texts()[\"show\"](GetMapStringAddr(slot(kind)), shown.addr)\n EUDEndIf()\n self.used(shown)\n fields[\"strid\"] = slot(kind)\n count = None\n for variable in st.get(\"variables\") or []:\n value = as_var(self.num(variable[\"expr\"]))\n field = variable[\"field\"]\n if field == \"modifier\":\n # A unit count: the byte field is not a variable's place, so the action is done once per\n # unit — as many as the variable says, 0 being none (in the record, 0 means \"all\").\n count = value\n continue\n name = {\"target\": \"player2\", \"time\": \"time\", \"player\": \"player1\", \"location\": \"locid1\", \"text\": \"strid\", \"wav\": \"wavid\", \"unitId\": \"unitid\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: no variable can stand in the %s field%s\" % (field, where(st)))\n if name == \"unitid\":\n # A unit type the game has: past the table, an action reads what is not a unit.\n value = fresh(value)\n if EUDIf()(value >= 228):\n value << 228\n EUDEndIf()\n fields[name] = value\n if count is None:\n DoActions(Action(**fields))\n return\n fields[\"amount\"] = 1\n for _ in EUDLoopRange(0, count):\n DoActions(Action(**fields))\n\n def print_(self, st):\n \"\"\"Text with values in it: every value first, then the text, shown only on the screen of the player it is for.\"\"\"\n args = []\n temps = []\n for part in st[\"parts\"]:\n k = part[\"kind\"]\n if k == \"text\":\n args.append(part[\"text\"])\n elif k == \"value\":\n v = self.text(part[\"text\"])\n temps.append(v)\n args.append(ptr2s(v.addr))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n args.append(str(v if part.get(\"unsigned\") else signed(v)))\n elif part.get(\"unsigned\"):\n args.append(v)\n else:\n # A number below zero: its minus sign, then how far below it is.\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n args.append(ptr2s(sign))\n args.append(size)\n elif k == \"name\":\n args.append(PName(self.one_player(part[\"player\"], st)))\n elif k == \"color\":\n args.append(PColor(self.one_player(part[\"player\"], st)))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n if not args:\n return\n # eudplib prints for the current player, and only on that player's own computer.\n show = (lambda: f_eprintln(*args)) if st.get(\"position\") == \"center\" else (lambda: GetGlobalStringBuffer().print(*args))\n to = int(st.get(\"to\", CURRENT_PLAYER))\n if to == CURRENT_PLAYER:\n show()\n self.used(*temps)\n return\n for p in group_slots(to, st):\n f_setcurpl(p)\n show()\n f_setcurpl(self.current())\n self.used(*temps)\n\n # ── texts ──\n def has_id(self, e):\n \"\"\"Whether a text is one of the built map's table whatever happens in the game (compiler/ir.ts#textHasId).\"\"\"\n k = e[\"kind\"]\n if k in (\"text\", \"textOf\"):\n return True\n if k == \"textVar\":\n return not isinstance(self.var(e[\"id\"], e), TextStorage)\n if k == \"textTernary\":\n return self.has_id(e[\"whenTrue\"]) and self.has_id(e[\"whenFalse\"])\n return False\n\n def text_id_of(self, e):\n \"\"\"The id of a text that has one: an int, or a variable.\"\"\"\n k = e[\"kind\"]\n if k == \"text\":\n return text_id(e[\"text\"])\n if k == \"textVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"textOf\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"textTernary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.text_id_of(e[\"whenTrue\"])\n if EUDElse()():\n t << self.text_id_of(e[\"whenFalse\"])\n EUDEndIf()\n return t\n raise Fail(\"trigscript: this text has no id%s\" % where(e))\n\n def used(self, *values):\n \"\"\"Values that have been used: a block that was the value's own goes back to the heap.\"\"\"\n for v in values:\n if v.taken and not isinstance(v.block, int):\n texts()[\"release\"](v.block)\n\n def texts_of(self, exprs):\n \"\"\"Several texts in the order they are written. A variable's text is only looked at - unless working a later\n one out runs a call, which may give that variable another text and its block back to the heap: then it is a copy.\"\"\"\n out = []\n for i, e in enumerate(exprs):\n v = self.text(e)\n if not v.taken and any(has_call(x) for x in exprs[i + 1:]):\n v = self.own(v)\n out.append(v)\n return out\n\n def own(self, v):\n \"\"\"A value as something to keep: its own block as it is, a copy of a variable's.\"\"\"\n if v.taken:\n return v\n addr, block, length = texts()[\"copy\"](v.addr, v.block, v.length())\n return TextVal(addr, block, length, True)\n\n def store_text(self, st):\n \"\"\"A text into cell i of three arrays (a row's): worked out first - it may be made from what the cells hold -\n then the block they held goes back, then they take the text, a copy of one that was only looked at.\"\"\"\n v = self.own(self.text(st[\"value\"]))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n i = fresh(self.num(st[\"index\"]))\n held = self.array(st[\"block\"], st)\n texts()[\"release\"](as_var(held.get(i)))\n self.array(st[\"addr\"], st).set(i, addr)\n held.set(i, block)\n self.array(st[\"chars\"], st).set(i, length)\n\n def put(self, storage, e, kind):\n \"\"\"A value into a variable. A text is worked out first - it may be made from what the variable holds - and only then does the block the variable held go back.\"\"\"\n if kind != \"text\":\n storage.set(self.value(e, kind))\n return\n if not isinstance(storage, TextStorage):\n storage.set(self.text_id_of(e))\n return\n v = self.own(self.text(e))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n texts()[\"release\"](storage.cell(storage.block))\n storage.write(addr, block, length)\n\n def written(self, parts, st):\n \"\"\"The parts of a text in the scratch, and where it ends. Every value is worked out first, since working one\n out may use the scratch itself; then the parts are written a group at a time, a group being what cannot\n come to more than a text holds, so that checking where the writing has got to between groups keeps it inside.\"\"\"\n T = texts()\n items, temps = [], []\n for part in parts:\n k = part[\"kind\"]\n if k == \"text\":\n data = part[\"text\"].encode(\"utf-8\")[:TEXT_BYTES].decode(\"utf-8\", \"ignore\")\n items.append(([data], len(data.encode(\"utf-8\")), None))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n data = str(v if part.get(\"unsigned\") else signed(v))\n items.append(([data], len(data), None))\n elif part.get(\"unsigned\"):\n items.append(([fresh(v)], 10, None))\n else:\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n items.append(([ptr2s(sign), size], 11, None))\n elif k == \"name\":\n items.append(([PName(self.one_player(part[\"player\"], st))], 25, None))\n elif k == \"color\":\n items.append(([PColor(self.one_player(part[\"player\"], st))], 1, None))\n elif k == \"value\":\n v = self.text(part[\"text\"])\n if not v.taken and has_call(parts[parts.index(part) + 1:]):\n v = self.own(v)\n temps.append(v)\n items.append((None, TEXT_BYTES, v))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n pos = fresh(T[\"scratch\"])\n f_bwrite(pos, 0)\n group, room, first = [], 0, [True]\n\n def flush():\n if not group:\n return\n if first[0]:\n pos << f_dbstr_print(pos, *group)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << f_dbstr_print(pos, *group)\n EUDEndIf()\n first[0] = False\n del group[:]\n\n for args, size, value in items:\n if value is not None:\n flush()\n room = 0\n if first[0]:\n pos << T[\"append\"](pos, value.addr)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << T[\"append\"](pos, value.addr)\n EUDEndIf()\n first[0] = False\n continue\n if room + size > TEXT_BYTES + 1:\n flush()\n room = 0\n group.extend(args)\n room += size\n flush()\n return pos, temps\n\n def text(self, e):\n \"\"\"A text: where it is, its block, its length (TextVal).\"\"\"\n T = texts()\n k = e[\"kind\"]\n if k == \"text\":\n return TextVal(fresh(GetMapStringAddr(e[\"text\"]) if e[\"text\"] else T[\"empty\"]), 0, len(e[\"text\"]), True)\n if k == \"textVar\" and isinstance(self.var(e[\"id\"], e), TextStorage):\n return self.var(e[\"id\"], e).get()\n if k == \"textAt\":\n # Cells that were never given a text are 0: the empty one, as a variable's are.\n i = fresh(self.num(e[\"index\"]))\n addr = fresh(self.array(e[\"addr\"], e).get(i))\n if EUDIf()(addr == 0):\n addr << T[\"empty\"]\n EUDEndIf()\n return TextVal(addr, fresh(self.array(e[\"block\"], e).get(i)), fresh(self.array(e[\"chars\"], e).get(i)), False)\n if self.has_id(e):\n return TextVal(T[\"address\"](as_var(self.text_id_of(e))), 0, None, True)\n if k == \"template\":\n pos, temps = self.written(e[\"parts\"], e)\n addr, block, length = T[\"make\"](pos)\n self.used(*temps)\n return TextVal(addr, block, length, True)\n if k == \"textTernary\":\n addr, block, length = EUDVariable(), EUDVariable(), EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n v = self.own(self.text(e[\"whenTrue\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n if EUDElse()():\n v = self.own(self.text(e[\"whenFalse\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n EUDEndIf()\n return TextVal(addr, block, length, True)\n if k == \"textSlice\":\n of = self.text(e[\"of\"])\n start = self.num(e[\"start\"]) if e.get(\"start\") is not None else 0\n end = self.num(e[\"end\"]) if e.get(\"end\") is not None else 0x7FFFFFFF\n addr, block, length = T[\"slice\"](of.addr, start, end)\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textPad\":\n of, fill = self.texts_of([e[\"of\"], e[\"with\"]])\n width = self.num(e[\"width\"])\n addr, block, length = T[\"pad\"](of.addr, of.length(), width, fill.addr, 1 if e[\"side\"] == \"start\" else 0)\n self.used(of, fill)\n return TextVal(addr, block, length, True)\n if k == \"textRepeat\":\n of = self.text(e[\"of\"])\n addr, block, length = T[\"repeat\"](of.addr, self.num(e[\"count\"]))\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textCall\":\n self.call(e[\"call\"])\n result = e[\"call\"].get(\"result\")\n if result is None:\n return TextVal(fresh(T[\"empty\"]), 0, 0, True)\n return self.var(result[\"decl\"][\"id\"], e).take_out()\n raise Fail(\"trigscript: unknown text %r%s\" % (k, where(e)))\n\n def text_number(self, e):\n T = texts()\n k = e[\"kind\"]\n if k == \"textIndexOf\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n start = self.num(e[\"from\"]) if e.get(\"from\") is not None else 0\n out = T[\"find\"](of.addr, find.addr, start)\n self.used(of, find)\n return out\n of = self.text(e[\"of\"])\n if k == \"textLength\":\n n = of.length()\n out = n if isinstance(n, int) else fresh(n)\n self.used(of)\n return out\n out = T[\"code\"](of.addr, self.num(e[\"index\"]))\n self.used(of)\n return out\n\n def text_truth(self, e):\n \"\"\"A comparison of two texts, or a test of one, as 0 / 1.\"\"\"\n T = texts()\n t = fresh(0)\n if e[\"kind\"] == \"textTest\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n if e[\"test\"] == \"startsWith\":\n t << T[\"starts\"](of.addr, find.addr)\n elif e[\"test\"] == \"endsWith\":\n t << T[\"ends\"](of.addr, find.addr)\n else:\n if EUDIfNot()(T[\"find\"](of.addr, find.addr, 0) == 0xFFFFFFFF):\n t << 1\n EUDEndIf()\n self.used(of, find)\n return t\n op = e[\"op\"]\n if op in (\"==\", \"!=\") and self.has_id(e[\"left\"]) and self.has_id(e[\"right\"]):\n # Two texts of the table: the same text is the same id.\n a, b = self.text_id_of(e[\"left\"]), self.text_id_of(e[\"right\"])\n if isinstance(a, int) and isinstance(b, int):\n return 1 if (a == b) == (op == \"==\") else 0\n if EUDIf()(as_var(a) == b):\n t << 1\n EUDEndIf()\n else:\n a, b = self.texts_of([e[\"left\"], e[\"right\"]])\n # The difference of the first bytes that differ, as a number of 32 bits: 0, below zero, or above.\n d = f_strcmp(a.addr, b.addr)\n self.used(a, b)\n if op in (\"==\", \"!=\"):\n if EUDIf()(d == 0):\n t << 1\n EUDEndIf()\n else:\n below = (lambda: d >= SIGN)\n if op in (\"<\", \">=\"):\n if EUDIf()(below()):\n t << 1\n EUDEndIf()\n else: # \"<=\" and \">\": whether it is not above\n if EUDIf()(EUDOr(d == 0, below())):\n t << 1\n EUDEndIf()\n return flipped(t) if op in (\">=\", \">\") else t\n return flipped(t) if op == \"!=\" else t\n\n def text_loop(self, st, ctx):\n \"\"\"`for (const ch of s)`: the text walked once, a character a turn, each a made text of its own.\"\"\"\n T = texts()\n turn = self.declare(st[\"decl\"])\n over = self.own(self.text(st[\"of\"]))\n held = fresh(over.block)\n at = fresh(over.addr)\n head, done = Forward(), Forward()\n inner = dict(ctx)\n inner[\"break\"], inner[\"continue\"] = done, head\n leave = None\n if ctx.get(\"fn\") is not None:\n # A return inside the loop leaves through here, so that the text the loop holds goes back first.\n leave = Forward()\n inner[\"fn\"] = dict(ctx[\"fn\"], end=leave)\n head << NextTrigger()\n n = T[\"size\"](at)\n EUDJumpIf(n == 0, done)\n f_memcpy(T[\"scratch\"], at, n)\n f_bwrite(T[\"scratch\"] + n, 0)\n addr, block, length = T[\"make\"](T[\"scratch\"] + n)\n T[\"release\"](turn.cell(turn.block))\n turn.write(addr, block, length)\n at += n\n if self.straight(st[\"body\"], inner):\n EUDJump(head)\n done << NextTrigger()\n T[\"release\"](held)\n if leave is not None:\n after = Forward()\n EUDJump(after)\n leave << NextTrigger()\n T[\"release\"](held)\n EUDJump(ctx[\"fn\"][\"end\"])\n after << NextTrigger()\n\n def value(self, e, kind):\n \"\"\"An expression as what a variable of `kind` holds.\"\"\"\n return self.num(e) if kind == \"number\" else self.unit(e) if kind == \"unit\" else self.truth(e)\n\n def function(self, id_, node):\n \"\"\"A called function: its parameters' cells, its result's, and its one body — an EUDFunc of no\n arguments, since the cells are the program's own (a row a player, as any variable is).\"\"\"\n made = self.made.get(id_)\n if made is not None:\n return made\n f = self.functions.get(id_)\n if f is None:\n raise Fail(\"trigscript: unknown function %r%s\" % (id_, where(node)))\n params = [self.declare(d) for d in f[\"params\"]]\n kind = f[\"result\"][\"kind\"] if f.get(\"result\") else \"void\"\n result = self.declare(f[\"result\"][\"decl\"]) if f.get(\"result\") else None\n lowering = self\n if f.get(\"recursive\"):\n return self.recursive_function(id_, f, params, kind, result)\n\n @EUDFunc\n def body():\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n lowering.in_function += 1\n try:\n lowering.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n lowering.in_function -= 1\n end << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n return made\n\n def recursive_function(self, id_, f, params, kind, result):\n \"\"\"A function that calls itself. eudplib's EUDFunc keeps one return address and is not there to be called\n until its body is whole, so this one is triggers of our own: the body in a scope apart, ended by a trigger\n whose next-trigger field is the return address - a cell a call writes, and a frame keeps. It is on record\n before its body is lowered, so a call of it met in that body finds it.\"\"\"\n start, tail = Forward(), Forward()\n # The return address a second time, in a variable: a frame keeps it from there, since reading a cell of\n # the game's memory back costs some thirty triggers and writing a variable out costs two.\n back = EUDVariable()\n\n def body():\n after = Forward()\n DoActions([SetNextPtr(tail, after), back.SetNumber(after)])\n EUDJump(start)\n after << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n entry = {\"f\": f, \"tail\": tail, \"back\": back}\n PushTriggerScope()\n start << NextTrigger()\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n self.in_function += 1\n self.within.append(entry)\n try:\n self.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n self.within.pop()\n self.in_function -= 1\n end << NextTrigger()\n tail << RawTrigger()\n PopTriggerScope()\n return made\n\n def saved_cells(self, saves, node):\n \"\"\"What a frame keeps, as (read, write) pairs a cell: the variables, a unit three cells, an array's handle four.\"\"\"\n pairs = []\n for id_ in saves.get(\"vars\", []):\n s = self.vars.get(id_)\n if s is None:\n # Declared further down the body: it has no cell yet, and the declaration that makes it sets it.\n continue\n if isinstance(s, UnitStorage):\n for cell in (s.ptr, s.epd, s.uid):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, TextStorage):\n for cell in (s.addr, s.block, s.len):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, Storage):\n pairs.append(self.cell_pair(s.store, s.rowed))\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, node)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n pairs.append(self.cell_pair(a.handle[name], a.rowed))\n return pairs\n\n def saved_texts(self, saves):\n \"\"\"The made texts among what a frame keeps: each owns a block, which the frame keeps for it.\"\"\"\n return [s for s in (self.vars.get(id_) for id_ in saves.get(\"vars\", [])) if isinstance(s, TextStorage)]\n\n def cell_pair(self, cell, rowed):\n if rowed:\n return (lambda: cell[self.player_of()]), (lambda v: cell.__setitem__(self.player_of(), v))\n return (lambda: cell), (lambda v: cell << v)\n\n def keep(self, call):\n \"\"\"Before a call that may come back into the function it is in: that function's cells and its return\n address onto the stack, the handles of its arrays set to \"no block\". Past the depth the map allows the\n program says so and stops for good.\"\"\"\n saves = call[\"saves\"]\n st = stack()\n if not self.within:\n raise Fail(\"trigscript: a call keeps a frame outside a function that calls itself%s\" % where(call))\n mine = self.within[-1]\n if EUDIf()(st[\"depth\"] >= STACK_DEPTH):\n GetGlobalStringBuffer().print(\"\\x06TrigScript: stack overflow in %s, line %s - %d calls deep. The program has stopped.\" % (saves.get(\"within\", \"a function\"), (call.get(\"at\") or {}).get(\"line\", \"?\"), STACK_DEPTH))\n st[\"depth\"] << 0\n st[\"at\"] << st[\"base\"]\n self.set_state(DONE)\n EUDJump(self.frame_end)\n EUDEndIf()\n pairs = self.saved_cells(saves, call)\n at = st[\"at\"]\n f_dwwrite_epd(at, mine[\"back\"])\n at += 1\n for read, _ in pairs:\n f_dwwrite_epd(at, read())\n at += 1\n st[\"depth\"] += 1\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n a.put(name, 0)\n # A text the function holds keeps its block on the stack; the variable holds none for the length of the call,\n # so that the inner run's first text does not give the outer run's block back.\n held = self.saved_texts(saves)\n for s in held:\n s.write(0, 0, 0)\n return pairs, mine, held\n\n def bring_back(self, call, kept):\n pairs, mine, held = kept\n st = stack()\n # The block the inner run left in a text goes back to the heap before the text is the outer run's again.\n for s in held:\n texts()[\"release\"](s.cell(s.block))\n # The block the inner run left in a handle goes back to the heap before the handle is the outer run's again.\n for id_ in call[\"saves\"].get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n ptr = fresh(a.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n a.heap[\"give\"](ptr, a.field(\"k\"))\n EUDEndIf()\n at = st[\"at\"]\n for _, write in reversed(pairs):\n at -= 1\n write(f_dwread_epd(at))\n at -= 1\n mine[\"back\"] << f_dwread_epd(at)\n f_dwwrite_epd(EPD(mine[\"tail\"]) + 1, mine[\"back\"])\n st[\"depth\"] -= 1\n\n def call_function(self, call, result):\n body, params, returned = self.function(call[\"fn\"], call)\n if len(params) != len(call[\"params\"]):\n raise Fail(\"trigscript: %s takes %d values, not %d%s\" % (call[\"fn\"], len(params), len(call[\"params\"]), where(call)))\n # Every argument first, then the parameters: an argument may be a call of this same function. A variable's\n # own cell is copied when a later argument holds a call, which may be of a function that writes that variable.\n values = []\n for i, p in enumerate(call[\"params\"]):\n if p[\"decl\"][\"kind\"] == \"text\":\n # A text is a copy of its own by the time the parameter takes it: three numbers, read now.\n t = self.own(self.text(p[\"init\"]))\n values.append((fresh(t.addr), fresh(t.block), fresh(t.length())))\n continue\n v = self.value(p[\"init\"], p[\"decl\"][\"kind\"])\n later = any(has_call(q[\"init\"]) for q in call[\"params\"][i + 1:])\n if later and p[\"decl\"][\"kind\"] != \"unit\" and not isinstance(v, int):\n v = fresh(v)\n values.append(v)\n # A call that may come back here keeps this function's frame: after the arguments are worked out (they read\n # it), before the parameters are set (they may be this function's own).\n kept = None\n if call.get(\"saves\"):\n # Copies: an argument may be one of this function's own parameters, which the ones set before it write over.\n values = [v if isinstance(v, (int, tuple)) else UnitRef(v.ptr if isinstance(v.ptr, int) else fresh(v.ptr), v.epd if isinstance(v.epd, int) else fresh(v.epd), v.uid if v.uid is None or isinstance(v.uid, int) else fresh(v.uid)) if isinstance(v, UnitRef) else fresh(v) for v in values]\n kept = self.keep(call)\n for s, v in zip(params, values):\n if isinstance(v, tuple):\n # What the parameter held from the call before goes back; then it holds this call's text.\n texts()[\"release\"](s.cell(s.block))\n s.write(*v)\n else:\n s.set(v)\n body()\n if kept is not None:\n self.bring_back(call, kept)\n if result is None:\n return 0\n if isinstance(result, TextStorage):\n # The function's text moves to the call's own result, from where a textCall takes it.\n v = returned.take_out()\n texts()[\"release\"](result.cell(result.block))\n result.write(v.addr, v.block, v.length())\n return 0\n result.set(returned.get())\n return result.get()\n\n def call(self, call):\n result = self.declare(call[\"result\"][\"decl\"]) if call.get(\"result\") else None\n if call.get(\"fn\") is not None:\n return self.call_function(call, result)\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and call[\"result\"][\"kind\"] != \"text\":\n result.set(NO_UNIT if call[\"result\"][\"kind\"] == \"unit\" else 0)\n for p in call[\"params\"]:\n s = self.declare(p[\"decl\"])\n self.put(s, p[\"init\"], p[\"decl\"][\"kind\"])\n end = Forward()\n self.straight(call[\"body\"], {\"fn\": {\"result\": result, \"kind\": call[\"result\"][\"kind\"] if call.get(\"result\") else \"void\", \"end\": end}})\n end << NextTrigger()\n return result.get() if result is not None else 0\n\n # ── one frame ──\n def frame(self):\n self.frame_end = Forward()\n start = Forward()\n skip = Forward()\n # Still waiting: count down and leave.\n EUDJumpIfNot(as_var(self.get_wait()) >= 1, skip)\n self.set_wait(as_var(self.get_wait()) - 1)\n EUDJump(self.frame_end)\n skip << NextTrigger()\n # The body once, resumes recorded as sleeps are met; the jump table is filled in after.\n table = Forward()\n EUDJump(table)\n start << NextTrigger()\n try:\n self.block(self.p[\"body\"], {})\n except Leave:\n pass\n # The body ended: the program stops for good, as the classic backend's does.\n self.set_state(DONE)\n EUDJump(self.frame_end)\n table << NextTrigger()\n EUDJumpIf(as_var(self.get_state()) == DONE, self.frame_end)\n for index, resume in self.resumes:\n EUDJumpIf(as_var(self.get_state()) == index, resume)\n EUDJump(start)\n self.frame_end << NextTrigger()\n\n def run(self):\n if self.per_player:\n for p in loop_players(self.slots):\n self.player = p\n f_setcurpl(p)\n self.frame()\n self.player = None\n else:\n # As a trigger owned by that player would: not at all once the player has left.\n if EUDIf()(f_playerexist(self.slots[0])):\n f_setcurpl(self.slots[0])\n self.frame()\n EUDEndIf()\n\n\ndef flipped(t):\n \"\"\"1 for 0 and 0 for anything else, in a variable of its own.\"\"\"\n out = fresh(0)\n if EUDIf()(t == 0):\n out << 1\n EUDEndIf()\n return out\n\n\ndef has_call(node):\n \"\"\"Whether a call is anywhere in a piece of the IR.\"\"\"\n if isinstance(node, list):\n return any(has_call(x) for x in node)\n if not isinstance(node, dict):\n return False\n if node.get(\"kind\") in (\"call\", \"textCall\") and isinstance(node.get(\"call\"), dict):\n return True\n return any(has_call(v) for v in node.values() if isinstance(v, (dict, list)))\n\n\ndef string_of(value):\n \"\"\"An action's text or sound: the string itself (eudplib adds it to the map), or an index the script named.\"\"\"\n if isinstance(value, str):\n return EncodeString(value) if value else 0\n return int(value or 0)\n\n\ndef group_slots(group, node):\n \"\"\"The player slots a player number stands for: a slot is itself, All Players and a force their human and computer players.\"\"\"\n playing = lambda p: GetPlayerInfo(p).typestr in (\"Human\", \"Computer\")\n if 0 <= group < 8:\n return [group]\n if group == 17:\n return [p for p in range(8) if playing(p)]\n if 18 <= group <= 21:\n return [p for p in range(8) if playing(p) and GetPlayerInfo(p).force == group - 18]\n raise Fail(\"trigscript: expected players 1 to 8, All Players or a force%s\" % where(node))\n\n\ndef owner_slots(program):\n \"\"\"The player slots a program runs for, from its owners and the map's player settings: a slot is\n itself, All Players and a force are their human and computer players — who a trigger runs for.\"\"\"\n slots = []\n for o in program.get(\"owners\", [program.get(\"owner\", 0)]):\n for p in group_slots(int(o), program):\n if p not in slots:\n slots.append(p)\n if not slots:\n raise Fail(\"trigscript: no human or computer player of this map is among the program's owners%s\" % where(program))\n return slots\n\n\ndef loop_players(slots):\n \"\"\"Each of `slots` who is in the game, as an EUDVariable: EUDLoopPlayer, for a list of our own.\"\"\"\n start, end = min(slots), max(slots)\n v = EUDVariable()\n v << start\n if EUDWhile()(v <= end):\n for i in range(start, end):\n if i not in slots:\n EUDContinueIf(v == i)\n EUDContinueIfNot(f_playerexist(v))\n yield v\n EUDSetContinuePoint()\n v += 1\n EUDEndWhile()\n\n\nBITWISE = (\"&\", \"|\", \"^\", \"<<\", \">>\", \">>>\")\nFLIPPED = {\"<\": \">\", \"<=\": \">=\", \">\": \"<\", \">=\": \"<=\", \"==\": \"==\", \"!=\": \"!=\"}\n\n\ndef points(value, bits):\n \"\"\"Whole points as the game stores them, 256 to a point; `bits` is how many bits of points the cell holds.\"\"\"\n if isinstance(value, int):\n return min(value, (1 << bits) - 1) * 256\n return f_mul(saturate(value, bits), 256)\n\n\ndef uses_random(node):\n if isinstance(node, dict):\n return node.get(\"kind\") in (\"random\", \"randomInt\") or (node.get(\"kind\") == \"pick\" and node.get(\"by\") == \"random\") or any(uses_random(v) for v in node.values())\n return isinstance(node, list) and any(uses_random(v) for v in node)\n\n\ndef distance(a, b):\n \"\"\"|a - b| of two places on the map, both from 0 up.\"\"\"\n d = fresh(a)\n d -= b\n if EUDIf()(d >= SIGN):\n d << 0 - d\n EUDEndIf()\n return d\n\n\ndef signed(v):\n \"\"\"The 32 bits as a signed number.\"\"\"\n v &= U32\n return v - (1 << 32) if v >= SIGN else v\n\n\ndef always(truth):\n \"\"\"A condition that is known when the map is built.\"\"\"\n return EUDVariable(1 if truth else 0) >= 1 # initial: a constant, never written\n\n\ndef relation(av, op, b):\n \"\"\"`av op b` between a variable and a variable or an int, both read from 0 up.\"\"\"\n if op == \"==\":\n return av == b\n if op == \"!=\":\n return av != b\n if op == \"<\":\n return av < b\n if op == \"<=\":\n return av <= b\n if op == \">\":\n return av > b\n return av >= b\n\n\ndef compare(a, op, b):\n return {\"==\": a == b, \"!=\": a != b, \"<\": a < b, \"<=\": a <= b, \">\": a > b, \">=\": a >= b}[op]\n\n\ndef condition(r):\n return Condition(r[\"location\"], r[\"player\"], r[\"amount\"], r[\"unitId\"], r[\"comparison\"], r[\"type\"], r[\"resource\"], r[\"flags\"], eudx=r.get(\"mask\", 0) or 0)\n\n\nclass Input:\n \"\"\"What the players do, as it reaches every computer: the cells chatEvent and MSQC write, by the\n names the editor put in their settings (compiler/input.ts, INPUT_NAMES), and the reads of them.\"\"\"\n\n MAX_NUMBER = 0xFFFFF\n\n def __init__(self, plan):\n self.plan = plan or {}\n self.keys = list(self.plan.get(\"keys\", []))\n self.buttons = list(self.plan.get(\"buttons\", []))\n self.chats = list(self.plan.get(\"chats\", []))\n self.mouse_base = self.plan.get(\"mouseBase\")\n # A person can sit in the map's human slots, which is where MSQC counts the mouse locations from.\n self.humans = [p for p in range(8) if GetPlayerInfo(p).typestr == \"Human\"]\n if plan and not self.humans:\n raise Fail(\"trigscript: a program reads keys, clicks, the mouse or chat, and the map has no human player to give any\")\n self.key_cells = [self.register(\"tsin_key%d\" % i, EUDArray(12)) for i in range(len(self.keys))]\n self.button_cells = [self.register(\"tsin_button%d\" % i, EUDArray(12)) for i in range(len(self.buttons))]\n self.captures = max([len(c[\"captures\"]) for c in self.chats] or [0])\n if self.chats:\n # Local: what chatEvent found on this computer, and what the patterns made of it.\n self.heard = self.register(\"tsin_heard\", EUDVariable())\n self.pointer = self.register(\"tsin_pointer\", EUDVariable())\n self.length = self.register(\"tsin_length\", EUDVariable())\n self.register(\"tsin_pattern\", EUDVariable())\n self.chat_local = self.register(\"tsin_chat\", EUDVariable())\n self.capture_local = [self.register(\"tsin_capture%d\" % i, EUDVariable()) for i in range(self.captures)]\n # Everyone's: what MSQC delivered this frame, by player; 0xFFFFFFFF on a frame with nothing.\n self.chat_in = self.register(\"tsin_chat_in\", EUDArray(12))\n self.capture_in = [self.register(\"tsin_capture%d_in\" % i, EUDArray(12)) for i in range(self.captures)]\n self.unit_table = None\n\n @staticmethod\n def register(name, cell):\n EUDRegisterObjectToNamespace(name, cell)\n return cell\n\n # ── reads ──\n def read(self, i, low, node):\n source = i.get(\"source\")\n p = low.one_player(i[\"player\"], node)\n if source == \"key\":\n return self.cell(self.key_cells, self.keys, i[\"key\"], node)[p]\n if source == \"click\":\n return self.cell(self.button_cells, self.buttons, i[\"button\"], node)[p]\n if source == \"mouse\":\n return self.mouse(p, 0 if i[\"axis\"] == \"x\" else 1, node)\n if source == \"chat\":\n number = self.chat_number(i[\"pattern\"], node)\n out = fresh(0)\n if EUDIf()(self.chat_in[p] == number):\n out << (1 if i.get(\"capture\") is None else self.capture_in[int(i[\"capture\"])][p])\n EUDEndIf()\n return out\n raise Fail(\"trigscript: unknown input %r%s\" % (source, where(node)))\n\n @staticmethod\n def cell(cells, names, name, node):\n if name not in names:\n raise Fail(\"trigscript: the IR's input plan has no %r%s\" % (name, where(node)))\n return cells[names.index(name)]\n\n def chat_number(self, pattern, node):\n for index, c in enumerate(self.chats):\n if c[\"pattern\"] == pattern:\n return index + 1\n raise Fail(\"trigscript: the IR's input plan has no pattern %r%s\" % (pattern, where(node)))\n\n def mouse(self, p, axis, node):\n \"\"\"MSQC keeps the mouse of the map's first human slot in location `mouseBase`, the next slot's in the next.\"\"\"\n if self.mouse_base is None:\n raise Fail(\"trigscript: the IR's input plan keeps no mouse%s\" % where(node))\n first = min(self.humans)\n cell = lambda h: EPD(MRGN + (int(self.mouse_base) - 1 + h - first) * 20) + axis\n if isinstance(p, int):\n return f_dwread_epd(cell(p)) if p in self.humans else 0\n out = fresh(0)\n for h in self.humans:\n if EUDIf()(p == h):\n out << f_dwread_epd(cell(h))\n EUDEndIf()\n return out\n\n # ── the typed line, on the computer it was typed on ──\n @staticmethod\n def hash_of(data):\n \"\"\"What `hashed` makes of the same bytes: capitals A to Z as small letters, h = h × 31 + byte.\"\"\"\n h = 0\n for b in data:\n if 65 <= b <= 90:\n b += 32\n h = (h * 31 + b) & U32\n return h\n\n def hashed(self, pos, stop_at_space):\n \"\"\"The hash of the line from `pos` to its end or, with `stop_at_space`, to the next space; `pos` moves past it.\"\"\"\n h = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = fresh(f_bread(self.pointer + pos))\n if stop_at_space:\n EUDBreakIf(ch == 32)\n if EUDIf()([ch >= 65, ch <= 90]):\n ch += 32\n EUDEndIf()\n h << f_mul(h, 31) + ch\n pos += 1\n EUDEndWhile()\n return h\n\n def units(self):\n \"\"\"Unit names by hash, in hash order, for a search by halves: two arrays side by side.\"\"\"\n if self.unit_table is None:\n by_hash = {}\n for name, unit in self.plan.get(\"unitNames\", []):\n by_hash.setdefault(self.hash_of(name.encode(\"utf-8\")), int(unit))\n ordered = sorted(by_hash.items())\n self.unit_table = (EUDArray([h for h, _ in ordered] or [0]), EUDArray([u for _, u in ordered] or [0]), len(ordered))\n return self.unit_table\n\n def capture(self, c, pos, fail):\n \"\"\"One value out of the line at `pos`, or a jump to `fail`.\"\"\"\n value = fresh(0)\n if c[\"kind\"] == \"number\":\n digits = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = f_bread(self.pointer + pos)\n EUDBreakIf(ch <= 47)\n EUDBreakIf(ch >= 58)\n value << f_mul(value, 10) + ch - 48\n if EUDIf()(value >= self.MAX_NUMBER + 1):\n value << self.MAX_NUMBER\n EUDEndIf()\n pos += 1\n digits += 1\n EUDEndWhile()\n EUDJumpIf(digits == 0, fail)\n elif c[\"kind\"] == \"word\":\n h = self.hashed(pos, True)\n found = fresh(0)\n for index, word in enumerate(c[\"words\"]):\n if EUDIf()(h == self.hash_of(word.encode(\"utf-8\"))):\n value << index\n found << 1\n EUDEndIf()\n EUDJumpIf(found == 0, fail)\n elif c[\"kind\"] == \"unit\":\n h = self.hashed(pos, False)\n hashes, units, count = self.units()\n lo, hi = fresh(0), fresh(count)\n if EUDWhile()(lo < hi):\n mid = f_div(lo + hi, 2)[0]\n if EUDIf()(hashes[mid] < h):\n lo << mid + 1\n if EUDElse()():\n hi << mid\n EUDEndIf()\n EUDEndWhile()\n EUDJumpIf(lo >= count, fail)\n EUDJumpIfNot(hashes[lo] == h, fail)\n value << units[lo]\n else:\n raise Fail(\"trigscript: unknown chat capture %r\" % (c[\"kind\"],))\n return value\n\n def match_line(self):\n \"\"\"The line the local player typed against every pattern, in order: the first that fits the whole\n line gives its number and values to the cells MSQC sends from. Nothing fits: nothing is sent.\"\"\"\n DoActions([self.chat_local.SetNumber(0)] + [c.SetNumber(0) for c in self.capture_local])\n if EUDIf()(self.heard >= 1):\n done = Forward()\n for index, chat in enumerate(self.chats):\n fail = Forward()\n pos = fresh(0)\n values = []\n for seg in chat[\"segments\"]:\n if isinstance(seg, str):\n data = seg.encode(\"utf-8\")\n EUDJumpIf(pos + len(data) >= self.length + 1, fail)\n EUDJumpIfNot(f_memcmp(self.pointer + pos, Db(data + b\"\\0\"), len(data)) == 0, fail)\n pos += len(data)\n else:\n values.append(self.capture(chat[\"captures\"][int(seg)], pos, fail))\n EUDJumpIfNot(pos == self.length, fail)\n DoActions(self.chat_local.SetNumber(index + 1))\n for cell, value in zip(self.capture_local, values):\n cell << value\n EUDJump(done)\n fail << NextTrigger()\n done << NextTrigger()\n EUDEndIf()\n\n\nINPUT = Input(IR.get(\"input\"))\nPROGRAMS = [Lowering(p) for p in IR.get(\"programs\", [])]\n\n\ndef onPluginStart():\n # eudplib's generator starts from the same seed in every game; the game's own randomness (a switch randomized) seeds it.\n if uses_random(IR.get(\"programs\", [])):\n f_randomize()\n\n\ndef beforeTriggerExec():\n # After chatEvent has looked for a typed line, before MSQC sends what it was.\n if INPUT.chats:\n INPUT.match_line()\n\n\ndef afterTriggerExec():\n for prog in PROGRAMS:\n prog.run()\n"; diff --git a/compiler/hoist.ts b/compiler/hoist.ts index 3f47db5..388ff9f 100644 --- a/compiler/hoist.ts +++ b/compiler/hoist.ts @@ -39,10 +39,15 @@ export interface PlanError { node: TS.Node; message: string } export type PlanItem = | { kind: "const"; decl: TS.VariableDeclaration } | { kind: "hoist"; index: number; expr: TS.Expression } - | { kind: "block"; items: PlanItem[] }; + | { kind: "block"; items: PlanItem[] } + /** A declaration the build-time parts after it name, made where it stands: an `enum` of the body. */ + | { kind: "statement"; node: TS.Statement }; export interface ProgramPlan { - arrow: TS.ArrowFunction | TS.FunctionExpression; + /** The program's or the game function's arrow; unset for a class's plan (`planClass`). */ + arrow?: TS.ArrowFunction | TS.FunctionExpression; + /** What the plan covers: the arrow's body, or the class. */ + root: TS.Node; body: TS.Block; /** A `game()` function with an expression body (`game((a: number) => a + 1)`): what it returns. */ expression?: TS.Expression; @@ -75,6 +80,11 @@ export function isGameCall(checker: TS.TypeChecker, call: TS.CallExpression): bo export function declarationOf(ts: typeof TS, checker: TS.TypeChecker, id: TS.Identifier): TS.Declaration | undefined { let sym = ts.isShorthandPropertyAssignment(id.parent) && id.parent.name === id ? checker.getShorthandAssignmentValueSymbol(id.parent) : checker.getSymbolAtLocation(id); if (sym && sym.flags & ts.SymbolFlags.Alias) sym = checker.getAliasedSymbol(sym); + // A function with overloads: the declaration with the body is the one that runs. + if (sym && sym.flags & ts.SymbolFlags.Function && (sym.declarations?.length ?? 0) > 1) { + const body = sym.declarations!.find((d) => ts.isFunctionDeclaration(d) && !!d.body); + if (body) return body; + } return sym?.valueDeclaration ?? sym?.declarations?.[0]; } @@ -84,6 +94,10 @@ export function libraryName(ts: typeof TS, checker: TS.TypeChecker, id: TS.Ident if (sym && sym.flags & ts.SymbolFlags.Alias) sym = checker.getAliasedSymbol(sym); const decl = sym?.valueDeclaration ?? sym?.declarations?.[0]; if (!decl || decl.getSourceFile().fileName !== DECLARATIONS_FILE) return null; + // (A member of one of the library's interfaces — a unit's `order()` method, a field of a record it declares — is that + // type's, not the library's `order()`: `first(…)?.order("move", …)` is the unit's. A member of one of its objects — + // `on.died`, `local.selected`, `memory.game` — is the library's.) + if ((ts.isMethodSignature(decl) || ts.isPropertySignature(decl)) && ts.isInterfaceDeclaration(decl.parent)) return null; return sym!.name; } @@ -98,6 +112,12 @@ export function libraryCallName(ts: typeof TS, checker: TS.TypeChecker, call: TS return id ? libraryName(ts, checker, id) : null; } +/** Whether a call is one of `on`'s — `on.died(…)` — with `on` the library's own object. */ +export function isOnCall(ts: typeof TS, checker: TS.TypeChecker, call: TS.CallExpression): boolean { + const callee = call.expression; + return ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && libraryName(ts, checker, callee.expression) === "on"; +} + /** Climb from a binding element to the declaration that owns it. */ function owningDeclaration(ts: typeof TS, decl: TS.Declaration): TS.Node { let d: TS.Node = decl; @@ -105,10 +125,50 @@ function owningDeclaration(ts: typeof TS, decl: TS.Declaration): TS.Node { return d; } +/** What `on.…` listens for (TrigScript 4, B2): `on.created(filter, handler)` and the rest. */ +export const ON_EVENTS = ["created", "died", "killed", "order", "morphed", "chat", "key"] as const; + /** The library calls that must not appear inside a program body: they would run at build time, silently. */ const FORBIDDEN_INSIDE = new Set(["trigger", "program", "hyperTriggers", "game"]); +/** + * JavaScript's clock and dice: `Date.now()`, `new Date()`, `Math.random()`, `performance.now()`. Worked out when the script + * is built they would be the computer's time and one number, the same in every game; inside a program they are refused, + * with the game's own in their place. At the top of a file they are the build's, as everything there is. + */ +export function clockCall(ts: typeof TS, checker: TS.TypeChecker, n: TS.Node): string | null { + const isLib = (id: TS.Expression) => { + const sf = checker.getSymbolAtLocation(id)?.declarations?.[0]?.getSourceFile(); + return !!sf && (sf.hasNoDefaultLib || /(^|\/)lib(\.[^/]*)?\.d\.ts$/.test(sf.fileName)); + }; + if (ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === "Date" && !n.arguments?.length && isLib(n.expression)) return "new Date()"; + if (!ts.isCallExpression(n) || !ts.isPropertyAccessExpression(n.expression) || !ts.isIdentifier(n.expression.expression)) return null; + const name = `${n.expression.expression.text}.${n.expression.name.text}`; + return ["Date.now", "Math.random", "performance.now"].includes(name) && isLib(n.expression.expression) ? `${name}()` : null; +} + +/** What a program has in place of one of JavaScript's clock and dice. */ +export function clockMessage(call: string): string { + return call === "Math.random()" + ? "Math.random() would be drawn once, when the map is built, and be the same number in every game. In a program use random(n), a whole number from 0 to n − 1 the game picks, or random() for a coin toss." + : `${call} is the time on the computer that builds the map, not the game's. In a program use elapsed(), game seconds since the start, or memory.game.frame, frames.`; +} + /** The library calls the game answers: never hoisted, the structured compiler lowers them. */ -const GAME_CALLS = new Set(["random", "sleep", "rose", "once", "shared"]); +const GAME_CALLS = new Set(["random", "sleep", "nextFrame", "until", "sync", "select", "start", "rose", "once", "shared", "lines", "centerLocation", ...ON_EVENTS]); +const EVENT_NAMES: ReadonlySet = new Set(ON_EVENTS); +/** + * Whether a library name is one of `GAME_CALLS`. An event's name is the game's as `on.order(…)` or a unit's + * `u.order(…)`, not as the action of the same name, `order(…)`, a function of the library's own whose arguments the + * script works out as any action's. + */ +function answeredByGame(ts: typeof TS, checker: TS.TypeChecker, id: TS.Identifier, lib: string): boolean { + if (!GAME_CALLS.has(lib)) return false; + if (!EVENT_NAMES.has(lib)) return true; + let sym = checker.getSymbolAtLocation(id); + if (sym && sym.flags & ts.SymbolFlags.Alias) sym = checker.getAliasedSymbol(sym); + const decl = sym?.valueDeclaration ?? sym?.declarations?.[0]; + return !(decl && ts.isFunctionDeclaration(decl)); +} /** * Calls that read the game — `minerals(P1)`, and a comparing condition without its comparison * (`deaths(P1, unit)`) — or show text a program fills in (`print`). The call is never a @@ -119,6 +179,8 @@ const GAME_CALLS = new Set(["random", "sleep", "rose", "once", "shared"]); */ const READ_CALLS = new Set([...READER_NAMES, ...UNIT_CALL_NAMES, ...INPUT_CALL_NAMES, "print"]); const isReadCall = (lib: string | null, args: number) => !!lib && (READ_CALLS.has(lib) || READ_ARITY.get(lib) === args); +/** The library objects whose fields are cells of the game (`memory.vision.countdown`): a field is a value, but nothing around it is the script's. */ +const GAME_CELL_ROOTS = new Set(["memory", "local"]); /** * The methods that take a function — `xs.map((x) => x * 2)`, `unitsOf(P1).forEach((u) => u.kill())`. Where such a call * is the program's, the function written in it is part of the program as a function declared in the body is: its @@ -135,6 +197,31 @@ export function isCallbackArgument(ts: typeof TS, node: TS.Node): node is TS.Arr return ts.isCallExpression(call) && call.arguments.includes(at as TS.Expression) && ts.isPropertyAccessExpression(call.expression) && CALLBACK_METHODS.has(call.expression.name.text); } +/** + * A function that is part of what a program waits for: the one `until(…)` asks every frame, the one given to `.then(…)`, + * an async arrow called where it is written — `(async () => { … })()` in a `Promise.all` — and the one start(…) runs as a task. Like a callback, its body + * is the program's and is walked, not run when the script is built. + */ +export function isTaskFunction(ts: typeof TS, checker: TS.TypeChecker, node: TS.Node): node is TS.ArrowFunction | TS.FunctionExpression { + if (!ts.isArrowFunction(node) && !ts.isFunctionExpression(node)) return false; + let at: TS.Node = node; + while (ts.isParenthesizedExpression(at.parent)) at = at.parent; + const call = at.parent; + if (!ts.isCallExpression(call)) return false; + if (call.expression === at) return !!node.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword); + if (!call.arguments.includes(at as TS.Expression)) return false; + if (ts.isPropertyAccessExpression(call.expression) && call.expression.name.text === "then") return true; + const lib = libraryCallName(ts, checker, call); + if (lib && (ON_EVENTS as readonly string[]).includes(lib) && isOnCall(ts, checker, call)) return call.arguments.includes(at as TS.Expression); + return lib === "until" || (lib === "start" && call.arguments[0] === at) || (lib === "lines" && call.arguments[1] === at); +} + +/** Whether a node is JSX or has JSX inside it: a text the program puts together, never one worked out when the script is built. */ +export function hasJsx(ts: typeof TS, node: TS.Node): boolean { + if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) return true; + return !!ts.forEachChild(node, (c) => hasJsx(ts, c) || undefined); +} + export function planProgram(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunction | TS.FunctionExpression, options: PlanOptions = {}): ProgramPlan { // A list a callback writes to (`xs.forEach((x) => out.push(x))`) is the program's, and that is found out after its // `const` was planned as a value of the script: the plan is made again knowing it. The set only grows, so this ends. @@ -146,11 +233,234 @@ export function planProgram(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.Ar } } -function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunction | TS.FunctionExpression, options: PlanOptions, forced: Set): ProgramPlan { - const plan: ProgramPlan = { arrow, body: ts.isBlock(arrow.body) ? arrow.body : undefined as unknown as TS.Block, hoisted: [], index: new Map(), game: new Set(), consts: new Map(), constList: [], tree: [], errors: [], grows: new Set() }; +/** + * A class declared at the top of a script's file, planned as a game function is: it is ordinary JavaScript when the script + * is built, and the program's own wherever a program makes an instance of it — its parameters and its variables the + * game's, what its fields' first values and its bodies compute when the script is built hoisted. Its errors are reported + * only where a program uses it. + */ +export function planClass(ts: typeof TS, checker: TS.TypeChecker, cls: TS.ClassDeclaration): ProgramPlan { + const forced = new Set(); + for (;;) { + const before = forced.size; + const plan = planOnce(ts, checker, cls, { parameters: true }, forced); + if (forced.size === before) return plan; + } +} + +/** + * `on.died(…)` written as a statement at the top of a script's file (TrigScript 4, B2): it listens from the start of the + * game, as a program of its own whose body is that one statement. + */ +export function isTopListener(ts: typeof TS, checker: TS.TypeChecker, node: TS.Node): node is TS.ExpressionStatement { + return ts.isExpressionStatement(node) && ts.isSourceFile(node.parent) && !node.parent.isDeclarationFile && ts.isCallExpression(node.expression) && isOnCall(ts, checker, node.expression); +} + +/** The plan for a listener at the top of a file (`isTopListener`), made as a program's is. */ +export function planListener(ts: typeof TS, checker: TS.TypeChecker, stmt: TS.ExpressionStatement): ProgramPlan { + const forced = new Set(); + for (;;) { + const before = forced.size; + const plan = planOnce(ts, checker, stmt, {}, forced); + if (forced.size === before) return plan; + } +} + +/** + * Whether a function is one of the script's own, declared at the top of one of its files (TrigScript 4): a program may + * call it with its own values, as it calls a `game()` function, and use it as a JSX component. Called with values the + * script knows, it still runs when the script is built. + */ +/** + * Whether a statement is at the top of one of the script's files: in the file itself, or in a `namespace` there (as deep + * as namespaces go), which is the file's too, under a name. + */ +export function atTop(ts: typeof TS, node: TS.Node): boolean { + let p = node.parent; + while (p && ts.isModuleBlock(p)) { + let ns: TS.Node = p.parent; + if ((ns as TS.ModuleDeclaration).modifiers?.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword)) return false; + // `namespace A.B { … }` is B inside A. + while (ns.parent && ts.isModuleDeclaration(ns.parent)) ns = ns.parent; + p = ns.parent; + } + return !!p && ts.isSourceFile(p) && !p.isDeclarationFile; +} + +export function isScriptFunction(ts: typeof TS, decl: TS.Node): decl is TS.FunctionDeclaration { + // A generator and an async function too (TrigScript 4): a library's `walkTo(u, at)` waits, `waves()` yields. + return ts.isFunctionDeclaration(decl) && !!decl.name && !!decl.body && atTop(ts, decl) && !decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword); +} + +/** `const Title = (props: …) => …` at the top of a file: the arrow, a function of the script's own as a declared one is. */ +export function scriptArrow(ts: typeof TS, decl: TS.Node): TS.ArrowFunction | TS.FunctionExpression | undefined { + if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name) || !decl.initializer) return undefined; + const list = decl.parent; + if (!ts.isVariableDeclarationList(list) || !(list.flags & ts.NodeFlags.Const)) return undefined; + const stmt = list.parent; + if (!ts.isVariableStatement(stmt) || !atTop(ts, stmt)) return undefined; + let init = decl.initializer; + while (ts.isParenthesizedExpression(init) || ts.isAsExpression(init) || ts.isSatisfiesExpression(init)) init = init.expression; + if (!ts.isArrowFunction(init) && !ts.isFunctionExpression(init)) return undefined; + if (init.asteriskToken) return undefined; + return init; +} + +/** The plan of a function at the top of a file (`isScriptFunction`, `scriptArrow`), made as a `game()` function's is. */ +export function planFunction(ts: typeof TS, checker: TS.TypeChecker, fn: TS.FunctionDeclaration | TS.ArrowFunction | TS.FunctionExpression): ProgramPlan { + return planProgram(ts, checker, fn as unknown as TS.FunctionExpression, { parameters: true }); +} + +const usesGameMemo = new WeakMap(); + +/** + * Whether a function of the script's own does what only the game can — draws a random number, reads the game, waits, + * prints — itself or through another of the script's functions: called with values the script knows, it still runs in + * the game, never when the script is built. + */ +export function usesGame(ts: typeof TS, checker: TS.TypeChecker, fn: TS.Node, seen = new Set()): boolean { + const hit = usesGameMemo.get(fn); + if (hit !== undefined) return hit; + if (seen.has(fn)) return false; + seen.add(fn); + let found = false; + const look = (n: TS.Node): void => { + if (found) return; + if (clockCall(ts, checker, n)) { found = true; return; } + if (ts.isIdentifier(n)) { + const lib = libraryName(ts, checker, n); + if (lib && (answeredByGame(ts, checker, n, lib) || READ_CALLS.has(lib) || READ_ARITY.has(lib) || GAME_CELL_ROOTS.has(lib))) { found = true; return; } + const decl = declarationOf(ts, checker, n); + const other = decl && decl !== fn && (isScriptFunction(ts, decl) ? decl : scriptArrow(ts, decl) ?? (classMember(ts, decl) ? decl : undefined)); + if (other && usesGame(ts, checker, other, seen)) { found = true; return; } + return; + } + // `new E()`: its fields' first values and its constructor run where the instance is made, its base class's before them. + if (ts.isNewExpression(n) && ts.isIdentifier(n.expression)) { + const cls = declarationOf(ts, checker, n.expression); + if (cls && isScriptClass(ts, cls) && usesGame(ts, checker, cls, seen)) { found = true; return; } + } + ts.forEachChild(n, look); + }; + if (ts.isClassDeclaration(fn)) { + for (const m of fn.members) { + if ((ts.isPropertyDeclaration(m) && m.initializer && !m.modifiers?.some((x) => x.kind === ts.SyntaxKind.StaticKeyword)) || ts.isConstructorDeclaration(m)) look(m); + } + const base = fn.heritageClauses?.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]?.expression; + const baseDecl = base && ts.isIdentifier(base) ? declarationOf(ts, checker, base) : undefined; + if (!found && baseDecl && isScriptClass(ts, baseDecl)) found = usesGame(ts, checker, baseDecl, seen); + } else { + const body = (fn as TS.FunctionLikeDeclaration).body; + if (body) look(body); + } + usesGameMemo.set(fn, found); + return found; +} + +const keepsStateMemo = new WeakMap(); + +/** + * Whether a function of the script's own changes what outlives a call of it: a variable declared outside the function + * that writes it (`count += 1` of a `let` at the top of a file; `n += 1` in the function `counter()` gives back, whose + * `n` is counter's), or a field of `this` outside a constructor — itself, in a function written inside it, or through + * another of the script's functions. Worked out when the script is built, a call of it in a program would change that + * once, where the call is written: a loop printing `next()` showed 1, 1, 1 for 1, 2, 3. So such a call is the program's, + * and the program's own checks say what the game cannot change. A function handed to `forEach` or `map` writing the + * variables of the function it is written in is that function's own business; a cache filled by a method + * (`films.set(…)`) is not an assignment, and gives the same answer each time. + */ +export function keepsState(ts: typeof TS, checker: TS.TypeChecker, fn: TS.Node, seen = new Set()): boolean { + const hit = keepsStateMemo.get(fn); + if (hit !== undefined) return hit; + if (seen.has(fn)) return false; + seen.add(fn); + let found = false; + const isFunction = (n: TS.Node): n is TS.FunctionLikeDeclaration => ts.isFunctionLike(n) && !!(n as TS.FunctionLikeDeclaration).body; + /** The function whose variables a write at `at` may change without outliving the call: through the callbacks it is written in. */ + const owner = (at: TS.Node): TS.FunctionLikeDeclaration | undefined => { + let f: TS.Node | undefined = at.parent; + while (f && !isFunction(f)) f = f.parent; + while (f && isCallbackArgument(ts, f)) { f = f.parent; while (f && !isFunction(f)) f = f.parent; } + return f as TS.FunctionLikeDeclaration | undefined; + }; + const strip = (e: TS.Expression): TS.Expression => { + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression; + return e; + }; + const outside = (target: TS.Expression, at: TS.Node): boolean => { + let t = strip(target); + if (ts.isArrayLiteralExpression(t)) return t.elements.some((x) => outside(ts.isSpreadElement(x) ? x.expression : x, at)); + if (ts.isObjectLiteralExpression(t)) { + return t.properties.some((p) => ts.isPropertyAssignment(p) ? outside(p.initializer, at) : ts.isShorthandPropertyAssignment(p) ? outside(p.name, at) : ts.isSpreadAssignment(p) && outside(p.expression, at)); + } + if (ts.isBinaryExpression(t) && t.operatorToken.kind === ts.SyntaxKind.EqualsToken) return outside(t.left, at); + while (ts.isPropertyAccessExpression(t) || ts.isElementAccessExpression(t)) t = strip(t.expression); + const f = owner(at); + if (t.kind === ts.SyntaxKind.ThisKeyword) { + // An arrow's `this` is the function's it is written in. + let g: TS.Node | undefined = f; + while (g && ts.isArrowFunction(g)) { g = g.parent; while (g && !isFunction(g)) g = g.parent; } + return !g || !ts.isConstructorDeclaration(g); + } + if (!ts.isIdentifier(t)) return false; + const decl = declarationOf(ts, checker, t); + if (!decl || decl.getSourceFile() !== at.getSourceFile()) return !!decl; + return !f || decl.pos < f.pos || decl.end > f.end; + }; + const other = (decl: TS.Node | undefined): boolean => + !!decl && decl !== fn && isFunction(decl) && !decl.getSourceFile().isDeclarationFile && keepsState(ts, checker, decl, seen); + const look = (n: TS.Node): void => { + if (found) return; + if (ts.isBinaryExpression(n) && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && n.operatorToken.kind <= ts.SyntaxKind.LastAssignment && outside(n.left, n)) { found = true; return; } + if ((ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken) && outside(n.operand, n)) { found = true; return; } + if (ts.isDeleteExpression(n) && outside(n.expression, n)) { found = true; return; } + if ((ts.isCallExpression(n) || ts.isNewExpression(n)) && other(checker.getResolvedSignature(n)?.declaration)) { found = true; return; } + if (ts.isIdentifier(n)) { + const decl = declarationOf(ts, checker, n); + if (decl && (isScriptFunction(ts, decl) || scriptArrow(ts, decl)) && other(isScriptFunction(ts, decl) ? decl : scriptArrow(ts, decl))) { found = true; return; } + return; + } + ts.forEachChild(n, look); + }; + const body = (fn as TS.FunctionLikeDeclaration).body; + if (body) look(body); + keepsStateMemo.set(fn, found); + return found; +} + +/** A method, an accessor or a constructor of one of the script's classes: what a call through an instance runs. */ +function classMember(ts: typeof TS, decl: TS.Node): boolean { + return (ts.isMethodDeclaration(decl) || ts.isGetAccessorDeclaration(decl) || ts.isSetAccessorDeclaration(decl) || ts.isConstructorDeclaration(decl)) && !!decl.body && isScriptClass(ts, decl.parent); +} + +/** Whether a class is one of the script's own, declared at the top of one of its files: a program may make instances of it. */ +export function isScriptClass(ts: typeof TS, decl: TS.Node): decl is TS.ClassDeclaration { + return ts.isClassDeclaration(decl) && !!decl.name && atTop(ts, decl) && !decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword); +} + +function planOnce(ts: typeof TS, checker: TS.TypeChecker, of: TS.ArrowFunction | TS.FunctionExpression | TS.ClassDeclaration | TS.ExpressionStatement, options: PlanOptions, forced: Set): ProgramPlan { + if (ts.isClassDeclaration(of)) return planClassOnce(ts, checker, of, forced); + if (ts.isExpressionStatement(of)) { + // A block that holds the statement where it stands: its range the statement's, so that it has a place in the file. + const body = ts.setTextRange(ts.factory.createBlock([of]), of); + (body as { parent: TS.Node }).parent = of.parent; + const plan: ProgramPlan = { root: of, body, hoisted: [], index: new Map(), game: new Set(), consts: new Map(), constList: [], tree: [], errors: [], grows: new Set() }; + return planWithin(ts, checker, plan, undefined, options, forced); + } + const arrow = of; + const plan: ProgramPlan = { arrow, root: arrow.body, body: ts.isBlock(arrow.body) ? arrow.body : undefined as unknown as TS.Block, hoisted: [], index: new Map(), game: new Set(), consts: new Map(), constList: [], tree: [], errors: [], grows: new Set() }; + return planWithin(ts, checker, plan, arrow, options, forced); +} + +function planClassOnce(ts: typeof TS, checker: TS.TypeChecker, cls: TS.ClassDeclaration, forced: Set): ProgramPlan { + const plan: ProgramPlan = { root: cls, body: ts.factory.createBlock([]), hoisted: [], index: new Map(), game: new Set(), consts: new Map(), constList: [], tree: [], errors: [], grows: new Set() }; + return planWithin(ts, checker, plan, undefined, { parameters: true }, forced); +} + +function planWithin(ts: typeof TS, checker: TS.TypeChecker, plan: ProgramPlan, arrow: TS.ArrowFunction | TS.FunctionExpression | undefined, options: PlanOptions, forced: Set): ProgramPlan { const error = (node: TS.Node, message: string) => plan.errors.push({ node, message }); const what = options.parameters ? "game()" : "program()"; - if (!ts.isBlock(arrow.body)) { + if (arrow && !ts.isBlock(arrow.body)) { if (!options.parameters) { error(arrow.body, "program() takes an arrow with a block body: program(() => { … })."); plan.body = ts.factory.createBlock([]); @@ -159,16 +469,22 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio plan.expression = arrow.body; plan.body = ts.factory.createBlock([]); } - if (arrow.parameters.length && !options.parameters) error(arrow.parameters[0], "The program's arrow takes no parameters."); - if (arrow.asteriskToken || arrow.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) error(arrow, `A ${what} body cannot be async or a generator.`); + if (arrow && arrow.parameters.length && !options.parameters) error(arrow.parameters[0], "The program's arrow takes no parameters."); + // A function at the top of a file may be a generator (TrigScript 4): a for…of in a program walks it there. + if (arrow?.asteriskToken && !(ts.isFunctionDeclaration(arrow as TS.Node) && atTop(ts, arrow as TS.Node))) error(arrow, `A ${what} body cannot be a generator.`); /* ── Bindings ── */ const declare = (node: TS.Node) => plan.game.add(node); - for (const p of arrow.parameters) { + for (const p of arrow?.parameters ?? []) { if (!options.parameters) break; - if (!ts.isIdentifier(p.name)) error(p, "Destructured parameters are not supported in a game function."); - else if (p.dotDotDotToken) error(p, "Rest parameters are not supported in a game function."); - else declare(p); + // `...xs`: the rest of a call's arguments, an array the call makes (structured.ts, inlineWith). + if (p.dotDotDotToken && !ts.isIdentifier(p.name)) error(p, "The rest of the arguments takes a name: ...xs: number[]."); + else if (!ts.isIdentifier(p.name)) { + // `({ label, value }: Props)`: each name taken out is the game's, as a parameter is (a component's props). + const names = (n: TS.BindingName): void => { if (ts.isIdentifier(n)) return; for (const el of n.elements) if (!ts.isOmittedExpression(el)) { declare(el); names(el.name); } }; + declare(p); + names(p.name); + } else declare(p); } // A class *declared* in the body is the program's, as a function declared there is (see `classItems`); one written as a value is not. const isFunctionValue = (n: TS.Node) => ts.isArrowFunction(n) || ts.isFunctionExpression(n) || ts.isClassExpression(n); @@ -181,6 +497,8 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio for (const m of node.members) if (ts.isConstructorDeclaration(m) || ts.isMethodDeclaration(m) || ts.isAccessor(m)) for (const p of m.parameters) declare(p); } if (ts.isVariableDeclarationList(node) && !(node.flags & ts.NodeFlags.Const)) for (const d of node.declarations) declare(d); + // What `catch (e)` takes is an error thrown while the map is played: the game's. + if (ts.isCatchClause(node) && node.variableDeclaration) declare(node.variableDeclaration); // The variable of a for…of is bound per iteration when the loop is unrolled, like a parameter bound to a value. if ((ts.isForOfStatement(node) || ts.isForInStatement(node)) && ts.isVariableDeclarationList(node.initializer)) for (const d of node.initializer.declarations) declare(d); if (ts.isFunctionDeclaration(node)) { declare(node); for (const p of node.parameters) declare(p); } @@ -194,10 +512,10 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio } ts.forEachChild(node, collect); }; - collect(arrow.body); + collect(plan.root); // A `const` list the body stores into — `const hp = [0, 0, 0]; hp[i] = 5`, which TypeScript allows — is an array of the // program like a `let` one; a list that is only read stays a value of the script, which a program may still look up in. - const ARRAY_WRITERS = new Set(["push", "pop", "fill"]); + const ARRAY_WRITERS = new Set(["push", "pop", "fill", "shift", "unshift", "splice"]); const written = (node: TS.Node) => { if (isFunctionValue(node)) return; let target: TS.Expression | undefined; @@ -216,33 +534,88 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ARRAY_WRITERS.has(node.expression.name.text)) target = node.expression; while (target && (ts.isParenthesizedExpression(target) || ts.isNonNullExpression(target))) target = target.expression; store(target); + // `add(names, t)` where add pushes to its parameter: the array handed over is written, as if the push were here. + if (ts.isCallExpression(node)) node.arguments.forEach((arg, i) => { + const how = ts.isIdentifier(arg) ? parameterWrite(node, i) : undefined; + if (how) writeTo(arg as TS.Identifier, true, how === "grows" ? "push" : undefined, false); + }); ts.forEachChild(node, written); }; + /** How a function a call names writes its parameter `i`: pushes or pops it ("grows"), stores into it, or not at all. */ + const parameterWrite = (call: TS.CallExpression, i: number): "grows" | "stores" | undefined => { + let callee: TS.Expression = call.expression; + while (ts.isParenthesizedExpression(callee)) callee = callee.expression; + // `rng.shuffle(xs)`: a method, found by what the checker says it is. + let fn: TS.Node | undefined = ts.isPropertyAccessExpression(callee) ? checker.getSymbolAtLocation(callee.name)?.declarations?.[0] : ts.isIdentifier(callee) ? declarationOf(ts, checker, callee) : undefined; + if (fn && ts.isVariableDeclaration(fn) && fn.initializer) { + let init: TS.Expression = fn.initializer; + // `const add = game((xs, t) => …)` as well as `const add = (xs, t) => …`. + if (ts.isCallExpression(init) && init.arguments.length === 1) init = init.arguments[0]; + fn = init; + } + if (!fn || !(ts.isFunctionDeclaration(fn) || ts.isArrowFunction(fn) || ts.isFunctionExpression(fn) || ts.isMethodDeclaration(fn)) || !fn.body) return undefined; + const p = fn.parameters[i]; + if (!p || !ts.isIdentifier(p.name)) return undefined; + const name = p.name.text; + let how: "grows" | "stores" | undefined; + const look = (n: TS.Node): void => { + if (how === "grows") return; + let t: TS.Expression | undefined; + if (ts.isBinaryExpression(n) && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && n.operatorToken.kind <= ts.SyntaxKind.LastAssignment) t = n.left; + else if ((ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)) t = n.operand; + else if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && ARRAY_WRITERS.has(n.expression.name.text)) t = n.expression; + if (t && (ts.isElementAccessExpression(t) || ts.isPropertyAccessExpression(t))) { + let root: TS.Expression = t.expression; + while (ts.isElementAccessExpression(root) || ts.isPropertyAccessExpression(root) || ts.isParenthesizedExpression(root) || ts.isNonNullExpression(root)) root = root.expression; + if (ts.isIdentifier(root) && root.text === name) { + const member = ts.isPropertyAccessExpression(t) && ts.isIdentifier(t.expression) ? t.name.text : undefined; + how = member && member !== "fill" && ARRAY_WRITERS.has(member) || member === "length" ? "grows" : (how ?? "stores"); + } + } + ts.forEachChild(n, look); + }; + look(fn.body); + return how; + }; const store = (target: TS.Expression | undefined) => { // `grid[y][x] = v`, `waves[i].count++`: a store however deep is a store into the list at the root of it. let root: TS.Expression | undefined = target && (ts.isElementAccessExpression(target) || ts.isPropertyAccessExpression(target)) ? target.expression : undefined; const direct = !!root && ts.isIdentifier(root); while (root && (ts.isElementAccessExpression(root) || ts.isPropertyAccessExpression(root) || ts.isParenthesizedExpression(root) || ts.isNonNullExpression(root))) root = root.expression; if (target && root && ts.isIdentifier(root) && (ts.isElementAccessExpression(target) || ts.isPropertyAccessExpression(target))) { + const member = ts.isPropertyAccessExpression(target) ? target.name.text : undefined; + const atLength = ts.isElementAccessExpression(target) && ts.isPropertyAccessExpression(target.argumentExpression) && target.argumentExpression.name.text === "length" + && ts.isIdentifier(target.argumentExpression.expression) && target.argumentExpression.expression.text === root.text; + writeTo(root, direct, member, atLength); + } + }; + /** The array a store reaches, if it is one of the body's: the program's from now on, and one that grows when the store is a push, a pop, a shift, … */ + const writeTo = (root: TS.Identifier, direct: boolean, member: string | undefined, atLength: boolean) => { + { const decl = declarationOf(ts, checker, root); if (decl && ts.isVariableDeclaration(decl) && decl.initializer && inside(decl)) { const type = checker.getTypeAtLocation(decl.name); if (checker.isArrayType(type) || checker.isTupleType(type)) { if (plan.consts.has(decl)) forced.add(decl); declare(decl); - // xs.push(v), xs.pop(), xs.length = n and xs[xs.length] = v are what make an array one that grows. + // xs.push(v), xs.pop(), shift, unshift, splice, xs.length = n and xs[xs.length] = v are what make an array one that grows. if (!direct) return; - const name = root.text; - const member = ts.isPropertyAccessExpression(target) ? target.name.text : undefined; - const atLength = ts.isElementAccessExpression(target) && ts.isPropertyAccessExpression(target.argumentExpression) && target.argumentExpression.name.text === "length" - && ts.isIdentifier(target.argumentExpression.expression) && target.argumentExpression.expression.text === name; - if (member === "push" || member === "pop" || member === "length" || atLength) plan.grows.add(decl); + if (member === "push" || member === "pop" || member === "shift" || member === "unshift" || member === "splice" || member === "length" || atLength) plan.grows.add(decl); + } else { + // `const shop = { selected: 0 }; shop.selected++` — or a function it is handed writes a field: a record of the + // program's, as a `let` one is. One only read stays a value of the script. + let init: TS.Expression = decl.initializer; + while (ts.isParenthesizedExpression(init) || ts.isAsExpression(init) || ts.isSatisfiesExpression(init)) init = init.expression; + if (ts.isObjectLiteralExpression(init) && checker.getIndexInfosOfType(type).length === 0) { + if (plan.consts.has(decl)) forced.add(decl); + declare(decl); + } } } } }; - const inside = (decl: TS.Node) => { for (let n: TS.Node | undefined = decl; n; n = n.parent) if (n === arrow.body) return true; return false; }; - written(arrow.body); + const inside = (decl: TS.Node) => { for (let n: TS.Node | undefined = decl; n; n = n.parent) if (n === plan.root) return true; return false; }; + written(plan.root); for (const decl of forced) declare(decl); /* ── Hoistability ── */ @@ -265,14 +638,77 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio }; const memo = new Map(); const isGameDecl = (decl: TS.Declaration) => plan.game.has(owningDeclaration(ts, decl)); + /** + * Whether a text carries name() or color() — written in (`${name(P2)}`), or a constant made with one: when the script + * is built it holds a mark in their place (`runtime.ts#textMark`), not the name or the colour code the game fills in. + */ + const marks = new Map(); + const carriesMark = (x: TS.Node): boolean => { + if (ts.isFunctionLike(x)) return false; + const hit = marks.get(x); + if (hit !== undefined) return hit; + marks.set(x, false); + let out = false; + if (ts.isCallExpression(x) && (libraryCallName(ts, checker, x) === "name" || libraryCallName(ts, checker, x) === "color")) out = true; + else if (ts.isIdentifier(x)) { + const decl = declarationOf(ts, checker, x); + out = !!decl && ts.isVariableDeclaration(decl) && !!decl.initializer && (checker.getTypeAtLocation(x).flags & ts.TypeFlags.StringLike) !== 0 && carriesMark(decl.initializer); + } else out = !!ts.forEachChild(x, (c) => (carriesMark(c) ? true : undefined)); + marks.set(x, out); + return out; + }; + /** Whether an expression looks into a text that carries a mark — its length, a character, a search, a comparison — which only the game can answer. */ + const COMPARES = new Set([ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken, ts.SyntaxKind.LessThanToken, ts.SyntaxKind.LessThanEqualsToken, ts.SyntaxKind.GreaterThanToken, ts.SyntaxKind.GreaterThanEqualsToken]); + const isText = (x: TS.Expression) => (checker.getTypeAtLocation(x).flags & ts.TypeFlags.StringLike) !== 0; + const readsMark = (n: TS.Node): boolean => { + if ((ts.isPropertyAccessExpression(n) || ts.isElementAccessExpression(n)) && isText(n.expression) && carriesMark(n.expression)) return true; + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) && isText(n.expression.expression) && (carriesMark(n.expression.expression) || n.arguments.some((a) => isText(a) && carriesMark(a)))) return true; + return ts.isBinaryExpression(n) && COMPARES.has(n.operatorToken.kind) && ((isText(n.left) && carriesMark(n.left)) || (isText(n.right) && carriesMark(n.right))); + }; + /** `bounds(locations.Pen)`: a record of four cells of the game's location table, which a const may hold (`const b = bounds(…)`). */ + const isBoundsCall = (x: TS.Expression): boolean => { + while (ts.isParenthesizedExpression(x) || ts.isAsExpression(x) || ts.isNonNullExpression(x)) x = x.expression; + return ts.isCallExpression(x) && libraryCallName(ts, checker, x) === "bounds"; + }; + const holdsBounds = (id: TS.Identifier): boolean => { + const decl = declarationOf(ts, checker, id); + return !!decl && ts.isVariableDeclaration(decl) && !!decl.initializer && isBoundsCall(decl.initializer); + }; + /** `const { left, top } = bounds(…)`: each name one of the cells. */ + const isBoundsEdge = (id: TS.Identifier): boolean => { + const decl = declarationOf(ts, checker, id); + if (!decl || !ts.isBindingElement(decl) || !ts.isObjectBindingPattern(decl.parent)) return false; + const v = decl.parent.parent; + return ts.isVariableDeclaration(v) && !!v.initializer && isBoundsCall(v.initializer); + }; + /** + * `memory.vision.countdown`, `local.camera.x` — a field of one of GAME_CELL_ROOTS, two names down; or `local.player`, one; + * or an edge of a location, `bounds(…).left` and `b.left` with `const b = bounds(…)`. + */ + const isGameCell = (n: TS.Node): boolean => { + if (ts.isPropertyAccessExpression(n) && ts.isIdentifier(n.expression) && libraryName(ts, checker, n.expression) === "local") return true; + if (ts.isPropertyAccessExpression(n) && (isBoundsCall(n.expression) || (ts.isIdentifier(n.expression) && holdsBounds(n.expression)))) return true; + if (ts.isIdentifier(n) && !(ts.isPropertyAccessExpression(n.parent) && n.parent.name === n) && isBoundsEdge(n)) return true; + if (!ts.isPropertyAccessExpression(n) || !ts.isPropertyAccessExpression(n.expression)) return false; + const root = n.expression.expression; + if (!ts.isIdentifier(root)) return false; + const lib = libraryName(ts, checker, root); + // `local.selected.hp` is a field of a unit, not a cell: the cell is `local.selected`. + if (lib === "local" && n.expression.name.text === "selected") return false; + return !!lib && GAME_CELL_ROOTS.has(lib); + }; const hoistable = (e: TS.Node): boolean => { const hit = memo.get(e); if (hit !== undefined) return hit; let ok = true; + // JSX is the program's own text, put together in the game (`structured.ts#jsx`), whatever is in it. + if (hasJsx(ts, e)) { memo.set(e, false); return false; } // Inside a function value the syntax is the function's own business; only what it refers to matters. const scan = (n: TS.Node): void => { if (!ok) return; if (ts.isIdentifier(n)) { identifier(n, n.parent); return; } + // `this` inside an arrow written in a method is the instance the method runs on, which the program has. + if (n.kind === ts.SyntaxKind.ThisKeyword || n.kind === ts.SyntaxKind.SuperKeyword) { ok = false; return; } ts.forEachChild(n, scan); }; const identifier = (n: TS.Identifier, p: TS.Node) => { @@ -280,19 +716,33 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio const property = ts.isPropertyAccessExpression(p) && p.name === n; if ((ts.isPropertyAssignment(p) && p.name === n) || (ts.isMethodDeclaration(p) && p.name === n) || ts.isQualifiedName(p)) return; const lib = libraryName(ts, checker, n); - if (lib && GAME_CALLS.has(lib)) { ok = false; return; } + if (lib && answeredByGame(ts, checker, n, lib)) { ok = false; return; } if (lib && FORBIDDEN_INSIDE.has(lib) && ts.isCallExpression(p.parent) && p.parent.expression === p && property) { ok = false; return; } if (lib && FORBIDDEN_INSIDE.has(lib) && ts.isCallExpression(p) && p.expression === n) { ok = false; return; } if (property) return; const decl = declarationOf(ts, checker, n); if (decl && isGameDecl(decl)) { ok = false; return; } + // A class of the script's own: inside a program, `new C()` is an instance of the program's, whatever its arguments. + if (decl && isScriptClass(ts, decl)) { ok = false; return; } + // A function of the script's own that does what only the game can (`range(2, 4)` draws the game's random numbers): + // run in the game, whatever its arguments. + const own = decl && (isScriptFunction(ts, decl) ? decl : scriptArrow(ts, decl)); + if (own && (usesGame(ts, checker, own) || keepsState(ts, checker, own))) { ok = false; return; } + // A function of the script's own kept in an array or a record (`const fs = [a, b]`): the program's functions as + // values, which it calls with its own values. Handed to a function (`xs.map(sq)`) or called, it is the script's. + if (decl && (isScriptFunction(ts, decl) || scriptArrow(ts, decl)) && (ts.isArrayLiteralExpression(p) || (ts.isPropertyAssignment(p) && p.initializer === n) || ts.isShorthandPropertyAssignment(p))) { ok = false; return; } + // A generator is walked by the program's for…of, a copy of its body at each loop: never run when the script is built. + if (decl && (ts.isFunctionDeclaration(decl) || ts.isFunctionExpression(decl)) && decl.asteriskToken) { ok = false; return; } }; const check = (n: TS.Node): void => { if (!ok) return; + // The cell itself is a value (its handle); an expression around it — `memory.game.frame * 2` — is the program's. + if (n !== e && isGameCell(n)) { ok = false; return; } // `waves.forEach((w) => createUnit(P2, w.unit, w.count, at))`: nothing of the program is named, but forEach is there for // what its function does, and what this one does is the game's. Run when the script is built it would make actions // nobody receives, and the line would silently do nothing; it is the program's loop, unrolled. if (isCallbackArgument(ts, n) && doesSomething(n)) { ok = false; return; } + if ((ts.isArrowFunction(n) || ts.isFunctionExpression(n)) && n.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) { ok = false; return; } if (isFunctionValue(n)) { scan(n); return; } if (ts.isIdentifier(n)) { // A property name (`obj.name`, `{ name: v }`) is not a value reference. @@ -300,8 +750,15 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio return; } if (n.kind === ts.SyntaxKind.ThisKeyword || n.kind === ts.SyntaxKind.SuperKeyword || ts.isAwaitExpression(n) || ts.isYieldExpression(n)) { ok = false; return; } + if (clockCall(ts, checker, n)) { ok = false; return; } + if (readsMark(n)) { ok = false; return; } // A call of a game() function runs in the game, whatever its arguments are. if (ts.isCallExpression(n) && isGame(n)) { ok = false; return; } + // So does one of a function that changes what outlives the call, a closure's variable or a field (`keepsState`). + if (ts.isCallExpression(n)) { + const callee = checker.getResolvedSignature(n)?.declaration; + if (callee && ts.isFunctionLike(callee) && (callee as TS.FunctionLikeDeclaration).body && !callee.getSourceFile().isDeclarationFile && keepsState(ts, checker, callee)) { ok = false; return; } + } if (ts.isCallExpression(n) && isReadCall(libraryCallName(ts, checker, n), n.arguments.length)) { ok = false; return; } if (ts.isBinaryExpression(n)) { const k = n.operatorToken.kind; @@ -309,6 +766,9 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio } if (ts.isPrefixUnaryExpression(n) && (n.operator === ts.SyntaxKind.ExclamationToken || n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)) { ok = false; return; } if (ts.isPostfixUnaryExpression(n)) { ok = false; return; } + // (A `?:` picks its side by what its test is in the game — a condition of the game is a record here, true to + // JavaScript: the sides are hoisted on their own, and the compiler tests the condition where it stands.) + if (ts.isConditionalExpression(n)) { ok = false; return; } ts.forEachChild(n, check); }; check(e); @@ -327,25 +787,51 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio if (!e) return; // `...base` is not an expression on its own: what it spreads is. if (ts.isSpreadElement(e)) { value(e.expression, items); return; } + // A function written where a value goes — `let f = (x) => …`, `[a, () => …]`, `twice(() => …)` — is the program's (B3). + const fn = functionWritten(e); + if (fn) { callback(fn, items); return; } + if (holdsFunction(e)) { descend(e, items); return; } if (hoistable(e)) { hoist(e, items); return; } descend(e, items); }; + /** The arrow or function expression `e` is, through parentheses and type assertions. */ + const functionWritten = (e: TS.Node): TS.ArrowFunction | TS.FunctionExpression | undefined => { + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isNonNullExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression; + return ts.isArrowFunction(e) || ts.isFunctionExpression(e) ? e : undefined; + }; + /** Whether a function is one of what an expression gives — an item of a list written out, a field, either side of a choice — rather than handed to a call. */ + const holdsFunction = (e: TS.Node): boolean => { + if (functionWritten(e)) return true; + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e) || ts.isNonNullExpression(e) || ts.isTypeAssertionExpression(e)) e = e.expression; + if (ts.isArrayLiteralExpression(e)) return e.elements.some(holdsFunction); + if (ts.isObjectLiteralExpression(e)) return e.properties.some((p) => (ts.isPropertyAssignment(p) && holdsFunction(p.initializer)) || ts.isMethodDeclaration(p)); + if (ts.isConditionalExpression(e)) return holdsFunction(e.whenTrue) || holdsFunction(e.whenFalse); + if (ts.isBinaryExpression(e) && (e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || e.operatorToken.kind === ts.SyntaxKind.BarBarToken || e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)) return holdsFunction(e.left) || holdsFunction(e.right); + return false; + }; const descend = (e: TS.Node, items: PlanItem[]) => { - if (isFunctionValue(e)) { error(e, "A function written inside program() cannot use the program's variables; declare it with function so it is inlined, or move it outside."); return; } + const fn = functionWritten(e); + if (fn) { callback(fn, items); return; } + if (isFunctionValue(e)) { error(e, "A class written as a value is not supported inside program(); declare it with class."); return; } if (ts.isIdentifier(e)) return; if (ts.isPropertyAccessExpression(e)) { value(e.expression, items); return; } if (ts.isElementAccessExpression(e)) { value(e.expression, items); value(e.argumentExpression, items); return; } if (ts.isCallExpression(e) || ts.isNewExpression(e)) { const lib = ts.isCallExpression(e) ? libraryCallName(ts, checker, e) : null; + const clock = clockCall(ts, checker, e); + if (clock) { error(e, clockMessage(clock)); return; } if (lib && FORBIDDEN_INSIDE.has(lib)) error(e, `${lib}() defines triggers of its own and cannot be used inside program(); inside, write conditions in an if and actions as statements.`); // `random()`, `sleep()`, … are the game's: their callee is never a value, however it is spelt (`ts.random()` through a namespace import). // Of a method call the object is the value, not the method: `cost.get(u.type)` has to leave `cost` — the Map — in reach, and a // method read off its object on its own has lost it. - if (!lib || !GAME_CALLS.has(lib)) value(ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression) ? e.expression.expression : e.expression, items); + let callee: TS.Expression = e.expression; + while (ts.isParenthesizedExpression(callee)) callee = callee.expression; + if (isTaskFunction(ts, checker, callee)) callback(callee, items); + else if (!lib || !answeredByGame(ts, checker, ts.isPropertyAccessExpression(callee) ? callee.name as TS.Identifier : callee as TS.Identifier, lib)) value(ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression) ? e.expression.expression : e.expression, items); for (const a of e.arguments ?? []) { let fn: TS.Expression = a; while (ts.isParenthesizedExpression(fn)) fn = fn.expression; - if (isCallbackArgument(ts, fn)) callback(fn, items); else value(a, items); + if (isCallbackArgument(ts, fn) || isTaskFunction(ts, checker, fn)) callback(fn, items); else value(a, items); } return; } @@ -354,6 +840,19 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isNonNullExpression(e) || ts.isSatisfiesExpression(e) || ts.isTypeAssertionExpression(e) || ts.isSpreadElement(e) || ts.isAwaitExpression(e) || ts.isTypeOfExpression(e) || ts.isVoidExpression(e) || ts.isDeleteExpression(e)) { value(e.expression, items); return; } if (ts.isConditionalExpression(e)) { value(e.condition, items); value(e.whenTrue, items); value(e.whenFalse, items); return; } if (ts.isTemplateExpression(e)) { for (const s of e.templateSpans) value(s.expression, items); return; } + // JSX: what is inside its braces, in attributes and among the children, is a value like any other. + if (ts.isJsxElement(e) || ts.isJsxSelfClosingElement(e) || ts.isJsxFragment(e)) { + const attributes = ts.isJsxElement(e) ? e.openingElement.attributes : ts.isJsxSelfClosingElement(e) ? e.attributes : undefined; + for (const a of attributes?.properties ?? []) { + if (ts.isJsxAttribute(a) && a.initializer && ts.isJsxExpression(a.initializer)) value(a.initializer.expression, items); + else if (ts.isJsxSpreadAttribute(a)) value(a.expression, items); + } + for (const c of ts.isJsxSelfClosingElement(e) ? [] : e.children) { + if (ts.isJsxExpression(c)) value(c.expression, items); + else if (!ts.isJsxText(c)) descend(c, items); + } + return; + } if (ts.isArrayLiteralExpression(e)) { for (const el of e.elements) value(el, items); return; } if (ts.isObjectLiteralExpression(e)) { for (const p of e.properties) { @@ -404,11 +903,19 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio return; } if (ts.isClassDeclaration(s)) { deferred.push({ kind: "block", items: classItems(s) }); return; } + // `enum Phase { Wait, Fight }` in the body: its members are known when the script is built, as at the top of a file. + if (ts.isEnumDeclaration(s)) { items.push({ kind: "statement", node: s }); return; } if (ts.isReturnStatement(s)) { value(s.expression, items); return; } if (ts.isSwitchStatement(s)) { value(s.expression, items); for (const c of s.caseBlock.clauses) { if (ts.isCaseClause(c)) value(c.expression, items); items.push({ kind: "block", items: block(c.statements) }); } return; } if (ts.isForOfStatement(s) || ts.isForInStatement(s)) { value(s.expression, items); items.push({ kind: "block", items: block([s.statement]) }); return; } if (ts.isLabeledStatement(s)) { statement(s.statement, items, deferred); return; } if (ts.isThrowStatement(s)) { value(s.expression, items); return; } + if (ts.isTryStatement(s)) { + items.push({ kind: "block", items: block(s.tryBlock.statements) }); + if (s.catchClause) items.push({ kind: "block", items: block(s.catchClause.block.statements) }); + if (s.finallyBlock) items.push({ kind: "block", items: block(s.finallyBlock.statements) }); + return; + } // break / continue / empty / types: nothing runs at build time. }; // What a class of the body computes when the script is built: inside a field's first value, and inside each body, as a declared function's. @@ -416,7 +923,11 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio const items: PlanItem[] = []; for (const m of c.members) { if (ts.isPropertyDeclaration(m)) value(m.initializer, items); - else if ((ts.isConstructorDeclaration(m) || ts.isMethodDeclaration(m) || ts.isAccessor(m)) && m.body) items.push({ kind: "block", items: block(m.body.statements) }); + else if ((ts.isConstructorDeclaration(m) || ts.isMethodDeclaration(m) || ts.isAccessor(m)) && m.body) { + // A parameter's default (`size = 8`, `limit = 30 * SECOND`) is worked out where the call leaves it out. + for (const p of m.parameters) if (p.initializer) value(p.initializer, items); + items.push({ kind: "block", items: block(m.body.statements) }); + } } return items; }; @@ -426,8 +937,20 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio if (isConst && plan.game.has(d)) { value(d.initializer, items); continue; } if (isConst) { if (!d.initializer) { error(d, "A constant needs a value."); continue; } - if (hoistable(d.initializer)) { plan.consts.set(d, plan.constList.length); plan.constList.push(d); items.push({ kind: "const", decl: d }); continue; } - if (isFunctionValue(d.initializer)) { error(d, `${ts.isIdentifier(d.name) ? d.name.text : "This constant"} is a function that uses the program's variables; declare it with function so it is inlined at each call.`); continue; } + // (`const w = memory.map.width` reads the game: a const of the program's, like one computed from its variables.) + let init: TS.Expression = d.initializer; + while (ts.isParenthesizedExpression(init)) init = init.expression; + // `const f = (x) => …` (B3): a function the program calls with its own values, so its body is planned as a declared + // function's. One that uses nothing of the program stays a value of the script too, which build-time code may call. + const fn = functionWritten(init); + if (fn) { + if (hoistable(d.initializer)) { plan.consts.set(d, plan.constList.length); plan.constList.push(d); items.push({ kind: "const", decl: d }); } + else plan.game.add(d); + callback(fn, items); + continue; + } + if (holdsFunction(init)) { plan.game.add(d); memo.clear(); descend(d.initializer, items); continue; } + if (hoistable(d.initializer) && !isGameCell(init)) { plan.consts.set(d, plan.constList.length); plan.constList.push(d); items.push({ kind: "const", decl: d }); continue; } // A const computed from the program's variables lives in the game like a let; the checker keeps it from being reassigned. plan.game.add(d); memo.clear(); @@ -445,10 +968,58 @@ function planOnce(ts: typeof TS, checker: TS.TypeChecker, arrow: TS.ArrowFunctio return items; }; if (plan.expression) { const items: PlanItem[] = []; value(plan.expression, items); plan.tree = items; return plan; } + if (ts.isClassDeclaration(plan.root)) { plan.tree = classItems(plan.root); return plan; } plan.tree = block(plan.body.statements); + // A parameter's default, worked out where a call leaves it out: what it names of the file is the script's. + if (options.parameters) { + const defaults: PlanItem[] = []; + for (const p of arrow?.parameters ?? []) if (p.initializer) value(p.initializer, defaults); + if (defaults.length) plan.tree = [...defaults, ...plan.tree]; + } return plan; } +/** The `__kind` brand of a type (`"fixed"`, `"unit"`, …), when it has one. */ +export function brandOfType(ts: typeof TS, checker: TS.TypeChecker, type: TS.Type): string | undefined { + void ts; + for (const t of type.isIntersection() ? type.types : [type]) { + const p = t.getProperty("__kind"); + if (!p) continue; + const pt = checker.getTypeOfSymbol(p); + const name = (pt.isUnion() ? pt.types : [pt]).find((x): x is TS.StringLiteralType => x.isStringLiteral()); + if (name) return name.value; + } + return undefined; +} + +/** Whether a quotient written in a program divides whole numbers: neither side is typed `fixed` (`7 / 2` is 3; `7 / fixed(2)` is 3.5). */ +export function wholeQuotient(ts: typeof TS, checker: TS.TypeChecker, e: TS.BinaryExpression): boolean { + return wholeArithmetic(ts, checker, e) !== "fraction"; +} + +/** + * How a sum, difference, product or quotient written in a program computes when its sides are known as the script is + * built: as the game would — in 32 bits, signed ("i32") or from 0 up ("u32"), by what the sides are typed — or as a + * fraction when either side is typed `fixed`. (A side whose value is a fraction computes as one whatever this says.) + */ +export function wholeArithmetic(ts: typeof TS, checker: TS.TypeChecker, e: TS.BinaryExpression): "i32" | "u32" | "fraction" { + const brands = [brandOfType(ts, checker, checker.getTypeAtLocation(e.left)), brandOfType(ts, checker, checker.getTypeAtLocation(e.right))]; + return brands.includes("fixed") ? "fraction" : brands.includes("u32") ? "u32" : "i32"; +} + +/** The arithmetic of whole numbers at build time, as JavaScript source: `(a, b) => …` taking the operator's sides. */ +function wholeHelper(ts: typeof TS, f: TS.NodeFactory, op: "+" | "-" | "*" | "/", bits: "i32" | "u32"): TS.ArrowFunction { + const a = f.createIdentifier("a"), b = f.createIdentifier("b"); + const isWhole = (x: TS.Identifier) => f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Number"), "isInteger"), undefined, [x]); + let test: TS.Expression = f.createLogicalAnd(isWhole(a), isWhole(b)); + if (op === "/") test = f.createLogicalAnd(test, f.createStrictInequality(b, f.createNumericLiteral(0))); + const raw: TS.Expression = op === "+" ? f.createAdd(a, b) : op === "-" ? f.createSubtract(a, b) : op === "*" ? f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Math"), "imul"), undefined, [a, b]) : f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Math"), "trunc"), undefined, [f.createDivide(a, b)]); + const wrapped = op === "/" ? raw : bits === "u32" ? f.createUnsignedRightShift(f.createParenthesizedExpression(raw), f.createNumericLiteral(0)) : f.createBitwiseOr(f.createParenthesizedExpression(raw), f.createNumericLiteral(0)); + const plain: TS.Expression = op === "+" ? f.createAdd(a, b) : op === "-" ? f.createSubtract(a, b) : op === "*" ? f.createMultiply(a, b) : f.createDivide(a, b); + return f.createArrowFunction(undefined, undefined, [f.createParameterDeclaration(undefined, undefined, "a"), f.createParameterDeclaration(undefined, undefined, "b")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createConditionalExpression(test, undefined, wrapped, undefined, plain)); +} + + /** What a descriptor's `hoisted()` returns: a thunk per hoisted expression, and a memoised thunk per build-time constant. */ export interface HoistedThunks { h: (() => unknown)[]; c: (() => unknown)[] } @@ -477,8 +1048,43 @@ export function hoistedFunction(ts: typeof TS, checker: TS.TypeChecker, plan: Pr const call = f.createCallExpression(f.createElementAccessExpression(c, f.createNumericLiteral(i)), undefined, []); return ts.isBindingElement(decl) && ts.isIdentifier(decl.name) ? f.createPropertyAccessExpression(call, decl.name.text) : call; }; + const SHIFT_OPS: Partial>" | ">>>">> = { [ts.SyntaxKind.LessThanLessThanToken]: "<<", [ts.SyntaxKind.GreaterThanGreaterThanToken]: ">>", [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken]: ">>>" }; + const WHOLE_OPS: Partial> = { [ts.SyntaxKind.PlusToken]: "+", [ts.SyntaxKind.MinusToken]: "-", [ts.SyntaxKind.AsteriskToken]: "*", [ts.SyntaxKind.SlashToken]: "/" }; const rewrite = (node: TS.Node): TS.Node => { if (ts.isTypeNode(node)) return node; + // Whole numbers compute in a program as they do in the game: `7 / 2` is 3 and `i + 2147483647` wraps (`7 / fixed(2)` + // is 3.5, a u32 wraps from 0 up). A sum, difference, product or quotient whose sides are not typed `fixed` goes + // through a helper that computes it so when both sides are whole, and as JavaScript does otherwise. + // (Math.abs of a whole number wraps as the game's does: abs(−2 147 483 648) is −2 147 483 648.) + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "Math" && node.expression.name.text === "abs" && node.arguments.length === 1 && brandOfType(ts, checker, checker.getTypeAtLocation(node.arguments[0])) !== "fixed") { + const v = f.createIdentifier("v"); + const abs = f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Math"), "abs"), undefined, [v]); + const helper = f.createArrowFunction(undefined, undefined, [f.createParameterDeclaration(undefined, undefined, "v")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createConditionalExpression(f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Number"), "isInteger"), undefined, [v]), undefined, f.createBitwiseOr(abs, f.createNumericLiteral(0)), undefined, abs)); + return f.createCallExpression(f.createParenthesizedExpression(helper), undefined, [ts.visitNode(node.arguments[0], rewrite) as TS.Expression]); + } + // (A shift by 32 or more, or by a number below zero, leaves nothing but the sign, as the game's does.) + // (The negative of a whole number wraps as the game's does; a literal's negative is the literal's own.) + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken && !ts.isNumericLiteral(node.operand) && brandOfType(ts, checker, checker.getTypeAtLocation(node.operand)) !== "fixed") { + const v = f.createIdentifier("v"); + const helper = f.createArrowFunction(undefined, undefined, [f.createParameterDeclaration(undefined, undefined, "v")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createConditionalExpression(f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Number"), "isInteger"), undefined, [v]), undefined, f.createBitwiseOr(f.createParenthesizedExpression(f.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, v)), f.createNumericLiteral(0)), undefined, f.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, v))); + return f.createCallExpression(f.createParenthesizedExpression(helper), undefined, [ts.visitNode(node.operand, rewrite) as TS.Expression]); + } + if (ts.isBinaryExpression(node) && SHIFT_OPS[node.operatorToken.kind]) { + const op = SHIFT_OPS[node.operatorToken.kind]!; + const a = f.createIdentifier("a"), b = f.createIdentifier("b"); + const far = f.createGreaterThanEquals(f.createUnsignedRightShift(b, f.createNumericLiteral(0)), f.createNumericLiteral(32)); + const none: TS.Expression = op === ">>" ? f.createConditionalExpression(f.createLessThan(a, f.createNumericLiteral(0)), undefined, f.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, f.createNumericLiteral(1)), undefined, f.createNumericLiteral(0)) : f.createNumericLiteral(0); + const shifted = op === "<<" ? f.createLeftShift(a, b) : op === ">>" ? f.createRightShift(a, b) : f.createUnsignedRightShift(a, b); + const helper = f.createArrowFunction(undefined, undefined, [f.createParameterDeclaration(undefined, undefined, "a"), f.createParameterDeclaration(undefined, undefined, "b")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createConditionalExpression(far, undefined, none, undefined, shifted)); + return f.createCallExpression(f.createParenthesizedExpression(helper), undefined, [ts.visitNode(node.left, rewrite) as TS.Expression, ts.visitNode(node.right, rewrite) as TS.Expression]); + } + if (ts.isBinaryExpression(node) && WHOLE_OPS[node.operatorToken.kind]) { + const bits = wholeArithmetic(ts, checker, node); + if (bits !== "fraction") { + const helper = wholeHelper(ts, f, WHOLE_OPS[node.operatorToken.kind]!, bits); + return f.createCallExpression(f.createParenthesizedExpression(helper), undefined, [ts.visitNode(node.left, rewrite) as TS.Expression, ts.visitNode(node.right, rewrite) as TS.Expression]); + } + } if (ts.isIdentifier(node) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) && !(ts.isPropertyAssignment(node.parent) && node.parent.name === node)) return constant(node) ?? node; if (ts.isShorthandPropertyAssignment(node)) { const value = constant(node.name); @@ -514,6 +1120,7 @@ export function hoistedFunction(ts: typeof TS, checker: TS.TypeChecker, plan: Pr } case "hoist": return f.createExpressionStatement(f.createAssignment(f.createElementAccessExpression(h, f.createNumericLiteral(item.index)), thunk(item.expr))); case "block": return f.createBlock(emit(item.items), true); + case "statement": return ts.visitNode(item.node, rewrite) as TS.Statement; } }); // const __m = (i, g) => { let d = false, v; return () => { if (d) return v; try { v = g(); } catch (e) { if (e instanceof Object && e.__trigscriptConst === undefined) e.__trigscriptConst = i; throw e; } d = true; return v; }; }; @@ -557,6 +1164,12 @@ export interface TransformContext { fileIndex: (sf: TS.SourceFile) => number; /** The plan for a program's arrow, made before the transform. */ planFor: (arrow: TS.ArrowFunction | TS.FunctionExpression) => ProgramPlan | undefined; + /** The plan for a class of the script's own (`planClass`), made before the transform. */ + planForClass?: (cls: TS.ClassDeclaration) => ProgramPlan | undefined; + /** The plan for a listener at the top of a file (`planListener`), made before the transform. */ + planForListener?: (stmt: TS.ExpressionStatement) => ProgramPlan | undefined; + /** The plan for a function at the top of a file (`planFunction`), made before the transform. */ + planForFunction?: (fn: TS.FunctionDeclaration | TS.ArrowFunction | TS.FunctionExpression) => ProgramPlan | undefined; } /** @@ -574,7 +1187,7 @@ export function transformer(ts: typeof TS, checker: TS.TypeChecker, ctx: Transfo while (out.length < upTo) out.push(f.createIdentifier("undefined")); return out; }; - const visit = (node: TS.Node): TS.Node => { + const visit = (node: TS.Node): TS.VisitResult => { if (ts.isCallExpression(node)) { const lib = libraryCallName(ts, checker, node); if (lib === "trigger" && node.arguments.length >= 3 && node.arguments.length <= 4) { @@ -596,6 +1209,63 @@ export function transformer(ts: typeof TS, checker: TS.TypeChecker, ctx: Transfo } } } + // `on.died(…)` at the top of a file: handed to the run as a program of its own (`__listen`), which listens from the + // start of the game. + if (isTopListener(ts, checker, node)) { + const plan = ctx.planForListener?.(node); + if (plan) { + const descriptor = f.createObjectLiteralExpression([ + f.createPropertyAssignment("__trigscript", f.createStringLiteral("program")), + f.createPropertyAssignment("at", at(node)), + f.createPropertyAssignment("pos", f.createNumericLiteral(node.getStart(sf))), + f.createPropertyAssignment("hoisted", hoistedFunction(ts, checker, plan, context)), + ], true); + return f.createExpressionStatement(f.createCallExpression(f.createIdentifier("__listen"), undefined, [descriptor, at(node)])); + } + } + // `class Squad { … }` at the top of a file: the class as it is, and after it what its bodies compute when the script is + // built, handed to the run (`__class`), for a program that makes an instance of it. + if (ts.isClassDeclaration(node) && node.name && atTop(ts, node)) { + const plan = ctx.planForClass?.(node); + const visited = ts.visitEachChild(node, visit, context); + if (!plan) return visited; + const descriptor = f.createObjectLiteralExpression([ + f.createPropertyAssignment("__trigscript", f.createStringLiteral("class")), + f.createPropertyAssignment("at", at(node)), + f.createPropertyAssignment("pos", f.createNumericLiteral(node.getStart(sf))), + f.createPropertyAssignment("hoisted", hoistedFunction(ts, checker, plan, context)), + ], true); + return [visited, f.createExpressionStatement(f.createCallExpression(f.createIdentifier("__class"), undefined, [f.createIdentifier(node.name.text), descriptor]))]; + } + // `function bar() { … }` at the top of a file: the function as it is, and after it what its body computes when the + // script is built, handed to the run (`__fn`), for a program that calls it with its own values. + if (isScriptFunction(ts, node)) { + const plan = ctx.planForFunction?.(node); + const visited = ts.visitEachChild(node, visit, context); + if (!plan) return visited; + const descriptor = f.createObjectLiteralExpression([ + f.createPropertyAssignment("__trigscript", f.createStringLiteral("function")), + f.createPropertyAssignment("at", at(node)), + f.createPropertyAssignment("pos", f.createNumericLiteral(node.getStart(sf))), + f.createPropertyAssignment("hoisted", hoistedFunction(ts, checker, plan, context)), + ], true); + return [visited, f.createExpressionStatement(f.createCallExpression(f.createIdentifier("__fn"), undefined, [f.createIdentifier(node.name!.text), descriptor]))]; + } + if (ts.isVariableStatement(node) && atTop(ts, node)) { + const arrows = node.declarationList.declarations.map((d) => [d, scriptArrow(ts, d)] as const).filter(([, a]) => a && ctx.planForFunction?.(a)); + if (arrows.length) { + const visited = ts.visitEachChild(node, visit, context); + // An exported const of a namespace has no name of its own once compiled (`Geo.mk = …`): it is the namespace's. + const ns = ts.isModuleBlock(node.parent) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ? (node.parent.parent as TS.ModuleDeclaration).name : undefined; + const named = (d: TS.VariableDeclaration) => ns && ts.isIdentifier(ns) ? f.createPropertyAccessExpression(f.createIdentifier(ns.text), (d.name as TS.Identifier).text) : f.createIdentifier((d.name as TS.Identifier).text); + return [visited, ...arrows.map(([d, a]) => f.createExpressionStatement(f.createCallExpression(f.createIdentifier("__fn"), undefined, [named(d), f.createObjectLiteralExpression([ + f.createPropertyAssignment("__trigscript", f.createStringLiteral("function")), + f.createPropertyAssignment("at", at(a!)), + f.createPropertyAssignment("pos", f.createNumericLiteral(a!.getStart(sf))), + f.createPropertyAssignment("hoisted", hoistedFunction(ts, checker, ctx.planForFunction!(a!)!, context)), + ], true)])))]; + } + } return ts.visitEachChild(node, visit, context); }; return ts.visitNode(sf, visit) as TS.SourceFile; diff --git a/compiler/image.ts b/compiler/image.ts new file mode 100644 index 0000000..532a2a5 --- /dev/null +++ b/compiler/image.ts @@ -0,0 +1,426 @@ +/** + * Images a script imports: `import logo from "./logo.image"`. An `.image` file is one of the script's files, kept in + * the map with the others: the pixels of a picture as the editor decoded them when it was imported — at the size the + * person chose, in grey or in colour, with its transparency. The source file is not kept, for the reason a video's is + * not (`media.ts`): a browser decodes a PNG or a JPEG through its own colour management, and what the build reads has + * to be the same on every computer. + * + * What a script gets is an `Image` (below), worked on when the script is built: its pixels read one by one, and new + * images made from it — cropped, scaled, flipped, turned, inverted — and, at the end, rows of text for `fog.picture` + * (`threshold`, or `halftone` for greys the fog can show) or rows of tones a library draws its own way (`tones`). None of it reaches the game but what + * the script makes of it. + * + * The file is text, one line of JSON: the sizes, which planes it has, and the planes in base64. Each plane is coded the + * way lossless JPEG codes one: each pixel predicted from the three before it (left, above, above left — the median + * edge detector of JPEG-LS), and what the prediction missed by coded with LZMA's range coder, its odds chosen by how + * busy the neighbourhood is. Colour is coded as green, and red and blue as their differences from green, which are + * small wherever a picture is near grey. + */ +import { FOG_SHAPES } from "./fogShapes"; +import { fromBase64, PROB_HALF, RangeDecoder, RangeEncoder, toBase64 } from "./rangeCoder"; + +/** The extension of an image among a script's files. */ +export const IMAGE_EXTENSION = ".image"; +/** The most pixels an image is each way. */ +export const MAX_IMAGE_SIDE = 1024; + +/** The format's version, in the file. */ +const VERSION = 1; + +/** What the editor writes from a decoded picture: width × height pixels, four bytes each — red, green, blue, alpha. */ +export interface ImagePixels { + width: number; + height: number; + rgba: Uint8Array; + /** Keep grey levels only, whatever colour the picture has. */ + grey?: boolean; + /** The file it was imported from, for the hover; a name, never a path. */ + source?: string; +} + +interface ImageHeader { trigscript: "image"; version: number; width: number; height: number; color: boolean; alpha: boolean; source?: string; data: string } + +/** An image as the build reads it. */ +export interface ImageData { + readonly width: number; + readonly height: number; + /** Whether red, green and blue differ anywhere; when not, a pixel's grey is its red. */ + readonly color: boolean; + /** Whether any pixel is less than opaque. */ + readonly alpha: boolean; + readonly source?: string; + /** Four bytes a pixel, row by row: red, green, blue, alpha. */ + readonly rgba: Uint8Array; +} + +/** A pixel's grey, 0 black to 255 white: what Import Video… takes a picture's tiles by. */ +const luma = (r: number, g: number, b: number): number => Math.round(0.299 * r + 0.587 * g + 0.114 * b); + +/** The `.image` file's text for these pixels. */ +export function writeImage(p: ImagePixels): string { + const { width, height } = p; + checkSize(width, height); + const n = width * height; + if (p.rgba.length !== n * 4) throw new Error(`A ${width} × ${height} image has ${n * 4} bytes of red, green, blue and alpha, not ${p.rgba.length}.`); + const rgba = Uint8Array.from(p.rgba); + if (p.grey) for (let i = 0; i < n * 4; i += 4) rgba[i] = rgba[i + 1] = rgba[i + 2] = luma(rgba[i], rgba[i + 1], rgba[i + 2]); + let color = false, alpha = false; + for (let i = 0; i < n * 4; i += 4) { + if (rgba[i] !== rgba[i + 1] || rgba[i + 1] !== rgba[i + 2]) color = true; + if (rgba[i + 3] !== 255) alpha = true; + } + const coder = new RangeEncoder(); + const odds = newOdds(); + planes(rgba, n, color, alpha).forEach((plane, k) => codePlane(plane, width, height, (ctx, value) => { + // The value as eight bits, highest first, each with the odds of the bits above it. + for (let bit = 7, node = 1; bit >= 0; bit--) { + const b = (value >> bit) & 1; + coder.bit(odds, oddsAt(k, ctx, node), b); + node = node * 2 + b; + } + return value; + })); + const header: ImageHeader = { trigscript: "image", version: VERSION, width, height, color, alpha, ...(p.source ? { source: p.source } : {}), data: toBase64(coder.finish()) }; + return JSON.stringify(header); +} + +/** Read an `.image` file's text. Throws, in words for the person, when it is not one. */ +export function readImage(text: string): ImageData { + let h: Partial; + try { h = JSON.parse(text) as Partial; } catch { throw new Error("This is not an image TrigScript imported: an .image file is one line of JSON."); } + if (h?.trigscript !== "image" || typeof h.data !== "string") throw new Error("This is not an image TrigScript imported."); + if (h.version !== VERSION) throw new Error(`This image was written by a newer TrigScript (format ${h.version}); update the plugin to read it.`); + const width = Number(h.width), height = Number(h.height), color = h.color === true, alpha = h.alpha === true; + checkSize(width, height); + const n = width * height; + const coder = new RangeDecoder(fromBase64(h.data)); + const odds = newOdds(); + const got = planes(new Uint8Array(n * 4), n, color, alpha).map((plane, k) => { + codePlane(plane, width, height, (ctx) => { + let node = 1; + for (let bit = 0; bit < 8; bit++) node = node * 2 + coder.bit(odds, oddsAt(k, ctx, node)); + return node - 256; + }); + if (coder.overrun) throw new Error("The image's pixels end too soon: the file is cut short."); + return plane; + }); + const rgba = new Uint8Array(n * 4); + for (let i = 0; i < n; i++) { + if (color) { + const g = got[0][i]; + rgba[i * 4] = (got[1][i] + g - 128) & 255; + rgba[i * 4 + 1] = g; + rgba[i * 4 + 2] = (got[2][i] + g - 128) & 255; + } else rgba[i * 4] = rgba[i * 4 + 1] = rgba[i * 4 + 2] = got[0][i]; + rgba[i * 4 + 3] = alpha ? got[color ? 3 : 1][i] : 255; + } + return Object.freeze({ width, height, color, alpha, ...(typeof h.source === "string" ? { source: h.source } : {}), rgba }); +} + +/** What the hover on an import says: "An image, 64 × 48 pixels, in colour, with transparency, from logo.png." */ +export function describeImage(text: string): string { + try { + // The header alone: the pixels are not decoded for this. + const h = JSON.parse(text) as Partial; + if (h?.trigscript !== "image") return "This is not an image TrigScript imported."; + return `An image, ${Number(h.width)} × ${Number(h.height)} pixels, ${h.color ? "in colour" : "in grey"}${h.alpha ? ", with transparency" : ""}${typeof h.source === "string" ? `, from ${h.source}` : ""}.`; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +function checkSize(width: number, height: number): void { + if (!(Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0 && width <= MAX_IMAGE_SIDE && height <= MAX_IMAGE_SIDE)) { + throw new Error(`An image is 1 to ${MAX_IMAGE_SIDE} pixels each way, not ${width} × ${height}.`); + } +} + +/* ── The coding ── */ + +/** How many kinds of neighbourhood a pixel's odds are chosen by: flat, then busier by powers of two. */ +const BUSY = 8; + +const newOdds = () => new Uint16Array(4 * BUSY * 256).fill(PROB_HALF); +const oddsAt = (plane: number, busy: number, node: number) => (plane * BUSY + busy) * 256 + node; + +/** + * The planes a file holds, as arrays `codePlane` fills or reads: grey (or green, red − green + 128, blue − green + + * 128), then alpha when there is any. Writing, they are made from `rgba`; reading, they start empty. + */ +function planes(rgba: Uint8Array, n: number, color: boolean, alpha: boolean): Uint8Array[] { + const plane = (f: (i: number) => number) => Uint8Array.from({ length: n }, (_, i) => f(i * 4)); + const out = color + ? [plane((i) => rgba[i + 1]), plane((i) => (rgba[i] - rgba[i + 1] + 128) & 255), plane((i) => (rgba[i + 2] - rgba[i + 1] + 128) & 255)] + : [plane((i) => rgba[i])]; + if (alpha) out.push(plane((i) => rgba[i + 3])); + return out; +} + +/** + * Each pixel of a plane in turn, row by row: `code` is given how busy the neighbourhood is and what the prediction + * missed by (folded to 0 … 255: 0, −1, 1, −2, …) — the value to write, or, reading, a value it ignores — and gives + * back the miss, which puts the pixel in the plane. + */ +function codePlane(plane: Uint8Array, width: number, height: number, code: (busy: number, miss: number) => number): void { + for (let y = 0, i = 0; y < height; y++) for (let x = 0; x < width; x++, i++) { + // Off the picture: the first row has only its left, the first column only what is above. + const a = x > 0 ? plane[i - 1] : y > 0 ? plane[i - width] : 0; + const b = y > 0 ? plane[i - width] : a; + const c = x > 0 && y > 0 ? plane[i - width - 1] : b; + const guess = c >= Math.max(a, b) ? Math.min(a, b) : c <= Math.min(a, b) ? Math.max(a, b) : a + b - c; + const d = Math.abs(a - c) + Math.abs(b - c); + const busy = d === 0 ? 0 : Math.min(BUSY - 1, 1 + Math.floor(Math.log2(d))); + const e = (plane[i] - guess) & 255, s = e < 128 ? e : e - 256; + const got = code(busy, s >= 0 ? 2 * s : -2 * s - 1); + plane[i] = (guess + (got & 1 ? -(got + 1) / 2 : got / 2)) & 255; + } +} + +/* ── What a script gets ── */ + +/** Rows of text, as `fog.picture` takes them. */ +type Rows = readonly string[]; + +/** A brush of a halftone: which of the fog's shapes (0 the first), centred on tile (x, y) of the picture. */ +export interface HalftoneBrush { readonly x: number; readonly y: number; readonly shape: number } + +/** What a script imports: `import logo from "./logo.image"`. Every method is worked out when the script is built. */ +export interface Image { + readonly __trigscript: "image"; + readonly width: number; + readonly height: number; + /** Whether it has colour; when not, its red, green and blue are its grey. */ + readonly color: boolean; + /** Whether any pixel is less than opaque. */ + readonly transparent: boolean; + grey(x: number, y: number): number; + rgb(x: number, y: number): { readonly r: number; readonly g: number; readonly b: number }; + alpha(x: number, y: number): number; + crop(x: number, y: number, width: number, height: number): Image; + scale(width: number, height?: number): Image; + flip(axis: "horizontal" | "vertical"): Image; + rotate(turn: 90 | 180 | 270): Image; + invert(): Image; + threshold(level?: number, options?: { readonly ink?: "light" | "dark" }): Rows; + tones(count: number): Rows; + halftone(cellWidth: number, cellHeight?: number, options?: { readonly diffuse?: boolean }): Rows; +} + +export const isImage = (v: unknown): v is Image => typeof v === "object" && v !== null && (v as Image).__trigscript === "image"; + +/** A pixel at least this opaque is part of the picture: anything less is never ink. */ +const OPAQUE = 128; + +const whole = (v: unknown, what: string): number => { + if (typeof v !== "number" || !Number.isInteger(v)) throw new Error(`${what} is a whole number, not ${String(v)}.`); + return v; +}; + +const inkOf = (options: { readonly ink?: unknown } | undefined): boolean => { + const ink = options?.ink ?? "light"; + if (ink !== "light" && ink !== "dark") throw new Error(`ink is "light" or "dark", not ${JSON.stringify(ink)}.`); + return ink === "dark"; +}; + +/** The value of the image these pixels are. */ +export function makeImage(d: ImageData): Image { + const { width: w, height: h, rgba } = d; + const n = w * h; + const at = (x: unknown, y: unknown): number => { + whole(x, "x"); + whole(y, "y"); + if ((x as number) < 0 || (y as number) < 0 || (x as number) >= w || (y as number) >= h) throw new RangeError(`The image is ${w} × ${h}; there is no pixel (${String(x)}, ${String(y)}).`); + return ((y as number) * w + (x as number)) * 4; + }; + const greyAt = (i: number) => (d.color ? luma(rgba[i], rgba[i + 1], rgba[i + 2]) : rgba[i]); + // An image of another size, each pixel taken from this one's pixel at `from(x, y)`. + const remap = (nw: number, nh: number, from: (x: number, y: number) => number): Image => { + const out = new Uint8Array(nw * nh * 4); + for (let y = 0, o = 0; y < nh; y++) for (let x = 0; x < nw; x++, o += 4) out.set(rgba.subarray(from(x, y), from(x, y) + 4), o); + return makeImage({ ...d, width: nw, height: nh, rgba: out }); + }; + // Rows of text, a character a pixel; a pixel that is not opaque enough is ".". + const rows = (char: (grey: number, i: number) => string): Rows => { + const out: string[] = []; + for (let y = 0; y < h; y++) { + let row = ""; + for (let x = 0; x < w; x++) { + const i = (y * w + x) * 4; + row += rgba[i + 3] < OPAQUE ? "." : char(greyAt(i), y * w + x); + } + out.push(row); + } + return Object.freeze(out); + }; + const image: Image = { + __trigscript: "image", + width: w, + height: h, + color: d.color, + transparent: d.alpha, + grey: (x, y) => greyAt(at(x, y)), + rgb: (x, y) => { const i = at(x, y); return Object.freeze({ r: rgba[i], g: rgba[i + 1], b: rgba[i + 2] }); }, + alpha: (x, y) => rgba[at(x, y) + 3], + crop(x, y, cw, ch) { + for (const [v, what] of [[x, "x"], [y, "y"], [cw, "width"], [ch, "height"]] as const) whole(v, what); + if (x < 0 || y < 0 || cw < 1 || ch < 1 || x + cw > w || y + ch > h) throw new RangeError(`crop(${x}, ${y}, ${cw}, ${ch}) is not inside the image, which is ${w} × ${h}.`); + return remap(cw, ch, (px, py) => ((y + py) * w + x + px) * 4); + }, + scale(sw, sh) { + whole(sw, "width"); + const th = sh === undefined ? Math.max(1, Math.round((sw * h) / w)) : whole(sh, "height"); + checkSize(sw, th); + return makeImage({ ...d, width: sw, height: th, rgba: resample(rgba, w, h, sw, th) }); + }, + flip(axis) { + if (axis === "horizontal") return remap(w, h, (x, y) => (y * w + (w - 1 - x)) * 4); + if (axis === "vertical") return remap(w, h, (x, y) => ((h - 1 - y) * w + x) * 4); + throw new Error(`flip() takes "horizontal" or "vertical", not ${JSON.stringify(axis)}.`); + }, + rotate(turn) { + // Clockwise: the new image's top row is the old one's left column, read from the bottom up. + if (turn === 90) return remap(h, w, (x, y) => ((h - 1 - x) * w + y) * 4); + if (turn === 180) return remap(w, h, (x, y) => ((h - 1 - y) * w + (w - 1 - x)) * 4); + if (turn === 270) return remap(h, w, (x, y) => (x * w + (w - 1 - y)) * 4); + throw new Error(`rotate() turns by 90, 180 or 270 degrees clockwise, not ${String(turn)}.`); + }, + invert() { + const out = Uint8Array.from(rgba); + for (let i = 0; i < n * 4; i += 4) { out[i] = 255 - out[i]; out[i + 1] = 255 - out[i + 1]; out[i + 2] = 255 - out[i + 2]; } + return makeImage({ ...d, rgba: out }); + }, + threshold(level = 128, options) { + if (typeof level !== "number" || !(level >= 0 && level <= 256)) throw new RangeError(`threshold() takes a grey from 0 to 256, not ${String(level)}.`); + const dark = inkOf(options); + return rows((g) => ((g >= level) !== dark ? "#" : ".")); + }, + tones(count) { + whole(count, "tones()' count"); + if (count < 2 || count > 10) throw new RangeError(`tones() makes 2 to 10 tones, a digit each, not ${count}.`); + return rows((g) => String(Math.min(count - 1, Math.floor((g * count) / 256)))); + }, + halftone(cw, ch, options) { + whole(cw, "halftone()'s cell width"); + const cellH = ch === undefined ? cw + 1 : whole(ch, "halftone()'s cell height"); + if (cw < 3 || cellH < 4 || cw > 32 || cellH > 32) throw new RangeError(`halftone() takes cells 3 to 32 tiles across and 4 to 32 down (the smallest brush is 3 × 4), not ${cw} × ${cellH}.`); + return halftone(w, h, (i) => (rgba[i * 4 + 3] < OPAQUE ? -1 : greyAt(i * 4)), cw, cellH, options?.diffuse !== false); + }, + }; + return Object.freeze(image); +} + +/** + * Grey in the fog: the picture cut into cells, each lit by one of the fog's own brushes or none — a larger one for a + * lighter cell, the largest a tile past the cell on each side so that neighbours run together into white. Cells are + * taken row by row: each chooses, of none and each brush centred on it, the one whose new light — the tiles it lights + * that nothing lit before, wherever they are — is nearest the cell's grey times its size; what that missed by is + * handed on to the cells after it (Floyd–Steinberg, a cell a step), so an area's share of light is its grey. Without + * `diffuse`, each cell's brush is the nearest to its own grey alone: an even grid of dots, the greys in steps. One brush a cell at + * most, each drawn exactly as the game lights it and inside the picture; the rows carry them as `brushes`. `grey(i)` is pixel i's grey, or −1 where it is not opaque enough; + * a cell mostly of those is left dark. + */ +function halftone(w: number, h: number, grey: (i: number) => number, cw: number, ch: number, diffuse: boolean): Rows { + const lit = new Uint8Array(w * h), brushes: HalftoneBrush[] = []; + // Where the cells start, each way; what is left over at the end, too thin for a brush, goes to the cell before it. + const starts = (size: number, cell: number, least: number) => { + const at: number[] = []; + for (let p = 0; p < size; p += cell) at.push(p); + if (at.length > 1 && size - at[at.length - 1] < least) at.pop(); + return [...at, size]; + }; + const xs = starts(w, cw, 3), ys = starts(h, ch, 4); + const cols = xs.length - 1, rowsOfCells = ys.length - 1; + const err = new Float32Array(cols * rowsOfCells); + for (let cy = 0; cy < rowsOfCells; cy++) for (let cx = 0; cx < cols; cx++) { + const x0 = xs[cx], y0 = ys[cy], x1 = xs[cx + 1], y1 = ys[cy + 1]; + const area = (x1 - x0) * (y1 - y0); + let sum = 0, seen = 0; + for (let y = y0; y < y1; y++) for (let x = x0; x < x1; x++) { const g = grey(y * w + x); if (g >= 0) { sum += g; seen++; } } + if (seen * 2 < area) continue; + // A brush centred on the cell — moved in from an edge of the picture, which it never lights past — the tiles it + // lights and the tile it is centred on; null for one larger than the picture. + const place = (shape: readonly string[]) => { + const fit = (at: number, size: number, room: number) => Math.max(0, Math.min(room - size, at)); + const sx = fit(x0 + ((x1 - x0 - shape[0].length) >> 1), shape[0].length, w), sy = fit(y0 + ((y1 - y0 - shape.length) >> 1), shape.length, h), tiles: number[] = []; + let centre = { x: 0, y: 0 }, inside = true; + shape.forEach((row, dy) => { + for (let dx = 0; dx < row.length; dx++) { + const x = sx + dx, y = sy + dy; + if (row[dx] === "@") centre = { x, y }; + if (row[dx] === ".") continue; + if (x < 0 || y < 0 || x >= w || y >= h) inside = false; + else tiles.push(y * w + x); + } + }); + return inside ? { tiles, centre } : null; + }; + // What a brush gives, as a share of the cell: every tile it lights that nothing lit before, in this cell or past + // it — what spills into a neighbour is light this cell put there, and the neighbour counts only its own. + const share = (tiles: readonly number[]) => tiles.reduce((n, i) => n + (lit[i] ? 0 : 1), 0) / area; + const want = sum / seen / 255 + err[cy * cols + cx]; + let best: { tiles: number[]; centre: { x: number; y: number }; shape: number } | null = null, bestShare = 0; + for (const [k, shape] of FOG_SHAPES.entries()) { + if (shape[0].length > x1 - x0 + 2 || shape.length > y1 - y0 + 2) break; + const at = place(shape); + if (!at) continue; + const s = share(at.tiles); + if (Math.abs(s - want) < Math.abs(bestShare - want)) { best = { ...at, shape: k }; bestShare = s; } + } + if (best) { + for (const i of best.tiles) lit[i] = 1; + brushes.push(Object.freeze({ ...best.centre, shape: best.shape })); + } + const miss = diffuse ? want - bestShare : 0; + const give = (dx: number, dy: number, part: number) => { + const nx = cx + dx, ny = cy + dy; + if (nx >= 0 && nx < cols && ny < rowsOfCells) err[ny * cols + nx] += (miss * part) / 16; + }; + give(1, 0, 7); give(-1, 1, 3); give(0, 1, 5); give(1, 1, 1); + } + const out: string[] = []; + for (let y = 0; y < h; y++) { let row = ""; for (let x = 0; x < w; x++) row += lit[y * w + x] ? "#" : "."; out.push(row); } + // The rows carry the brushes that drew them, which fog.picture takes as they are: covering their union again could + // take more, where discs run together. + return Object.freeze(Object.assign(out, { brushes: Object.freeze(brushes) })); +} + +/** + * Pixels at another size, each the average of the part of the picture under it — the parts of pixels it covers + * counting for as much as they cover. Shrinking, that is an area's average; growing by a whole number, each pixel + * becomes a block of itself. Colour is averaged by how opaque it is, so transparent pixels darken no edge. + */ +function resample(rgba: Uint8Array, w: number, h: number, tw: number, th: number): Uint8Array { + // For each new column (row), the old columns (rows) under it and how much of each. + const spans = (from: number, to: number) => Array.from({ length: to }, (_, t) => { + const lo = (t * from) / to, hi = ((t + 1) * from) / to, parts: [number, number][] = []; + for (let s = Math.floor(lo); s < Math.min(from, Math.ceil(hi)); s++) parts.push([s, Math.min(hi, s + 1) - Math.max(lo, s)]); + return parts; + }); + const xs = spans(w, tw), ys = spans(h, th); + const out = new Uint8Array(tw * th * 4); + for (let y = 0; y < th; y++) for (let x = 0; x < tw; x++) { + let r = 0, g = 0, b = 0, a = 0, area = 0; + for (const [sy, wy] of ys[y]) for (const [sx, wx] of xs[x]) { + const i = (sy * w + sx) * 4, part = wx * wy, opaque = (rgba[i + 3] / 255) * part; + r += rgba[i] * opaque; g += rgba[i + 1] * opaque; b += rgba[i + 2] * opaque; + a += rgba[i + 3] * part; + area += part; + } + const o = (y * tw + x) * 4, weight = (a / 255) || 1; + out[o] = Math.round(r / weight); out[o + 1] = Math.round(g / weight); out[o + 2] = Math.round(b / weight); + out[o + 3] = Math.round(a / area); + } + return out; +} + +/** The value an import of an `.image` file gives, kept while the text is the same (a build reads the files each time). */ +const values = new Map(); + +export function imageValue(text: string): Image { + const hit = values.get(text); + if (hit) return hit; + const image = makeImage(readImage(text)); + if (values.size >= 8) values.delete(values.keys().next().value!); + values.set(text, image); + return image; +} diff --git a/compiler/input.ts b/compiler/input.ts index e0883c4..6721b41 100644 --- a/compiler/input.ts +++ b/compiler/input.ts @@ -2,17 +2,13 @@ * What the players do — keys, clicks, the mouse, what they type — as a program reads it. * * None of it is in the game's own tables where every computer sees the same: a key is pressed on - * one computer. Two euddraft plugins the eudplib library carries bring it to everyone in step: - * **chatEvent** finds the line the local player typed, and **MSQC** sends what happened on one - * computer to all of them, a frame or a few later, as the player it happened to. The compiler - * composes both from what the programs ask for (`inputPlan`, `inputPlugins`); the script never - * names them. What arrives lands in arrays of the lowering's own (`python/trigscript.py`), one - * cell per player, which last one frame — so an input is true on the frame it arrives and a - * program that wants it looks every frame. - * - * What it takes from the map: MSQC keeps one location for itself and, when the mouse is read, one - * more per player slot (eight in a row); it uses one unit type for its command units (the - * Valkyrie) which the map must not use, and Player 12 to hold them. + * one computer. The build carries it to every computer in step itself (`backend/input.ts`, step 12 + * of the way off eudplib): hidden units of Player 12 are given orders on the computer the input + * happened on, and the game sends the orders to everyone, where the programs read them back a + * frame or a few later. What arrives lasts one frame — so an input is true on the frame it + * arrives and a program that wants it looks every frame. This module is the front end's part: + * which keys, buttons, typed patterns and sync slots the programs read (`inputPlan`), and what a + * typed line matches (`matchChat`). */ import type { NameTable } from "./names"; import type { NumExpr, Program, Stmt, TextExpr, TextMap, UnitExpr, BoolExpr, Call, At } from "./ir"; @@ -20,9 +16,8 @@ import { bodiesOf, isTextExpr, isUnitExpr, mapText, mapTextOperands, mapTextPart export type MouseButton = "left" | "right" | "middle"; export const MOUSE_BUTTONS: readonly MouseButton[] = ["left", "right", "middle"]; -const MSQC_BUTTON: Record = { left: "L", right: "R", middle: "M" }; -/** The keys `keyPressed()` takes, as a script writes them, and the name MSQC knows each by. */ +/** The keys `keyPressed()` takes, as a script writes them, and the name each had in MSQC's settings (the build before step 12 carried input through that euddraft plugin; `virtualKey` still goes by these names). */ const NAMED_KEYS: Record = { Space: "SPACE", Enter: "ENTER", Escape: "ESC", Tab: "TAB", Shift: "SHIFT", Ctrl: "LCTRL", Alt: "LALT", Left: "LEFT", Up: "UP", Right: "RIGHT", Down: "DOWN", Backspace: "BACK", Delete: "DELETE", Insert: "INSERT", @@ -33,9 +28,14 @@ const DIGITS = "0123456789".split(""); /** * Keys the game never reports a press of, with why: the slice 4 probe pressed F6 in two builds — once * first in MSQC's settings, once third — and nothing arrived either time, while F7, F8, a digit and - * the letters beside it did. Left out of the list, so writing one is an error and not a silent key. + * the letters beside it did. The B2 keys probe (2026-10-17) found F2 to F5 the same, through on.key and + * keyPressed alike, while Q, F7 and F9 answered: F2–F4 are the game's camera keys. Left out of the list, + * so writing one is an error and not a silent key. F1, F10, F11 and F12 have not been played. */ -export const DEAF_KEYS: Readonly> = { F6: "StarCraft: Remastered keeps F6 to itself and reports no press of it (played and seen); F7 and F8 work" }; +const kept = (key: string, what: string) => `StarCraft: Remastered keeps ${key} to itself${what} and reports no press of it (played and seen); F7, F8 and F9 work`; +export const DEAF_KEYS: Readonly> = { + F2: kept("F2", " (a camera key)"), F3: kept("F3", " (a camera key)"), F4: kept("F4", " (a camera key)"), F5: kept("F5", ""), F6: kept("F6", ""), +}; /** Every key name, as the declarations list them. */ export const KEY_NAMES: readonly string[] = [ ...LETTERS, ...DIGITS, ...Array.from({ length: 12 }, (_, i) => `F${i + 1}`).filter((k) => !(k in DEAF_KEYS)), ...Object.keys(NAMED_KEYS), ...DIGITS.map((d) => `Numpad${d}`), @@ -47,18 +47,28 @@ export function keyName(v: string): string | null { return KEY_BY_LOWER.get(v.trim().toLowerCase()) ?? null; } -/** The name MSQC's settings take for a key of `KEY_NAMES`. */ +/** The name MSQC's settings took for a key of `KEY_NAMES` (`virtualKey` keys on it). */ export function msqcKey(key: string): string { return NAMED_KEYS[key] ?? key.toUpperCase(); } +/** The virtual key a key of `KEY_NAMES` is to the game (a byte of its key table, 0x596A18), or null for a name that is none. */ +export function virtualKey(key: string): number | null { + const k = msqcKey(key); + if (/^[A-Z0-9]$/.test(k)) return k.charCodeAt(0); + if (/^F([1-9]|1[0-2])$/.test(k)) return 0x6f + Number(k.slice(1)); + if (/^NUMPAD[0-9]$/.test(k)) return 0x60 + Number(k.slice(6)); + const named: Record = { SPACE: 0x20, ENTER: 0x0d, ESC: 0x1b, TAB: 0x09, SHIFT: 0x10, LCTRL: 0x11, LALT: 0x12, LEFT: 0x25, UP: 0x26, RIGHT: 0x27, DOWN: 0x28, BACK: 0x08, DELETE: 0x2e, INSERT: 0x2d, HOME: 0x24, END: 0x23, PGUP: 0x21, PGDN: 0x22 }; + return k in named ? named[k] : null; +} + /* ── Chat patterns ── */ -/** The most values a typed line can carry: each is one of MSQC's command units per player. */ +/** The most values a typed line can carry (a limit from the carriers' days under MSQC, kept). */ export const MAX_CHAT_CAPTURES = 3; /** What the game lets a player type. */ export const MAX_CHAT_BYTES = 78; -/** The most a number in a typed line comes through as: what MSQC can send on the smallest map. */ +/** The most a number in a typed line comes through as (what the carriers could send on the smallest map, kept). */ export const MAX_CHAT_NUMBER = 0xfffff; export type ChatCapture = @@ -167,34 +177,34 @@ export function matchChat(p: ChatPattern, line: string, unitByName: (lower: stri /** One thing a program reads from the players; `player` is a slot, or 13 for the current player. */ export type InputSource = - /** 1 on the frame the key's press arrives. */ - | { source: "key"; key: string; player: number } + /** 1 on the frame the key's press arrives; `held`, on every frame it is down. */ + | { source: "key"; key: string; player: number; held?: boolean } /** 1 on the frame the button's press arrives. */ | { source: "click"; button: MouseButton; player: number } /** Where the player's mouse is on the map, in pixels. */ | { source: "mouse"; axis: "x" | "y"; player: number } /** A typed line: with `capture` null, 1 on the frame a line matching the pattern arrives; else that capture's value on that frame, 0 otherwise. */ - | { source: "chat"; pattern: string; capture: number | null; player: number }; + | { source: "chat"; pattern: string; capture: number | null; player: number } + /** What `syncSend` of the slot sent from `player`'s computer, plus 1, once it has arrived; 0 until then (B5). */ + | { source: "sync"; slot: number; player: number }; -/** Everything the input plugins are set up from, written into the IR file beside the programs. */ +/** Everything the programs read of the players, for the build (`backend/input.ts`) and the simulator. */ export interface InputPlan { keys: string[]; + /** The keys a program asks are held, each read on every frame it is down. */ + held?: string[]; buttons: MouseButton[]; chats: ChatPattern[]; /** Unit names a `{…:unit}` capture knows, lower case, with the type each means; only when one is used. */ unitNames?: [name: string, id: number][]; - /** The location MSQC keeps for itself, as its 0-based slot. */ - qcLocation: number; - /** The 1-based number of the first of eight locations in a row MSQC keeps the players' mice in; null when no program reads the mouse. */ - mouseBase: number | null; + /** Whether a program reads the players' mice. */ + mouse: boolean; + /** How many `sync()` slots the programs use (B5): each a value sent from one computer to all; absent when none. */ + syncs?: number; } -/** The unit type MSQC makes its command units of (the Valkyrie), and the player that holds them, as MSQC's setting takes it: what Magenta's probe maps were played with. */ -export const QC_UNIT = 58; -export const QC_PLAYER = 11; -const MOUSE_SLOTS = 8; -/** Input's locations come from the first 63: every map's location table has those. */ -const LAST_SLOT = 62; +/** A library's reserved location comes from the first 63 (`reserveLocation`): every map's location table has those. */ +export const LAST_SLOT = 62; /** Every input a program reads, in the order met, and where the first is. */ export function inputsOf(programs: Program[]): { sources: InputSource[]; mouse: boolean; at: At | null } { @@ -224,6 +234,7 @@ export function inputsOf(programs: Program[]): { sources: InputSource[]; mouse: case "and": case "or": e.items.forEach(expr); break; case "not": case "test": expr(e.expr); break; case "edge": expr(e.cond); break; + case "running": expr(e.task); break; case "call": call(e.call); break; default: break; } @@ -244,19 +255,25 @@ export function inputsOf(programs: Program[]): { sources: InputSource[]; mouse: case "unitLoop": s.body.forEach(stmt); break; case "unitWrite": unit(s.unit); expr(s.value); break; case "unitDo": unit(s.unit); if (s.verb.do === "damage" || s.verb.do === "heal") expr(s.verb.amount); break; + case "select": s.units.forEach(unit); break; case "tableWrite": any(s.value); break; - case "centerLocation": expr(s.x); expr(s.y); break; + case "centerLocation": expr(s.x); expr(s.y); if (s.size) { expr(s.size.width); expr(s.size.height); } break; + case "syncSend": sources.push({ source: "sync", slot: s.slot, player: s.player }); at ??= s.at; expr(s.value); break; case "if": expr(s.cond); s.then.forEach(stmt); s.else?.forEach(stmt); break; case "while": if (s.cond) expr(s.cond); s.body.forEach(stmt); break; case "do": s.body.forEach(stmt); expr(s.cond); break; case "for": if (s.cond) expr(s.cond); s.update.forEach(stmt); s.body.forEach(stmt); break; case "unrolled": s.iterations.forEach((i) => i.forEach(stmt)); break; + case "parallel": s.branches.forEach((b) => b.forEach(stmt)); break; + case "start": call(s.call); break; + case "cancel": expr(s.task); break; case "switch": expr(s.value); s.cases.forEach((c) => c.body.forEach(stmt)); break; case "return": if (s.value) any(s.value); break; case "action": for (const v of s.variables ?? []) expr(v.expr); if (s.text) text(s.text); break; - case "print": mapTextParts(s.parts, looks); break; + case "print": case "buildText": mapTextParts(s.parts, looks); break; case "call": call(s.call); break; case "block": s.body.forEach(stmt); break; + case "try": s.body.forEach(stmt); s.catch.forEach(stmt); break; default: break; } }; @@ -265,37 +282,28 @@ export function inputsOf(programs: Program[]): { sources: InputSource[]; mouse: } /** - * The plan for a compile's programs: null when none reads the players. `locations` is the map's - * table, to keep clear of the slots it uses. Throws an Error worded for the author when the map - * has no room. + * The plan for a compile's programs: null when none reads the players. `units` is the map's + * table of unit names, for a `{…:unit}` capture of a typed line. */ -export function inputPlan(programs: Program[], locations: NameTable, units: NameTable): InputPlan | null { +export function inputPlan(programs: Program[], units: NameTable): InputPlan | null { const { sources, mouse } = inputsOf(programs); if (sources.length === 0 && !mouse) return null; const keys: string[] = []; + const held: string[] = []; const buttons: MouseButton[] = []; const chats: ChatPattern[] = []; for (const s of sources) { - if (s.source === "key" && !keys.includes(s.key)) keys.push(s.key); + if (s.source === "key" && s.held) { if (!held.includes(s.key)) held.push(s.key); } + else if (s.source === "key" && !keys.includes(s.key)) keys.push(s.key); else if (s.source === "click" && !buttons.includes(s.button)) buttons.push(s.button); else if (s.source === "chat" && !chats.some((c) => c.pattern === s.pattern)) chats.push(parseChatPattern(s.pattern)); } - // The highest free slots among the first 63, so the low numbers a map maker reaches for first stay free. - const used = new Set(locations.entries.map((e) => e.value - 1)); - const free = (slot: number) => slot >= 0 && slot <= LAST_SLOT && !used.has(slot); - let qcLocation = -1; - for (let slot = LAST_SLOT; slot >= 0; slot--) if (free(slot)) { qcLocation = slot; break; } - if (qcLocation < 0) throw new Error("Reading keys, clicks or chat needs one free location among the map's first 63 for the plugin that carries them between the players' computers; this map uses them all."); - let mouseBase: number | null = null; - if (mouse) { - for (let slot = LAST_SLOT - MOUSE_SLOTS + 1; slot >= 0 && mouseBase === null; slot--) { - let ok = true; - for (let i = 0; i < MOUSE_SLOTS; i++) if (!free(slot + i) || slot + i === qcLocation) ok = false; - if (ok) mouseBase = slot + 1; - } - if (mouseBase === null) throw new Error("Reading the mouse needs eight free locations in a row among the map's first 63, one per player, besides one more for the plugin that carries input; this map has no such run."); - } - const plan: InputPlan = { keys, buttons, chats, qcLocation, mouseBase }; + const syncs = Math.max(0, ...sources.map((s) => (s.source === "sync" ? s.slot + 1 : 0))); + // With a held key in the game, keys go every frame they are down, and a multiplayer game may bring two frames' commands + // at once — the later replaces the earlier, since a carrier has one order. A press of its own ("went down") would be + // lost that way, so every key is sent as held and a press is worked out where it arrives: held now, not before. + if (held.length) for (const k of keys) if (!held.includes(k)) held.push(k); + const plan: InputPlan = { keys, ...(held.length ? { held } : {}), buttons, chats, mouse, ...(syncs ? { syncs } : {}) }; if (chats.some((c) => c.captures.some((x) => x.kind === "unit"))) { const seen = new Set(); plan.unitNames = []; @@ -307,40 +315,23 @@ export function inputPlan(programs: Program[], locations: NameTable, units: Name return plan; } -/** The names the lowering registers its cells under, which is how MSQC's and chatEvent's settings reach them. */ -export const INPUT_NAMES = { - key: (i: number) => `tsin_key${i}`, - button: (i: number) => `tsin_button${i}`, - chatLocal: "tsin_chat", - chatIn: "tsin_chat_in", - captureLocal: (i: number) => `tsin_capture${i}`, - captureIn: (i: number) => `tsin_capture${i}_in`, - heard: "tsin_heard", pointer: "tsin_pointer", length: "tsin_length", pattern: "tsin_pattern", -}; - /** - * The euddraft plugin sections for a plan, in the order they have to run around `trigscript`: - * chatEvent before it (it finds the typed line the lowering then reads), MSQC after it (it sends - * what the lowering made of the line). Every address is a name the lowering registers. + * Each program numbers its `sync()` slots from 0; the slots of a build are one list, so a program's are moved past those of + * the programs before it. Gives how many the program has. */ -export function inputPlugins(plan: InputPlan): { before: Record>; after: Record> } { - const before: Record> = {}; - if (plan.chats.length) before.chatEvent = { __addr__: INPUT_NAMES.heard, __ptrAddr__: INPUT_NAMES.pointer, __lenAddr__: INPUT_NAMES.length, __patternAddr__: INPUT_NAMES.pattern }; - const msqc: Record = { QCUnit: QC_UNIT, QCLoc: plan.qcLocation, QCPlayer: QC_PLAYER, QCDebug: "false" }; - plan.keys.forEach((key, i) => { msqc[`KeyPress(${msqcKey(key)}); NotTyping`] = `${INPUT_NAMES.key(i)}, 1`; }); - plan.buttons.forEach((b, i) => { msqc[`MouseDown(${MSQC_BUTTON[b]})`] = `${INPUT_NAMES.button(i)}, 1`; }); - if (plan.mouseBase !== null) msqc.Mouse = plan.mouseBase; - if (plan.chats.length) { - const sent = `${INPUT_NAMES.chatLocal}.AtLeast(1)`; - msqc[`${sent}; val, ${INPUT_NAMES.chatLocal}`] = INPUT_NAMES.chatIn; - const captures = Math.max(0, ...plan.chats.map((c) => c.captures.length)); - for (let i = 0; i < captures; i++) msqc[`${sent}; val, ${INPUT_NAMES.captureLocal(i)}`] = INPUT_NAMES.captureIn(i); - } - return { before, after: { MSQC: msqc } }; -} - -/** The whole `plugins` of a build, in order: what the service contributes and the tests build with. */ -export function buildPlugins(plan: InputPlan | null, irPath: string): Record> { - const input = plan ? inputPlugins(plan) : { before: {}, after: {} }; - return { ...input.before, trigscript: { ir: irPath }, ...input.after, eudTurbo: {} }; +export function numberSyncSlots(program: Program, base: number): number { + let count = 0; + // An expression may be one object in several places: each is moved once. + const seen = new Set(); + const walk = (v: unknown): void => { + if (Array.isArray(v)) { v.forEach(walk); return; } + if (!v || typeof v !== "object" || seen.has(v)) return; + seen.add(v); + const o = v as Record; + if ((o.kind === "syncSend" || o.source === "sync") && typeof o.slot === "number") { count = Math.max(count, o.slot + 1); o.slot += base; } + for (const k in o) if (k !== "at") walk(o[k]); + }; + walk(program.body); + walk(program.functions ?? []); + return count; } diff --git a/compiler/ir.ts b/compiler/ir.ts index 9be9a21..a1329c5 100644 --- a/compiler/ir.ts +++ b/compiler/ir.ts @@ -2,14 +2,25 @@ * The intermediate representation: what a `program(() => { … })` body means, with * everything TypeScript-specific already settled — build-time values evaluated, records * split into their fields, functions inlined at their calls, `for…of` unrolled — and - * nothing about eudplib decided yet. `structured.ts` emits it from the TS AST, - * `python/trigscript.py` lowers it to eudplib, `simulateIr.ts` interprets it. - * Plain data, JSON-serialisable (`eud.ts#serializeIr`); `docs/ir.md` is the reference. + * nothing about triggers decided yet. `structured.ts` emits it from the TS AST, the + * backend (`backend/emit.ts`) lowers it to triggers, `simulateIr.ts` interprets it. + * Plain data, JSON-serialisable; `docs/ir.md` is the reference. */ import type { ActionRecord, ConditionRecord } from "../vendor/triggers"; import type { InputSource } from "./input"; /** + * 26: a `graph` statement — a loop the emitter (`backend/emit.ts`) lowered to triggers of its own, which a backend makes as they are; `original` is the loop, for what does not lower (the simulator). + * 25: value ranges — `range` on a number of the IR, `[lowest, highest]` of its signed reading, which `ranges.ts` found it can never leave (absent: any 32 bits). A backend may take only the bits a range has; it is never told anything that is not so, and one that reads no ranges does what it did. + * 24: closures over locals — `VarDecl.held`: a variable that holds a reference the closures count, read by its own `declare` (what it held is let go of) and so 0 before it was ever declared. Everything else of closures is the front end's: an environment is a row of arrays of the program, and the counting is statements. + * 22: exceptions — `taskHandle`, the number of the task an expression runs in; `try` (a body, and the statements that run when an error is thrown in it), `throw` (the error in flight goes to the nearest `try` around it, out through the calls it is in), `uncaught` (the error in flight is said in red and given up), and `Program.errors`, the variables an error in flight is kept in. A called function that may throw returns at once with the error in flight, and its call goes on to the nearest `try`. A function that calls itself throws a `RangeError` when the stack runs out, where it used to stop the program. + * 21: functions as values — `fn` on a `const`: the number is a function's, the id of the `FuncDecl` it runs, which is what keeps that function in the map. A call through such a number is a call of a function the front end made (a *dispatcher*, a `switch` on its first parameter), so no new kind of statement. A function may now be called, and so lowered, before a variable it uses is declared: a backend makes a variable where it is first used. + * 20: fixed-point numbers — `VarDecl.fixed` / `ArrayDecl.fixed` (a 16.16 number: the cell holds the value times 65 536), `fixed` on a `const` (its value is the raw cell), the intrinsics `fmul` / `fdiv` (a product and a quotient of two fixed numbers, rounded towards zero), `sqrt` (of a fixed, rounded down) and `atan2` (`table`: the program's array of arc tangents it reads), and `fixed` on a printed number (shown as a decimal). `numbers.ts` writes them; a backend never works a type out. + * 19: more events — `unitLoop.source` also `killed` (a death in a fight, the killer in `was`), `ordered` (a unit that took up a new order, the one it had in `was`) and `morphed` (a unit that turned into another type, the type it was in `was`); `unitLoop.event` filters them by the new order or the killer. + * 18: events — `unitLoop.source`: the units that came onto the map (`created`) or died (`died`) since the frame before, as the frame's events pass found them, where a loop without one goes through the unit table. + * 17: texts built a piece at a time — `buildText` writes parts into a buffer (`reset` starts it empty) and `built` is what the buffer holds, which is how a JSX text is put together without copying it at every piece; `char` among the parts, one byte from a number (a colour code). + * 16: tasks — `start`: a task that runs beside what started it, a copy of an async function's body with the arguments it was started with; `cancel` and `running` on what `start()` gives back. A task's variables and arrays (`VarDecl.task`, `ArrayDecl.task`) are a row a task, as many rows as tasks of it can run at once. + * 15: `parallel` — `await Promise.all([…])` / `Promise.race([…])`: branches that run side by side, each a coroutine of its own inside the program's frame. * 14: a text in the cells of three arrays — `textAt`, `storeText`, `releaseText` — which is what lets a row of an array of records hold one. * 13: texts — a `text` variable (`VarDecl.text`: the id of a text of the built map's table, or a text that was made: where it is, the block of the heap it owns, its length in characters), `TextExpr`, `assignText`, `textLoop`, a text among a `print`'s parts, in an action's text field (`action.text`) and as a unit type's name; `textLength` / `textIndexOf` / `textCode` among the numbers, `textCompare` / `textTest` among the conditions; `ArrayDecl.texts`. * 12: arrays inside things — `ArrayDecl.slice` (a window on another array's cells) and `ArrayDecl.through` (a growing array whose handle is a cell of four others). @@ -24,7 +35,8 @@ import type { InputSource } from "./input"; * 3: reads (`read`), `random(n)` as a number, the bitwise operators and `print` with its text in parts. * 2: a record's text and sound are written out in the JSON (1 had the map's string indices); `cyclesPerSecond` is gone. */ -export const IR_VERSION = 14; +/** The most a buffer of `buildText` holds, in bytes. Past it the text is cut. */ +export const BUILD_BYTES = 2047; /** Where a node came from; `column` is 1-based like `line`. */ export interface At { file: string; line: number; column: number } @@ -43,14 +55,46 @@ export interface VarDecl { * just made is moved), and assigning or declaring again gives the old block back first. */ text?: "id" | "made"; + /** + * A map of our own only (`compiler/backend/texts.ts`): kept in one cell and read where it is used, as a variable the + * emitter copies is — for one read in many places and written often (a write is one action, not one a place). + */ + routed?: boolean; + /** + * A map of our own only (`compiler/backend/heap.ts`): never below zero and at most this, whatever is written — a copy + * of it into a place that holds a multiple of it is then worked out over only the bits it can have. + */ + top?: number; + /** + * A map of our own only (`compiler/backend/heap.ts`): one cell for the whole map — every program and every run of one + * — that no program declares, 0 as the map starts, and read where it is used (`routed` too). + */ + global?: boolean; + /** + * A map of our own only (`compiler/backend/heap.ts`): kept in every place it is read, however many — written seldom and + * read often (where an array's block starts), so that a read never copies it in, as a variable past a trigger's worth + * of places otherwise is. + */ + everywhere?: boolean; + /** + * A map of our own only (`compiler/backend/heap.ts`): declared in a loop's body and read nowhere outside the loop — a + * `for…of`'s variable — so its cell need not hold what is read into it. + */ + scoped?: boolean; /** `shared(…)`: one cell for every player of a per-player program. */ shared: boolean; /** A `u8` / `u16` annotation; unset for the full 32 bits. */ bits?: 8 | 16; /** A `u32`: the 32 bits read as 0 … 4 294 967 295. Unset, a number is signed (a `u8` / `u16` never goes below zero either way). */ unsigned?: boolean; + /** A `fixed`: a 16.16 number, the cell the value times 65 536 (signed). Declared so, or found by `numbers.ts` to be stored a fraction. */ + fixed?: boolean; /** A backend's scratch value that dies with the statement (a function's result). */ temp?: boolean; + /** A variable of a task's body (`start`, its `task`): a cell for each of its slots, within each player's row. */ + task?: number; + /** A reference the closures count (24): its declare lets go of what it held before, so it is read first — 0 the first time, as a backend's cell is. */ + held?: boolean; at: At; } @@ -69,9 +113,13 @@ export interface ArrayDecl { shared: boolean; bits?: 8 | 16; unsigned?: boolean; + /** Its cells are `fixed` numbers (16.16), as a variable's are; `values` are then the raw cells. */ + fixed?: boolean; values?: number[]; /** A list of texts the script has, which a program indexes with a variable (`titles[level]`): `values` are their places here, the lowering's their ids in the built map's table. */ texts?: string[]; + /** An array declared in a task's body (`start`, its `task`): its cells once for each of its slots. */ + task?: number; /** * The array grows: something pushes to it, pops from it or sets its length. Its cells are a block of the * programs' heap (`HEAP_CELLS`), reached through a handle — where the block is, how many cells are in use, how @@ -105,6 +153,12 @@ export interface ArrayDecl { export const HEAP_CELLS = 16384; export const HEAP_CELLS_MIN = 1024; export const HEAP_CELLS_MAX = 1 << 20; +/** + * Up to this many heap cells, growing arrays keep cells of their own beside the heap's (72 bytes each while the map is + * played, read and written in a few triggers); past it they share the heap's four-byte cells, as in 3.x, and a read + * takes about three times as long. + */ +export const CELL_HEAP_MOST = 1 << 16; /** A heap size as the build takes it: whole, within the limits; the default for anything that is not a number. */ export function heapCells(value: unknown): number { return typeof value === "number" && Number.isFinite(value) ? Math.min(HEAP_CELLS_MAX, Math.max(HEAP_CELLS_MIN, Math.floor(value))) : HEAP_CELLS; @@ -117,6 +171,8 @@ export const HEAP_SMALLEST = 4; * largest frame any program has — and only there when some function recurses, so the limit is the same whatever the * programs' arrays hold: the simulator and the game stop at the same call. */ +/** How many tasks of one `start` can run at once, unless the start says (`{ max }`, 1 … 255). */ +export const TASK_MAX = 16; export const STACK_DEPTH = 1024; export const STACK_DEPTH_MIN = 16; export const STACK_DEPTH_MAX = 65536; @@ -138,8 +194,12 @@ export type RaceId = 0 | 1 | 2; * race or (`race` null) of the race the player is. `player` is a slot, or 13 for the * current player. */ +/** + * `epd` (a map of our own only, `compiler/backend/texts.ts`): the condition's player field is this value — a Deaths of + * unit 0 then reads the dword at that EPD, wherever it is. + */ export type ReadSource = - | { source: "condition"; record: ConditionRecord } + | { source: "condition"; record: ConditionRecord; epd?: NumExpr } | { source: "player"; fact: "race" | "slot" | "left"; player: number } | { source: "supply"; of: "used" | "max" | "provided"; race: RaceId | null; player: number }; @@ -150,15 +210,24 @@ export type ReadSource = */ export interface UnitFilter { type?: number; owner?: number; at?: number } -/** A number of a unit on the map. Hit points, shields and energy are whole points; the timers and the cooldown frames. */ +/** + * Where the units of an events pass loop come from (`unitLoop.source`): the ones that came onto the map, died, died in a + * fight, took up a new order, or turned into another type since the pass of the frame before. + */ +export type EventSource = "created" | "died" | "killed" | "ordered" | "morphed"; + +/** + * A number of a unit on the map. Hit points, shields and energy are whole points; the timers, the cooldown and the + * remaining build time frames; `matrix` the defensive matrix's frames; `speed` the current speed in 256ths of a pixel a frame. + */ export type UnitNumField = "hp" | "maxHp" | "shields" | "maxShields" | "energy" | "owner" | "type" | "x" | "y" | "kills" | "orderId" | "cooldown" | "resources" - | "stim" | "ensnare" | "plague" | "lockdown" | "maelstrom" | "irradiate" | "stasis"; + | "stim" | "ensnare" | "plague" | "lockdown" | "maelstrom" | "irradiate" | "stasis" | "buildTime" | "rank" | "matrix" | "speed"; /** The ones a program may write; the rest the game keeps for itself (a position write ends the game). */ -export const UNIT_WRITABLE: ReadonlySet = new Set(["hp", "shields", "energy", "kills", "cooldown", "resources", "stim", "ensnare", "plague", "lockdown", "maelstrom", "irradiate", "stasis", "invincible"]); -export const UNIT_NUM_FIELDS: readonly UnitNumField[] = ["hp", "maxHp", "shields", "maxShields", "energy", "owner", "type", "x", "y", "kills", "orderId", "cooldown", "resources", "stim", "ensnare", "plague", "lockdown", "maelstrom", "irradiate", "stasis"]; -/** A true / false of a unit on the map; only `invincible` takes a write. */ -export type UnitFlag = "hallucinated" | "cloaked" | "burrowed" | "invincible" | "underAttack"; -export const UNIT_FLAGS: readonly UnitFlag[] = ["hallucinated", "cloaked", "burrowed", "invincible", "underAttack"]; +export const UNIT_WRITABLE: ReadonlySet = new Set(["hp", "shields", "energy", "kills", "cooldown", "resources", "stim", "ensnare", "plague", "lockdown", "maelstrom", "irradiate", "stasis", "buildTime", "rank", "matrix", "invincible", "hallucinated", "speedUpgrade"]); +export const UNIT_NUM_FIELDS: readonly UnitNumField[] = ["hp", "maxHp", "shields", "maxShields", "energy", "owner", "type", "x", "y", "kills", "orderId", "cooldown", "resources", "stim", "ensnare", "plague", "lockdown", "maelstrom", "irradiate", "stasis", "buildTime", "rank", "matrix", "speed"]; +/** A true / false of a unit on the map; `invincible`, `hallucinated` and `speedUpgrade` take a write. */ +export type UnitFlag = "hallucinated" | "cloaked" | "burrowed" | "invincible" | "underAttack" | "speedUpgrade" | "hasTarget" | "inTransport"; +export const UNIT_FLAGS: readonly UnitFlag[] = ["hallucinated", "cloaked", "burrowed", "invincible", "underAttack", "speedUpgrade", "hasTarget", "inTransport"]; /** A unit on the map, or none. */ export type UnitExpr = @@ -177,7 +246,9 @@ export type UnitExpr = */ | { kind: "unitAt"; ptr: NumExpr; epd: NumExpr; uid: NumExpr; at: At } /** A call inlined here whose result is a unit. */ - | { kind: "call"; call: Call }; + | { kind: "call"; call: Call } + /** The unit in slot `index` of this computer's selection (`local.selected` is slot 0; B5), or none: a Local value. */ + | { kind: "selected"; index: number; at: At }; /** What a unit is told to do. `to` and `target` are known when the script is built; an amount is the program's. */ export type UnitVerb = @@ -219,8 +290,15 @@ export const I32_MAX = 0x7fffffff; export const I32_MIN = -0x80000000; export const U32_MAX = 0xffffffff; +/** + * A number of the IR. Any of them may carry `range: [lo, hi]` (IR 25): `ranges.ts` found its signed reading always + * between the two, both included. It is a promise a backend may lean on — take only the bits a value can have, drop a + * check that cannot fail — and never needs to. + */ export type NumExpr = - | { kind: "const"; value: number } + /** `fixed`: a 16.16 number, `value` its raw cell (the value times 65 536); `numbers.ts` reads it so, a backend takes `value` as it is. */ + /** `fn`: the number is a function kept as a value (B3), the id of the function it stands for — what keeps that function in the map. */ + | { kind: "const"; value: number; fixed?: boolean; fn?: string } | { kind: "var"; id: string } /** `hp[i]`: a cell of an array of numbers; 0 when the index is past either end. */ | { kind: "element"; array: string; index: NumExpr; at: At } @@ -235,7 +313,7 @@ export type NumExpr = | { kind: "pop"; array: string; at: At } | { kind: "unary"; op: "-"; expr: NumExpr; at: At } /** `u32(x)` / `i32(x)`: the same 32 bits read the other way. Nothing is computed; it is there for `numbers.ts`, which works out what each operation reads its sides as. */ - | { kind: "cast"; to: "u32" | "i32"; expr: NumExpr; at: At } + | { kind: "cast"; to: "u32" | "i32" | "fixed"; expr: NumExpr; at: At } /** `unsigned`, on `/` and `%`: both sides are `u32`s. Unset, the division is signed and rounds towards zero, the remainder taking the dividend's sign. */ | { kind: "binary"; op: ArithOp; left: NumExpr; right: NumExpr; unsigned?: boolean; at: At; label: string } /** A value of the game, read when the expression is evaluated. */ @@ -249,14 +327,24 @@ export type NumExpr = /** What a player did, as it reached every computer (`input.ts`): a key or a click reads 1 on its frame, the mouse its place on the map, a typed line 1 or a value it carried. */ | { kind: "input"; input: InputSource; at: At; label: string } | { kind: "ternary"; cond: BoolExpr; whenTrue: NumExpr; whenFalse: NumExpr; at: At; label: string } - /** `unsigned`, on `min` and `max`: the arguments are compared as `u32`s. `abs` is of a signed number. */ - | { kind: "intrinsic"; name: "min" | "max" | "abs"; args: NumExpr[]; unsigned?: boolean; at: At; label: string } + /** + * `unsigned`, on `min` and `max`: the arguments are compared as `u32`s. `abs` is of a signed number. + * Of fixed numbers (16.16, raw cells): `fmul` is a × b / 65 536 and `fdiv` a × 65 536 / b, each rounded towards zero + * and wrapping at 32 bits (a divisor of 0 gives 0); `sqrt` is the square root of a fixed, rounded down (0 below + * zero), or with `unsigned` of a whole number read from 0 up, the root still a fixed; `atan2` of (y, x) is the angle in radians, a fixed from −π to π, read from `table`, an array of the program + * whose 1 025 cells are atan(i / 1 024), between its cells; `sin` / `cos` of (x, scale) read `table`, the 4 096 cells + * sin(2π i / 4 096), at fmul(x, scale) (a quarter turn on for `cos`), between its cells. The front end also gives + * `floor` / `ceil` / `round` / `trunc`, which `numbers.ts` turns into the operations above and no backend meets. + */ + | { kind: "intrinsic"; name: "min" | "max" | "abs" | "fmul" | "fdiv" | "sqrt" | "atan2" | "sin" | "cos" | "floor" | "ceil" | "round" | "trunc"; args: NumExpr[]; unsigned?: boolean; table?: string; at: At; label: string } /** `s.length`, in characters (code points). */ | { kind: "textLength"; of: TextExpr; at: At; label: string } /** `s.indexOf(find, from)`: the place of the first character of the first match at or after `from`, in characters; −1 when there is none. */ | { kind: "textIndexOf"; of: TextExpr; find: TextExpr; from?: NumExpr; at: At; label: string } /** `s.codePointAt(i)`: the character's number; −1 past either end. */ | { kind: "textCode"; of: TextExpr; index: NumExpr; at: At; label: string } + /** The number of the task this runs in — what `start()` gave for it, `slot | task << 8 | generation << 16` — or 0 outside a task (B4: where a task's error is kept for an `await` of it). */ + | { kind: "taskHandle"; at: At } /** A call inlined here: its body runs, its result is the number. */ | { kind: "call"; call: Call }; @@ -291,9 +379,11 @@ export type TextExpr = /** `s.repeat(n)`; nothing when n is below 1. */ | { kind: "textRepeat"; of: TextExpr; count: NumExpr; at: At; label: string } /** A call inlined here whose result is a text: the value is taken out of the call's result, which then holds none. */ - | { kind: "textCall"; call: Call }; + | { kind: "textCall"; call: Call } + /** What buffer `buffer` holds, as the `buildText` statements before it left it: looked at where it is, never owned. */ + | { kind: "built"; buffer: string }; -const TEXT_KINDS: ReadonlySet = new Set(["text", "textVar", "textOf", "textAt", "template", "textTernary", "textSlice", "textPad", "textRepeat", "textCall"]); +const TEXT_KINDS: ReadonlySet = new Set(["text", "textVar", "textOf", "textAt", "template", "textTernary", "textSlice", "textPad", "textRepeat", "textCall", "built"]); export const isTextExpr = (e: { kind: string }): e is TextExpr => TEXT_KINDS.has(e.kind); /** What a pass that walks or rewrites every expression does with the numbers, the conditions and the calls inside a text; the texts inside it are walked here. */ @@ -302,7 +392,7 @@ export interface TextMap { num: (e: NumExpr) => NumExpr; bool: (e: BoolExpr) => const innerText = (t: TextExpr, f: TextMap): TextExpr => (f.text ? f.text(t) : mapText(t, f)); export function mapTextParts(parts: TextPart[], f: TextMap): TextPart[] { - return parts.map((p) => (p.kind === "number" ? { ...p, expr: f.num(p.expr) } : p.kind === "value" ? { ...p, text: innerText(p.text, f) } : p)); + return parts.map((p) => (p.kind === "number" || p.kind === "char" ? { ...p, expr: f.num(p.expr) } : p.kind === "value" ? { ...p, text: innerText(p.text, f) } : p)); } /** A text with everything it is worked out from handed to `f` — a copy; a pass that only looks gives back what it was handed. */ @@ -344,7 +434,11 @@ export function textHasId(e: TextExpr, kinds: (id: string) => "id" | "made" | un export type BoolExpr = | { kind: "const"; value: boolean } /** A trigger condition the script wrote, its fields known when the script is built. */ - | { kind: "cond"; record: ConditionRecord } + /** + * `amount`: a map of our own's backend only (`Rewriter.asked`), a read compared with a value — the condition's + * amount that value plus `add`, a place of it. + */ + | { kind: "cond"; record: ConditionRecord; amount?: { expr: NumExpr; add: number }; epd?: NumExpr } /** A boolean variable. */ | { kind: "var"; id: string } /** `alive[i]`: a cell of an array of booleans; false when the index is past either end. */ @@ -363,6 +457,8 @@ export type BoolExpr = | { kind: "not"; expr: BoolExpr } /** `random()`: a coin toss, fresh at every evaluation. */ | { kind: "random"; at: At } + /** `t.running`: the task the number stands for (what `start()` gave) has not ended, nor been cancelled. 0 is no task. */ + | { kind: "running"; task: NumExpr; at: At } /** `if (target)`: the variable holds a unit and that unit is still on the map. */ | { kind: "unitAlive"; unit: UnitExpr; at: At; label: string } /** `u == target`: both name the same unit of the game (and there is one). */ @@ -382,12 +478,20 @@ export type BoolExpr = /** A piece of a `print`'s text: written text, a number's digits, a player's name, the colour code of a player's colour. */ export type TextPart = | { kind: "text"; text: string } - /** `unsigned`: printed as a `u32`; unset, a number below zero has its minus sign. */ - | { kind: "number"; expr: NumExpr; unsigned?: boolean } + /** `unsigned`: printed as a `u32`; unset, a number below zero has its minus sign. `fixed`: a 16.16 number, printed as a decimal of up to four places (3.5, -0.25). */ + | { kind: "number"; expr: NumExpr; unsigned?: boolean; fixed?: boolean } | { kind: "name"; player: number } | { kind: "color"; player: number } /** A text of the program: a variable's, a function's result. */ - | { kind: "value"; text: TextExpr }; + | { kind: "value"; text: TextExpr } + /** One byte, the number's low eight bits: a colour code chosen when the map is played. 0 writes nothing. */ + | { kind: "char"; expr: NumExpr } + /** + * The text of the case `by` equals: only a map of our own makes it (`compiler/backend/texts.ts`), for a text known + * when built chosen while the game runs; `by` is a variable or a constant, always one case's `when` or between it + * and its `upTo`. + */ + | { kind: "pick"; by: NumExpr; cases: { when: NumExpr; upTo?: NumExpr; text: string }[] }; /** A field of an action filled in by the program: `bits` 8 is a unit count (done once per unit), 16 a unit type, 32 an amount. */ export interface ActionVariable { field: keyof ActionRecord; bits: 8 | 16 | 32; name: string; expr: NumExpr } @@ -465,8 +569,16 @@ export type Stmt = /** * `for (const u of unitsAt(…))`: the body once for every unit the filter matches, in the order of * the game's unit table, all within the frame — no `sleep` inside. `decl` is the unit of the turn. + * `source` (`on.created` / `on.died`): instead of the table, the units of this frame's events pass — the ones that + * came onto the map since the pass of the frame before (`created`; one that has gone since is passed over), or that + * died or were removed since then (`died`; the unit of the turn is the dead one, its fields read as it died, and + * nothing done to it does anything); `killed` (those of `died` that died in a fight: their hit points 0, a player's + * attack the last that hurt them); `ordered` (living units whose order is not the one the pass before found); `morphed` + * (living units whose type is not the one the pass before found). `was`, when there: set at each turn to the killer, + * the order the unit had, or the type it was. `event`: `ordered` only the units whose new order is `order`; `killed` + * only the units `by` killed. */ - | { kind: "unitLoop"; decl: VarDecl; filter: UnitFilter; body: Stmt[]; at: At; label: string } + | { kind: "unitLoop"; decl: VarDecl; filter: UnitFilter; source?: EventSource; was?: VarDecl; event?: { order?: number; by?: number }; body: Stmt[]; at: At; label: string } /** `u.hp = 40`, `u.invincible = true`: nothing happens when the unit is none or gone. */ | { kind: "unitWrite"; unit: UnitExpr; field: UnitNumField | UnitFlag; value: NumExpr | BoolExpr; at: At; label: string } /** `u.kill()`, `u.order("move", there)`: nothing happens when the unit is none or gone. */ @@ -476,7 +588,8 @@ export type Stmt = | { kind: "if"; cond: BoolExpr; then: Stmt[]; else?: Stmt[]; at: At; label: string } /** `cond` absent means `while (true)`. */ | { kind: "while"; cond?: BoolExpr; body: Stmt[]; at: At; label: string } - | { kind: "do"; body: Stmt[]; cond: BoolExpr; at: At; label: string; condLabel: string } + /** `before`: statements run before the test each turn, a `continue` included — made by the emitter's rewriter for a map of our own (a test that needs a value computed first); the front end writes none. */ + | { kind: "do"; body: Stmt[]; cond: BoolExpr; at: At; label: string; condLabel: string; before?: Stmt[] } /** `for` over a variable: `init` ran already (it is emitted before), this is the loop with its update. */ | { kind: "for"; cond?: BoolExpr; update: Stmt[]; body: Stmt[]; at: At; label: string; /** The loop is `name.sort(…)`: for the hint on its line; a lowering takes no notice. */ sorts?: string } /** A loop unrolled when the script was built: the body once per value, in order. */ @@ -488,6 +601,32 @@ export type Stmt = | { kind: "return"; value?: NumExpr | BoolExpr | UnitExpr | TextExpr; at: At; label: string } /** `cycles` is a count of frames (`frames(n)`; `cycles(n)` is the older word for the same). */ | { kind: "sleep"; ms?: number; cycles?: number; at: At; label: string } + /** + * `await Promise.all([…])` (`all`) / `await Promise.race([…])` (`race`): each branch is a coroutine of its own, started + * here, in order, within this frame. From then on, every frame the program is here, each branch that has not ended + * goes on from where it slept, in order, and the program goes on past this statement on the frame the last branch + * ends (`all`) or the first does (`race`, whose other branches then stop where they are). A branch's `sleep` is its + * own; `break`, `continue` and `return` never leave a branch. + */ + | { kind: "parallel"; mode: "all" | "race"; branches: Stmt[][]; at: At; label: string } + /** + * `start(wave(n))`: a task — `call`, an async function's body inlined, its parameters copies of this start's arguments — + * that runs beside what started it. Each `start` statement is a body of its own, `task` its number (they are numbered + * from 1 in the order they are met, a task inside another's body after it), with `max` slots: that many of its tasks can + * run at once, and a start with every slot taken starts nothing (the game says so once). A task starts in the frame it + * is started in, once the program has given the frame back (it slept, waited or ended); from then on it goes on every + * frame, after the program, the tasks in order of their number and slot. It runs on after the program ends. `handle`: + * the variable that takes a number for the task — `slot | task << 8 | generation << 16`, the generation one more at + * every start in the slot — or 0 when nothing started. + */ + /** + * `listen` (on.created, on.died, on.chat, on.key): the task is a listener, whose body waits a frame at the top of a loop + * that hands on what happened, so a turn when nothing did does nothing. Once it has begun, it is given a turn only in + * a frame the events pass found units of `source`, or one of `inputs` arrived. A lowering may also give it every turn. + */ + | { kind: "start"; task: number; max: number; call: Call; handle?: string; listen?: { source?: EventSource; inputs?: InputSource[] }; at: At; label: string } + /** `t.cancel()`: the task stops where it is. Nothing happens when it has ended, or the number is no task's. */ + | { kind: "cancel"; task: NumExpr; at: At; label: string } /** A trigger action; each of `variables` names a field that takes an expression's value instead of the record's. */ /** * `text`: the action's text is the program's. One of the table goes into the action as its id. One that was made is @@ -497,20 +636,75 @@ export type Stmt = */ | { kind: "action"; record: ActionRecord; variables?: ActionVariable[]; text?: TextExpr; at: At; label: string } /** Centre a location on a point of the map, in pixels, its size kept. */ - | { kind: "centerLocation"; location: number; x: NumExpr; y: NumExpr; at: At; label: string } + /** `size`: the location takes it too, centred on the point (nothing of the old size is read). */ + /** + * `sync(player, value)`'s first half (B5): on every computer, `player`'s arrival cell of slot `slot` (the `sync` input + * source) is cleared to 0; on `player`'s own computer `value` + 1 is put where the input plugin sends it from, to arrive + * on every computer as that cell a few frames later. `value` may be a `Local` one: it is the one thing that may be. + */ + | { kind: "syncSend"; slot: number; player: number; value: NumExpr; at: At; label: string } + /** + * `select(player, units)` (B5): on `player`'s own computer, its selection becomes these units (up to 12, none left out + * but those that are gone) — the panel told to show them again — and a Select command goes to every computer, so the + * player's orders go to them everywhere. Done on every other computer, it does nothing. + */ + | { kind: "select"; player: number; units: UnitExpr[]; at: At; label: string } + | { kind: "centerLocation"; location: number; x: NumExpr; y: NumExpr; size?: { width: NumExpr; height: NumExpr }; at: At; label: string } /** * Text with values in it, shown to `to` — a slot, 13 for the current player, All Players or a * force — in the chat area or on the line in the middle of the screen the game's own errors use. */ | { kind: "print"; parts: TextPart[]; to: number; position: "chat" | "center"; at: At; label: string } + /** + * The parts written at the end of buffer `buffer` (`reset`: the buffer emptied first): a text put together a piece at a + * time, read back with `built`. A buffer is one place for the whole game, used up within the frame it is built in. + */ + | { kind: "buildText"; buffer: string; parts: TextPart[]; reset?: boolean; at: At; label: string } | { kind: "call"; call: Call; at: At; label: string } /** A block only for scoping; nothing of its own. */ | { kind: "block"; body: Stmt[]; at: At } + /** + * `try { … } catch { … }`: `body` runs; when an error is thrown in it — a `throw`, a call that comes back with an error in + * flight, a stack that ran out — the body stops there and `catch` runs, with the error still in flight + * (`Program.errors`): its first statements are the front end's, which take the error and set `thrown` back to 0. + * `finally` is the front end's too: a copy of it before every way out, and a `try` around the whole that runs it and + * throws again. A `break`, `continue` or `return` inside `body` leaves it as it would leave a block. + */ + | { kind: "try"; body: Stmt[]; catch: Stmt[]; at: At; label: string } + /** + * The error in flight (`Program.errors`, which the statements before this one set) goes to the nearest `try` around this + * statement: the `catch` of one in this body, or, inside a called function, out of the function — which returns at + * once, the error still in flight, to a call that goes on in the same way. `rethrow`: the error is one that was caught + * and is thrown again, so where it was first thrown is still where it came from. + */ + | { kind: "throw"; rethrow?: boolean; at: At; label: string } + /** + * The error in flight reached the end of a program, a task or a handler with nothing to catch it: said in red, as a fault + * of the game's is — its class's name (`Program.errors.names` at `thrown`), its message and the line it was thrown at — and + * given up (`thrown` 0). What it was thrown in then ends, as JavaScript's does: a program or a task stops, a handler of + * an event gives up only this turn. + */ + | { kind: "uncaught"; at: At; label: string } /** A word for the editor about a line: a loop unrolled when the script was built. */ - | { kind: "remark"; text: string; short?: string; at: At }; + | { kind: "remark"; text: string; short?: string; at: At } + /** + * A map of our own only (`compiler/backend/texts.ts`): `decl`, a number, declared as the EPD of block `block` — a + * string of the map's `bytes` long, laid out for it (one block for every statement that names it), empty at first or + * holding `text`. `data`: bytes of the payload's instead, never shown as a string (the pool of blocks the cells of + * arrays of texts that grow take: the game's own table of strings names only its first 64 KB in 1.16.1). + */ + | { kind: "textBlock"; decl: VarDecl; block: string; bytes: number; text?: string; data?: boolean; at: At } + /** A map of our own only: Display Text of block `block` as it is now, to `to` as a print's is. */ + | { kind: "showText"; block: string; bytes: number; to: number; at: At } + /** + * A map of our own only (`compiler/backend/units.ts`): the events pass's watch of the game's unit places — a trigger + * for each, whose conditions read `fields` of the unit record where it is (`dword` into it, `mask`) against what the + * trigger saw there last, its own numbers. A place as it was goes on to the next; one that changed runs `handler`, + * with `@ev.e` the record's EPD and the current player at the trigger's first condition's number (`emit.ts`). + */ + | { kind: "watch"; fields: { dword: number; mask?: number; compare: "atLeast" | "exactly" }[]; handler: Stmt[]; gate?: boolean; at: At; label: string }; export interface Program { - version: number; name?: string; owner: number; owners: number[]; @@ -519,10 +713,38 @@ export interface Program { arrays: ArrayDecl[]; /** The functions that are called rather than inlined; absent when there is none. */ functions?: FuncDecl[]; + /** Present when the program throws or catches anything (B4): where an error in flight is kept. */ + errors?: ProgramErrors; + /** + * A map of our own only (`compiler/backend/units.ts`): the frame's events pass — what came onto the map, died, took up + * an order or turned into another type since the pass before — run before the programs' turns, once for the map. + */ + pass?: Stmt[]; + /** + * A map of our own only (`compiler/backend/input.ts`): the frame's send pass — what this computer's player did, + * sent to every computer as game commands — run after the programs' turns, once for the map. + */ + after?: Stmt[]; body: Stmt[]; at: At; } +/** + * The error in flight, between a `throw` and the `catch` that takes it: `thrown` is its class's code — 0 when no error is + * in flight, `ERROR_CODES` for the built-in classes, 16 and up for a program's own — and `message` its message, a made + * text. Both are `shared` variables of the program, declared at the start of its body: an error is never in flight + * across a frame, so one of each serves every player and every task. A class's own fields go through variables of their + * own, which the front end copies; a backend knows only these two. `names` is the array of texts of the classes' names + * by their code, which an uncaught error is said with. + */ +export interface ProgramErrors { thrown: string; message: string; names: string } + +/** The codes of the built-in classes of error; a program's own classes are numbered from `FIRST_ERROR_CLASS`. */ +export const ERROR_CODES = { Error: 1, RangeError: 2, TypeError: 3 } as const; +export const FIRST_ERROR_CLASS = 16; +/** What a stack that ran out throws, as JavaScript's does. */ +export const STACK_OVERFLOW_MESSAGE = "Maximum call stack size exceeded"; + /** Every statement list of a program: its body, then the body of each function that is called. */ export function bodiesOf(program: Pick): Stmt[][] { return [program.body, ...(program.functions ?? []).map((f) => f.body)]; @@ -548,6 +770,15 @@ export function eachCall(root: unknown, visit: (c: Call) => void): void { for (const v of Object.values(o)) if (v && typeof v === "object") eachCall(v, visit); } +/** Every `start` of a piece of IR, in the order of their numbers: outer ones before the ones in their bodies. */ +export function eachStart(root: unknown, visit: (s: Extract) => void): void { + if (Array.isArray(root)) { for (const x of root) eachStart(x, visit); return; } + if (!root || typeof root !== "object") return; + const o = root as Record; + if (o.kind === "start" && typeof o.task === "number" && o.call && typeof o.call === "object") visit(o as Extract); + for (const v of Object.values(o)) if (v && typeof v === "object") eachStart(v, visit); +} + export const isUnitExpr = (e: NumExpr | BoolExpr | UnitExpr | TextExpr): e is UnitExpr => e.kind === "unitNull" || e.kind === "unitVar" || e.kind === "pick" || e.kind === "unitAt" || (e.kind === "call" && e.call.result?.kind === "unit"); @@ -558,7 +789,7 @@ export const isNumExpr = (e: NumExpr | BoolExpr | UnitExpr | TextExpr): e is Num case "var": return false; // ambiguous by shape; callers know the variable's kind case "element": case "pop": return false; // ambiguous by shape, as a variable is case "length": case "unitPart": return true; - case "unary": case "cast": case "binary": case "intrinsic": case "read": case "randomInt": case "unitField": case "tableRead": case "input": return true; + case "unary": case "cast": case "binary": case "intrinsic": case "read": case "randomInt": case "unitField": case "tableRead": case "input": case "taskHandle": return true; case "ternary": return isNumExpr(e.whenTrue); case "call": return e.call.result?.kind === "number"; default: return false; @@ -582,22 +813,28 @@ export function declarations(body: Stmt[]): VarDecl[] { case "storeText": expr(s.index); text(s.value); break; case "releaseText": expr(s.index); break; case "textLoop": text(s.of); out.push(s.decl); s.body.forEach(stmt); break; - case "unitLoop": out.push(s.decl); s.body.forEach(stmt); break; + case "unitLoop": out.push(s.decl); if (s.was) out.push(s.was); s.body.forEach(stmt); break; case "unitWrite": unit(s.unit); init(s.value); break; case "unitDo": unit(s.unit); if (s.verb.do === "damage" || s.verb.do === "heal") expr(s.verb.amount); break; + case "select": s.units.forEach(unit); break; case "tableWrite": init(s.value); break; case "if": init(s.cond); s.then.forEach(stmt); s.else?.forEach(stmt); break; case "while": if (s.cond) init(s.cond); s.body.forEach(stmt); break; case "do": s.body.forEach(stmt); init(s.cond); break; case "for": if (s.cond) init(s.cond); s.update.forEach(stmt); s.body.forEach(stmt); break; case "unrolled": s.iterations.forEach((i) => i.forEach(stmt)); break; + case "parallel": s.branches.forEach((b) => b.forEach(stmt)); break; + case "start": call(s.call); break; + case "cancel": expr(s.task); break; case "switch": expr(s.value); s.cases.forEach((c) => c.body.forEach(stmt)); break; case "return": if (s.value) init(s.value); break; case "action": for (const v of s.variables ?? []) expr(v.expr); if (s.text) text(s.text); break; - case "centerLocation": expr(s.x); expr(s.y); break; - case "print": parts(s.parts); break; + case "centerLocation": expr(s.x); expr(s.y); if (s.size) { expr(s.size.width); expr(s.size.height); } break; + case "syncSend": expr(s.value); break; + case "print": case "buildText": parts(s.parts); break; case "call": call(s.call); break; case "block": s.body.forEach(stmt); break; + case "try": s.body.forEach(stmt); s.catch.forEach(stmt); break; default: break; } }; @@ -608,7 +845,7 @@ export function declarations(body: Stmt[]): VarDecl[] { c.body.forEach(stmt); }; const init = (e: NumExpr | BoolExpr | UnitExpr | TextExpr) => (isTextExpr(e) ? text(e) : isUnitExpr(e) ? unit(e) : isNumExpr(e) ? expr(e) : bool(e)); - const parts = (ps: TextPart[]) => { for (const p of ps) { if (p.kind === "number") expr(p.expr); else if (p.kind === "value") text(p.text); } }; + const parts = (ps: TextPart[]) => { for (const p of ps) { if (p.kind === "number" || p.kind === "char") expr(p.expr); else if (p.kind === "value") text(p.text); } }; const text = (t: TextExpr) => { switch (t.kind) { case "textOf": case "textAt": expr(t.index); break; @@ -651,6 +888,7 @@ export function declarations(body: Stmt[]): VarDecl[] { case "ternary": bool(b.cond); bool(b.whenTrue); bool(b.whenFalse); break; case "textCompare": text(b.left); text(b.right); break; case "textTest": text(b.of); text(b.find); break; + case "running": expr(b.task); break; case "call": call(b.call); break; default: break; } diff --git a/compiler/jsx.ts b/compiler/jsx.ts new file mode 100644 index 0000000..4cbcd73 --- /dev/null +++ b/compiler/jsx.ts @@ -0,0 +1,118 @@ +/** + * JSX in a script: a text the game shows, written as elements. In a program the structured compiler puts it together in + * the game (`structured.ts#jsx`); outside one, where the script runs when it is built, `jsxAtBuild` makes the same text + * there. The game facts behind it are the UI spike's (TrigScript 4, S2, played 2026-10-16): eleven text lines; ■ □ ● + * one width; a colour code takes no width; after 05 (grey) every later code on its line is ignored. + */ + +/** The colours a text in the game takes, by name: the colour code each one is. Measured in Remastered (the UI spike). */ +export const TEXT_COLORS = Object.freeze({ + white: 0x04, + grey: 0x05, + red: 0x08, + orange: 0x11, + yellow: 0x03, + paleYellow: 0x19, + green: 0x07, + darkGreen: 0x18, + sage: 0x1d, + teal: 0x0f, + cyan: 0x1f, + blue: 0x0e, + lightBlue: 0x1c, + paleBlue: 0x02, + purple: 0x10, + lavender: 0x1e, + brown: 0x15, + cream: 0x1b, +} as const); +export type TextColorName = keyof typeof TEXT_COLORS; + +/** The colour of a line that says none. */ +export const DEFAULT_COLOR = TEXT_COLORS.white; +/** A cell that is filled, in its colour, and one that is empty: the same width (the spike's B1). */ +export const FILLED_CELL = "■"; +export const EMPTY_CELL = "□"; +/** An empty cell's colour: it reads on black and over terrain, and no piece is lavender (the spike's second round). */ +export const EMPTY_COLOR = TEXT_COLORS.lavender; + +/** The elements a text is made of, with the attributes each takes. */ +export const JSX_ELEMENTS: Readonly> = Object.freeze({ + line: ["color"], + span: ["color"], + cell: ["color"], + bar: ["value", "max", "width", "color"], +}); + +/** The colour code for what a script wrote as a colour: a name of TEXT_COLORS, or the code itself (0 for none). */ +export function colorCode(v: unknown): number | null { + if (typeof v === "string") return Object.hasOwn(TEXT_COLORS, v) ? TEXT_COLORS[v as TextColorName] : null; + if (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 0xff) return v; + return null; +} + +/* ── Outside a program: the same text, made when the script is built ── */ + +/** A colour to be put back after an element that changed it: the element around it fills in its own. */ +const RESTORE = "\u0000"; + +/** What an element gives when the script is built: its text, and whether it is lines (a ``, or holds some). */ +class Built extends String { + readonly lines: boolean; + constructor(text: string, lines: boolean) { super(text); this.lines = lines; } +} + +const flat = (children: unknown[]): unknown[] => children.flatMap((c) => (Array.isArray(c) ? flat(c) : [c])); + +/** The children one after another, a line break before each line but the first thing written — as in the game. */ +function joined(children: unknown[]): { text: string; lines: boolean } { + let text = "", lines = false, any = false; + for (const c of flat(children)) { + if (c === null || c === undefined || typeof c === "boolean") continue; + const isLine = c instanceof Built && c.lines; + if (isLine && any) text += "\n"; + text += String(c); + any = true; + lines ||= isLine; + } + return { text, lines }; +} + +/** The script's JSX when it runs at build time (the factory `__jsx`): the text the game would be shown. */ +export function jsxAtBuild(tag: unknown, props: Record | null, ...children: unknown[]): string { + const p = props ?? {}; + if (tag === JSX_FRAGMENT) { const j = joined(children); return new Built(j.text, j.lines) as unknown as string; } + if (typeof tag !== "string" || !Object.hasOwn(JSX_ELEMENTS, tag)) throw new Error(`<${String(tag)}> is not an element of a text: they are ${Object.keys(JSX_ELEMENTS).map((t) => `<${t}>`).join(", ")}.`); + const code = (fallback: number | null) => { + if (p.color === undefined) return fallback; + const c = colorCode(p.color); + if (c === null) throw new Error(`<${tag}>'s color is one of ${Object.keys(TEXT_COLORS).join(", ")}, or a colour code; got ${JSON.stringify(p.color)}.`); + return c; + }; + const ch = (c: number | null) => (c ? String.fromCharCode(c) : ""); + const inside = (own: number) => joined(children).text.split(RESTORE).join(ch(own)); + switch (tag) { + case "line": { const own = code(DEFAULT_COLOR)!; return new Built(tidy(ch(own) + inside(own)), true) as unknown as string; } + case "span": { const own = code(null); return new Built(own === null ? joined(children).text : ch(own) + inside(own) + RESTORE, false) as unknown as string; } + case "cell": { const own = code(0)!; return new Built(own ? ch(own) + FILLED_CELL + RESTORE : ch(EMPTY_COLOR) + EMPTY_CELL + RESTORE, false) as unknown as string; } + case "bar": { + const value = Number(p.value ?? 0), max = Number(p.max ?? 0), width = Number(p.width ?? 10); + const filled = max > 0 ? Math.max(0, Math.min(width, Math.floor((value * width) / max))) : 0; + return new Built(ch(code(TEXT_COLORS.green)) + FILLED_CELL.repeat(filled) + ch(EMPTY_COLOR) + EMPTY_CELL.repeat(width - filled) + RESTORE, false) as unknown as string; + } + } + throw new Error(`<${tag}> is not an element of a text.`); +} + +/** What `<>…` passes as its tag. */ +export const JSX_FRAGMENT = "__fragment"; + +/** A line's text with what changes nothing taken out: a colour code that another follows, or that ends it. */ +function tidy(s: string): string { + return s.replace(/[\x01-\x08\x0e-\x1f\u0000]+(?=[\x01-\x08\x0e-\x1f]|$)/g, "").replace(/\u0000/g, ""); +} + +/** The text a built value of JSX is, wherever it ends up: what is left to put back is the default colour. */ +export function builtText(v: unknown): string | undefined { + return v instanceof Built ? String(v).split(RESTORE).join(String.fromCharCode(DEFAULT_COLOR)) : undefined; +} diff --git a/compiler/link.ts b/compiler/link.ts index 75c0449..d7c8801 100644 --- a/compiler/link.ts +++ b/compiler/link.ts @@ -40,17 +40,22 @@ export function resolvePath(from: string, spec: string): string { export function resolveModule(files: ReadonlySet, from: string, spec: string): string | null { if (!spec.startsWith("./") && !spec.startsWith("../")) return null; const path = resolvePath(from, spec); - for (const candidate of [path, `${path}.ts`, path.replace(/\.js$/, ".ts"), `${path}/index.ts`]) if (files.has(candidate)) return candidate; + for (const candidate of [path, `${path}.ts`, `${path}.tsx`, path.replace(/\.js$/, ".ts"), path.replace(/\.js$/, ".tsx"), `${path}/index.ts`, `${path}/index.tsx`]) if (files.has(candidate)) return candidate; return null; } const SOURCE_URL_PREFIX = "trigscript://"; +/** Where the standard library's modules sit among a script's files: "trigscript/fog" is `__trigscript__/fog.ts` (`stdlib.ts`). */ +export const STDLIB_DIR = "__trigscript__"; +/** A module of the standard library among the script's files: its extension is `.tsx` when it has JSX in it (`stdlib.ts#STDLIB_EXT`). */ +export const stdlibFile = (name: string, ext: ".ts" | ".tsx" = ".ts") => `${STDLIB_DIR}/${name}${ext}`; + /** * Run the entry module with the library in scope. Resolves to null when the script ran * to the end, else to the error it threw, located when possible. */ -export function runModules(files: ReadonlyMap, entry: string, library: Record, moduleName: string, options: { imported?: readonly string[]; then?: readonly string[] } = {}): RunError | null { +export function runModules(files: ReadonlyMap, entry: string, library: Record, moduleName: string, options: { imported?: readonly string[]; then?: readonly string[]; media?: (path: string) => unknown } = {}): RunError | null { const names = new Set(files.keys()); const cache = new Map }>(); // `imported`: names the module has and the globals do not — a script may well have a `test` of its own. @@ -63,11 +68,19 @@ export function runModules(files: ReadonlyMap, entry: string cache.set(file, module); const require = (spec: string): unknown => { if (spec === moduleName) return library; + // The standard library: a module of TrigScript that ships with the compiler, among the files when imported. + if (spec.startsWith(`${moduleName}/`)) { + const name = spec.slice(moduleName.length + 1); + for (const ext of [".ts", ".tsx"] as const) if (names.has(stdlibFile(name, ext))) return load(stdlibFile(name, ext)); + } + // A video, an image or a sound (`media.ts`): the value its file gives, by the path the import names. + const asMedia = spec.startsWith(".") && options.media ? options.media(resolvePath(file, spec)) : undefined; + if (asMedia !== undefined) return { __esModule: true, default: asMedia }; const target = resolveModule(names, file, spec); if (!target) { throw new Error(spec.startsWith(".") ? `Cannot find "${spec}" from ${file}: the script's files are ${[...names].join(", ")}.` - : `Cannot import "${spec}": a script imports its own files (./name) and "${moduleName}" only.`); + : `Cannot import "${spec}": a script imports its own files (./name), "${moduleName}" and the standard library ("${moduleName}/fog").`); } return load(target); }; @@ -87,6 +100,50 @@ export function runModules(files: ReadonlyMap, entry: string } } +/** + * Where in the script the code calling this is now: the innermost line of a file of the script on the stack. Called + * every frame of a debugged test (A5), so on V8 it reads the stack as call sites rather than text: Node with source + * maps on formats every frame's path, which cost a debugged run more than the game did. + */ +const placed = new WeakMap>(); + +export function here(files: ReadonlyMap): { file: string; line: number; column: number } | null { + type Site = { getFileName(): string | null; getScriptNameOrSourceURL?(): string | null; getLineNumber(): number | null; getColumnNumber(): number | null }; + const E = Error as unknown as { prepareStackTrace?: unknown; captureStackTrace?: (o: object) => void }; + if (typeof E.captureStackTrace === "function") { + const was = E.prepareStackTrace; + const holder: { stack?: unknown } = {}; + try { + E.prepareStackTrace = (_e: unknown, sites: Site[]) => sites; + E.captureStackTrace(holder); + const sites = holder.stack; + if (Array.isArray(sites)) { + for (const site of sites as Site[]) { + // (A Function's body is named by its sourceURL, which only this says.) + const name = site.getScriptNameOrSourceURL?.() ?? site.getFileName(); + if (!name || !name.startsWith(SOURCE_URL_PREFIX)) continue; + const file = name.slice(SOURCE_URL_PREFIX.length), linked = files.get(file); + if (!linked) continue; + const line = (site.getLineNumber() ?? 1) - 2, column = site.getColumnNumber() ?? 1; + let seen = placed.get(linked); + if (!seen) { seen = new Map(); placed.set(linked, seen); } + const key = line * 65536 + column; + let out = seen.get(key); + if (!out) { + const original = linked.map ? mapPosition(linked.map, line, column) : null; + out = original ? { file, line: original.line, column: original.column } : { file, line: Math.max(1, line), column }; + seen.set(key, out); + } + return out; + } + return null; + } + } catch { /* the text below */ } finally { E.prepareStackTrace = was; } + } + const at = locate(new Error(), files); + return at.file ? { file: at.file, line: at.line ?? 1, column: at.column ?? 1 } : null; +} + /** The error's message and, from its stack, where in the script it happened. */ export function locate(err: unknown, files: ReadonlyMap): RunError { const message = err instanceof Error ? err.message : String(err); diff --git a/compiler/local.ts b/compiler/local.ts new file mode 100644 index 0000000..f11795d --- /dev/null +++ b/compiler/local.ts @@ -0,0 +1,302 @@ +/** + * Local values (TrigScript 4, B5): what only one computer knows — `local.camera`, `local.mouse`, `local.player` and the + * rest of `LOCAL_MEMORY` — may be shown, and may not change the game. + * + * Every computer in a game runs every trigger, and stays in step only because each does the same with the same. A value + * read from a computer's own memory is different on each, so whatever depends on it would happen differently on each, + * and the game would split (StarCraft drops a player when it notices). What may depend on one is what is only shown on + * that computer: a text (print, Display Text, ui.lines), a sound, the camera, a ping on the minimap. And `sync()`, which + * is how a player's value reaches every computer. + * + * The check follows the values through the program, as far as the program's own cells go: a variable, an array, a + * text buffer or a function's parameter that is ever given a Local value is Local itself, and so is everything done + * under a test of one (an `if`, a loop's condition, a `switch`) — so is a variable assigned there, whatever it is + * assigned. It goes round until nothing more becomes Local, then says what may not depend on what it found: + * - an action that is not only shown, a unit changed or ordered, a location moved, a table of the game written; + * - under a test of a Local value: waiting (a sleep, an await), starting or cancelling a task, throwing, a random + * number (each draw moves the game's random numbers on), and leaving a loop or a function that was entered on every + * computer (break, continue, return) — after which every computer would be somewhere else in the program. + * A called function run under a Local test is walked as if all of it were under one. + * + * A `memory(address, …)` condition on one of those addresses is a Local value too. + * + * Not followed: the made texts' heap (a Local text may be longer on one computer, and take more of the heap). + */ +import { ActionType, ConditionType, type ConditionRecord } from "../vendor/triggers"; +import type { ProgramDiagnostic } from "./check"; +import { localMemoryAt } from "./memory"; +import { programDeclarations, type At, type Call, type Program, type Stmt } from "./ir"; + +/** The deaths table, which memory(address, …) is a condition on. */ +const DEATHS = 0x58a364; + +/** Actions that only show something, on the computer of the player they are for. */ +const SHOWN = new Set([ActionType.PlayWav, ActionType.DisplayText, ActionType.CenterView, ActionType.MinimapPing, ActionType.SetMissionObjectives]); + +/** The statement lists inside a statement: walked as statements, not looked at as operands. */ +const NESTED = new Set(["then", "else", "body", "catch", "iterations", "cases", "update", "branches"]); + +interface Ctx { + /** Under a test of a Local value: why, in words; null when not. */ + under: string | null; + /** Why this part is walked as if under a test although it is not written so, for the words; when it is. */ + note?: string; + /** + * What a `break` leaves (the nearest loop or switch), a `continue` (the nearest loop) and a `return` (the function, the + * program): left early under a Local test, it goes differently from computer to computer after that point, so it is + * walked again as a whole under the test (`whole`). `…Local`: it already is, and leaving it changes nothing. + */ + breaks?: object; + breaksLocal?: boolean; + continues?: object; + continuesLocal?: boolean; + returns?: object; + returnsLocal?: boolean; + /** The variable a `return` here writes. */ + result?: string; +} + +export interface LocalCheck { + errors: ProgramDiagnostic[]; + /** The ids of the program's cells that may hold a Local value: what a copy of the game may hold differently from another. */ + locals: string[]; +} + +export function checkLocal(program: Program): LocalCheck { + const names = new Map(programDeclarations(program).map((d) => [d.id, d.name])); + for (const a of program.arrays ?? []) names.set(a.id, a.name); + const functions = new Map((program.functions ?? []).map((f) => [f.id, f])); + /** Each cell found to hold a Local value, with what it came from, in words. */ + const locals = new Map(); + /** The called functions run under a Local test, with why. */ + const under = new Map(); + /** The loops, switches, functions and program bodies some computers leave early under a Local test, with why. */ + const whole = new Map(); + const leave = (target: object | undefined, why: string) => { if (target && !whole.has(target)) { whole.set(target, why); changed = true; } }; + let changed = false; + let report = false; + const errors = new Map(); + const fail = (at: At, message: string) => { if (report) errors.set(`${at.file}:${at.line}:${at.column}:${message}`, { at, message }); }; + /** A cell found Local: it keeps where its value came from in the end (`n (from local.mouse.x)` keeps `local.mouse.x`). */ + const mark = (id: string | undefined, why: string) => { + if (id === undefined || locals.has(id)) return; + locals.set(id, /\(from (.+)\)$/.exec(why)?.[1] ?? why); + changed = true; + }; + + /** + * What of an operand is Local, or null: `say` names it as the script does (`x (from local.camera.x)`), `root` is where + * it came from in the end (`local.camera.x`), which is what a cell given it keeps. Calls count by their result only. + */ + const source = (v: unknown): { say: string; root: string } | null => { + if (Array.isArray(v)) { for (const x of v) { const o = source(x); if (o) return o; } return null; } + if (!v || typeof v !== "object") return null; + const o = v as Record; + const cellOf = (id: string) => { + const root = locals.get(id)!; + const name = names.get(id); + return { say: name && !name.startsWith("(") ? `${name} (from ${root})` : root, root }; + }; + switch (o.kind) { + case "tableRead": { + const cell = o.cell as { name: string; base: number; stride: number }; + const mem = cell.stride === 0 ? localMemoryAt(cell.base) : undefined; + if (mem) { const root = cell.name.startsWith("local.selection[") ? "local.selectionCount" : cell.name.startsWith("local.") ? cell.name : mem; return { say: root, root }; } + break; + } + case "cond": { + // memory(address, …) is a Deaths condition of unit 0 whose player is the address's place from the deaths table. + const r = o.record as ConditionRecord; + if (r.type === ConditionType.Deaths && r.unitId === 0) { + const address = (DEATHS + r.player * 4) >>> 0; + const mem = localMemoryAt(address); + if (mem) { const root = `memory(0x${address.toString(16).toUpperCase()}, …), ${mem}`; return { say: root, root }; } + } + break; + } + case "selected": return { say: "local.selected", root: "local.selected" }; + case "var": case "textVar": case "unitVar": if (locals.has(o.id as string)) return cellOf(o.id as string); break; + case "element": case "textOf": case "length": case "pop": if (locals.has(o.array as string)) return cellOf(o.array as string); break; + case "built": if (locals.has(o.buffer as string)) { const root = locals.get(o.buffer as string)!; return { say: root, root }; } break; + case "textAt": if (locals.has(o.addr as string)) return cellOf(o.addr as string); break; + case "call": case "textCall": { + const call = o.call as Call; + if (!call.result || !locals.has(call.result.decl.id)) return null; + const root = locals.get(call.result.decl.id)!; + return { say: `${call.name ? `${call.name}()` : "a call"} (from ${root})`, root }; + } + } + for (const [k, x] of Object.entries(o)) if (k !== "at" && x && typeof x === "object") { const r = source(x); if (r) return r; } + return null; + }; + const origin = (v: unknown): string | null => source(v)?.say ?? null; + /** Where a cell given these operands gets its Local value from, in the end. */ + const rootOf = (v: unknown): string | null => source(v)?.root ?? null; + + /** The operands of a statement: everything but the statement lists inside it. */ + const operands = (s: Stmt): unknown[] => Object.entries(s).filter(([k]) => !NESTED.has(k) && k !== "at" && k !== "label").map(([, v]) => v); + + /** Each call inside some operands, not looking into the calls' own bodies (which `call` walks). */ + const calls = (v: unknown, ctx: Ctx): void => { + if (Array.isArray(v)) { v.forEach((x) => calls(x, ctx)); return; } + if (!v || typeof v !== "object") return; + const o = v as Record; + if ((o.kind === "call" || o.kind === "textCall") && o.call && typeof o.call === "object") { call(o.call as Call, ctx); return; } + for (const [k, x] of Object.entries(o)) if (k !== "at" && x && typeof x === "object") calls(x, ctx); + }; + + /** Random numbers drawn in some operands: under a Local test, a draw moves the game's random numbers on for some computers only. */ + const draws = (v: unknown): boolean => { + if (Array.isArray(v)) return v.some(draws); + if (!v || typeof v !== "object") return false; + const o = v as Record; + if (o.kind === "randomInt" || o.kind === "random" || (o.kind === "pick" && o.by === "random")) return true; + if (o.kind === "call") return false; + return Object.entries(o).some(([k, x]) => k !== "at" && x && typeof x === "object" && draws(x)); + }; + + const call = (c: Call, ctx: Ctx) => { + for (const p of c.params) { + calls(p.init, ctx); + const o = ctx.under ?? rootOf(p.init); + if (o) mark(p.decl.id, o); + } + if (c.fn) { + const f = functions.get(c.fn); + if (ctx.under && f && !under.has(f.id)) { under.set(f.id, ctx.under); changed = true; } + if (f?.result && c.result && locals.has(f.result.decl.id)) mark(c.result.decl.id, locals.get(f.result.decl.id)!); + return; + } + const why = ctx.under ?? whole.get(c) ?? null; + walk(c.body, { under: why, ...(ctx.under ? { note: ctx.note } : why ? { note: LEFT_EARLY.return } : {}), returns: c, returnsLocal: why !== null, ...(c.result ? { result: c.result.decl.id } : {}) }); + }; + + const walk = (body: Stmt[], ctx: Ctx) => { for (const s of body) stmt(s, ctx); }; + + const stmt = (s: Stmt, ctx: Ctx): void => { + const need = (what: string, why: string, because?: string) => words(what, why, because, ctx.note); + if (s.kind === "call") { call(s.call, ctx); return; } + if (s.kind === "start") { + if (ctx.under) fail(s.at, need("Starting a task", ctx.under)); + call({ ...s.call, body: [] }, { ...ctx, under: null }); + return; + } + calls(operands(s), ctx); + const L = ctx.under; + if (L && draws(operands(s))) fail(s.at, need("A random number", L, "each draw moves the game's random numbers on, so the next would differ from computer to computer")); + else if (draws(operands(s))) { + // `local.mouse.x > 3 && random()`: whether the draw is made at all depends on the Local value. + const o = origin(operands(s)); + if (o) fail(s.at, need("A random number", o, "each draw moves the game's random numbers on, so the next would differ from computer to computer")); + } + /** What this statement's value is Local by: the test it is under, or its operands. */ + const by = (...vs: unknown[]): string | null => L ?? rootOf(vs); + const sink = (what: string, ...vs: unknown[]) => { + if (L) { fail(s.at, need(what, L)); return; } + const o = origin(vs); + if (o) fail(s.at, `${what} would depend on ${o}, which only this computer knows (a Local value): it would go differently on each player's computer and split the game. Show it (print, ui.lines, centerView) or send it to every computer first: const v = await sync(P1, …).`); + }; + const nested = (cond: unknown) => L ?? origin(cond); + /** A loop's body: under its condition's test, or walked whole when some computers leave it early. */ + const loop = (cond: unknown): Ctx => { + const o = nested(cond); + const why = o ?? whole.get(s) ?? null; + const note = o ? ctx.note : why ? LEFT_EARLY.loop : undefined; + return { ...ctx, under: why, ...(note ? { note } : { note: undefined }), breaks: s, breaksLocal: why !== null, continues: s, continuesLocal: why !== null }; + }; + switch (s.kind) { + case "declare": { const o = by(s.init); if (o) mark(s.decl.id, o); return; } + case "assign": case "assignBool": case "assignUnit": case "assignText": { const o = by(s.value); if (o) mark(s.target, o); return; } + case "storeText": { const o = by(s.value, s.index); if (o) { mark(s.addr, o); mark(s.block, o); } return; } + case "releaseText": return; + case "declareArray": { const o = by(s.init, s.fill); if (o) mark(s.array, o); return; } + case "store": { const o = by(s.index, s.value); if (o) mark(s.array, o); return; } + case "push": { const o = by(s.value); if (o) mark(s.array, o); return; } + case "pop": { if (L) mark(s.array, L); return; } + case "setLength": { const o = by(s.value); if (o) mark(s.array, o); return; } + case "textLoop": { + const inner = loop(s.of); + if (inner.under) mark(s.decl.id, inner.under); + walk(s.body, inner); + return; + } + case "unitLoop": { + const inner = loop(null); + if (inner.under) mark(s.decl.id, inner.under); + walk(s.body, inner); + return; + } + case "if": { const o = nested(s.cond); const inner = { ...ctx, under: o, ...(o && !L ? { note: undefined } : {}) }; walk(s.then, inner); if (s.else) walk(s.else, inner); return; } + case "while": case "do": walk(s.body, loop(s.cond)); return; + case "for": { const inner = loop(s.cond); walk(s.body, inner); walk(s.update, inner); return; } + // A for loop with bounds known when built, written out turn by turn: still a loop to a break or a continue. + case "unrolled": { const inner = loop(null); for (const it of s.iterations) walk(it, inner); return; } + case "switch": { + const o = nested(s.value); + const why = o ?? whole.get(s) ?? null; + const note = o ? ctx.note : why ? LEFT_EARLY.switch : undefined; + for (const c of s.cases) walk(c.body, { ...ctx, under: why, note, breaks: s, breaksLocal: why !== null }); + return; + } + case "block": walk(s.body, ctx); return; + case "try": walk(s.body, ctx); walk(s.catch, ctx); return; + case "parallel": if (L) fail(s.at, need("Waiting on several things at once", L)); for (const b of s.branches) walk(b, ctx); return; + // Leaving early under a Local test: what is left goes on differently from computer to computer, so it is walked + // again as a whole under the test, and what it does there is said where it does it. + case "break": if (L && !ctx.breaksLocal) leave(ctx.breaks, L); return; + case "continue": if (L && !ctx.continuesLocal) leave(ctx.continues, L); return; + case "return": { + if (L && !ctx.returnsLocal) leave(ctx.returns, L); + if (s.value && ctx.result) { const o = by(s.value); if (o) mark(ctx.result, o); } + return; + } + case "sleep": if (L) fail(s.at, need("Waiting", L, "each computer would go on at a different frame")); return; + case "cancel": if (L) fail(s.at, need("Cancelling a task", L)); return; + case "throw": case "uncaught": if (L) fail(s.at, need("Throwing", L)); return; + // A Select command from the player's computer, run on every computer: whatever chose the units, all agree. + case "select": return; + case "syncSend": if (L) fail(s.at, need("sync()", L, "every computer has to reach the same sync() — test the value after it arrives instead")); return; + case "action": { + if (SHOWN.has(s.record.type)) return; + const name = Object.entries(ActionType).find(([, v]) => v === s.record.type)?.[0]; + sink(name ? `${name[0].toLowerCase()}${name.slice(1)}()` : "This action", s.variables?.map((v) => v.expr), s.text); + return; + } + case "print": return; + case "buildText": { const o = by(s.parts); if (o) mark(s.buffer, o); return; } + case "centerLocation": sink("Moving a location", s.x, s.y, s.size); return; + case "unitWrite": sink("Changing a unit", s.unit, s.value); return; + case "unitDo": sink("This order to a unit", s.unit, s.verb); return; + case "tableWrite": if (!localMemoryAt(s.cell.base)) sink("Writing the game's tables", s.value); return; + case "remark": return; + } + }; + + const run = () => { + const top = whole.get(program) ?? null; + walk(program.body, { under: top, ...(top ? { note: LEFT_EARLY.program } : {}), returns: program, returnsLocal: top !== null }); + for (const f of program.functions ?? []) { + const called = under.get(f.id) ?? null; + const why = called ?? whole.get(f) ?? null; + const note = called ? `${f.name} is called under that test` : why ? LEFT_EARLY.return : undefined; + walk(f.body, { under: why, ...(note ? { note } : {}), returns: f, returnsLocal: why !== null, ...(f.result ? { result: f.result.decl.id } : {}) }); + } + }; + for (let round = 0; round < 64; round++) { changed = false; run(); if (!changed) break; } + report = true; + run(); + return { errors: [...errors.values()], locals: [...locals.keys()] }; +} + +/** Why a part is walked whole under a Local test that is not written around it. */ +const LEFT_EARLY = { + loop: "a break or continue under that test leaves this loop on some computers and not others, so all of it goes on differently", + switch: "a break under that test leaves this switch on some computers and not others", + return: "a return under that test leaves this function on some computers and not others, so all of it goes on differently", + program: "a return under that test ends the program on some computers and not others", +}; + +/** The words for something done under a test of a Local value. */ +function words(what: string, why: string, because = "it would happen on some players' computers and not others, and split the game", note?: string): string { + return `${what} under a test of ${why}, which only this computer knows (a Local value)${note ? ` — ${note}` : ""}: ${because}. Only what is shown (print, ui.lines, a sound, the camera) may depend on it; to act on it, send it to every computer first: const v = await sync(P1, …), then test v.`; +} diff --git a/compiler/media.ts b/compiler/media.ts new file mode 100644 index 0000000..4977ada --- /dev/null +++ b/compiler/media.ts @@ -0,0 +1,209 @@ +/** + * Videos a script imports (TrigScript 4, B8): `import badApple from "./bad-apple.video"`. A `.video` file is one of the + * script's files, kept in the map with the others: the frames of a video as the editor decoded them when it was + * imported — so many tiles wide and high, a frame for every frame of the game, each tile ink or not. The source video + * is not kept; what is kept is what the build reads, and it reads the same on every computer, whichever browser or + * codec decoded the original. + * + * The file is text, one line of JSON: the sizes, and the frames in base64, compressed the way JBIG compresses a fax: + * each tile, row by row, is coded with a binary arithmetic coder (LZMA's range coder) whose odds come from the tiles + * around it already coded — four above and two to the left in this frame, five around it in the frame before — so a + * tile is cheap wherever it is what its neighbours and the last frame say it will be: most of every tile of a video. + * The odds adapt as the coder goes, the same way when the file is read. Bad Apple — 5,216 frames of 128 × 96 — is + * a few hundred kilobytes so, where a byte a tile would be 64 MB and runs of tiles 1.6 MB. + * + * What a script gets is a `Video` (below): its sizes, its length and each frame as rows of text, `#` ink — what + * `fog.picture` takes. `fog.light(player, x, y, video, frame)` draws one while the game runs. + */ + +import { fromBase64, PROB_HALF, RangeDecoder, RangeEncoder, toBase64 } from "./rangeCoder"; +import { describeImage, IMAGE_EXTENSION, imageValue } from "./image"; +import { clipValue, describeSound, SOUND_EXTENSION } from "./sound"; +import { describeNotes, NOTES_EXTENSION, notesValue } from "./notes"; + +/** The extension of a video among a script's files. */ +export const VIDEO_EXTENSION = ".video"; +/** A video, an image (`image.ts`), a sound (`sound.ts`) or notes (`notes.ts`): a file of the script that is no TypeScript, whose import gives a value. */ +export const isMediaFile = (path: string): boolean => /\.(?:video|image|sound|notes)$/i.test(path); +export const isVideoFile = (path: string): boolean => path.toLowerCase().endsWith(VIDEO_EXTENSION); +export const isImageFile = (path: string): boolean => path.toLowerCase().endsWith(IMAGE_EXTENSION); +export const isSoundFile = (path: string): boolean => path.toLowerCase().endsWith(SOUND_EXTENSION); +export const isNotesFile = (path: string): boolean => path.toLowerCase().endsWith(NOTES_EXTENSION); + +/** A game frame on Fastest, in milliseconds: what a video imported "at the game's speed" has a frame for. */ +export const FASTEST_FRAME_MS = 42; + +/** The format's version, in the file. */ +const VERSION = 1; + +/** What the editor writes from a decoded video: each frame width × height bytes, row by row, non-zero for ink. */ +export interface VideoFrames { + width: number; + height: number; + /** How long a frame shows, in milliseconds: 42 is a frame of the game on Fastest. */ + frameMs: number; + frames: readonly Uint8Array[]; + /** The file it was imported from, for the hover; a name, never a path. */ + source?: string; +} + +interface VideoHeader { trigscript: "video"; version: number; width: number; height: number; frames: number; frameMs: number; source?: string; data: string } + +/** The `.video` file's text for these frames. */ +export function writeVideo(v: VideoFrames): string { + const { width, height } = v; + if (!(Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0 && width <= 256 && height <= 256)) throw new Error(`A video is 1 to 256 tiles each way, not ${width} × ${height}.`); + const n = width * height; + const coder = new RangeEncoder(); + const odds = new Uint16Array(1 << CONTEXT_BITS).fill(PROB_HALF); + let previous = new Uint8Array(n), now = new Uint8Array(n); + for (const frame of v.frames) { + if (frame.length !== n) throw new Error(`A frame of a ${width} × ${height} video has ${n} tiles, not ${frame.length}.`); + for (let i = 0; i < n; i++) now[i] = frame[i] ? 1 : 0; + for (let y = 0, i = 0; y < height; y++) for (let x = 0; x < width; x++, i++) coder.bit(odds, context(now, previous, width, height, x, y), now[i]); + [previous, now] = [now, previous]; + } + const header: VideoHeader = { trigscript: "video", version: VERSION, width, height, frames: v.frames.length, frameMs: v.frameMs, ...(v.source ? { source: v.source } : {}), data: toBase64(coder.finish()) }; + return JSON.stringify(header); +} + +/** A video as the build reads it: every frame unpacked to a bit a tile, a frame's bytes made when asked for. */ +export interface VideoData { + readonly width: number; + readonly height: number; + readonly length: number; + readonly frameMs: number; + readonly source?: string; + /** Frame i, width × height bytes, 1 for ink. A new array each time. */ + tiles(i: number): Uint8Array; +} + +/** Read a `.video` file's text. Throws, in words for the person, when it is not one. */ +export function readVideo(text: string): VideoData { + let h: Partial; + try { h = JSON.parse(text) as Partial; } catch { throw new Error("This is not a video TrigScript imported: a .video file is one line of JSON."); } + if (h?.trigscript !== "video" || typeof h.data !== "string") throw new Error("This is not a video TrigScript imported."); + if (h.version !== VERSION) throw new Error(`This video was written by a newer TrigScript (format ${h.version}); update the plugin to read it.`); + const width = Number(h.width), height = Number(h.height), length = Number(h.frames), frameMs = Number(h.frameMs); + if (![width, height, length].every((k) => Number.isInteger(k) && k >= 0) || !(frameMs > 0)) throw new Error("The video's sizes are not whole numbers."); + const n = width * height, stride = (n + 7) >> 3; + const coder = new RangeDecoder(fromBase64(h.data)); + const odds = new Uint16Array(1 << CONTEXT_BITS).fill(PROB_HALF); + const bits = new Uint8Array(stride * length); + let previous = new Uint8Array(n), now = new Uint8Array(n); + for (let f = 0; f < length; f++) { + const base = f * stride; + for (let y = 0, i = 0; y < height; y++) for (let x = 0; x < width; x++, i++) { + const b = coder.bit(odds, context(now, previous, width, height, x, y)); + now[i] = b; + if (b) bits[base + (i >> 3)] |= 1 << (i & 7); + } + if (coder.overrun) throw new Error("The video's frames end too soon: the file is cut short."); + [previous, now] = [now, previous]; + } + return Object.freeze({ + width, height, length, frameMs, ...(typeof h.source === "string" ? { source: h.source } : {}), + tiles(i: number): Uint8Array { + if (!(Number.isInteger(i) && i >= 0 && i < length)) throw new RangeError(`The video has frames 0 … ${length - 1}; there is no frame ${i}.`); + const out = new Uint8Array(n), base = i * stride; + for (let k = 0; k < n; k++) out[k] = (bits[base + (k >> 3)] >> (k & 7)) & 1; + return out; + }, + }); +} + +/** What a script imports: `import badApple from "./bad-apple.video"`. */ +export interface Video { + readonly __trigscript: "video"; + readonly width: number; + readonly height: number; + /** How many frames. */ + readonly length: number; + /** How long a frame shows, in milliseconds (42: a game frame on Fastest). */ + readonly frameMs: number; + /** How long the whole video plays, in seconds. */ + readonly seconds: number; + /** Frame i as rows of text, `#` ink and `.` not: what `fog.picture` takes. */ + frame(i: number): readonly string[]; + /** Frame i as bytes, 1 for ink; for the compiler's own work (`cover()`), not in the declarations. */ + tiles(i: number): Uint8Array; +} + +export const isVideo = (v: unknown): v is Video => typeof v === "object" && v !== null && (v as Video).__trigscript === "video"; + +/** + * The value an import of a `.video` file gives, kept while the text is the same: a build reads the file again each + * time the script is compiled, and what is worked out from a video (`cover()` of its frames) is kept with it. + */ +const values = new Map(); + +export function videoValue(text: string): Video { + const hit = values.get(text); + if (hit) return hit; + const data = readVideo(text); + const video: Video = Object.freeze({ + __trigscript: "video" as const, + width: data.width, height: data.height, length: data.length, frameMs: data.frameMs, + seconds: (data.length * data.frameMs) / 1000, + frame(i: number): readonly string[] { + const t = data.tiles(i), rows: string[] = []; + for (let y = 0; y < data.height; y++) { + let row = ""; + for (let x = 0; x < data.width; x++) row += t[y * data.width + x] ? "#" : "."; + rows.push(row); + } + return Object.freeze(rows); + }, + tiles: (i: number) => data.tiles(i), + }); + // A few videos at most are imported at a time; the oldest goes when a sixth arrives (an edit of the file is a new text). + if (values.size >= 5) values.delete(values.keys().next().value!); + values.set(text, video); + return video; +} + +/** What the hover on an import says: "a video, 128 × 96 tiles, 5,216 frames at the game's speed (3:39), from bad-apple.mkv". */ +export function describeVideo(text: string): string { + try { + // The header alone: the frames are not decoded for this. + const h = JSON.parse(text) as Partial; + if (h?.trigscript !== "video") return "This is not a video TrigScript imported."; + const v = { width: Number(h.width), height: Number(h.height), length: Number(h.frames), frameMs: Number(h.frameMs), source: typeof h.source === "string" ? h.source : undefined }; + const seconds = Math.round((v.length * v.frameMs) / 1000); + const speed = v.frameMs === FASTEST_FRAME_MS ? "at the game's speed on Fastest" : `${(1000 / v.frameMs).toFixed(1)} a second`; + return `A video, ${v.width} × ${v.height} tiles, ${v.length.toLocaleString("en-US")} frame${v.length === 1 ? "" : "s"} ${speed} (${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")})${v.source ? `, from ${v.source}` : ""}.`; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +/** The value an import of a media file gives: an `Image` for an `.image`, a `Clip` for a `.sound`, `Notes` for a `.notes`, a `Video` for a `.video`. */ +export const mediaValue = (path: string, text: string): unknown => (isImageFile(path) ? imageValue(text) : isSoundFile(path) ? clipValue(text) : isNotesFile(path) ? notesValue(text) : videoValue(text)); + +/** What the Explorer's hover says about a media file. */ +export const describeMedia = (path: string, text: string): string => (isImageFile(path) ? describeImage(text) : isSoundFile(path) ? describeSound(text) : isNotesFile(path) ? describeNotes(text) : describeVideo(text)); + +/* ── The coding: a tile's odds by the tiles around it, for LZMA's binary range coder (`rangeCoder.ts`) ── */ + +const CONTEXT_BITS = 11; + +/** + * Which odds tile (x, y) is coded with: in this frame the two to its left and the four above (NW N NE, and N of N); + * in the frame before, itself and the four beside it. Off the picture a tile is 0. Only tiles already coded are used, + * so reading the file can work out the same. + */ +function context(now: Uint8Array, previous: Uint8Array, width: number, height: number, x: number, y: number): number { + const i = y * width + x; + const left = x > 0, right = x + 1 < width, up = y > 0; + return (left ? now[i - 1] : 0) + | (x > 1 ? now[i - 2] : 0) << 1 + | (up && left ? now[i - width - 1] : 0) << 2 + | (up ? now[i - width] : 0) << 3 + | (up && right ? now[i - width + 1] : 0) << 4 + | (y > 1 ? now[i - 2 * width] : 0) << 5 + | previous[i] << 6 + | (left ? previous[i - 1] : 0) << 7 + | (right ? previous[i + 1] : 0) << 8 + | (up ? previous[i - width] : 0) << 9 + | (y + 1 < height ? previous[i + width] : 0) << 10; +} diff --git a/compiler/memory.ts b/compiler/memory.ts new file mode 100644 index 0000000..bbde5cc --- /dev/null +++ b/compiler/memory.ts @@ -0,0 +1,119 @@ +/** + * `memory`: cells of the game's memory that have no other word in the language (TrigScript 4, B7). Each is one cell + * at a fixed address — a table cell with no index, so it is read and written as `stats()` fields are (`tableRead` / + * `tableWrite`), and the simulator keeps it as it keeps them. + * + * Every field says what StarCraft: Remastered does with it, from its own EUD emulator (R0, `data/remastered.json`) and + * from played probes; the hover shows it. A field the game only keeps for itself is `readonly` in the declarations, + * so a write is a type error before anything is built. Addresses are 1.16.1's, which Remastered emulates. + */ +import type { TableField } from "./tables"; + +export interface MemoryField { + /** Where it sits in `memory`: ["vision", "countdown"] is `memory.vision.countdown`. */ + path: [string, string]; + doc: string; + base: number; + width: 1 | 2 | 4; + readonly?: boolean; + /** What Remastered does with it, as the hover says it. */ + remastered: string; +} + +/** The groups of `memory`, each an interface of its own in the declarations. */ +export const MEMORY_GROUPS: Record = { + vision: ["MemoryVision", "How the game keeps fog of war."], + game: ["MemoryGame", "The game as a whole."], + map: ["MemoryMap", "The map being played."], +}; + +export const MEMORY_FIELDS: MemoryField[] = [ + { + path: ["vision", "countdown"], base: 0x51ce98, width: 4, + doc: "Frames until the game works out again what every player sees. It counts down from 100 each frame; at 0 every tile goes back to fog and every unit reveals what it sees again. Between, a tile once revealed stays revealed. Write 1 after drawing and the next frame starts from a wiped board — `fog` does this for you.", + remastered: "Remastered: read and written as in 1.16.1 (its own EUD emulator; its frame step is 1.16.1's line for line). Played: the S4 fog probes.", + }, + { + path: ["game", "frame"], base: 0x57f23c, width: 4, readonly: true, + doc: "Game frames since the game began: 24 a second on Fastest, the same on every player's computer.", + remastered: "Remastered: read allowed (its own EUD emulator). The game keeps it; a write would only confuse its own count.", + }, + { + path: ["map", "width"], base: 0x57f1d4, width: 2, readonly: true, + doc: "The map's width, in tiles (a tile is 32 pixels).", + remastered: "Remastered: read allowed (its own EUD emulator).", + }, + { + path: ["map", "height"], base: 0x57f1d6, width: 2, readonly: true, + doc: "The map's height, in tiles (a tile is 32 pixels).", + remastered: "Remastered: read allowed (its own EUD emulator).", + }, +]; + +/** The game's locations: 255 records of 20 bytes — left, top, right, bottom in pixels, then the elevation flags. */ +export const LOCATION_TABLE = 0x58dc60, LOCATION_BYTES = 20; +export const LOCATION_SIDES = ["left", "top", "right", "bottom"] as const; +/** `bounds(location).left`: one edge of a location as a table cell, which the program reads and writes where it is now. */ +export function locationField(side: (typeof LOCATION_SIDES)[number]): TableField { + return { name: side, doc: `The location's ${side} edge, in pixels.`, base: LOCATION_TABLE + 4 * LOCATION_SIDES.indexOf(side), stride: LOCATION_BYTES, width: 4 }; +} + +/** + * `local`: what only the computer the game runs on knows (TrigScript 4, B5) — who sits at it, where its screen is, where + * its mouse is on the screen. Each is a cell read as `memory`'s are, and every one is a `Local` value: it may differ from + * one computer to the next, so the compiler keeps it away from anything that changes the game (`local.ts`) and `sync()` + * is how it reaches everyone. Read from Remastered's emulator and played (`probes/local-reads`, 2026-10-17). + */ +export const LOCAL_FIELDS: MemoryField[] = [ + { + path: ["player", ""], base: 0x512684, width: 4, readonly: true, + doc: "The player sitting at this computer: 0 for Player 1, 1 for Player 2, … Every computer has its own.", + remastered: "Remastered: the real local player number (its own EUD emulator). Played: 0 in a game hosted alone.", + }, + { + path: ["camera", "x"], base: 0x62848c, width: 4, readonly: true, + doc: "Where this computer's screen is on the map: the left edge, in pixels. The screen is 640 pixels wide in Classic graphics and 853 in Remastered's at 16:9.", + remastered: "Remastered: the real camera, read live (its own EUD emulator); a write is ignored — centerView() moves it. Played.", + }, + { + path: ["camera", "y"], base: 0x6284a8, width: 4, readonly: true, + doc: "Where this computer's screen is on the map: the top edge, in pixels.", + remastered: "Remastered: the real camera, read live (its own EUD emulator); a write is ignored. Played.", + }, + { + path: ["mouse", "x"], base: 0x6cddc4, width: 4, readonly: true, + doc: "Where this computer's mouse is on the screen, from its left edge, in the game's pixels: 0 to 639 in Classic graphics, to 852 in Remastered's at 16:9. On the map it is local.camera.x + local.mouse.x; mouse(p) is that place, sent to every computer.", + remastered: "Remastered: its input record, read live; in widescreen wider than 640 (2560 screen pixels read as 853). Played.", + }, + { + path: ["mouse", "y"], base: 0x6cddc8, width: 4, readonly: true, + doc: "Where this computer's mouse is on the screen, from its top edge, in the game's pixels: 0 to 479.", + remastered: "Remastered: its input record, read live. Played.", + }, +]; + +/** The memory that is one computer's own: what a read of it gives is a `Local` value. Not only `LOCAL_FIELDS`: the keys and the selection too. */ +export const LOCAL_MEMORY: readonly { name: string; from: number; size: number }[] = [ + { name: "the local player", from: 0x512684, size: 4 }, + { name: "the keys held", from: 0x596a18, size: 256 }, + { name: "the camera", from: 0x62848c, size: 4 }, + { name: "the camera", from: 0x6284a8, size: 4 }, + { name: "the selection", from: 0x6284b8, size: 48 }, + { name: "the mouse buttons", from: 0x6cddc0, size: 4 }, + { name: "the mouse", from: 0x6cddc4, size: 8 }, +]; + +/** What of `LOCAL_MEMORY` an address is in, if any. */ +export function localMemoryAt(address: number): string | undefined { + return LOCAL_MEMORY.find((m) => address >= m.from && address < m.from + m.size)?.name; +} + +/** The name a `local` field goes by: `local.camera.x`, `local.player`. */ +export function localName(f: MemoryField): string { + return f.path[1] ? `local.${f.path[0]}.${f.path[1]}` : `local.${f.path[0]}`; +} + +/** A field as the table machinery takes it: one cell, no index. */ +export function memoryTableField(f: MemoryField): TableField { + return { name: f.path.join("."), doc: f.doc, base: f.base, stride: 0, width: f.width, ...(f.readonly ? { readonly: true } : {}) }; +} diff --git a/compiler/names.ts b/compiler/names.ts index 2927513..4f3be3c 100644 --- a/compiler/names.ts +++ b/compiler/names.ts @@ -16,6 +16,7 @@ import { PlayerGroup, SWITCH_COUNT } from "../vendor/triggers"; import { AI_SCRIPT_CHOICES, aiScriptCode, PLAYER_GROUP_CHOICES, UNIT_CLASS_CHOICES } from "../vendor/triggerDefs"; import { UNIT_NAMES } from "../vendor/units"; import { TECH_NAMES, UPGRADE_NAMES, WEAPON_NAMES } from "../vendor/gameNames"; +import { ORDER_NAMES } from "./orders"; /** The location slot the game calls Anywhere (0-based); a script names it `locations.Anywhere`, value 64. */ export const ANYWHERE_INDEX = 63; @@ -45,10 +46,14 @@ export interface ScriptNames { weapons: NameTable; upgrades: NameTable; techs: NameTable; + /** orders.dat ids: what a unit is doing (`u.orderId`) and what `on.order()` listens for. */ + orders: NameTable; + /** The map's sounds, the Sound Editor's list: each the string of its path, what Play WAV takes. */ + sounds: NameTable; } /** Every table, in the order the declarations list them. */ -export const allTables = (n: ScriptNames): NameTable[] => [n.players, n.units, n.locations, n.switches, n.aiScripts, n.weapons, n.upgrades, n.techs]; +export const allTables = (n: ScriptNames): NameTable[] => [n.players, n.units, n.locations, n.switches, n.aiScripts, n.weapons, n.upgrades, n.techs, n.orders, n.sounds]; /** What a map contributes to the tables; every part is optional (the fixed lists stand without a map). */ export interface NameSources { @@ -60,6 +65,8 @@ export interface NameSources { switchNames?: (string | null)[]; /** A unit type's custom name, null or blank for the default. */ unitCustomName?: (id: number) => string | null; + /** The map's sounds: the string each one's path is (1-based, as a trigger names it) and the path. */ + sounds?: { stringIndex: number; path: string }[]; } /** `Terran Siege Tank (Tank Mode)` → `TerranSiegeTankTankMode`; never empty, never starts with a digit. */ @@ -129,7 +136,7 @@ export const defaultSwitchName = (index: number) => `Switch ${index + 1}`; /** The tables for a map's names — or, with nothing given, the fixed lists alone (what tests use). */ export function scriptNames(src: NameSources = {}): ScriptNames { - const withMap = !!(src.forceNames || src.locations || src.switchNames || src.unitCustomName); + const withMap = !!(src.forceNames || src.locations || src.switchNames || src.unitCustomName || src.sounds); const locations: NameEntry[] = [{ value: 0, keys: ["NoLocation", "No Location"] }]; const listed = (src.locations ?? []).slice().sort((a, b) => a.index - b.index); if (!listed.some((l) => l.index === ANYWHERE_INDEX)) listed.push({ index: ANYWHERE_INDEX, name: "Anywhere" }); @@ -151,6 +158,12 @@ export function scriptNames(src: NameSources = {}): ScriptNames { weapons: table("weapons", "Weapon", "Weapons, for stats() and a unit type's groundWeapon / airWeapon.", [...WEAPON_NAMES.map((name, id) => ({ value: id, keys: keysFor(name) })), { value: WEAPON_NAMES.length, keys: ["None"] }]), upgrades: table("upgrades", "Upgrade", "Upgrades, for stats().", UPGRADE_NAMES.map((name, id) => ({ value: id, keys: keysFor(name) }))), techs: table("techs", "Tech", "Technologies, for stats().", TECH_NAMES.map((name, id) => ({ value: id, keys: keysFor(name) }))), + orders: table("orders", "Order", "Orders, what a unit is doing: u.orderId, and what on.order() listens for.", ORDER_NAMES.map((name, id) => ({ value: id, keys: [name] }))), + // `staredit\wav\bad apple.ogg` is sounds.BadApple — the file's name without its folder or extension — or by its path. + sounds: table("sounds", "Sound", "The map's sounds (the Sound Editor's list), for playWav() and transmission().", (src.sounds ?? []).filter((w) => w.stringIndex > 0).map((w) => { + const file = w.path.split(/[\\/]/).pop() ?? w.path; + return { value: w.stringIndex, keys: [identifier(file.replace(/\.[^.]*$/, "")), w.path] }; + })), }; } diff --git a/compiler/notes.ts b/compiler/notes.ts new file mode 100644 index 0000000..5b117a5 --- /dev/null +++ b/compiler/notes.ts @@ -0,0 +1,193 @@ +/** + * Notes a script imports (TrigScript 4, music step 3): `import tune from "./tune.notes"`. A `.notes` file is one of the + * script's files, kept in the map with the others: the notes of a MIDI file as the editor read it when it was + * imported — each track's name, channel and instrument, and each note's start, length, pitch and velocity, in beats. + * The MIDI file is not kept; what is kept is what the build reads. + * + * The file is text, one line of JSON. What a script gets is `Notes` (below), which `music.midi` turns into a song with + * an instrument for each track it names. + */ + +/** The extension of a script's notes. */ +export const NOTES_EXTENSION = ".notes"; +/** The format's version, in the file. */ +const VERSION = 1; + +/** A note: its start and length in beats (quarter notes) from the start, its MIDI number (60 is C4), how hard (1 … 127). */ +export interface NoteEvent { readonly at: number; readonly beats: number; readonly pitch: number; readonly velocity: number } + +/** A track of a MIDI file as imported: notes of one channel of one track of the file. */ +export interface NotesTrackData { + readonly name: string; + /** 1 … 16; 10 is the drums. */ + readonly channel: number; + /** The General MIDI instrument the file asks for, 1 … 128 (1 when it asks for none). */ + readonly program: number; + readonly notes: readonly NoteEvent[]; +} + +/** What a `.notes` file holds. */ +export interface NotesData { + /** The tempo the file starts at. */ + readonly bpm: number; + /** Quarter notes a bar, from its time signature (4 for 4/4, 3 for 6/8). */ + readonly beats: number; + /** Whether the tempo changes later (a song plays at one). */ + readonly tempoChanges: number; + readonly tracks: readonly NotesTrackData[]; + readonly source?: string; +} + +/* ── Reading a MIDI file ── */ + +/** The notes of a Standard MIDI File (format 0 or 1). Throws, in words for the person, when it is not one. */ +export function readMidi(bytes: Uint8Array, source?: string): NotesData { + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const tag = (at: number) => String.fromCharCode(...bytes.subarray(at, at + 4)); + if (bytes.length < 14 || tag(0) !== "MThd") throw new Error("This is not a MIDI file: a MIDI file starts with MThd."); + const headerLength = view.getUint32(4), format = view.getUint16(8), count = view.getUint16(10), division = view.getUint16(12); + if (format > 1) throw new Error("This MIDI file holds separate songs (format 2): only formats 0 and 1 are read."); + if (division & 0x8000) throw new Error("This MIDI file counts time in SMPTE frames: only files that count in beats are read."); + const ppq = division; + let at = 8 + headerLength; + const tempos: [number, number][] = []; + let meter: [number, number] | null = null; + // Notes by file track and channel, in the order they turn up. + const groups = new Map(); + for (let t = 0; t < count && at + 8 <= bytes.length; t++) { + if (tag(at) !== "MTrk") { at += 8 + view.getUint32(at + 4); t--; continue; } + const end = Math.min(bytes.length, at + 8 + view.getUint32(at + 4)); + let p = at + 8, tick = 0, status = 0, trackName = ""; + const programs = new Map(); + const open = new Map(); + const varint = () => { let v = 0; for (let k = 0; k < 4; k++) { const b = bytes[p++]; v = (v << 7) | (b & 0x7f); if (!(b & 0x80)) break; } return v; }; + const group = (channel: number) => { + const key = `${t}:${channel}`; + let g = groups.get(key); + if (!g) { g = { track: t, channel, name: "", program: programs.get(channel) ?? 0, notes: [] }; groups.set(key, g); } + return g; + }; + const close = (channel: number, pitch: number, now: number) => { + const list = open.get(channel * 128 + pitch); + const on = list?.shift(); + if (on && now > on.tick) group(channel).notes.push({ at: on.tick / ppq, beats: (now - on.tick) / ppq, pitch, velocity: on.velocity }); + }; + while (p < end) { + tick += varint(); + let b = bytes[p]; + if (b & 0x80) { status = b; p++; } else if (!status) throw new Error("This MIDI file is damaged: a track starts with data and no event."); + b = status; + if (b === 0xff) { + const type = bytes[p++], length = varint(), data = bytes.subarray(p, p + length); + p += length; + if (type === 0x51 && length === 3) tempos.push([tick, (data[0] << 16) | (data[1] << 8) | data[2]]); + else if (type === 0x58 && length >= 2 && !meter) meter = [data[0], 2 ** data[1]]; + else if (type === 0x03 && !trackName) trackName = new TextDecoder("latin1").decode(data).trim(); + else if (type === 0x2f) break; + status = 0; + continue; + } + if (b === 0xf0 || b === 0xf7) { p += varint(); status = 0; continue; } + const kind = b & 0xf0, channel = b & 0x0f; + const one = bytes[p++], two = kind === 0xc0 || kind === 0xd0 ? 0 : bytes[p++]; + if (kind === 0x90 && two > 0) { + const key = channel * 128 + one; + const list = open.get(key); + // A note struck again while it sounds ends the first. + if (list?.length) close(channel, one, tick); + (open.get(key) ?? open.set(key, []).get(key)!).push({ tick, velocity: two }); + group(channel); + } else if (kind === 0x80 || kind === 0x90) close(channel, one, tick); + else if (kind === 0xc0) { programs.set(channel, one); const g = groups.get(`${t}:${channel}`); if (g && g.notes.length === 0) g.program = one; } + } + // A note never let go ends with its track. + for (const [key, list] of open) for (const on of list) if (tick > on.tick) group(key >> 7).notes.push({ at: on.tick / ppq, beats: (tick - on.tick) / ppq, pitch: key & 127, velocity: on.velocity }); + for (const g of groups.values()) if (g.track === t) g.name = trackName; + at = end; + } + tempos.sort((a, b) => a[0] - b[0]); + const first = tempos.find((x) => x[0] === 0) ?? tempos[0]; + const bpm = first ? 60_000_000 / first[1] : 120; + const kept = [...groups.values()].filter((g) => g.notes.length > 0); + const several = new Map(); + for (const g of kept) several.set(g.track, (several.get(g.track) ?? 0) + 1); + const tracks = kept.map((g) => ({ + name: g.name ? (several.get(g.track)! > 1 ? `${g.name} (channel ${g.channel + 1})` : g.name) : `Track ${g.track + 1}${several.get(g.track)! > 1 || count === 1 ? ` channel ${g.channel + 1}` : ""}`, + channel: g.channel + 1, program: g.program + 1, + notes: g.notes.sort((a, b) => a.at - b.at || a.pitch - b.pitch), + })); + if (tracks.length === 0) throw new Error("This MIDI file has no notes."); + // Names made unique, so a script can name each track. + const seen = new Map(); + for (const tr of tracks) { const n = (seen.get(tr.name) ?? 0) + 1; seen.set(tr.name, n); if (n > 1) tr.name = `${tr.name} ${n}`; } + let changes = 0; + for (let k = 1; k < tempos.length; k++) if (tempos[k][1] !== tempos[k - 1][1]) changes++; + return { bpm: Math.round(bpm * 1000) / 1000, beats: meter ? (meter[0] * 4) / meter[1] : 4, tempoChanges: changes, tracks, ...(source ? { source } : {}) }; +} + +/* ── The .notes file ── */ + +/** The `.notes` file's text: a note four numbers, in 960ths of a beat. */ +export function writeNotes(data: NotesData): string { + const q = (x: number) => Math.round(x * 960); + return JSON.stringify({ + trigscript: "notes", version: VERSION, bpm: data.bpm, beats: data.beats, tempoChanges: data.tempoChanges, ...(data.source ? { source: data.source } : {}), + tracks: data.tracks.map((t) => ({ name: t.name, channel: t.channel, program: t.program, notes: t.notes.flatMap((n) => [q(n.at), q(n.beats), n.pitch, n.velocity]) })), + }); +} + +/** A `.notes` file as the build reads it. Throws, in words for the person, when it is not one. */ +export function readNotes(text: string): NotesData { + let h: { trigscript?: unknown; version?: unknown; bpm?: unknown; beats?: unknown; tempoChanges?: unknown; source?: unknown; tracks?: unknown }; + try { h = JSON.parse(text); } catch { throw new Error("These are not notes TrigScript imported: a .notes file is one line of JSON."); } + if (h?.trigscript !== "notes") throw new Error("These are not notes TrigScript imported."); + if (h.version !== VERSION) throw new Error(`These notes are of version ${String(h.version)}; this TrigScript reads version ${VERSION}.`); + const bpm = Number(h.bpm), beats = Number(h.beats); + if (!(bpm > 0) || !(beats > 0) || !Array.isArray(h.tracks)) throw new Error("These notes are damaged: no tempo, no bar or no tracks."); + const tracks = h.tracks.map((t: { name?: unknown; channel?: unknown; program?: unknown; notes?: unknown }, k: number) => { + const ns = t?.notes; + if (!Array.isArray(ns) || ns.length % 4 !== 0 || ns.some((x) => typeof x !== "number" || !Number.isFinite(x))) throw new Error(`These notes are damaged: track ${k + 1}'s notes are not four numbers each.`); + const notes: NoteEvent[] = []; + for (let i = 0; i < ns.length; i += 4) notes.push({ at: ns[i] / 960, beats: ns[i + 1] / 960, pitch: ns[i + 2], velocity: ns[i + 3] }); + return { name: String(t.name ?? `Track ${k + 1}`), channel: Number(t.channel ?? 1), program: Number(t.program ?? 1), notes }; + }); + return { bpm, beats, tempoChanges: Number(h.tempoChanges ?? 0), tracks, ...(typeof h.source === "string" ? { source: h.source } : {}) }; +} + +/* ── What a script gets ── */ + +const NAMES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; +/** A MIDI number's note name: 60 is "C4". */ +export const noteName = (n: number): string => NAMES[n % 12] + (Math.floor(n / 12) - 1); + +/** The value an import of a `.notes` file gives, kept while the text is the same (a build reads the files each time). */ +const values = new Map(); + +export function notesValue(text: string): unknown { + const kept = values.get(text); + if (kept) return kept; + const data = readNotes(text); + const value = Object.freeze({ + bpm: data.bpm, beats: data.beats, tempoChanges: data.tempoChanges, ...(data.source ? { source: data.source } : {}), + tracks: Object.freeze(data.tracks.map((t) => Object.freeze({ + name: t.name, channel: t.channel, program: t.program, drums: t.channel === 10, + notes: Object.freeze(t.notes.map((n) => Object.freeze({ at: n.at, beats: n.beats, pitch: n.pitch, note: noteName(n.pitch), velocity: n.velocity }))), + }))), + }); + if (values.size >= 8) values.delete(values.keys().next().value!); + values.set(text, value); + return value; +} + +/** What the hover on an import says: "notes, 4 tracks, 1,204 notes, 143 beats a minute, from tune.mid". */ +export function describeNotes(text: string): string { + try { + const d = readNotes(text); + const n = d.tracks.reduce((s, t) => s + t.notes.length, 0); + const end = Math.max(0, ...d.tracks.flatMap((t) => t.notes.map((x) => x.at + x.beats))); + const seconds = Math.round((end * 60) / d.bpm); + return `Notes: ${d.tracks.length} track${d.tracks.length === 1 ? "" : "s"} (${d.tracks.map((t) => t.name).join(", ")}), ${n.toLocaleString("en-US")} notes, ${Math.round(d.bpm * 100) / 100} beats a minute (${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")})${d.tempoChanges ? `, the tempo changing ${d.tempoChanges} time${d.tempoChanges === 1 ? "" : "s"} (a song plays at the first)` : ""}${d.source ? `, from ${d.source}` : ""}.`; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} diff --git a/compiler/numbers.ts b/compiler/numbers.ts index 6249a33..d83c3b5 100644 --- a/compiler/numbers.ts +++ b/compiler/numbers.ts @@ -22,12 +22,58 @@ * `chatted()` read are, `wave += 1` is not (it wraps, in the end). That is found first, by * going over the program once without changing it and dropping variables until none is left * that some store could put below zero. + * + * And the fourth, `fixed` (TrigScript 4, B6): a number with a fraction, 16.16 — its cell is the value times 65 536, + * so 1.5 is 98 304 — from −32 768 to 32 767.99998. A fraction in a constant (`0.5`, `Math.PI`), a `fixed` + * annotation, `fixed(x)`, `Math.sqrt` / `sin` / `cos` / `tan` / `atan2` / `hypot` make one; so does arithmetic with + * one in it. Nothing else changes for a program that has none. + * + * - **A variable is fixed when anything stored into it is**, as JavaScript's numbers would hold the fraction: `let x = + * 0; x += 0.5` makes `x` fixed everywhere. That is found as the never-below-zero ones are, by going over the program + * until no variable changes; a `u8` / `u16` / `u32` stays whole and storing a fraction into one is an error. + * - **Mixing converts, as TypeScript would compute:** a whole number beside a fixed one is turned into one (`<< 16`, + * or the constant times 65 536), except where it need not be — a fixed times a whole number, and a fixed divided by + * one, are the raw cell's own `*` and `/`. A product or a quotient of two fixed numbers is `fmul` / `fdiv`. + * - **Where only a whole number makes sense** — an index, a count, a place on the map, a unit's field, a shift, a + * bitwise operator — a fixed one is an error that names `Math.floor` / `round` / `trunc`; `x | 0` is `Math.trunc`. + * - **Rounding** (`floor`, `ceil`, `round`, `trunc`, which the front end gives for every `Math.` one) is turned into + * shifts and a division here, and is nothing at all of a whole number. `sin` / `cos` read a table of 4 096 cells this + * pass adds to the program (`__sin`), and `atan2` one of 1 025 (`__atan`), both between their cells, so that both + * backends read the same values and are as close as a fixed number can be. */ import { I32_MAX, UNIT_FLAGS, isTextExpr, mapText, mapTextOperands, programDeclarations, type At, type BoolExpr, type Call, type NumExpr, type Program, type Stmt, type TextExpr, type TextMap, type TextPart, type UnitExpr, type VarDecl } from "./ir"; -import type { ProgramDiagnostic } from "./eud"; +import type { ProgramDiagnostic } from "./check"; +import { divisionByThresholds } from "./thresholds"; + +/** `flex`: a constant from 0 to 2 147 483 647, which is the same whichever way it is read. `fixed`: a 16.16 number. */ +type NumType = "i32" | "u32" | "flex" | "fixed"; + +/** One, as a fixed number's cell. */ +export const FIXED_ONE = 65536; +/** The fixed numbers' ends, as values. */ +export const FIXED_MIN = -32768; +export const FIXED_MAX = 32767.99998474121; +/** The sine table's size, a whole turn: `sin(x)` reads cell round(x × 4 096 / 2π) mod 4 096. */ +export const SIN_STEPS = 4096; +/** x × this (a fixed number) is x's place in the sine table, in its steps. */ +export const SIN_SCALE = fixedRaw(SIN_STEPS / (2 * Math.PI)); +/** The arc tangent table's: atan(i / 1 024) for i from 0 to 1 024. */ +export const ATAN_STEPS = 1024; + +/** A value as a fixed number's raw cell, rounded to the nearest; what a constant with a fraction is in the IR. */ +export function fixedRaw(value: number): number { + return Math.round(value * FIXED_ONE) | 0; +} -/** `flex`: a constant from 0 to 2 147 483 647, which is the same whichever way it is read. */ -type NumType = "i32" | "u32" | "flex"; +/** The cells of the sine table: sin(2π i / 4 096) as fixed numbers. */ +export function sinTable(): number[] { + return Array.from({ length: SIN_STEPS }, (_, i) => fixedRaw(Math.sin((2 * Math.PI * i) / SIN_STEPS))); +} + +/** The cells of the arc tangent table: atan(i / 1 024) as fixed numbers. */ +export function atanTable(): number[] { + return Array.from({ length: ATAN_STEPS + 1 }, (_, i) => fixedRaw(Math.atan(i / ATAN_STEPS))); +} const FLAGS: ReadonlySet = new Set(UNIT_FLAGS); @@ -36,32 +82,100 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { /** The signed variables nothing ever puts below zero, and every store the program makes, from which they are found. */ const never = new Set(); const stores: [string, NumExpr][] = []; + /** + * Loop counters (cheaper lowering): `for (…; i < e; i++)` adds 1 to `i` only after `i < e` held with that same `i`, so + * from a start that is never below zero it never reaches past 2 147 483 647. The update's stored value, so that the + * search below takes it as never below zero while `i` is; the counters of the loops being walked; and the counters + * that cannot be trusted that way — stored into inside their loop, or by a function, which could put them anywhere. + */ + const counted = new WeakSet(); + let counter: string | null = null; + const counting: string[] = []; + const spoiled = new Set(); + let inFunction = false; let settled = false; + /** Whether a pass found another variable or array fixed: the passes before the last go on until one finds none. */ + let grew = false; + /** Where each variable or array found fixed was first stored a fraction: what an error about it as a whole number points at. */ + const madeFixed = new Map(); const decls = new Map(programDeclarations(program).map((d) => [d.id, d])); const functions = new Map((program.functions ?? []).map((f) => [f.id, f])); /** An array is typed as a variable is, and found never below zero the same way: by everything ever stored into a cell of it. */ const arrays = new Map(program.arrays.map((a) => [a.id, a])); for (const a of program.arrays) if (a.values && !a.unsigned && a.values.every((v) => v >= 0)) never.add(a.id); const mixed = (at: At, what: string) => errors.push({ at, message: `${what} mixes a number, which is signed, with a u32. Say which is meant: u32(x) reads a number's 32 bits from 0 up, i32(x) a u32's as a signed number.` }); + const mixedFixed = (at: At, what: string) => errors.push({ at, message: `${what} mixes a fixed number, one with a fraction, with a u32. Say which is meant: i32(x) reads the u32 as a signed whole number, which mixes with a fixed one.` }); + const labelOf = (e: NumExpr): string => ("label" in e ? e.label : ""); + /** Why a variable is fixed, when a pass found it so: said beside an error about it as a whole number. */ + const whyFixed = (e: NumExpr): string => { + const id = e.kind === "var" ? e.id : e.kind === "element" || e.kind === "pop" ? e.array : undefined; + const at = id === undefined ? undefined : madeFixed.get(id); + const name = id === undefined ? undefined : (decls.get(id) ?? arrays.get(id))?.name; + return at && name ? ` (${name} is fixed: line ${at.line} stores a fraction into it)` : ""; + }; + const fraction = (at: At, what: string, e: NumExpr) => errors.push({ at, message: `${what} is a fixed number, one with a fraction${whyFixed(e)}, and only a whole number goes here: say how to round it — Math.floor(x), Math.round(x) or Math.trunc(x).` }); - /** The type two sides of one operation share; an error when one is signed and the other a `u32`. */ + /** The type two sides of one operation share; an error when one is signed and the other a `u32`. A fixed side makes it fixed. */ const unify = (a: NumType, b: NumType, at: At, what: string): NumType => { + if (a === "fixed" || b === "fixed") { + if (a === "u32" || b === "u32") mixedFixed(at, what); + return "fixed"; + } if (a === "flex") return b; if (b === "flex" || a === b) return a; mixed(at, what); return "i32"; }; + /** A value as a fixed number, on the pass that is for good: a whole one shifted up 16 bits (a constant times 65 536). */ + const asFixed = (e: NumExpr, t: NumType, at: At): NumExpr => { + if (!settled || t === "fixed") return e; + if (e.kind === "const") { + if (e.value < FIXED_MIN || e.value > Math.floor(FIXED_MAX)) { + errors.push({ at, message: `${e.value} is reckoned with a fixed number here, and a fixed number holds −32 768 … 32 767.99998. Keep the whole numbers apart, or scale them down first.` }); + return { kind: "const", value: 0, fixed: true }; + } + return { kind: "const", value: (e.value * FIXED_ONE) | 0, fixed: true }; + } + return { kind: "binary", op: "<<", left: e, right: { kind: "const", value: 16 }, at, label: labelOf(e) }; + }; + const fixedOp = (name: "fmul" | "fdiv", a: NumExpr, b: NumExpr, at: At, label: string): NumExpr => ({ kind: "intrinsic", name, args: [a, b], at, label }); + + /** A fixed number rounded to a whole one: shifts for floor, ceil and round, a division towards zero for trunc. */ + const rounded = (how: "floor" | "ceil" | "round" | "trunc", x: NumExpr, at: At, label: string): NumExpr => { + const k = (value: number): NumExpr => ({ kind: "const", value }); + if (how === "trunc") return { kind: "binary", op: "/", left: x, right: k(FIXED_ONE), at, label }; + const shifted = how === "floor" ? x : { kind: "binary", op: "+", left: x, right: k(how === "ceil" ? FIXED_ONE - 1 : FIXED_ONE / 2), at, label } as NumExpr; + return { kind: "binary", op: ">>", left: shifted, right: k(16), at, label }; + }; + + /** A table this pass adds to the program, once: the same cells for the game and the simulator. */ + const table = (id: string, name: string, values: () => number[]): string => { + if (!arrays.has(id)) { + const v = values(); + const a = { id, name, kind: "number" as const, length: v.length, shared: true, fixed: true, values: v, at: program.at }; + program.arrays.push(a); + arrays.set(id, a); + } + return id; + }; + + /** sin(x) / cos(x) of a fixed number of radians: the sine table read between its cells, x × 4 096 / 2π the place (the scale is the second argument). */ + const sine = (cos: boolean, x: NumExpr, at: At, label: string): NumExpr => + ({ kind: "intrinsic", name: cos ? "cos" : "sin", args: [x, { kind: "const", value: SIN_SCALE, fixed: true }], table: table("__sin", "Math.sin's table", sinTable), at, label }); + + const arrayType = (id: string): NumType => { const a = arrays.get(id); return a?.fixed ? "fixed" : a?.unsigned ? "u32" : "i32"; }; + /** Whether a signed value can be seen never to be below zero. */ const nonNegative = (e: NumExpr): boolean => { switch (e.kind) { case "const": return e.value >= 0 && e.value <= I32_MAX; case "var": return !!decls.get(e.id)?.bits || never.has(e.id); case "element": case "pop": return !!arrays.get(e.array)?.bits || never.has(e.array); - case "length": case "textLength": return true; + case "length": case "textLength": case "taskHandle": return true; case "read": case "unitField": case "tableRead": case "input": case "randomInt": return true; case "ternary": return nonNegative(e.whenTrue) && nonNegative(e.whenFalse); - case "intrinsic": return e.unsigned ? false : e.name === "min" ? e.args.every(nonNegative) : e.name === "max" ? e.args.some(nonNegative) : false; + case "intrinsic": return e.unsigned ? false : e.name === "min" ? e.args.every(nonNegative) : e.name === "max" ? e.args.some(nonNegative) : e.name === "sqrt"; case "binary": if (e.unsigned) return false; switch (e.op) { @@ -76,18 +190,32 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { } }; + /** A number where only a whole one makes sense: an index, a switch's value, a task. */ + const whole = (e: NumExpr, at: At, what = "This"): NumExpr => { + const [value, t] = num(e); + if (t === "fixed") fraction(at, what, value); + return value; + }; + /** The expression with its operations marked and its casts removed, and what it is read as. */ const num = (e: NumExpr): [NumExpr, NumType] => { switch (e.kind) { - case "const": return [e, e.value < 0 ? "i32" : e.value > I32_MAX ? "u32" : "flex"]; - case "var": return [e, decls.get(e.id)?.unsigned ? "u32" : "i32"]; - case "element": e.index = num(e.index)[0]; return [e, arrays.get(e.array)?.unsigned ? "u32" : "i32"]; - case "pop": return [e, arrays.get(e.array)?.unsigned ? "u32" : "i32"]; + case "const": return [e, e.fixed ? "fixed" : e.value < 0 ? "i32" : e.value > I32_MAX ? "u32" : "flex"]; + case "var": { const d = decls.get(e.id); return [e, d?.fixed ? "fixed" : d?.unsigned ? "u32" : "i32"]; } + case "element": e.index = whole(e.index, e.at, "An index"); return [e, arrayType(e.array)]; + case "pop": return [e, arrayType(e.array)]; // The length of an array that does not grow is a constant. Only on the pass that is for good: by then the front end has met every push. case "length": { const a = arrays.get(e.array); return settled && a && !a.dynamic ? [{ kind: "const", value: a.length }, "flex"] : [e, "i32"]; } case "cast": { // Gone only on the pass that is for good: the first one still has to find it here the second time. - const inner = num(e.expr)[0]; + const [inner, t] = num(e.expr); + if (e.to === "fixed") { + if (t === "u32") mixedFixed(e.at, "fixed(…) of a u32"); + if (settled) return [asFixed(inner, t, e.at), "fixed"]; + e.expr = inner; + return [e, "fixed"]; + } + if (t === "fixed") fraction(e.at, `${e.to}(…) reads a whole number's 32 bits; what it is given`, inner); if (settled) return [inner, e.to]; e.expr = inner; return [e, e.to]; @@ -95,24 +223,42 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { case "unary": { const [inner, t] = num(e.expr); e.expr = inner; - return [e, t === "u32" ? "u32" : "i32"]; + return [e, t === "u32" ? "u32" : t === "fixed" ? "fixed" : "i32"]; } case "binary": { const [left, lt] = num(e.left); const [right, rt] = num(e.right); e.left = left; e.right = right; + const fixedSide = lt === "fixed" || rt === "fixed"; if (e.op === "<<" || e.op === ">>" || e.op === ">>>") { + if (fixedSide) { fraction(e.at, lt === "fixed" ? "What this shifts" : "This shift's count", lt === "fixed" ? left : right); return [e, "i32"]; } // What is shifted decides; the count is a count. `x >>> 0` is JavaScript's own way of writing u32(x). if (e.op === ">>>" && right.kind === "const" && right.value === 0) return [settled ? left : e, "u32"]; if (e.op === ">>" && lt === "u32") e.op = ">>>"; return [e, lt === "flex" ? "i32" : lt]; } + if (fixedSide && (e.op === "&" || e.op === "|" || e.op === "^")) { + // `x | 0`, JavaScript's way of dropping the fraction: Math.trunc(x). + if (e.op === "|" && lt === "fixed" && right.kind === "const" && right.value === 0 && !right.fixed) return [settled ? rounded("trunc", left, e.at, e.label) : e, "i32"]; + fraction(e.at, `What ${e.op} is given`, lt === "fixed" ? left : right); + return [e, "i32"]; + } + if (fixedSide) { + if (lt === "u32" || rt === "u32") mixedFixed(e.at, `This ${e.op === "+" ? "sum" : e.op === "-" ? "difference" : e.op === "*" ? "product" : "division"}`); + // A fixed number times (or divided by) a whole one is the raw cell's own product (or quotient): nothing to convert. + if (e.op === "*") return [lt === "fixed" && rt === "fixed" && settled ? fixedOp("fmul", left, right, e.at, e.label) : e, "fixed"]; + if (e.op === "/") return [rt === "fixed" && settled ? fixedOp("fdiv", asFixed(left, lt, e.at), right, e.at, e.label) : e, "fixed"]; + e.left = asFixed(left, lt, e.at); + e.right = asFixed(right, rt, e.at); + return [e, "fixed"]; + } const t = unify(lt, rt, e.at, `This ${e.op === "+" ? "sum" : e.op === "-" ? "difference" : e.op === "*" ? "product" : e.op === "/" || e.op === "%" ? "division" : "operation"}`); if ((e.op === "/" || e.op === "%") && t === "u32") e.unsigned = true; return [e, t === "flex" ? "i32" : t]; } case "randomInt": e.bound = floor(e.bound, e.at); return [e, "i32"]; + case "taskHandle": return [e, "i32"]; case "unitField": unit(e.unit); return [e, "i32"]; // A part of a unit is bits to keep, never a number to reckon with: read from 0 up, so nothing clamps or biases it. case "unitPart": unit(e.unit); return [e, "u32"]; @@ -120,31 +266,54 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { e.cond = bool(e.cond); const [a, at] = num(e.whenTrue); const [b, bt] = num(e.whenFalse); - e.whenTrue = a; - e.whenFalse = b; - return [e, unify(at, bt, e.at, "This ? :")]; + const t = unify(at, bt, e.at, "This ? :"); + e.whenTrue = t === "fixed" ? asFixed(a, at, e.at) : a; + e.whenFalse = t === "fixed" ? asFixed(b, bt, e.at) : b; + return [e, t]; } case "intrinsic": { const typed = e.args.map(num); e.args = typed.map(([a]) => a); - if (e.name === "abs") { - // A u32 is never below zero: it is its own distance from 0. - if (typed[0][1] === "u32") return [e.args[0], "u32"]; - return [e, "i32"]; + const types = typed.map(([, t]) => t); + switch (e.name) { + case "abs": + // A u32 is never below zero: it is its own distance from 0. + if (types[0] === "u32") return [e.args[0], "u32"]; + return [e, types[0] === "fixed" ? "fixed" : "i32"]; + case "floor": case "ceil": case "round": case "trunc": + // Of a whole number, nothing to do. + if (types[0] !== "fixed") return [settled ? e.args[0] : e, types[0]]; + return [settled ? rounded(e.name, e.args[0], e.at, e.label) : e, "i32"]; + case "fmul": case "fdiv": return [e, "fixed"]; + case "sqrt": + // Of a whole number, the root of it read from 0 up (so that Math.hypot of distances in pixels does not overflow a fixed square). + if (types[0] !== "fixed") { if (settled) e.unsigned = true; return [e, "fixed"]; } + return [e, "fixed"]; + case "atan2": case "sin": case "cos": + if (types.includes("u32")) mixedFixed(e.at, `Math.${e.name}()`); + if (!settled) return [e, "fixed"]; + e.args = e.args.map((a, i) => asFixed(a, types[i], e.at)); + if (e.name === "sin" || e.name === "cos") return [sine(e.name === "cos", e.args[0], e.at, e.label), "fixed"]; + if (e.name === "atan2") e.table = table("__atan", "Math.atan2's table", atanTable); + return [e, "fixed"]; } - const t = typed.map(([, x]) => x).reduce((a, b) => unify(a, b, e.at, `Math.${e.name}()`)); + const t = types.reduce((a, b) => unify(a, b, e.at, `Math.${e.name}()`)); + if (t === "fixed") { e.args = e.args.map((a, i) => asFixed(a, types[i], e.at)); return [e, "fixed"]; } if (t === "u32") e.unsigned = true; + // Math.min(H, Math.floor(C / x)): comparisons with thresholds worked out now, not a division (thresholds.ts). + if (settled && t !== "u32") { const tree = divisionByThresholds(e); if (tree) return [tree, "i32"]; } return [e, t === "flex" ? "i32" : t]; } - case "call": call(e.call); return [e, e.call.result?.decl.unsigned ? "u32" : "i32"]; + case "call": call(e.call); { const d = e.call.result?.decl; return [e, d?.fixed ? "fixed" : d?.unsigned ? "u32" : "i32"]; } case "textLength": case "textIndexOf": case "textCode": return [Object.assign(e, mapTextOperands(e, texts)), "i32"]; default: return [e, "i32"]; } }; - /** A number going where the game takes none below zero: `max(v, 0)` unless it is a `u32` or seen never to be below. */ + /** A number going where the game takes none below zero: `max(v, 0)` unless it is a `u32` or seen never to be below. A fixed one is an error: the game's numbers there are whole. */ const floor = (e: NumExpr, at: At, keepConstant = false): NumExpr => { const [value, t] = num(e); + if (t === "fixed") { fraction(at, "This", value); return value; } if (!settled || t === "u32" || nonNegative(value)) return value; // A constant below zero into a u8 / u16 is left for the width check to name. if (value.kind === "const") return keepConstant ? value : { kind: "const", value: 0 }; @@ -154,10 +323,18 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { const bool = (e: BoolExpr): BoolExpr => { switch (e.kind) { case "test": e.expr = num(e.expr)[0]; return e; - case "element": e.index = num(e.index)[0]; return e; + case "element": e.index = whole(e.index, e.at, "An index"); return e; case "compare": { const [left, lt] = num(e.left); const [right, rt] = num(e.right); + if (lt === "fixed" || rt === "fixed") { + // Both as fixed numbers, compared as the signed cells they are. + if (lt === "u32" || rt === "u32") mixedFixed(e.at, "This comparison"); + e.left = asFixed(left, lt, e.at); + e.right = asFixed(right, rt, e.at); + if (nonNegative(e.left) && nonNegative(e.right)) e.unsigned = true; + return e; + } e.left = left; e.right = right; const leftU = lt === "u32" || (lt === "flex" && rt === "u32"); @@ -166,11 +343,16 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { else if (leftU) e.unsigned = nonNegative(right) ? true : "left"; else if (rightU) e.unsigned = nonNegative(left) ? true : "right"; else if (nonNegative(left) && nonNegative(right)) e.unsigned = true; + // One side never below zero reads the same from 0 up: marked so, the lowering compares without an addition a side + // (a loop's `i < n`). Not for a constant beside a signed value, which has a cheaper way of its own. + else if (nonNegative(left) && right.kind !== "const" && left.kind !== "const") e.unsigned = "left"; + else if (nonNegative(right) && left.kind !== "const" && right.kind !== "const") e.unsigned = "right"; return e; } case "and": case "or": e.items = e.items.map(bool); return e; case "not": e.expr = bool(e.expr); return e; case "edge": e.cond = bool(e.cond); return e; + case "running": e.task = whole(e.task, e.at); return e; case "ternary": e.cond = bool(e.cond); e.whenTrue = bool(e.whenTrue); e.whenFalse = bool(e.whenFalse); return e; case "unitAlive": case "unitFlag": unit(e.unit); return e; case "unitSame": unit(e.left); unit(e.right); return e; @@ -184,10 +366,12 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { const shown = (parts: TextPart[]) => { for (const p of parts) { if (p.kind === "value") text(p.text); + if (p.kind === "char") { p.expr = whole(p.expr, { file: "", line: 0, column: 0 }); continue; } if (p.kind !== "number") continue; const [value, t] = num(p.expr); p.expr = value; if (t === "u32") p.unsigned = true; + if (t === "fixed") p.fixed = true; } }; const text = (t: TextExpr): TextExpr => { @@ -198,16 +382,32 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { const unit = (u: UnitExpr) => { if (u.kind === "call") call(u.call); else if (u.kind === "unitAt") { u.ptr = num(u.ptr)[0]; u.epd = num(u.epd)[0]; u.uid = num(u.uid)[0]; } }; - /** A value stored into a variable: a `u8` / `u16` takes nothing below zero, anything else keeps the 32 bits as they are. */ - const stored = (decl: Pick | undefined, value: NumExpr, at: At): NumExpr => { + /** + * A value stored into a variable: a `u8` / `u16` takes nothing below zero, anything else keeps the 32 bits as they are. + * A fraction stored makes the variable fixed (the passes before the last find which), and a whole number stored into a + * fixed one is turned into one. + */ + const stored = (decl: Pick | undefined, value: NumExpr, at: At): NumExpr => { if (decl?.bits) return floor(value, at, true); const [out, t] = num(value); + if (decl && t === "fixed" && !decl.fixed) { + if (decl.unsigned) errors.push({ at, message: `${decl.name} is a u32, which holds whole numbers, and this is a fixed number, one with a fraction. Round it (Math.floor(x), Math.round(x)), or declare ${decl.name} without the u32.` }); + else if (!settled) { decl.fixed = true; grew = true; madeFixed.set(decl.id, at); } + } + if (decl?.fixed) { + if (t === "u32") mixedFixed(at, `Storing a u32 into ${decl.name}, a fixed number,`); + return asFixed(out, t, at); + } // A constant that the variable's type cannot hold would read as another number altogether: better said than stored. if (decl && out.kind === "const" && value.kind === "const") { if (!decl.unsigned && t === "u32") errors.push({ at, message: `${decl.name} is a number, which holds −2 147 483 648 … 2 147 483 647, not ${out.value}. Declare it a u32 (let ${decl.name}: u32 = …), or write i32(${out.value}) for the signed number with those bits.` }); if (decl.unsigned && out.value < 0) errors.push({ at, message: `${decl.name} is a u32, which holds 0 … 4 294 967 295, not ${out.value}. Write u32(${out.value}) for the u32 with those bits.` }); } - if (!settled && decl && !decl.unsigned) stores.push([decl.id, out]); + if (!settled && decl && !decl.unsigned) { + if (counter === decl.id && isIncrement(value, decl.id)) counted.add(out); + else if (inFunction || counting.includes(decl.id)) spoiled.add(decl.id); + stores.push([decl.id, out]); + } return out; }; @@ -226,6 +426,8 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { for (const p of c.params) p.init = valueFor(p.decl, p.init, c.at); const returned = c.fn ? functions.get(c.fn)?.result?.decl : undefined; if (!settled && returned && c.result?.kind === "number" && !c.result.decl.unsigned) stores.push([c.result.decl.id, { kind: "var", id: returned.id }]); + // A function that returns a fraction gives its calls' results one too. + if (!settled && returned?.fixed && c.result?.kind === "number" && !c.result.decl.fixed && !c.result.decl.unsigned && !c.result.decl.bits) { c.result.decl.fixed = true; grew = true; madeFixed.set(c.result.decl.id, c.at); } const saved = result; result = c.result?.decl; c.body.forEach(stmt); @@ -252,20 +454,21 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { case "setLength": s.value = floor(s.value, s.at); break; case "store": { const a = arrays.get(s.array); - s.index = num(s.index)[0]; + s.index = whole(s.index, s.at, "An index"); s.value = a?.kind === "boolean" ? bool(s.value as BoolExpr) : stored(a, s.value as NumExpr, s.at); break; } case "assignUnit": unit(s.value); break; case "assignText": s.value = text(s.value); break; - case "storeText": s.index = num(s.index)[0]; s.value = text(s.value); break; - case "releaseText": s.index = num(s.index)[0]; break; + case "storeText": s.index = whole(s.index, s.at, "An index"); s.value = text(s.value); break; + case "releaseText": s.index = whole(s.index, s.at, "An index"); break; case "textLoop": s.of = text(s.of); s.body.forEach(stmt); break; case "unitLoop": s.body.forEach(stmt); break; case "unitWrite": unit(s.unit); s.value = FLAGS.has(s.field) ? bool(s.value as BoolExpr) : floor(s.value as NumExpr, s.at); break; + case "select": s.units.forEach(unit); break; case "unitDo": unit(s.unit); if (s.verb.do === "damage" || s.verb.do === "heal") s.verb.amount = floor(s.verb.amount, s.at); @@ -276,32 +479,72 @@ export function typeNumbers(program: Program): ProgramDiagnostic[] { case "if": s.cond = bool(s.cond); s.then.forEach(stmt); s.else?.forEach(stmt); break; case "while": if (s.cond) s.cond = bool(s.cond); s.body.forEach(stmt); break; case "do": s.body.forEach(stmt); s.cond = bool(s.cond); break; - case "for": if (s.cond) s.cond = bool(s.cond); s.update.forEach(stmt); s.body.forEach(stmt); break; + case "for": { + if (s.cond) s.cond = bool(s.cond); + const i = settled ? null : counterOf(s); + counter = i; + s.update.forEach(stmt); + counter = null; + if (i) counting.push(i); + s.body.forEach(stmt); + if (i) counting.pop(); + break; + } case "unrolled": s.iterations.forEach((i) => i.forEach(stmt)); break; - case "switch": s.value = num(s.value)[0]; s.cases.forEach((c) => c.body.forEach(stmt)); break; + case "parallel": s.branches.forEach((b) => b.forEach(stmt)); break; + case "start": call(s.call); break; + case "cancel": s.task = whole(s.task, s.at); break; + case "switch": s.value = whole(s.value, s.at, "A switch's value"); s.cases.forEach((c) => c.body.forEach(stmt)); break; case "return": if (s.value && result) s.value = valueFor(result, s.value, s.at); break; case "action": for (const v of s.variables ?? []) v.expr = floor(v.expr, s.at); if (s.text) s.text = text(s.text); break; - case "centerLocation": s.x = floor(s.x, s.at); s.y = floor(s.y, s.at); break; - case "print": shown(s.parts); break; + case "syncSend": s.value = floor(s.value, s.at); break; + case "centerLocation": s.x = floor(s.x, s.at); s.y = floor(s.y, s.at); if (s.size) s.size = { width: floor(s.size.width, s.at), height: floor(s.size.height, s.at) }; break; + case "print": case "buildText": shown(s.parts); break; case "call": call(s.call); break; case "block": s.body.forEach(stmt); break; + case "try": s.body.forEach(stmt); s.catch.forEach(stmt); break; default: break; } }; - // Once to see every store — marking an operation is harmless to repeat, and the casts are kept until the second time — then for good. + // Over and over until no variable is found fixed that was not — marking an operation is harmless to repeat, and the + // casts are kept until the last time — to see every store, then for good. const everything = () => { program.body.forEach(stmt); + inFunction = true; for (const f of program.functions ?? []) { result = f.result?.decl; f.body.forEach(stmt); result = undefined; } + inFunction = false; }; - everything(); + for (let passes = 0; passes < 64; passes++) { + grew = false; + everything(); + if (!grew) break; + } for (const [id] of stores) never.add(id); for (let changed = true; changed;) { changed = false; - for (const [id, value] of stores) if (never.has(id) && !nonNegative(value)) { never.delete(id); changed = true; } + for (const [id, value] of stores) if (never.has(id) && !(counted.has(value) && !spoiled.has(id)) && !nonNegative(value)) { never.delete(id); changed = true; } } settled = true; errors.length = 0; everything(); return errors; } + +/** `i = i + 1` (or `1 + i`) as a stored value. */ +function isIncrement(value: NumExpr, id: string): boolean { + if (value.kind !== "binary" || value.op !== "+") return false; + const me = (e: NumExpr) => e.kind === "var" && e.id === id, one = (e: NumExpr) => e.kind === "const" && e.value === 1; + return (me(value.left) && one(value.right)) || (one(value.left) && me(value.right)); +} + +/** The counter of a `for` whose test is `i < e` (or `e > i`, alone or one of an `&&`) and whose update adds 1 to it; null for any other. */ +function counterOf(s: Extract): string | null { + const tests = !s.cond ? [] : s.cond.kind === "and" ? s.cond.items : [s.cond]; + for (const t of tests) { + if (t.kind !== "compare" || t.unsigned) continue; + const id = t.op === "<" && t.left.kind === "var" ? t.left.id : t.op === ">" && t.right.kind === "var" ? t.right.id : null; + if (id && s.update.some((u) => u.kind === "assign" && u.target === id && isIncrement(u.value, id))) return id; + } + return null; +} diff --git a/compiler/orders.ts b/compiler/orders.ts new file mode 100644 index 0000000..5ec3b36 --- /dev/null +++ b/compiler/orders.ts @@ -0,0 +1,31 @@ +/** + * The game's orders, one per orders.dat id: what a unit is doing (`u.orderId`) and what `on.order(…)` listens for. The + * names are the community's, as BWAPI and scm-sim give them; the name tables offer them as identifiers (`orders.Move`). + */ +export const ORDER_NAMES: readonly string[] = [ + "Die", "Stop", "Guard", "PlayerGuard", "TurretGuard", "BunkerGuard", "Move", "ReaverStop", "Attack1", "Attack2", "AttackUnit", + "AttackFixedRange", "AttackTile", "Hover", "AttackMove", "InfestedCommandCenter", "UnusedNothing", "UnusedPowerup", "TowerGuard", + "TowerAttack", "VultureMine", "StayInRange", "TurretAttack", "Nothing", "Unused24", "DroneStartBuild", "DroneBuild", "CastInfestation", + "MoveToInfest", "InfestingCommandCenter", "PlaceBuilding", "PlaceProtossBuilding", "CreateProtossBuilding", "ConstructingBuilding", "Repair", + "MoveToRepair", "PlaceAddon", "BuildAddon", "Train", "RallyPointUnit", "RallyPointTile", "ZergBirth", "ZergUnitMorph", "ZergBuildingMorph", + "IncompleteBuilding", "IncompleteMorphing", "BuildNydusExit", "EnterNydusCanal", "IncompleteWarping", "Follow", "Carrier", "ReaverCarrierMove", + "CarrierStop", "CarrierAttack", "CarrierMoveToAttack", "CarrierIgnore2", "CarrierFight", "CarrierHoldPosition", "Reaver", "ReaverAttack", + "ReaverMoveToAttack", "ReaverFight", "ReaverHoldPosition", "TrainFighter", "InterceptorAttack", "ScarabAttack", "RechargeShieldsUnit", + "RechargeShieldsBattery", "ShieldBattery", "InterceptorReturn", "DroneLand", "BuildingLand", "BuildingLiftOff", "DroneLiftOff", "LiftingOff", + "ResearchTech", "Upgrade", "Larva", "SpawningLarva", "Harvest1", "Harvest2", "MoveToGas", "WaitForGas", "HarvestGas", "ReturnGas", + "MoveToMinerals", "WaitForMinerals", "MiningMinerals", "Harvest3", "Harvest4", "ReturnMinerals", "Interrupted", "EnterTransport", "PickupIdle", + "PickupTransport", "PickupBunker", "Pickup4", "PowerupIdle", "Sieging", "Unsieging", "WatchTarget", "InitCreepGrowth", "SpreadCreep", + "StoppingCreepGrowth", "GuardianAspect", "ArchonWarp", "CompletingArchonSummon", "HoldPosition", "QueenHoldPosition", "Cloak", "Decloak", + "Unload", "MoveUnload", "FireYamatoGun", "MoveToFireYamatoGun", "CastLockdown", "Burrowing", "Burrowed", "Unburrowing", "CastDarkSwarm", + "CastParasite", "CastSpawnBroodlings", "CastEMPShockwave", "NukeWait", "NukeTrain", "NukeLaunch", "NukePaint", "NukeUnit", + "CastNuclearStrike", "NukeTrack", "InitializeArbiter", "CloakNearbyUnits", "PlaceMine", "RightClickAction", "SuicideUnit", "SuicideLocation", + "SuicideHoldPosition", "CastRecall", "Teleport", "CastScannerSweep", "Scanner", "CastDefensiveMatrix", "CastPsionicStorm", "CastIrradiate", + "CastPlague", "CastConsume", "CastEnsnare", "CastStasisField", "CastHallucination", "Hallucination2", "ResetCollision", "ResetHarvestCollision", + "Patrol", "CTFCOPInit", "CTFCOPStarted", "CTFCOP2", "ComputerAI", "AtkMoveEP", "HarassMove", "AIPatrol", "GuardPost", "RescuePassive", + "Neutral", "ComputerReturn", "InitializePsiProvider", "SelfDestructing", "Critter", "HiddenGun", "OpenDoor", "CloseDoor", "HideTrap", + "RevealTrap", "EnableDoodad", "DisableDoodad", "WarpIn", "Medic", "MedicHeal", "HealMove", "MedicHoldPosition", "MedicHealToIdle", + "CastRestoration", "CastDisruptionWeb", "CastMindControl", "DarkArchonMeld", "CastFeedback", "CastOpticalFlare", "CastMaelstrom", "JunkYardDog", "Fatal", +]; + +/** The orders a unit is given by `u.order(…)` and the Order action: move, patrol, attack (an attack-move). */ +export const ORDER_OF_VERB: Readonly> = { move: 6, patrol: 152, attack: 14 }; diff --git a/compiler/rangeCoder.ts b/compiler/rangeCoder.ts new file mode 100644 index 0000000..8b56d7f --- /dev/null +++ b/compiler/rangeCoder.ts @@ -0,0 +1,83 @@ +/** + * LZMA's binary range coder, shared by the media a script imports (`media.ts`, `image.ts`): a bit at a time, each + * with odds that adapt as the coder goes — `odds[k]` the chance of a 0 out of 2048 — the same way when it is read. + */ + +const PROB_BITS = 11, MOVE_BITS = 5, TOP = 1 << 24; +/** Odds of even chances: what a table of odds starts at. */ +export const PROB_HALF = 1 << (PROB_BITS - 1); + +/** LZMA's range encoder, a bit at a time with adaptive odds (`odds[k]`: the chance of a 0, out of 2048). */ +export class RangeEncoder { + private low = 0; + private range = 0xffffffff; + private cache = 0; + private cacheSize = 1; + private out: number[] = []; + + bit(odds: Uint16Array, k: number, bit: number): void { + const p = odds[k], bound = (this.range >>> PROB_BITS) * p; + if (bit) { this.low += bound; this.range -= bound; odds[k] = p - (p >> MOVE_BITS); } + else { this.range = bound; odds[k] = p + (((1 << PROB_BITS) - p) >> MOVE_BITS); } + while (this.range < TOP) { this.range = (this.range * 256) >>> 0; this.shiftLow(); } + } + + private shiftLow(): void { + if (this.low < 0xff000000 || this.low >= 0x100000000) { + const carry = this.low >= 0x100000000 ? 1 : 0; + let byte = this.cache; + do { this.out.push((byte + carry) & 0xff); byte = 0xff; } while (--this.cacheSize !== 0); + this.cache = Math.floor(this.low / 0x1000000) & 0xff; + } + this.cacheSize++; + this.low = (this.low % 0x1000000) * 256; + } + + finish(): Uint8Array { + for (let i = 0; i < 5; i++) this.shiftLow(); + return Uint8Array.from(this.out); + } +} + +export class RangeDecoder { + private range = 0xffffffff; + private code = 0; + private at = 0; + /** Read past the end: the file was cut short. */ + overrun = false; + + private readonly bytes: Uint8Array; + + constructor(bytes: Uint8Array) { + this.bytes = bytes; + for (let i = 0; i < 5; i++) this.code = ((this.code * 256) + this.next()) % 0x100000000; + } + + private next(): number { + if (this.at < this.bytes.length) return this.bytes[this.at++]; + this.overrun = this.at++ > this.bytes.length + 4; + return 0; + } + + bit(odds: Uint16Array, k: number): number { + const p = odds[k], bound = (this.range >>> PROB_BITS) * p; + let bit: number; + if (this.code < bound) { this.range = bound; odds[k] = p + (((1 << PROB_BITS) - p) >> MOVE_BITS); bit = 0; } + else { this.code -= bound; this.range -= bound; odds[k] = p - (p >> MOVE_BITS); bit = 1; } + while (this.range < TOP) { this.range = (this.range * 256) >>> 0; this.code = ((this.code * 256) + this.next()) % 0x100000000; } + return bit; + } +} + +export function toBase64(bytes: Uint8Array): string { + let s = ""; + for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); + return btoa(s); +} + +export function fromBase64(text: string): Uint8Array { + const s = atob(text); + const out = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i); + return out; +} diff --git a/compiler/ranges.ts b/compiler/ranges.ts new file mode 100644 index 0000000..d694e6f --- /dev/null +++ b/compiler/ranges.ts @@ -0,0 +1,615 @@ +/** + * The values each number of a program can take (TrigScript 4, the limits): a range, lowest and highest, for every + * number variable, and written into each number of the IR whose range is narrower than its 32 bits (`NumExpr.range`). + * Nothing in the language changes. The Python backend takes only the bits a range has — a product by a constant, a + * quotient, a shift, an index — and drops a bounds check a range says cannot fail; the simulator reads no ranges. + * + * Run after `typeNumbers` (the casts are gone and every operation says how it reads its sides). A range is of the + * number's signed reading: a `u32` above 2 147 483 647 reads below zero, and an operation whose `unsigned` reading + * would differ takes a range only when its sides are known to be at or above zero, where both readings agree. + * + * How it is sound: + * - **Every store is seen.** A variable's range is the union of everything ever stored into it — declarations, + * assignments, a call's arguments into its parameters, returns into results — and 0, what a cell holds before its + * first store. A variable nothing visible stores into (a task's handle, the error in flight, a loop's `was`) is any + * number. The passes go on until no range grows; one still growing after eight is widened to any number. + * - **What a test says, while it holds.** Inside `if (i < n)` the pass knows `i` is below the highest `n` can be, + * until something stores into `i`. Such facts are dropped at everything that could change what they are about + * behind their back: a wait (another task runs), a call of a kept function, a statement whose expressions hold a + * call, the top of a loop for everything the loop stores into, a `catch`. A loop's own test holds at the top of its + * body and, when the body stores nothing into the variable and neither waits nor calls, at its update — so + * `for (let i = 0; i < 16; i++)` keeps `i` within 0 … 16 with no rule of its own. + * - **Arrays** take a range only when nothing else can reach their cells: a table known when built (`values`), or an + * array of a fixed length that is no window (`slice`), no row of another (`through`), no array of texts' rows, and + * that nothing views; any read of one also gives 0, past its ends. A `u8` / `u16` array is its bits' range. + * - **A statement kind the pass does not know** leaves the whole program without ranges. + */ +import { eachCall, isTextExpr, mapText, mapTextOperands, mapTextParts, programDeclarations, UNIT_FLAGS, type ArrayDecl, type BoolExpr, type Call, type NumExpr, type Program, type Stmt, type TextExpr, type TextMap, type TextPart, type UnitExpr, type VarDecl } from "./ir"; + +/** Lowest and highest, both inclusive, of the signed reading. */ +export type Range = readonly [number, number]; + +const MIN = -2147483648, MAX = 2147483647; +export const ANY: Range = [MIN, MAX]; +const FIXED_PI = 205887; + +const fits = (lo: number, hi: number): Range => (lo < MIN || hi > MAX || !Number.isFinite(lo) || !Number.isFinite(hi) ? ANY : [lo, hi]); +const join = (a: Range | null, b: Range | null): Range | null => (!a ? b : !b ? a : [Math.min(a[0], b[0]), Math.max(a[1], b[1])]); +const meet = (a: Range, b: Range): Range => { const lo = Math.max(a[0], b[0]), hi = Math.min(a[1], b[1]); return lo > hi ? a : [lo, hi]; }; +const isAny = (r: Range) => r[0] === MIN && r[1] === MAX; +const nonNeg = (r: Range) => r[0] >= 0; +const withZero = (r: Range): Range => [Math.min(r[0], 0), Math.max(r[1], 0)]; +const signed = (v: number) => v | 0; +/** The smallest 2^k − 1 at or above v (v ≥ 0). */ +const ones = (v: number) => (v <= 0 ? 0 : 2 ** Math.ceil(Math.log2(v + 1)) - 1); + +/** A unit's numbers as `field_of` reads them: whole points of a 24.8 cell, a byte, a word. 0 when the unit is gone. */ +const UNIT_FIELD: Record = { + hp: [0, 16777216], maxHp: [0, 16777215], shields: [0, 16777215], maxShields: [0, 65535], energy: [0, 255], + owner: [0, 255], type: [0, 65535], x: [0, 65535], y: [0, 65535], kills: [0, 255], orderId: [0, 255], cooldown: [0, 255], + resources: [0, 65535], stim: [0, 65535], ensnare: [0, 65535], plague: [0, 65535], lockdown: [0, 65535], maelstrom: [0, 65535], + irradiate: [0, 65535], stasis: [0, 65535], +}; + +const STMT_KINDS = new Set(["declare", "assignText", "storeText", "releaseText", "textLoop", "assign", "declareArray", "store", "push", "pop", "setLength", + "assignBool", "assignUnit", "unitLoop", "unitWrite", "unitDo", "tableWrite", "if", "while", "do", "for", "unrolled", "switch", "break", "continue", + "return", "sleep", "parallel", "start", "cancel", "action", "syncSend", "select", "centerLocation", "print", "buildText", "call", "block", "try", + "throw", "uncaught", "remark"]); + +type Facts = Map; +const FLAGS: ReadonlySet = new Set(UNIT_FLAGS); + +/** Whether anything in this piece of IR is a call (of a kept function or inlined): its body may store anything. */ +function holdsCall(root: unknown, seen = new WeakMap()): boolean { + if (!root || typeof root !== "object") return false; + const known = seen.get(root); + if (known !== undefined) return known; + seen.set(root, false); + const o = root as Record; + let out = (o.kind === "call" || o.kind === "textCall") && !!o.call && typeof o.call === "object"; + if (!out) for (const v of Array.isArray(root) ? root : Object.values(o)) if (v && typeof v === "object" && holdsCall(v, seen)) { out = true; break; } + seen.set(root, out); + return out; +} + +/** The number variables a list of statements stores into anywhere in it (calls' bodies and parameters too), and whether it waits. */ +function effects(stmts: Stmt[]): { stores: Set; waits: boolean } { + const stores = new Set(); + let waits = false; + const scan = (x: unknown) => { + if (!x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(scan); return; } + const o = x as Record; + if (o.kind === "assign" && typeof o.target === "string") stores.add(o.target); + if (o.kind === "declare" && o.decl && typeof o.decl === "object") stores.add((o.decl as VarDecl).id); + if (o.kind === "sleep" || o.kind === "parallel") waits = true; + if (o.kind === "unitLoop" && o.was) stores.add((o.was as VarDecl).id); + if (o.kind === "start" && typeof o.handle === "string") stores.add(o.handle); + if (Array.isArray(o.params)) for (const p of o.params as { decl?: VarDecl }[]) if (p?.decl?.id) stores.add(p.decl.id); + if (o.result && typeof o.result === "object" && (o.result as { decl?: VarDecl }).decl) stores.add((o.result as { decl: VarDecl }).decl.id); + for (const v of Object.values(o)) if (v && typeof v === "object") scan(v); + }; + scan(stmts); + return { stores, waits }; +} + +export function rangeNumbers(program: Program): void { + const functions = new Map((program.functions ?? []).map((f) => [f.id, f])); + const arrays = new Map(program.arrays.map((a) => [a.id, a])); + /** The ranges as the last pass left them, and what this pass finds. */ + let vars = new Map(); + let next = new Map(); + let cells = new Map(); + let nextCells = new Map(); + /** Variables any number can be stored into behind the program's back. */ + const wild = new Set(); + if (program.errors) wild.add(program.errors.thrown); + /** Arrays whose cells something reaches that the pass does not follow. */ + const reached = new Set(); + for (const a of program.arrays) { + if (a.slice) { reached.add(a.id); reached.add(a.slice.of); } + if (a.through) { reached.add(a.id); for (const k of ["ptr", "len", "room", "k"] as const) reached.add(a.through[k]); } + if (a.dynamic) reached.add(a.id); + } + /** How many places in the program store into each variable (declarations, assignments, parameters, results). */ + const sites = new Map(); + { + const site = (id: string) => sites.set(id, (sites.get(id) ?? 0) + 1); + const scan = (x: unknown) => { + if (!x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach(scan); return; } + const o = x as Record; + if (o.kind === "assign" && typeof o.target === "string") site(o.target); + if (o.kind === "declare" && o.decl && typeof o.decl === "object") site((o.decl as VarDecl).id); + if (o.kind === "unitLoop" && o.was) site((o.was as VarDecl).id); + if (o.kind === "start" && typeof o.handle === "string") site(o.handle); + if (Array.isArray(o.params)) for (const p of o.params as { decl?: VarDecl }[]) if (p?.decl?.id) site(p.decl.id); + if (o.result && typeof o.result === "object" && (o.result as { decl?: VarDecl }).decl) site((o.result as { decl: VarDecl }).decl.id); + for (const v of Object.values(o)) if (v && typeof v === "object") scan(v); + }; + scan(program); + } + const declOf = new Map(programDeclarations(program).map((d) => [d.id, d])); + /** + * A loop's counter whose only stores are its declaration and the loop's update: nothing else can change it, behind + * the loop's back or not, so what the loop's test said of it holds through a wait or a call in the body. Not one + * shared by several runs of the same code at once (a task's slots, a per-player program's rows). + */ + const pinned = new Set(); + let inTask = false; + const forget = (facts: Facts) => { for (const k of [...facts.keys()]) if (!pinned.has(k)) facts.delete(k); }; + const keepPinned = (facts: Facts): Facts => new Map([...facts].filter(([k]) => pinned.has(k))); + const effectCache = new WeakMap>(); + const effectsOf = (s: Stmt[]) => { let e = effectCache.get(s); if (!e) { e = effects(s); effectCache.set(s, e); } return e; }; + const callCache = new WeakMap(); + let unknown = false; + /** On the last pass: write each number's range into it. A node met twice (an expression the front end put in two places) takes the union. */ + let annotate = false; + const written = new WeakMap(); + const mark = (e: NumExpr, r: Range): Range => { + if (!annotate) return r; + const was = written.get(e); + const out = was ? (join(was, r) as Range) : r; + written.set(e, out); + if (isAny(out) || e.kind === "const") delete (e as { range?: Range }).range; + else (e as { range?: Range }).range = out; + return r; + }; + + /** After the first pass: the variables some store of the program names. One no store names is set behind its back. */ + let stored: Set | null = null; + const varRange = (id: string): Range => (wild.has(id) || (stored && !stored.has(id)) ? ANY : vars.get(id) ?? [0, 0]); + // (A list's lowest and highest once each: a video's table is hundreds of thousands of cells, too many to spread.) + const listed = new Map(); + const arrayRange = (a: ArrayDecl | undefined): Range => { + if (!a) return ANY; + if (a.values) { + let r = listed.get(a.id); + if (!r) { + let lo = Infinity, hi = -Infinity; + for (const v of a.values) { const x = signed(v); if (x < lo) lo = x; if (x > hi) hi = x; } + listed.set(a.id, (r = withZero(a.values.length ? [lo, hi] : [0, 0]))); + } + return r; + } + if (a.bits) return [0, 2 ** a.bits - 1]; + if (reached.has(a.id)) return ANY; + return withZero(cells.get(a.id) ?? [0, 0]); + }; + const store = (id: string, r: Range) => { next.set(id, join(next.get(id) ?? null, r)!); }; + const storeCell = (id: string, r: Range) => { nextCells.set(id, join(nextCells.get(id) ?? null, r)!); }; + + /** Facts a condition gives about variables, when it holds (`truth` true) or does not. */ + const factsOf = (c: BoolExpr, truth: boolean, facts: Facts, out: Facts) => { + if (c.kind === "not") { factsOf(c.expr, !truth, facts, out); return; } + if (c.kind === "and" && truth) { for (const i of c.items) factsOf(i, true, facts, out); return; } + if (c.kind === "or" && !truth) { for (const i of c.items) factsOf(i, false, facts, out); return; } + if (c.kind !== "compare") return; + // One side a u32, the other signed ("left" / "right"), compared exactly: when the u32 side reads at or above zero + // its value is its signed reading, and the comparison is an ordinary one. + if (c.unsigned === "left" && !nonNeg(num(c.left, facts, true))) return; + if (c.unsigned === "right" && !nonNeg(num(c.right, facts, true))) return; + const op = truth ? c.op : ({ "<": ">=", "<=": ">", ">": "<=", ">=": "<", "==": "!=", "!=": "==" } as const)[c.op]; + const side = (v: NumExpr, other: NumExpr, o: typeof op) => { + if (v.kind !== "var") return; + const r = num(other, facts, true); + const unsignedOk = c.unsigned !== true || (nonNeg(r) && nonNeg(varRange(v.id))); + if (!unsignedOk) return; + const have = out.get(v.id) ?? facts.get(v.id) ?? varRange(v.id); + let f: Range | null = null; + if (o === "<") f = [MIN, r[1] - 1]; + else if (o === "<=") f = [MIN, r[1]]; + else if (o === ">") f = [r[0] + 1, MAX]; + else if (o === ">=") f = [r[0], MAX]; + else if (o === "==") f = r; + if (f && f[0] <= f[1]) out.set(v.id, meet(have, f)); + }; + const flip = { "<": ">", "<=": ">=", ">": "<", ">=": "<=", "==": "==", "!=": "!=" } as const; + side(c.left, c.right, op); + side(c.right, c.left, flip[op]); + }; + const withFacts = (facts: Facts, c: BoolExpr, truth: boolean): Facts => { const out = new Map(facts); const add: Facts = new Map(); factsOf(c, truth, facts, add); for (const [k, v] of add) out.set(k, v); return out; }; + + const texts = (facts: Facts): TextMap => ({ num: (e) => { num(e, facts); return e; }, bool: (e) => { bool(e, facts); return e; }, call: (c) => { call(c, new Map()); return c; } }); + const text = (t: TextExpr, facts: Facts) => { mapText(t, texts(facts)); }; + const parts = (p: TextPart[], facts: Facts) => { mapTextParts(p, texts(facts)); }; + + /** A number's range under the facts; `quiet`: only looking (a fact's other side), nothing written. */ + function num(e: NumExpr, facts: Facts, quiet = false): Range { + const was = annotate; + if (quiet) annotate = false; + try { return mark(e, numOf(e, facts)); } finally { annotate = was; } + } + + function numOf(e: NumExpr, facts: Facts): Range { + switch (e.kind) { + case "const": { const v = signed(e.value); return [v, v]; } + case "var": { const r = varRange(e.id); const f = wild.has(e.id) ? undefined : facts.get(e.id); return f ? meet(r, f) : r; } + case "element": num(e.index, facts); return arrayRange(arrays.get(e.array)); + case "pop": return arrayRange(arrays.get(e.array)); + case "length": { const a = arrays.get(e.array); return a && !a.dynamic && !a.slice && !a.through ? [a.length, a.length] : [0, MAX]; } + case "unitPart": unit(e.unit, facts); return e.part === "uid" ? [0, 255] : ANY; + case "unary": { const r = num(e.expr, facts); return r[0] === MIN ? ANY : [-r[1], -r[0]]; } + case "cast": { const r = num(e.expr, facts); return e.to === "fixed" ? ANY : r; } + case "binary": return binary(e, num(e.left, facts), num(e.right, facts)); + case "read": return ANY; + case "randomInt": { const b = num(e.bound, facts); return nonNeg(b) ? [0, Math.max(0, b[1] - 1)] : ANY; } + case "unitField": unit(e.unit, facts); return UNIT_FIELD[e.field] ?? ANY; + case "tableRead": return ANY; + case "input": return ANY; + case "ternary": bool(e.cond, facts); return join(num(e.whenTrue, withFacts(facts, e.cond, true)), num(e.whenFalse, withFacts(facts, e.cond, false)))!; + case "intrinsic": return intrinsic(e, facts); + case "textLength": mapTextOperands(e, texts(facts)); return [0, 1023]; + case "textIndexOf": mapTextOperands(e, texts(facts)); return [-1, 1023]; + case "textCode": mapTextOperands(e, texts(facts)); return [-1, 0x10ffff]; + case "taskHandle": return [0, MAX]; + case "call": { call(e.call, new Map()); const d = e.call.result?.decl; return d ? varRange(d.id) : ANY; } + default: unknown = true; return ANY; + } + } + + function binary(e: Extract, a: Range, b: Range): Range { + const both = nonNeg(a) && nonNeg(b); + switch (e.op) { + case "+": return fits(a[0] + b[0], a[1] + b[1]); + case "-": return fits(a[0] - b[1], a[1] - b[0]); + case "*": { const p = [a[0] * b[0], a[0] * b[1], a[1] * b[0], a[1] * b[1]]; return fits(Math.min(...p), Math.max(...p)); } + case "/": { + if (e.unsigned && !both) return ANY; + const qs: number[] = []; + for (const d of [[b[0], Math.min(b[1], -1)], [Math.max(b[0], 1), b[1]]] as const) { + if (d[0] > d[1]) continue; + for (const x of [a[0], a[1]]) for (const y of [d[0], d[1]]) qs.push(Math.trunc(x / y)); + } + if (b[0] <= 0 && b[1] >= 0) qs.push(0); + return qs.length ? fits(Math.min(...qs), Math.max(...qs)) : [0, 0]; + } + case "%": { + if (e.unsigned && !both) return ANY; + const m = Math.max(Math.abs(b[0]), Math.abs(b[1])); + if (m === 0) return [0, 0]; // a divisor of 0 gives 0 + const lo = a[0] >= 0 ? 0 : Math.max(a[0], -(m - 1)), hi = a[1] <= 0 ? 0 : Math.min(a[1], m - 1); + return withZero([lo, hi]); + } + case "&": + if (both) return [0, Math.min(a[1], b[1])]; + if (nonNeg(a)) return [0, a[1]]; + if (nonNeg(b)) return [0, b[1]]; + return ANY; + case "|": case "^": return both ? [0, ones(Math.max(a[1], b[1]))] : ANY; + case "<<": + if (b[0] === b[1] && b[0] >= 0 && b[0] < 32) return fits(a[0] * 2 ** b[0], a[1] * 2 ** b[0]); + return ANY; + case ">>": + if (b[0] === b[1] && b[0] >= 0 && b[0] < 32) return [Math.floor(a[0] / 2 ** b[0]), Math.floor(a[1] / 2 ** b[0])]; + return nonNeg(a) && nonNeg(b) ? [0, a[1]] : ANY; + case ">>>": + if (b[0] === b[1] && b[0] >= 1 && b[0] < 32) return nonNeg(a) ? [Math.floor(a[0] / 2 ** b[0]), Math.floor(a[1] / 2 ** b[0])] : [0, Math.floor(0xffffffff / 2 ** b[0])]; + if (b[0] === b[1] && b[0] === 0) return a; + return nonNeg(a) && nonNeg(b) ? [0, a[1]] : ANY; + default: return ANY; + } + } + + function intrinsic(e: Extract, facts: Facts): Range { + const r = e.args.map((x) => num(x, facts)); + switch (e.name) { + case "min": return e.unsigned && !r.every(nonNeg) ? ANY : [Math.min(...r.map((x) => x[0])), Math.min(...r.map((x) => x[1]))]; + case "max": return e.unsigned && !r.every(nonNeg) ? ANY : [Math.max(...r.map((x) => x[0])), Math.max(...r.map((x) => x[1]))]; + case "abs": { const [lo, hi] = r[0]; if (lo === MIN) return ANY; return lo >= 0 ? [lo, hi] : hi <= 0 ? [-hi, -lo] : [0, Math.max(-lo, hi)]; } + case "fmul": { const [a, b] = r; const p = [a[0] * b[0], a[0] * b[1], a[1] * b[0], a[1] * b[1]].map((v) => Math.trunc(v / 65536)); return fits(Math.min(...p), Math.max(...p)); } + case "fdiv": { + const [a, b] = r; + if (b[0] <= 0 && b[1] >= 0) return ANY; + const q = [a[0] / b[0], a[0] / b[1], a[1] / b[0], a[1] / b[1]].map((v) => Math.trunc(v * 65536)); + return fits(Math.min(...q, 0), Math.max(...q, 0)); + } + case "sqrt": { + // sqrt_fixed: the root of a fixed number, a fixed (√raw × 256), 0 below zero; sqrt_whole (unsigned): √x × 65 536 + // of a u32 — a number below zero read so is past 2^30, and gives the largest. + const hi = e.unsigned ? (nonNeg(r[0]) ? Math.floor(Math.sqrt(r[0][1]) * 65536) : MAX) : r[0][1] <= 0 ? 0 : Math.floor(Math.sqrt(r[0][1]) * 256); + return [0, Math.min(hi, MAX)]; + } + case "atan2": return [-FIXED_PI, FIXED_PI]; + case "sin": case "cos": return [-65536, 65536]; + default: return ANY; + } + } + + function bool(e: BoolExpr, facts: Facts): void { + switch (e.kind) { + case "element": num(e.index, facts); return; + case "test": num(e.expr, facts); return; + case "compare": { const l = num(e.left, facts), r = num(e.right, facts); if (annotate) judge(e, l, r); return; } + case "and": { let f = facts; for (const i of e.items) { bool(i, f); f = withFacts(f, i, true); } return; } + case "or": { let f = facts; for (const i of e.items) { bool(i, f); f = withFacts(f, i, false); } return; } + case "not": bool(e.expr, facts); return; + case "running": num(e.task, facts); return; + case "unitAlive": unit(e.unit, facts); return; + case "unitSame": unit(e.left, facts); unit(e.right, facts); return; + case "unitFlag": unit(e.unit, facts); return; + case "edge": bool(e.cond, facts); return; + case "ternary": bool(e.cond, facts); bool(e.whenTrue, withFacts(facts, e.cond, true)); bool(e.whenFalse, withFacts(facts, e.cond, false)); return; + case "textCompare": case "textTest": mapTextOperands(e, texts(facts)); return; + case "call": call(e.call, new Map()); return; + default: return; + } + } + + function unit(u: UnitExpr, facts: Facts): void { + if (u.kind === "unitAt") { num(u.ptr, facts); num(u.epd, facts); num(u.uid, facts); } + else if (u.kind === "call") call(u.call, new Map()); + } + + /** + * A comparison whose sides are never below zero is read unsigned (one condition, where a signed one adds to each + * side), and one the ranges settle — at every place the pass met it — is a constant, when working its sides out + * does nothing (no call, no pop, no random number). + */ + const verdicts = new Map, { truth: boolean | null; nonNeg: boolean }>(); + const judge = (e: Extract, l: Range, r: Range) => { + const both = nonNeg(l) && nonNeg(r); + const ok = e.unsigned === true ? both : e.unsigned === "left" ? nonNeg(l) : e.unsigned === "right" ? nonNeg(r) : true; + const truth = !ok ? null : settle(e.op, l, r); + const was = verdicts.get(e); + verdicts.set(e, was ? { truth: was.truth === truth ? truth : null, nonNeg: was.nonNeg && both } : { truth, nonNeg: both }); + }; + + let result: VarDecl | undefined; + function call(c: Call, facts: Facts): void { + for (const p of c.params) { const r = value(p.decl.kind, p.init, facts); if (p.decl.kind === "number") store(p.decl.id, r); } + if (c.fn) { + const r = functions.get(c.fn)?.result?.decl; + if (c.result?.kind === "number") store(c.result.decl.id, r ? varRange(r.id) : ANY); + return; + } + const saved = result; + result = c.result?.decl; + const f = new Map(); + for (const p of c.params) if (p.decl.kind === "number") { const r = vars.get(p.decl.id); if (r) f.set(p.decl.id, r); } + list(c.body, f); + result = saved; + } + + /** A value of a declared kind (a text whatever the kind says, as `valueFor` in numbers.ts); a number's range, else ANY. */ + function value(kind: VarDecl["kind"], v: NumExpr | BoolExpr | UnitExpr | TextExpr, facts: Facts): Range { + if (isTextExpr(v as { kind: string })) { text(v as TextExpr, facts); return ANY; } + if (kind === "number") return num(v as NumExpr, facts); + if (kind === "boolean") bool(v as BoolExpr, facts); + else unit(v as UnitExpr, facts); + return ANY; + } + + /** A store into a number variable: its range, and what is known of it until the next store. */ + const assign = (id: string, r: Range, facts: Facts) => { store(id, r); facts.set(id, r); }; + + function list(stmts: Stmt[], facts: Facts): void { + for (const s of stmts) stmt(s, facts); + } + + /** What is still known inside a loop (and after it): nothing when it waits or calls, else all but what it stores into. */ + function loop(body: Stmt[][], facts: Facts, cond?: BoolExpr): Facts { + const { stores, waits } = effects(body.flat()); + if (waits || holdsCall(body, callCache) || (cond && holdsCall(cond, callCache))) { const out = keepPinned(facts); for (const id of stores) out.delete(id); return out; } + const out = new Map(facts); + for (const id of stores) out.delete(id); + return out; + } + + function stmt(s: Stmt, facts: Facts): void { + if (!STMT_KINDS.has(s.kind)) { unknown = true; return; } + if (s.kind !== "if" && s.kind !== "while" && s.kind !== "do" && s.kind !== "for" && s.kind !== "block" && s.kind !== "unrolled" && s.kind !== "switch" && s.kind !== "try" && holdsCall(s, callCache)) forget(facts); + switch (s.kind) { + case "declare": + if (s.failed) { if (s.decl.kind === "number") assign(s.decl.id, ANY, facts); return; } + { const r = value(s.decl.kind, s.init, facts); if (s.decl.kind === "number") assign(s.decl.id, r, facts); } + return; + case "assign": assign(s.target, num(s.value, facts), facts); return; + case "assignBool": bool(s.value, facts); return; + case "assignUnit": unit(s.value, facts); return; + case "assignText": text(s.value, facts); return; + case "storeText": num(s.index, facts); text(s.value, facts); reached.add(s.addr); reached.add(s.block); reached.add(s.chars); return; + case "releaseText": num(s.index, facts); reached.add(s.block); return; + case "textLoop": text(s.of, facts); list(s.body, loop([s.body], facts)); forget(facts); return; + case "declareArray": { + const a = arrays.get(s.array); + for (const v of [...(s.init ?? []), ...(s.fill ? [s.fill] : [])]) { + if (a?.kind === "number") storeCell(s.array, num(v as NumExpr, facts)); else bool(v as BoolExpr, facts); + } + return; + } + case "store": case "push": { + if (s.kind === "store") num(s.index, facts); + const a = arrays.get(s.array); + if (a?.kind === "number") storeCell(s.array, num(s.value as NumExpr, facts)); else bool(s.value as BoolExpr, facts); + return; + } + case "pop": return; + case "setLength": num(s.value, facts); return; + case "unitLoop": { + if (s.was) store(s.was.id, ANY); + const f = loop([s.body], facts); + if (s.was) f.delete(s.was.id); + list(s.body, f); + forget(facts); + return; + } + case "unitWrite": unit(s.unit, facts); if (FLAGS.has(s.field)) bool(s.value as BoolExpr, facts); else num(s.value as NumExpr, facts); return; + case "unitDo": unit(s.unit, facts); if (s.verb.do === "damage" || s.verb.do === "heal") num(s.verb.amount, facts); return; + case "tableWrite": if (isTextExpr(s.value as { kind: string })) text(s.value as TextExpr, facts); else if (s.boolean) bool(s.value as BoolExpr, facts); else num(s.value as NumExpr, facts); return; + case "if": { + if (holdsCall(s.cond, callCache)) forget(facts); + bool(s.cond, facts); + const t = withFacts(facts, s.cond, true), f = withFacts(facts, s.cond, false); + list(s.then, t); + if (s.else) list(s.else, f); + // What both ways leave known; a way that stored into a variable no longer has the fact from before. + const out = new Map(); + for (const [k, v] of t) { const w = f.get(k); if (w) out.set(k, join(v, w)!); } + facts.clear(); + for (const [k, v] of out) facts.set(k, v); + return; + } + case "while": { + const f = loop([s.body], facts, s.cond); + if (s.cond) bool(s.cond, f); + list(s.body, s.cond ? withFacts(f, s.cond, true) : f); + const after = loop([s.body], facts, s.cond); + facts.clear(); + for (const [k, v] of after) facts.set(k, v); + return; + } + case "do": { + const f = loop([s.body], facts, s.cond); + list(s.body, f); + bool(s.cond, loop([s.body], facts, s.cond)); + facts.clear(); + for (const [k, v] of loop([s.body], f, s.cond)) facts.set(k, v); + return; + } + case "for": { + const { stores, waits } = effectsOf(s.body); + const counters = [...effectsOf(s.update).stores].filter((id) => sites.get(id) === 2 && !stores.has(id) && !inTask && !(program.perPlayer && declOf.get(id)?.shared)); + const added = counters.filter((id) => !pinned.has(id)); + for (const id of added) pinned.add(id); + const f = loop([s.body, s.update], facts, s.cond); + if (s.cond) bool(s.cond, f); + const top = s.cond ? withFacts(f, s.cond, true) : f; + list(s.body, new Map(top)); + // The update, after the body: the test still holds of what the body stored nothing into, unless it waited or + // called — and of a pinned counter even then. + const atUpdate = new Map([...top].filter(([k]) => !stores.has(k) && (pinned.has(k) || !(waits || holdsCall(s.body, callCache))))); + list(s.update, atUpdate); + for (const id of added) pinned.delete(id); + const after = loop([s.body, s.update], facts, s.cond); + facts.clear(); + for (const [k, v] of after) facts.set(k, v); + return; + } + case "unrolled": for (const i of s.iterations) list(i, facts); return; + case "switch": { + if (holdsCall(s.value, callCache)) forget(facts); + num(s.value, facts); + for (const c of s.cases) list(c.body, new Map()); + facts.clear(); + return; + } + case "break": case "continue": case "remark": case "throw": case "uncaught": return; + case "return": + if (s.value && result) { const r = value(result.kind, s.value, facts); if (result.kind === "number") store(result.id, r); } + else if (s.value) eachCall(s.value, (c) => call(c, new Map())); + return; + case "sleep": forget(facts); return; + case "parallel": for (const b of s.branches) list(b, new Map()); forget(facts); return; + case "start": { + if (s.handle) assign(s.handle, ANY, facts); + const was = inTask, saved = [...pinned]; + inTask = true; + pinned.clear(); + call(s.call, facts); + inTask = was; + for (const k of saved) pinned.add(k); + return; + } + case "cancel": num(s.task, facts); return; + case "action": for (const v of s.variables ?? []) num(v.expr, facts); if (s.text) text(s.text, facts); return; + case "syncSend": num(s.value, facts); return; + case "select": for (const u of s.units) unit(u, facts); return; + case "centerLocation": num(s.x, facts); num(s.y, facts); if (s.size) { num(s.size.width, facts); num(s.size.height, facts); } return; + case "print": case "buildText": parts(s.parts, facts); return; + case "call": call(s.call, facts); forget(facts); return; + case "block": list(s.body, facts); return; + case "try": list(s.body, new Map(facts)); list(s.catch, new Map()); forget(facts); return; + } + } + + const everything = () => { + result = undefined; + list(program.body, new Map()); + for (const f of program.functions ?? []) { result = f.result?.decl; list(f.body, new Map()); } + result = undefined; + }; + + const equal = (a: Map, b: Map) => a.size === b.size && [...a].every(([k, v]) => { const w = b.get(k); return !!w && w[0] === v[0] && w[1] === v[1]; }); + /** + * The union of what a pass found with what the passes before did (nothing shrinks). Past eight passes a side that + * still moves goes to its end — `k = (k + 1) & 15` would otherwise creep up one a pass — and the narrowing passes + * after bring it back: a pass over ranges every store fits in finds ranges every store still fits in. + */ + const grow = (was: Map, found: Map, pass: number): Map => { + const out = new Map(was); + for (const [k, v] of found) { + const w = was.get(k), j = join(w ?? null, withZero(v))!; + out.set(k, pass >= 8 && w ? [j[0] < w[0] ? MIN : j[0], j[1] > w[1] ? MAX : j[1]] : j); + } + return out; + }; + const narrow = (was: Map, found: Map): Map => { + const out = new Map(was); + for (const [k, w] of was) { const v = found.get(k); out.set(k, v ? meet(w, withZero(v)) : w); } + return out; + }; + for (let pass = 0; pass < 48; pass++) { + next = new Map(); + nextCells = new Map(); + everything(); + if (unknown) break; + if (!stored) stored = new Set(next.keys()); + const v = grow(vars, next, pass), c = grow(cells, nextCells, pass); + const done = pass > 0 && equal(vars, v) && equal(cells, c); + vars = v; + cells = c; + if (done) break; + } + for (let pass = 0; pass < 4 && !unknown; pass++) { + next = new Map(); + nextCells = new Map(); + everything(); + const v = narrow(vars, next), c = narrow(cells, nextCells); + const done = equal(vars, v) && equal(cells, c); + vars = v; + cells = c; + if (done) break; + } + if (unknown) return; + // (Node only: a worker in the browser has no `process`, and reading it there threw — every program failed to compile.) + if (typeof process !== "undefined" && process.env?.RANGES_DEBUG) for (const [k, v] of vars) if (!isAny(v)) console.log(`ranges ${k} = ${v[0]} … ${v[1]}`); + annotate = true; + next = new Map(); + nextCells = new Map(); + everything(); + for (const [e, v] of verdicts) { + if (v.truth !== null && pure(e.left) && pure(e.right)) { + for (const k of Object.keys(e)) delete (e as Record)[k]; + Object.assign(e, { kind: "const", value: v.truth }); + } else if (v.nonNeg && e.unsigned !== true) e.unsigned = true; + } +} + +/** What `a op b` comes to for every pair of values in the two ranges; null when it depends on them. */ +function settle(op: string, l: Range, r: Range): boolean | null { + switch (op) { + case "<": return l[1] < r[0] ? true : l[0] >= r[1] ? false : null; + case "<=": return l[1] <= r[0] ? true : l[0] > r[1] ? false : null; + case ">": return l[0] > r[1] ? true : l[1] <= r[0] ? false : null; + case ">=": return l[0] >= r[1] ? true : l[1] < r[0] ? false : null; + case "==": return l[0] === l[1] && r[0] === r[1] && l[0] === r[0] ? true : l[1] < r[0] || r[1] < l[0] ? false : null; + case "!=": return l[0] === l[1] && r[0] === r[1] && l[0] === r[0] ? false : l[1] < r[0] || r[1] < l[0] ? true : null; + default: return null; + } +} + +/** Whether working a number out does nothing but give it: no call, no pop, no random number, nothing of a text. */ +function pure(e: NumExpr): boolean { + switch (e.kind) { + case "const": case "var": case "length": case "read": case "taskHandle": return true; + case "unitField": case "unitPart": return e.unit.kind === "unitVar" || e.unit.kind === "unitNull"; + case "element": return pure(e.index); + case "unary": case "cast": return pure(e.expr); + case "binary": return pure(e.left) && pure(e.right); + case "intrinsic": return e.args.every(pure); + default: return false; + } +} + +export type { Range as ValueRange }; diff --git a/compiler/recursion.ts b/compiler/recursion.ts index e0e39a9..cd81d5b 100644 --- a/compiler/recursion.ts +++ b/compiler/recursion.ts @@ -19,7 +19,7 @@ * A function off every cycle is left exactly as it was, and so is the program's body: only what recurses pays. */ import { STACK_DEPTH, UNIT_FLAGS, declarations, eachCall, isTextExpr, mapText, mapTextOperands, type TextExpr, type TextMap, type At, type BoolExpr, type Call, type FuncDecl, type NumExpr, type Program, type Stmt, type UnitExpr, type VarDecl } from "./ir"; -import type { ProgramDiagnostic } from "./eud"; +import type { ProgramDiagnostic } from "./check"; type Kind = "number" | "boolean" | "unit"; type Expr = NumExpr | BoolExpr | UnitExpr; @@ -316,6 +316,7 @@ export function settleRecursion(program: Program): ProgramDiagnostic[] { s.value = v as NumExpr | BoolExpr; break; } + case "select": { s.units = operands(s.units.map((e) => ({ e, kind: "unit" as const })), pre, w) as UnitExpr[]; break; } case "unitDo": { const items: { e: Expr; kind: Kind }[] = [{ e: s.unit, kind: "unit" }]; if (s.verb.do === "damage" || s.verb.do === "heal") items.push({ e: s.verb.amount, kind: "number" }); @@ -332,9 +333,15 @@ export function settleRecursion(program: Program): ProgramDiagnostic[] { if (s.text) s.text = rewriteText(s.text, pre, w); break; } - case "centerLocation": [s.x, s.y] = operands([{ e: s.x, kind: "number" }, { e: s.y, kind: "number" }], pre, w) as NumExpr[]; break; - case "print": { - const numbers = s.parts.filter((p) => p.kind === "number"); + case "syncSend": { s.value = (operands([{ e: s.value, kind: "number" as const }], pre, w) as NumExpr[])[0]; break; } + case "centerLocation": { + const done = operands([s.x, s.y, ...(s.size ? [s.size.width, s.size.height] : [])].map((e) => ({ e, kind: "number" as const })), pre, w) as NumExpr[]; + [s.x, s.y] = done; + if (s.size) s.size = { width: done[2], height: done[3] }; + break; + } + case "print": case "buildText": { + const numbers = s.parts.filter((p) => p.kind === "number" || p.kind === "char"); const done = operands(numbers.map((p) => ({ e: p.expr, kind: "number" as const })), pre, w) as NumExpr[]; numbers.forEach((p, i) => { p.expr = done[i]; }); for (const p of s.parts) if (p.kind === "value") p.text = rewriteText(p.text, pre, w); @@ -373,7 +380,9 @@ export function settleRecursion(program: Program): ProgramDiagnostic[] { continue; } case "unrolled": s.iterations = s.iterations.map((i) => body(i, returns)); break; + case "parallel": s.branches = s.branches.map((b) => body(b, returns)); break; case "block": s.body = body(s.body, returns); break; + case "try": s.body = body(s.body, returns); s.catch = body(s.catch, returns); break; case "textLoop": errors.push({ at: s.at, message: `${f.name} calls itself, and this loop over a text holds such a call: the loop's place in the text is not something a call can keep. Walk it by place — for (let i = 0; i < s.length; i++) — which a call keeps as it keeps any number.` }); s.body = body(s.body, returns); @@ -398,12 +407,12 @@ export function settleRecursion(program: Program): ProgramDiagnostic[] { if (s.kind === "call" && s.call.fn === f.id) return true; if (s.kind === "if" && s.else && never(s.then) && never(s.else)) return true; if (s.kind === "block" && never(s.body)) return true; - // Anything else that holds a return (or is one) may be the way out. - if (s.kind !== "remark" && mentions(s, "return")) return false; + // Anything else that holds a return or a throw (or is one) may be the way out. + if (s.kind !== "remark" && (mentions(s, "return") || mentions(s, "throw"))) return false; } return false; }; - if (never(f.body)) errors.push({ at: f.at, message: `${f.name} calls itself on every path through it, so no call of it ever returns: the stack would run out the first time it is called. Give it a way out — an if that returns before the call.` }); + if (never(f.body)) errors.push({ at: f.at, message: `${f.name} calls itself on every path through it, so no call of it ever returns: the stack would run out the first time it is called. Give it a way out — an if that returns (or throws) before the call.` }); // An array declared in the function is each run's own: a block of the heap, so that a frame keeps its handle. const local: string[] = []; diff --git a/compiler/remasteredCost.ts b/compiler/remasteredCost.ts new file mode 100644 index 0000000..828598e --- /dev/null +++ b/compiler/remasteredCost.ts @@ -0,0 +1,165 @@ +/** + * What a frame costs Remastered, in time (TrigScript 4, cost step 1): the lifted game's count of conditions, actions + * and triggers (scm-sim's `CostTap`), each weighed by what `probes/frame-weights` measured Remastered spending on it. + * The tap asks `RemasteredWeigh` about every trigger the walk passes and every record it runs, in the game's order, + * and adds the answers up by frame and by line. + * + * Why the answer depends on more than the record's kind (read in Ghidra, Remastered 1.23.10.13515; see the plan's + * "What Remastered charges"): every Deaths and Set Deaths of an EUD map goes through the EUD emulator, which keeps the + * region it read last and the region it wrote last, and searches a tree of ~12,300 regions for any other. Every unit, + * sprite and image is a region of its own (`data/remastered-regions.json`). The walk finds each trigger through the + * same two caches and reads its next pointer through the read side. So the weigher keeps the two caches as Remastered + * would and charges a search where Remastered would make one. + * + * The numbers are a Ryzen 9 7900X's (2026-10-18, single player at Fastest, the game's own work 5.4 ms a frame): a + * slower computer takes longer for all of them alike, so they say how much of a frame a script takes on a fast one. + */ +import regionsData from "../data/remastered-regions.json"; + +/** + * Nanoseconds, from probes/frame-weights and probes/fast-cells played 2026-10-18 (the slopes between 200 and 800 + * thousand records a frame). + * A kind the probe did not measure is counted as the measured kind closest to it, said beside each. + */ +export const WEIGHTS = { + /** A trigger the walk passes: found through the caches, its next pointer read (empty triggers 9.0; 1 against 64 actions to a trigger 8.6). */ + trigger: 8.6, + /** A Deaths condition, the region cached. */ + deathsCondition: 4.8, + /** A Set Deaths, the region cached. */ + setDeaths: 9.7, + /** More for a masked Set Deaths: a read before the write. */ + masked: 3.0, + /** More for a field of a unit: the unit region's own read (measured on reads; writes counted the same). */ + unitField: 7.5, + /** More when the emulator searches its tree: the region is not the one cached. */ + search: 7.3, + /** The search from one unit to another: the two lie close in the tree (unitWalk against unitSame). */ + searchNextUnit: 4.7, + /** + * An action outside the emulator: probes/fast-cells (2026-10-18) measured Set Switch 3.3, Set Resources 3.7–4.2 and + * Set Score 3.8–4.2 (frame-weights had Set Switch 3.1). Display Text, Create Unit and the rest are not measured, and + * likely more. + */ + otherAction: 3.8, + /** A condition outside the emulator: Switch 3.0, Accumulate 3.8, Score 3.7 (probes/fast-cells). */ + otherCondition: 3.6, +} as const; + +/** Remastered's frame on that computer: what the game takes of 42 ms (Fastest) before the triggers, and what is left. */ +export const FRAME_MS = 42, GAME_MS = 5.4; +/** The trigger work a frame took with no flicker anyone saw (frame-weights, 15 ms of one-action triggers), and the most before the game slows. */ +export const SMOOTH_MS = 15, MOST_MS = FRAME_MS - GAME_MS; + +const DEATHS = 0x58a364, CURRENT_PLAYER = 0x6509b0; +const DEATHS_CONDITION = 15, SET_DEATHS = 45, MASKED = 0x4353; // 'SC' in the record's last word + +interface Regions { strings: number; pools: { name: string; base: number; stride: number; count: number }[]; regions: [number, number, number][] } + +/** Which emulator region an address is in, as a number: equal numbers are one region. */ +export class RegionMap { + private readonly starts: number[]; + private readonly ends: number[]; + private readonly pools: Regions["pools"]; + private readonly strings: number; + private readonly unitsBase: number; + private readonly unitsEnd: number; + /** Numbers past every region's own: the pools' elements, then pages of what no region names. */ + private static readonly POOLS = 1 << 20; + + constructor(data: Regions = regionsData as unknown as Regions) { + const r = [...data.regions].sort((a, b) => a[0] - b[0]); + this.starts = r.map((x) => x[0]); + this.ends = r.map((x) => x[0] + x[1]); + this.pools = data.pools; + this.strings = data.strings; + const units = data.pools.find((p) => p.name === "units"); + this.unitsBase = units?.base ?? 0; + this.unitsEnd = units ? units.base + units.stride * units.count : 0; + } + + /** The region of `address`: the strings region (eudplib's payload and variables) is -1. */ + of(address: number): number { + if (address >= this.strings) return -1; + let lo = 0, hi = this.starts.length - 1; + while (lo <= hi) { + const mid = (lo + hi) >> 1; + if (this.starts[mid] <= address) lo = mid + 1; else hi = mid - 1; + } + if (hi >= 0 && address < this.ends[hi]) return hi; + let base = RegionMap.POOLS; + for (const p of this.pools) { + if (address >= p.base && address < p.base + p.stride * p.count) return base + Math.floor((address - p.base) / p.stride); + base += p.count; + } + return base + (address >>> 8); // nothing Remastered names: its own region, roughly + } + + /** Whether `address` is in a unit. */ + unit(address: number): boolean { return address >= this.unitsBase && address < this.unitsEnd; } +} + +/** The weigher scm-sim's tap asks (`CostWeigh`, by shape: TrigScript never imports the engine). */ +export class RemasteredWeigh { + private read = -2; + private write = -2; + private readUnit = false; + private writeUnit = false; + /** The trigger before, whose next pointer the walk reads (through the read cache) before the next trigger. */ + private before = -1; + private readonly m: DataView; + private readonly regions: RegionMap; + + constructor(m: DataView, regions = new RegionMap()) { this.m = m; this.regions = regions; } + + trigger(trigger: number): number { + if (this.before >= 0) this.cacheRead(this.before); + this.before = trigger; + const r = this.regions.of(trigger); + // The walk looks for the trigger's region in both caches, and searches only when neither holds it; it caches nothing. + return WEIGHTS.trigger + (r === this.read || r === this.write ? 0 : WEIGHTS.search); + } + + record(record: number, type: number, action: boolean): number { + const m = this.m; + if (record + 0x20 > m.byteLength) return action ? WEIGHTS.otherAction : WEIGHTS.otherCondition; + if (!action && type === DEATHS_CONDITION) { + const masked = m.getUint16(record + 0x12, true) === MASKED; + const a = this.address(m.getUint32(record + 4, true), m.getUint16(record + 0xc, true), masked, false); + return a < 0 ? WEIGHTS.otherCondition : WEIGHTS.deathsCondition + this.cacheRead(a); + } + if (action && type === SET_DEATHS) { + const masked = m.getUint16(record + 0x1e, true) === MASKED; + const a = this.address(m.getUint32(record + 0x10, true), m.getUint16(record + 0x18, true), masked, true); + if (a < 0) return WEIGHTS.otherAction; + return WEIGHTS.setDeaths + (masked ? WEIGHTS.masked + this.cacheRead(a) - (this.regions.unit(a) ? WEIGHTS.unitField : 0) : 0) + this.cacheWrite(a); + } + return action ? WEIGHTS.otherAction : WEIGHTS.otherCondition; + } + + reset() { this.read = this.write = -2; this.readUnit = this.writeUnit = false; this.before = -1; } + + /** The dword a Deaths record names, as the emulator takes it; -1 when the game's own routine does it (a group of players, the unit classes). */ + private address(player: number, unit: number, masked: boolean, write: boolean): number { + const p = player === 13 ? (CURRENT_PLAYER + 4 <= this.m.byteLength ? this.m.getUint32(CURRENT_PLAYER, true) : -1) : player >= 14 && player <= 26 ? -1 : player; + if (p < 0 || (!masked && (write ? p < 8 && unit >= 0xe4 && unit <= 0xe8 : unit >= 0xe5 && unit <= 0xe8))) return -1; + return (DEATHS + 4 * (p + 12 * unit)) >>> 0; + } + + /** What a read of `address` costs beyond the record's own, and the read cache after it. */ + private cacheRead(address: number): number { + const r = this.regions.of(address), unit = this.regions.unit(address); + const cost = (r === this.read ? 0 : unit && this.readUnit ? WEIGHTS.searchNextUnit : WEIGHTS.search) + (unit ? WEIGHTS.unitField : 0); + this.read = r; + this.readUnit = unit; + return cost; + } + + private cacheWrite(address: number): number { + const r = this.regions.of(address), unit = this.regions.unit(address); + const cost = (r === this.write ? 0 : unit && this.writeUnit ? WEIGHTS.searchNextUnit : WEIGHTS.search) + (unit ? WEIGHTS.unitField : 0); + this.write = r; + this.writeUnit = unit; + return cost; + } +} diff --git a/compiler/runtime.ts b/compiler/runtime.ts index 2ce5321..ccf2eb4 100644 --- a/compiler/runtime.ts +++ b/compiler/runtime.ts @@ -17,13 +17,19 @@ import { type ActionRecord, type ConditionRecord, type TriggerRecord, } from "../vendor/triggers"; import { aiScriptByName, type ActionDef, type ArgKind, type ConditionDef } from "../vendor/triggerDefs"; -import { ACTION_IDENTS, CANONICAL, choiceOf, choiceWords, CONDITION_IDENTS, scriptParams, TRIGGER_OPTION_NAMES } from "./api"; +import { ACTION_IDENTS, CANONICAL, choiceOf, choiceWords, CONDITION_IDENTS, orderOfAction, scriptParams, TRIGGER_OPTION_NAMES } from "./api"; import { ACTION_FIELDS, CONDITION_FIELDS } from "./record"; import { hyperTriggers, negateCondition, PLAYER_SLOTS } from "./lower"; import { allTables, type ScriptNames } from "./names"; import type { RaceId, ReadSource, TableCell, TextPart, UnitFilter } from "./ir"; -import { DEAF_KEYS, keyName, KEY_NAMES, MOUSE_BUTTONS, parseChatPattern, type ChatPattern, type InputSource, type MouseButton } from "./input"; +import { DEAF_KEYS, keyName, KEY_NAMES, LAST_SLOT, MOUSE_BUTTONS, parseChatPattern, type ChatPattern, type InputSource, type MouseButton } from "./input"; +import { JSX_FRAGMENT, TEXT_COLORS, builtText, jsxAtBuild } from "./jsx"; +import type { HoistedThunks } from "./hoist"; import { PLAYER_COLORS, TABLE_FIELDS, TABLE_SIZE, tableOfBrand, type TableField, type TableKind } from "./tables"; +import { LOCAL_FIELDS, LOCATION_SIDES, localName, locationField, MEMORY_FIELDS, MEMORY_GROUPS, memoryTableField } from "./memory"; +import { coverValue } from "./cover"; +import { isVideo } from "./media"; +import { clipFile, fm, isClip, mixdown, noise, pluck, silence, together, tone, type ClipFile } from "./sound"; /** `memory(address, …)` reads `deaths` at player `EPD(address)`, unit 0: the deaths table starts here in 1.16.1's memory. */ export const DEATHS_TABLE_ADDRESS = 0x58a364; @@ -74,6 +80,14 @@ export const isUnitQuery = (v: unknown): v is UnitQueryValue => typeof v === "ob /** What `first(…)`, `nearest(…)` and `randomUnit(…)` return when the script is built: how the game is to pick one unit. */ export interface UnitPickValue { readonly __trigscript: "pick"; readonly by: "first" | "nearest" | "random"; readonly filter: UnitFilter; readonly near?: number; readonly mouse?: number; readonly within?: number; readonly ident: string } export const isUnitPick = (v: unknown): v is UnitPickValue => typeof v === "object" && v !== null && (v as UnitPickValue).__trigscript === "pick"; +/** `local.selected` (B5): the unit in a slot of this computer's selection. */ +export interface SelectedValue { readonly __trigscript: "selected"; readonly index: number; readonly ident: string } +export const isSelected = (v: unknown): v is SelectedValue => typeof v === "object" && v !== null && (v as SelectedValue).__trigscript === "selected"; +/** `local.selectionCount` (B5): how many units this computer has selected. */ +export interface SelectionCountValue { readonly __trigscript: "selectionCount"; readonly ident: string } +export const isSelectionCount = (v: unknown): v is SelectionCountValue => typeof v === "object" && v !== null && (v as SelectionCountValue).__trigscript === "selectionCount"; +/** The selection a computer keeps, twelve unit addresses (Remastered's emulator gives each unit's 1.16.1 address). */ +export const SELECTION_ADDRESS = 0x6284b8; /** * A property of `stats(…)`: a cell of the game's tables. Like a read it has no value when the @@ -96,7 +110,7 @@ export interface ChatValue { readonly __trigscript: "chat"; readonly pattern: Ch export const isChat = (v: unknown): v is ChatValue => typeof v === "object" && v !== null && (v as ChatValue).__trigscript === "chat"; /** A value only the game has: nothing the script computes when it is built may take it as a number. */ -export const isGameValue = (v: unknown): v is ReadValue | TableValue | UnitPickValue | InputValue | MouseValue | ChatValue => isRead(v) || isTable(v) || isUnitPick(v) || isInput(v) || isMouse(v) || isChat(v); +export const isGameValue = (v: unknown): v is ReadValue | TableValue | UnitPickValue | InputValue | MouseValue | ChatValue | SelectedValue | SelectionCountValue => isRead(v) || isTable(v) || isUnitPick(v) || isInput(v) || isMouse(v) || isChat(v) || isSelected(v) || isSelectionCount(v); /** The colour byte for what a script wrote as a player colour: a word of `PLAYER_COLORS`, or the byte itself. */ export function playerColor(v: unknown): number { @@ -147,7 +161,7 @@ export const READER_NAMES = ["minerals", "gas", "resources", "countUnits", "kill /** The library's functions about units on the map and the game's tables: the game's too, never computed when the script is built. */ export const UNIT_CALL_NAMES = ["unitsAt", "unitsOf", "allUnits", "first", "nearest", "randomUnit", "underMouse", "stats"] as const; /** The library's functions about what the players do, and the one that moves a location to a point: the game's as well. */ -export const INPUT_CALL_NAMES = ["keyPressed", "clicked", "mouse", "chatted", "centerLocation"] as const; +export const INPUT_CALL_NAMES = ["keyPressed", "keyHeld", "clicked", "mouse", "chatted", "centerLocation"] as const; /** * What the transformer turns `program(() => { … })` — and the arrow of `game(…)` — into: @@ -198,9 +212,19 @@ export class ScriptError extends Error { /** What a run of the script produces, in execution order. */ export class Collector { readonly entries: Entry[] = []; + /** The classes at the top of the script's files, by where each is (`file:position`): what their bodies compute when the script is built. */ + readonly classes = new Map HoistedThunks }>(); + /** The functions at the top of the script's files, by where they are: what their bodies compute when the script is built. */ + readonly functions = new Map HoistedThunks }>(); readonly strings: ScriptString[] = []; + /** The clips the script plays, as the files the map's archive gets, by their names there (`sound.ts`). */ + readonly sounds = new Map(); + /** Whether those files are made (a build), or only named (a check: `CompileOptions.sounds`). */ + soundContents = true; /** The script emitted hyper triggers: the trigger loop runs twelve times a second, not once in two. */ hyper = false; + /** Locations a library took with reserveLocation(), by the reason it gave: the location's number. */ + readonly reserved = new Map(); /** * The script's own statements are running — as opposed to a program's build-time parts, which the * compiler asks for afterwards. What a player does has no meaning there, and `if (keyPressed(…))` @@ -280,6 +304,11 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: const argValue = (kind: ArgKind, v: unknown, what: string): number => { switch (kind) { case "text": case "wav": + if (builtText(v) !== undefined) v = builtText(v); + // A clip (`sound.ts`): its file goes in the map's archive, and the action plays it by the name it goes under. A + // trigger's is a WAV whatever its length — the triggers play in 1.16.1 too, which plays no Ogg; a program's + // (Remastered) is Ogg Vorbis when it is long. + if (kind === "wav" && isClip(v)) { const file = clipFile(collector.running ? v.wav() : v, collector.soundContents); collector.sounds.set(file.path, file); v = file.path; } if (typeof v === "string") return v === "" ? 0 : collector.localString({ text: v }); if (typeof v === "number") return v === 0 ? 0 : collector.localString({ index: integer(v, what) }); throw new ScriptError(`${what}: expected text, got ${describe(v)}.`); @@ -298,7 +327,7 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: if (n === undefined) throw new ScriptError(`${what}: unknown ${kind} ${describe(v)}: one of ${choiceWords(kind).map((w) => JSON.stringify(w)).join(", ")}.`); return n; } - return integer(v, what); + return kind === "order" ? orderOfAction(integer(v, what)) : integer(v, what); } }; @@ -350,10 +379,20 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: if (n % 4 !== 0) throw new ScriptError(`${what}: expected a 4-byte-aligned memory address.`); return ((n - DEATHS_TABLE_ADDRESS) / 4) >>> 0; }; + /** + * A dword as a Deaths names it: its EPD in the player field, unit 0. A dword 12 to 26 dwords into the deaths table + * (Player 2's Ghosts to Player 3's Vultures) cannot be named so — the game reads the player group of that number + * instead (13 the current player, 17 all players, 18 to 21 the forces) — and is the deaths of player e mod 12 of + * unit e div 12, the same cell, so it is named as that in every build. + */ + const deathsAt = (address: unknown, what: string): { player: number; unitId: number } => { + const e = epd(address, what); + return e >= 12 && e <= 26 ? { player: e % 12, unitId: Math.floor(e / 12) } : { player: e, unitId: 0 }; + }; rt.memory = (address: unknown, comparison: unknown, value: unknown) => - condition({ ...emptyCondition(), type: ConditionType.Deaths, player: epd(address, "memory: address"), unitId: 0, comparison: argValue("comparison", comparison, "memory: comparison"), amount: integer(value, "memory: value") }); + condition({ ...emptyCondition(), type: ConditionType.Deaths, ...deathsAt(address, "memory: address"), comparison: argValue("comparison", comparison, "memory: comparison"), amount: integer(value, "memory: value") }); rt.setMemory = (address: unknown, modifier: unknown, value: unknown) => - action({ ...emptyAction(), type: ActionType.SetDeaths, player: epd(address, "setMemory: address"), unitId: 0, modifier: argValue("modifier", modifier, "setMemory: modifier"), target: integer(value, "setMemory: value") }); + action({ ...emptyAction(), type: ActionType.SetDeaths, ...deathsAt(address, "setMemory: address"), modifier: argValue("modifier", modifier, "setMemory: modifier"), target: integer(value, "setMemory: value") }); rt.disabled = (item: unknown) => { if (isCondition(item)) return condition({ ...item.record, flags: item.record.flags | ConditionFlag.Disabled }); if (isAction(item)) return action({ ...item.record, flags: item.record.flags | ActionFlag.Disabled }); @@ -427,6 +466,10 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: // The word from before 3.0, when a program's clock was the trigger cycle; a program's clock is the frame now. rt.cycles = (n: unknown): DurationValue => ({ __trigscript: "duration", cycles: Math.max(1, Math.round(number(n, "cycles"))) }); rt.sleep = () => { throw new ScriptError("sleep() pauses a program: use it inside program(), as a statement — sleep(seconds(2))."); }; + rt.nextFrame = () => { throw new ScriptError("nextFrame() waits for the next frame: use it inside program(), as a statement — await nextFrame();"); }; + rt.start = () => { throw new ScriptError("start() starts a task inside a program: start(wave(3)), inside program(async () => { … })."); }; + rt.until = () => { throw new ScriptError("until() waits inside a program: await until(() => …), inside program(async () => { … })."); }; + rt.sync = () => { throw new ScriptError("sync() sends a player's value to every computer inside a program: const x = await sync(P1, local.camera.x), inside program(async () => { … })."); }; rt.rose = () => { throw new ScriptError("rose() is true on the cycle its condition becomes true: use it inside program(), in an if."); }; rt.once = () => { throw new ScriptError("once() is true the first time its condition holds: use it inside program(), in an if."); }; rt.shared = () => { throw new ScriptError("shared() marks a variable every player of a per-player program shares: let total = shared(0), inside program()."); }; @@ -437,6 +480,8 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: }; rt.u32 = (v: unknown) => signed(v, "u32: value") >>> 0; rt.i32 = (v: unknown) => signed(v, "i32: value") | 0; + // A number's own value: when the script is built, numbers have their fractions already. + rt.fixed = (v: unknown) => signed(v, "fixed: value"); rt.clamp = (v: unknown, lo: unknown, hi: unknown) => Math.min(Math.max(signed(v, "clamp: value"), signed(lo, "clamp: low")), signed(hi, "clamp: high")); /* ── Reads: what the game holds, as a value of a program ── */ @@ -484,6 +529,8 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: const onlyInProgram = (ident: string, how: string): never => { throw new ScriptError(`${ident}() is about the units in the game, which exist while it runs: ${how}, inside program().`); }; const filterOf = (ident: string, v: unknown, given: UnitFilter): UnitFilter => { const out: UnitFilter = { ...given }; + // `first(unitsAt(locations.Base, { owner: P1 }))`: the set's own filter. + if (isUnitQuery(v)) v = v.filter; if (v !== undefined && v !== null) { if (typeof v !== "object" || Array.isArray(v) || isGameValue(v)) throw new ScriptError(`${ident}: the filter is an object such as { type: units.TerranMarine, owner: P1, at: locations.Base }, got ${describe(v)}.`); for (const [key, value] of Object.entries(v as Record)) { @@ -548,13 +595,15 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: if (collector.running) throw new ScriptError(`${what.split(":")[0]}() asks what a player does while the game runs: use it inside program().`); return onePlayer(v, what, 8); }; - rt.keyPressed = (player: unknown, key: unknown) => { + const keyRead = (what: "keyPressed" | "keyHeld") => (player: unknown, key: unknown) => { const name = typeof key === "string" ? keyName(key) : null; const deaf = typeof key === "string" ? DEAF_KEYS[key.trim().toUpperCase()] : undefined; - if (deaf) throw new ScriptError(`keyPressed: ${deaf}.`); - if (!name) throw new ScriptError(`keyPressed: the key is one of ${KEY_NAMES.slice(0, 3).map((k) => JSON.stringify(k)).join(", ")} … "F1" … "Space", "Enter", "Escape", "Left" …; got ${describe(key)}.`); - return inputValue(`keyPressed(…, ${JSON.stringify(name)})`, { source: "key", key: name, player: human(player, "keyPressed: player") }, true); + if (deaf) throw new ScriptError(`${what}: ${deaf}.`); + if (!name) throw new ScriptError(`${what}: the key is one of ${KEY_NAMES.slice(0, 3).map((k) => JSON.stringify(k)).join(", ")} … "F1" … "Space", "Enter", "Escape", "Left" …; got ${describe(key)}.`); + return inputValue(`${what}(…, ${JSON.stringify(name)})`, { source: "key", key: name, player: human(player, `${what}: player`), ...(what === "keyHeld" ? { held: true } : {}) }, true); }; + rt.keyPressed = keyRead("keyPressed"); + rt.keyHeld = keyRead("keyHeld"); rt.clicked = (player: unknown, button: unknown = "left") => { const b = typeof button === "string" ? button.trim().toLowerCase() : ""; if (!(MOUSE_BUTTONS as readonly string[]).includes(b)) throw new ScriptError(`clicked: the button is "left", "right" or "middle", got ${describe(button)}.`); @@ -573,7 +622,44 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: parsed.captures.forEach((c, i) => { values[c.name] = inputValue(`chatted(…).${c.name}`, { source: "chat", pattern, capture: i, player: who }, false); }); return Object.freeze({ __trigscript: "chat" as const, pattern: parsed, matched: inputValue("chatted(…)", { source: "chat", pattern, capture: null, player: who }, true), values: Object.freeze(values) }); }; + /** + * For a library: a location of the map's first 63 that the map does not use, the highest free one — the one a map + * maker reaches for last. Asked again with the same reason, the same location. Input's own (`input.ts`) keeps clear. + */ + rt.reserveLocation = (why: unknown): number => { + if (typeof why !== "string" || !why.trim()) throw new ScriptError(`reserveLocation() takes the reason, which the map's requirements show: reserveLocation("fog: where each brush is made").`); + const had = collector.reserved.get(why); + if (had !== undefined) return had; + const used = new Set([...names.locations.entries.map((e) => e.value), ...collector.reserved.values()]); + for (let n = LAST_SLOT + 1; n >= 1; n--) if (!used.has(n)) { collector.reserved.set(why, n); return n; } + throw new ScriptError(`reserveLocation("${why}"): the map uses every one of its first 63 locations; free one for it.`); + }; + /** Build-time: a picture's ink, or each frame of a video's, as the brushes of shapes that cover it (`cover.ts`). */ + rt.cover = (picture: unknown, shapes: unknown) => { + try { return coverValue(picture, shapes, { isVideo }); } catch (err) { throw new ScriptError(err instanceof Error ? err.message : String(err)); } + }; + /** Build-time: clips made from nothing (`sound.ts`). */ + const built = (f: (...a: A) => unknown) => (...a: A) => { try { return f(...a); } catch (err) { throw new ScriptError(err instanceof Error ? err.message : String(err)); } }; + rt.tone = built(tone); + rt.noise = built(noise); + rt.silence = built(silence); + rt.mixdown = built(mixdown); + rt.together = built(together); + rt.fm = built(fm); + rt.pluck = built(pluck); rt.centerLocation = () => { throw new ScriptError("centerLocation() moves a location while the game runs: use it inside program(), as a statement — centerLocation(locations.Cursor, x, y)."); }; + /** `bounds(location)`: the four edges as cells of the game's location table, read and written where the location is now. */ + rt.bounds = (location: unknown) => { + const n = integer(location, "bounds: location"); + if (n < 1 || n > 255) throw new ScriptError("bounds() takes one of locations.*."); + const who = `bounds(${names.locations.entries.find((l) => l.value === n)?.keys[0] ?? `location ${n}`})`; + return Object.freeze(Object.fromEntries(LOCATION_SIDES.map((side) => { + const field = locationField(side), ident = `${who}.${side}`; + const fail = (): never => { throw new ScriptError(`${ident} is where the location is while the game runs: it has no value when the script is built. Inside program(), assign it to a let, or use it in the program's own arithmetic and comparisons.`); }; + const cell: TableCell = { name: `location.${side}`, base: field.base, stride: field.stride, index: n - 1, width: 4 }; + return [side, { __trigscript: "table", cell, field, ident, valueOf: fail, toString: fail } as TableValue]; + }))); + }; /* ── The game's tables ── */ const tableValue = (kind: TableKind, field: TableField, index: number, ident: string, key?: number): TableValue => { @@ -600,6 +686,34 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: } return Object.freeze(out); }; + /** `memory`: each field a table cell with no index (`memory.ts`), named by its path. */ + // On the trigger level's memory(address, …) condition, which keeps its name: memory.vision.countdown. + Object.assign(rt.memory as object, Object.fromEntries(Object.keys(MEMORY_GROUPS).map((group) => [group, Object.freeze(Object.fromEntries( + MEMORY_FIELDS.filter((f) => f.path[0] === group).map((f) => { + const field = memoryTableField(f); + const ident = `memory.${field.name}`; + const fail = (): never => { throw new ScriptError(`${ident} is a value the game holds: it has no value when the script is built. Inside program(), assign to it, assign it to a let, or use it in the program's own arithmetic and comparisons.`); }; + const cell: TableCell = { name: ident, base: field.base, stride: 0, index: 0, width: field.width }; + return [f.path[1], { __trigscript: "table", cell, field, ident, valueOf: fail, toString: fail } as TableValue]; + }), + ))]))); + /** `local` (B5): the cells only this computer knows, read as `memory`'s are; the compiler keeps them from the game (`local.ts`). */ + const localCell = (f: (typeof LOCAL_FIELDS)[number]): TableValue => { + const field = memoryTableField(f); + const ident = localName(f); + const fail = (): never => { throw new ScriptError(`${ident} is what this computer knows while the game runs: it has no value when the script is built. Inside program(), show it, or sync() it to act on it.`); }; + const cell: TableCell = { name: ident, base: field.base, stride: 0, index: 0, width: field.width }; + return { __trigscript: "table", cell, field: { ...field, name: ident.slice("local.".length) }, ident, valueOf: fail, toString: fail } as TableValue; + }; + rt.local = Object.freeze({ + ...Object.fromEntries([...new Set(LOCAL_FIELDS.map((f) => f.path[0]))].map((g) => { + const fs = LOCAL_FIELDS.filter((f) => f.path[0] === g); + return [g, fs.length === 1 && !fs[0].path[1] ? localCell(fs[0]) : Object.freeze(Object.fromEntries(fs.map((f) => [f.path[1], localCell(f)])))]; + })), + selected: Object.freeze({ __trigscript: "selected", index: 0, ident: "local.selected" } satisfies SelectedValue), + selectionCount: Object.freeze({ __trigscript: "selectionCount", ident: "local.selectionCount" } satisfies SelectionCountValue), + }); + rt.select = () => { throw new ScriptError("select() changes what a player has selected while the game runs: use it inside program()."); }; const tableNames: Record = { unit: names.units, weapon: names.weapons, upgrade: names.upgrades, tech: names.techs, player: names.players }; const entryName = (kind: TableKind, value: number): string => { const t = tableNames[kind]; @@ -608,10 +722,39 @@ export function createRuntime(names: ScriptNames, collector: Collector, options: }; rt.colors = Object.freeze({ ...PLAYER_COLORS }); + /* ── JSX: a text as elements ── */ + // After each class at the top of a file (hoist.ts#transformer): what its bodies compute when the script is built, for a + // program that makes an instance of it. + rt.__class = (_cls: unknown, d: { at: [number, number]; pos: number; hoisted: () => HoistedThunks }) => { collector.classes.set(`${d.at[0]}:${d.pos}`, { hoisted: d.hoisted }); }; + rt.__fn = (_fn: unknown, d: { at: [number, number]; pos: number; hoisted: () => HoistedThunks }) => { collector.functions.set(`${d.at[0]}:${d.pos}`, { hoisted: d.hoisted }); }; + rt.textColors = TEXT_COLORS; + rt.__jsx = jsxAtBuild; + rt.__fragment = JSX_FRAGMENT; + rt.ui = Object.freeze({ + lines: () => { throw new ScriptError("ui.lines() shows a text in a program's lines, inside program(async () => { … }): ui.lines(P1, () => …)."); }, + }); + const listens = (what: string, example: string) => () => { throw new ScriptError(`on.${what}() listens while the game runs: at the top of a script, or inside program(async () => { … }): ${example}.`); }; + rt.on = Object.freeze({ + created: listens("created", "on.created(units.ZergZergling, (u) => { … })"), + died: listens("died", "on.died({ owner: P8 }, (u) => { … })"), + killed: listens("killed", "on.killed({ owner: P8 }, (u, by) => { … })"), + order: listens("order", "on.order(units.ProtossHighTemplar, orders.CastPsionicStorm, (u) => { … })"), + morphed: listens("morphed", "on.morphed(units.TerranSiegeTankSiegeMode, (u, was) => { … })"), + chat: listens("chat", "on.chat(\"-give {n}\", (p, { n }) => { … })"), + key: listens("key", "on.key(\"F7\", (p) => { … })"), + }); + // `on.died(…)` at the top of a script: the transformer hands the call here as a program of its own, which listens from + // the start of the game (hoist.ts#transformer). + rt.__listen = (body: unknown, at?: unknown) => { + if (!isProgramDescriptor(body)) throw new ScriptError("on.…() at the top of a script is written directly, as a statement of its own."); + collector.entries.push({ kind: "program", descriptor: body, options: { owners: [0], perPlayer: false }, at: isAt(at) ? at : null }); + }; + /* ── Text a program fills in ── */ rt.name = (player: unknown) => textMark("n", onePlayer(player, "name: player", 12)); rt.color = (player: unknown) => textMark("c", onePlayer(player, "color: player", 12)); rt.print = (text: unknown, options?: unknown): PrintValue => { + if (builtText(text) !== undefined) text = builtText(text); if (typeof text !== "string") throw new ScriptError(`print: expected text, got ${describe(text)}.`); let to: number = PlayerGroup.CurrentPlayer; let position: PrintValue["position"] = "chat"; @@ -691,8 +834,9 @@ export function runtimeNames(names: ScriptNames): string[] { out.push("CurrentPlayer", "AllPlayers"); out.push(...CONDITION_IDENTS.keys(), ...ACTION_IDENTS.keys(), "preserve"); out.push("condition", "action", "memory", "setMemory", "disabled", "not", "trigger", "hyperTriggers", "program", "game", "random"); - out.push("seconds", "minutes", "frames", "cycles", "sleep", "rose", "once", "shared", "clamp", "u32", "i32"); + out.push("seconds", "minutes", "frames", "cycles", "sleep", "nextFrame", "until", "sync", "select", "local", "start", "rose", "once", "shared", "clamp", "u32", "i32", "fixed"); out.push(...READER_NAMES, "races", "slots", "name", "color", "print"); - out.push(...UNIT_CALL_NAMES, "colors", ...INPUT_CALL_NAMES); + out.push(...UNIT_CALL_NAMES, "colors", ...INPUT_CALL_NAMES, "bounds"); + out.push("textColors", "ui", "on", "reserveLocation", "cover", "tone", "noise", "silence", "mixdown", "together", "fm", "pluck", "__jsx", "__fragment", "__class", "__fn", "__listen"); return out; } diff --git a/compiler/scope.ts b/compiler/scope.ts index fc3c28f..5d9d5f7 100644 --- a/compiler/scope.ts +++ b/compiler/scope.ts @@ -30,7 +30,7 @@ export type Binding = * array with a cell for every id there is. `values` is absent for a Set; `present` says which keys were set (absent for * a Record, whose every key reads its value or 0); `size` counts them. `as` is how the source reaches it. */ - | { kind: "keyed"; as: "record" | "map" | "set"; name: string; domain: number; key: string; values?: ArrayDecl; present?: ArrayDecl; size?: VarDecl } + | { kind: "keyed"; as: "record" | "map" | "set"; name: string; domain: number; key: string; values?: ArrayDecl; present?: ArrayDecl; size?: VarDecl; /** Keys that are texts of a union known when the script is built (`Map<"red" | "blue", number>`): each the cell at its place here. */ literals?: readonly string[] } /** * A `Map` or a `Set` over any number: the entries in the order they went in — `keys`, `values` (a Map's), `live` (false * once deleted) — and `slots`, the table a key is found through: a power of two of cells, each 0 or an entry's place @@ -61,7 +61,27 @@ export type Binding = * `cls`: the record is an instance — `new Squad()` — and this is its class, known when the script is built, which is what a * method call, a getter, `instanceof` and an overridden method are settled by. */ - | { kind: "record"; fields: Map; truth?: VarDecl; cls?: TS.ClassDeclaration }; + | { kind: "record"; fields: Map; truth?: VarDecl; cls?: TS.ClassDeclaration; /** An instance of a generic class: what each of its type parameters is (`new Signal()`). */ typeArgs?: TypeArgs; /** A reference (B9): the row of the object table it is, and the classes it may be an instance of — what a method called on it asks among. */ ref?: { place: NumExpr; classes: readonly TS.ClassDeclaration[] } } + /** A function known when the script is built (TrigScript 4, B3): a call of it is a call of its body, inlined or called as any function is. */ + | { kind: "fn"; fn: FnValue } + /** `number | undefined`, `boolean | undefined` (B9): the value, a variable or a cell, and beside it whether there is one. */ + | { kind: "maybe"; value: Binding; there: Binding }; + +/** + * A function as a value: where it is written, and the scope it closes over — what the names in its body are bound to + * where it was written. `body` is the file's walk the function belongs to (`structured.ts#Body`), typed loosely here. + */ +export interface FnValue { + decl: TS.FunctionDeclaration | TS.ArrowFunction | TS.FunctionExpression; + parameters: readonly TS.ParameterDeclaration[]; + body: TS.Block | TS.Expression; + closure: Scope | null; + target: unknown; + name?: string; +} + +/** What each type parameter of a generic class or function stands for, in one instance or one call. */ +export type TypeArgs = Map; /** What `this` is bound under in the scope of a method's body: no declaration of the source stands for it. */ export const THIS = { kind: -1 } as unknown as TS.Node; @@ -87,19 +107,49 @@ export type RowField = /** A text: `addr`, `block`, `chars` — where it is, the block of the heap it owns (0 for one of the map's table), its length. The row owns the block. */ | { kind: "text" } /** A record, or an instance, inside the row: its fields' columns under its name. */ - | { kind: "record"; shape: RowShape; cls?: TS.ClassDeclaration }; + | { kind: "record"; shape: RowShape; cls?: TS.ClassDeclaration } + /** A number or a boolean that may be undefined (B9): its column, and `there`, whether it holds one. */ + | { kind: "maybe"; of: "number" | "boolean"; width: { bits?: 8 | 16; unsigned?: boolean } }; /** The parts of a unit, and of an array's handle, in the order their columns are kept. */ export const UNIT_PARTS = ["ptr", "epd", "uid"] as const; export const HANDLE_PARTS = ["block", "length", "room", "size"] as const; export const TEXT_PARTS = ["addr", "block", "chars"] as const; +let scopes = 0; + export class Scope { private readonly map = new Map(); readonly parent: Scope | null; - constructor(parent: Scope | null) { this.parent = parent; } - bind(decl: TS.Node, b: Binding) { this.map.set(decl, b); } + /** In the order scopes were made: one made after another began is inside what that one's walk made. */ + readonly serial = ++scopes; + /** + * Told of every name found outside the scope it was looked up in (B3): a function kept as a value is walked with one + * watching, which is how a variable it may not keep is found. + */ + static watch: ((b: Binding, found: Scope, decl: TS.Node) => void) | null = null; + /** + * Closures over locals (TrigScript 4): a variable a kept function uses is kept in an environment on the heap, not a + * variable. `boxer` is told of every binding and may give another in its place (the variable's cell of the + * environment); `resolver` is told of every one found outside the scope it was looked up in and may give the same + * cell reached from there (through the environment a kept function was handed); `created` is told of every new scope. + */ + static boxer: ((scope: Scope, decl: TS.Node, b: Binding) => Binding | undefined) | null = null; + static resolver: ((b: Binding, found: Scope, from: Scope) => Binding) | null = null; + static created: ((scope: Scope) => void) | null = null; + constructor(parent: Scope | null) { this.parent = parent; Scope.created?.(this); } + bind(decl: TS.Node, b: Binding) { this.map.set(decl, Scope.boxer?.(this, decl, b) ?? b); } lookup(decl: TS.Node): Binding | undefined { - return this.map.get(decl) ?? this.parent?.lookup(decl); + for (let s: Scope | null = this; s; s = s.parent) { + const b = s.map.get(decl); + if (b) { + if (s === this) return b; + Scope.watch?.(b, s, decl); + return Scope.resolver ? Scope.resolver(b, s, this) : b; + } + } + return undefined; } + /** Every binding of this scope itself, not its parents'. */ + own(): IterableIterator { return this.map.values(); } } diff --git a/compiler/simulateIr.ts b/compiler/simulateIr.ts index 7903d6c..509a8cb 100644 --- a/compiler/simulateIr.ts +++ b/compiler/simulateIr.ts @@ -1,7 +1,10 @@ /** * The program interpreter: runs `program()` bodies from the IR, one player, frame by * frame. It is what Simulate shows and what the tests prove programs with; the model is - * the Python lowering's (`python/trigscript.py`), which is what the game runs. + * the backend's (`compiler/backend/`, the map of our own), which is what the game runs — and was + * the Python lowering's (`python/trigscript.py`), which laid the same programs out through eudplib + * until TrigScript 4's step 17; the gate before it (step 16, `tests/random-scripts.test.ts`) holds + * the two to the same lines. * * A program is a coroutine the game resumes every frame. Its body runs until it sleeps or * ends: loops run to completion within the frame, only `sleep` gives the frame back @@ -34,14 +37,20 @@ * the next frame finds. A key, a click and a typed line last that one frame, as they do in the * game; the mouse stays where it was put. */ +import { fixedAtan2, fixedDiv, fixedMul, fixedSin, fixedSqrt, fixedText } from "./fixedMath"; import { ActionType, Comparison, ConditionType, SetModifier, SWITCH_COUNT, SwitchAction, SwitchState, type ActionRecord, type ConditionRecord } from "../vendor/triggers"; import { emptyAction, ResourceType } from "../vendor/triggers"; -import { HEAP_CELLS, HEAP_SMALLEST, STACK_DEPTH, TEXT_BYTES, TEXT_FIELD_BYTES, heapCells, isTextExpr, stackDepth } from "./ir"; -import type { TextExpr, ArrayDecl, At, BoolExpr, Call, NumExpr, Program, ReadSource, Stmt, TableCell, TextPart, UnitExpr, UnitFilter, UnitNumField, UnitVerb, VarDecl } from "./ir"; +import { ORDER_OF_VERB } from "./orders"; +import { BUILD_BYTES, ERROR_CODES, HEAP_CELLS, HEAP_SMALLEST, STACK_DEPTH, STACK_OVERFLOW_MESSAGE, TEXT_BYTES, TEXT_FIELD_BYTES, UNIT_FLAGS, eachStart, heapCells, isTextExpr, programDeclarations, stackDepth } from "./ir"; +import type { TextExpr, ArrayDecl, At, BoolExpr, Call, EventSource, NumExpr, Program, ReadSource, Stmt, TableCell, TextPart, UnitExpr, UnitFilter, UnitNumField, UnitVerb, VarDecl } from "./ir"; import { cellMax } from "./tables"; import { inputsOf, keyName, matchChat, parseChatPattern, type ChatPattern, type InputSource, type MouseButton } from "./input"; import { Simulation, type SimulationOptions } from "./simulate"; import { TABLE_FIELDS } from "./tables"; +import { FOG_SHAPES } from "./fogShapes"; + +/** The tiles across and down the simulator's fog keeps: the largest map's. */ +const FOG_SIZE = 256; import type { SimBounds, SimUnit, SimUnitInit, World } from "./world"; export type { SimBounds, SimUnit, SimUnitInit, SimUnitProperties } from "./world"; @@ -50,6 +59,11 @@ export const FRAMES_PER_SECOND = 24; /** What Elapsed Time and the countdown count: a game second is sixteen frames (the slice 2 probe: 21 after 336 frames), not the twenty-four of a second at Fastest. */ export const FRAMES_PER_GAME_SECOND = 16; const U32 = 0x1_0000_0000; +/** Frames with no message before a held key counts as let go; the backend's input has the same number (`compiler/backend/input.ts`). */ +const HELD_GRACE = 2; + +/** The code of the game's texts nearest each player's colour, by slot, when the map does not change them: red, blue, teal, purple, orange, brown, white, yellow. */ +const SLOT_COLOR_CODES = [8, 14, 15, 16, 17, 21, 22, 23]; export interface ProgramEvent { /** 0-based run of the programs. */ @@ -66,7 +80,7 @@ export interface ProgramEvent { } /** The most a field of a unit holds: a byte for the timers, the cooldown and the kills, 255 points of energy, a word of resources. */ -const UNIT_FIELD_MAX: Partial> = { hp: 0xff_ffff, shields: 0xff_ffff, energy: 255, kills: 255, cooldown: 255, resources: 0xffff, stim: 255, ensnare: 255, plague: 255, lockdown: 255, maelstrom: 255, irradiate: 255, stasis: 255 }; +const UNIT_FIELD_MAX: Partial> = { hp: 0xff_ffff, shields: 0xff_ffff, energy: 255, kills: 255, cooldown: 255, resources: 0xffff, stim: 255, ensnare: 255, plague: 255, lockdown: 255, maelstrom: 255, irradiate: 255, stasis: 255, buildTime: 0xffff, rank: 255, matrix: 255 }; export interface ProgramSimulationOptions extends Pick { /** The trigger interpreter holding the world; one is made when absent. */ world?: Simulation; @@ -82,6 +96,8 @@ export interface ProgramSimulationOptions extends Pick string; + /** The code of the game's texts a player's colour is (8 red, 14 blue, …); default the colour of the player's slot when a map does not change it. */ + playerColor?: (player: number) => number; /** The units on the map, in the order of the game's unit table. With a `world` of the caller's they are added to it. */ units?: SimUnitInit[]; /** The map's locations as boxes, by their 1-based number. A location the simulation does not know holds every unit. */ @@ -121,6 +137,23 @@ interface Ctx { class Halt extends Error {} /** The program stops for good, as the game stops it: its stack ran out. Said as a fault where it happened. */ class Stopped extends Error {} +/** An error of the program's is in flight (`Program.errors` holds it), on its way to the nearest `try`. */ +class Thrown extends Error {} + +/** A slot of a `start`: the task in it (none: free), its generation, the frame it last ran in, and its own cells. */ +interface TaskSlot { + exec: Exec | null; + apart: Exec[]; + gen: number; + ran: number; + vars: Map; + units: Map; + texts: Map; + arrays: Map; + latches: Map; +} +/** A `start` statement's tasks: its slots, and the ids of the variables and arrays a slot has a copy of. */ +interface TaskPool { start: Extract; vars: string[]; arrays: ArrayDecl[]; slots: TaskSlot[]; said: boolean } /** One program's run: its variables, its latches, and the coroutine that is its body. */ /** What a recursive function holds, kept for the length of a call that may come back into it. */ @@ -197,7 +230,10 @@ class ProgramRun { readonly bits = new Map(); /** The `u32` variables. */ readonly unsigned = new Set(); - readonly latches = new Map(); + /** Every latch's state; a task has its own, swapped in while it runs. */ + latches = new Map(); + /** The units of on.died() turns: read as they died, though they are gone. */ + readonly asDied = new Set(); /** The program's arrays: their cells, as a variable's value is kept. */ readonly arrays: Map = new (class extends Map { run!: ProgramRun; @@ -210,15 +246,28 @@ class ProgramRun { /** How many calls deep the program is in functions that call themselves: frames on the stack. Nothing is on it between frames. */ depth = 0; /** The bodies being run apart from the one that called them, innermost last (`Apart`). */ - private readonly apart: Exec[] = []; + private apart: Exec[] = []; + /** The number of the task whose turn it is (`taskHandle`); 0 in the program's own turn. */ + private taskNow = 0; + /** Where the error in flight was thrown: what an uncaught one is said at. */ + private thrownAt: At | undefined; + /** The `start` statements' tasks, in order of their numbers. */ + private readonly pools: TaskPool[] = []; + /** Frames the tasks have run, for a slot to tell whether it has had its turn in this one. */ + private taskFrame = 0; + /** A task was started during this frame's turn of the tasks: they are gone through again, for it to start in this frame too. */ + private again = false; readonly sim: ProgramSimulation; readonly index: number; readonly program: Program; /** The player this run is: who Current Player is while it runs. */ readonly player: number; private readonly common: Common; + /** The variables the closures count (`VarDecl.held`): read by their own declare, 0 before it. */ + private readonly held: ReadonlySet; constructor(sim: ProgramSimulation, index: number, program: Program, player: number, common: Common = newCommon()) { + this.held = new Set(programDeclarations(program).filter((d) => d.held).map((d) => d.id)); this.sim = sim; this.index = index; this.program = program; @@ -235,6 +284,14 @@ class ProgramRun { this.arrays.set(decl.id, held); } this.body = this.run(); + const decls = programDeclarations(this.program); + const starts: Extract[] = []; + eachStart([program.body, program.functions ?? []], (st) => starts.push(st)); + starts.sort((a, b) => a.task - b.task); + for (const start of starts) { + const slots = Array.from({ length: start.max }, (): TaskSlot => ({ exec: null, apart: [], gen: 0, ran: 0, vars: new Map(), units: new Map(), texts: new Map(), arrays: new Map(), latches: new Map() })); + this.pools.push({ start, vars: decls.filter((d) => d.task === start.task).map((d) => d.id), arrays: (program.arrays ?? []).filter((a) => a.task === start.task), slots, said: false }); + } } private *run(): Exec { @@ -243,27 +300,109 @@ class ProgramRun { return "next"; } - /** One frame: resume the body until it gives the frame back or ends. */ + /** One frame: resume the body until it gives the frame back or ends, then each task that is running. */ tick(): void { - if (this.done || !this.body) return; this.steps = 0; + if (!this.done && this.body) { + try { + if (this.drive(this.body)) { this.done = true; this.body = null; } + } catch (err) { + this.apart.length = 0; + if (!(err instanceof Stopped)) throw err; + this.done = true; + this.body = null; + this.depth = 0; + } + } + this.runTasks(); + } + + /** Whether a task of the program is still running. */ + get tasksRunning(): boolean { return this.pools.some((p) => p.slots.some((s) => s.exec)); } + + /** + * The tasks' turn, after the program's: each that is running goes on until it gives the frame back or ends, in order of + * their start's number and then of their slot. A task started meanwhile has its first turn in this frame too. + */ + private runTasks(): void { + if (!this.pools.length) return; + this.taskFrame++; + do { + this.again = false; + for (const pool of this.pools) { + for (const slot of pool.slots) { + if (!slot.exec || slot.ran === this.taskFrame) continue; + slot.ran = this.taskFrame; + this.runSlot(pool, slot); + } + } + } while (this.again); + } + + /** A task's turn: its cells in place of the slot's, its body driven as the program's is. */ + private runSlot(pool: TaskPool, slot: TaskSlot): void { + const exec = slot.exec!; + const outer = this.apart, latches = this.latches; + this.taskNow = pool.slots.indexOf(slot) | (pool.start.task << 8) | (slot.gen << 16); + this.swap(pool, slot, true); + this.apart = slot.apart; + this.latches = slot.latches; try { - if (this.drive()) { this.done = true; this.body = null; } + if (this.drive(exec)) { slot.exec = null; slot.apart.length = 0; } } catch (err) { - this.apart.length = 0; + slot.apart.length = 0; if (!(err instanceof Stopped)) throw err; - this.done = true; - this.body = null; + slot.exec = null; this.depth = 0; + } finally { + this.taskNow = 0; + this.apart = outer; + this.latches = latches; + this.swap(pool, slot, false); } } + /** A slot's cells into the run's (`into`), or back out of them: between turns nothing of a task is among the run's. */ + private swap(pool: TaskPool, slot: TaskSlot, into: boolean): void { + const move = (run: Map, own: Map, id: string) => { + if (into) { if (own.has(id)) run.set(id, own.get(id)!); else run.delete(id); } + else { if (run.has(id)) own.set(id, run.get(id)!); else own.delete(id); run.delete(id); } + }; + for (const id of pool.vars) { move(this.vars, slot.vars, id); move(this.unitVars, slot.units, id); move(this.textVars, slot.texts, id); } + for (const a of pool.arrays) { + if (into) this.arrays.set(a.id, slot.arrays.get(a.id) ?? this.newHeld(a)); + else { const held = (this.arrays as Map).get(a.id); if (held) slot.arrays.set(a.id, held); } + } + } + + private newHeld(decl: ArrayDecl): Held { + return { decl, room: 0, cells: decl.values ? [...decl.values] : decl.dynamic ? [] : new Array(decl.length).fill(decl.kind === "number" ? 0 : false) }; + } + + /** The pool and slot a task's number names, while that task is in it; undefined for a number that is no running task's. */ + private taskOf(handle: number): { pool: TaskPool; slot: TaskSlot } | undefined { + const pool = this.pools.find((p) => p.start.task === ((handle >>> 8) & 0xff)); + const slot = pool?.slots[handle & 0xff]; + return pool && slot?.exec && slot.gen === handle >>> 16 ? { pool, slot } : undefined; + } + /** The run until it gives the frame back (false) or ends (true): the body, and above it each body that was handed over to be run apart. */ - private drive(): boolean { + private drive(body: Exec): boolean { let send: unknown; + // An error thrown in a body run apart goes on in the body that called it, at the call. + let thrown: Thrown | undefined; for (;;) { - const top = this.apart[this.apart.length - 1] ?? this.body!; - const r = top.next(send); + const top = this.apart[this.apart.length - 1] ?? body; + let r: IteratorResult; + try { + r = thrown ? top.throw(thrown) : top.next(send); + } catch (err) { + if (!(err instanceof Thrown) || !this.apart.length) throw err; + this.apart.pop(); + thrown = err; + continue; + } + thrown = undefined; send = undefined; if (r.done) { if (!this.apart.length) return true; @@ -282,6 +421,7 @@ class ProgramRun { private read(id: string, at?: At): Value { const v = this.vars.get(id); + if (v === undefined && this.held.has(id)) return 0; if (v === undefined) throw new Error(`The variable ${id} was read before it was declared${at ? ` (line ${at.line})` : ""}.`); return v; } @@ -326,7 +466,7 @@ class ProgramRun { } /** - * Room for `cells` in a growing array, as the game finds it (`python/trigscript.py`): a block twice the size from the + * Room for `cells` in a growing array, as the game finds it (`compiler/backend/heap.ts`): a block twice the size from the * heap, the old one given back. False, and said once, when the heap has none left. */ private grow(a: { decl: ArrayDecl; cells: Value[]; room: number }, cells: number, at: At): boolean { @@ -390,12 +530,15 @@ class ProgramRun { // A kept unit is its place in the table, from 1, and the place's uniqueness byte: a unit made since in that place is another. case "unitAt": { const ptr = (yield* this.num(e.ptr)) >>> 0; yield* this.num(e.epd); const uid = (yield* this.num(e.uid)) >>> 0; return ptr >= 1 ? this.sim.game.at(ptr - 1, uid) : null; } case "call": { yield* this.call(e.call); return e.call.result ? this.unitVars.get(e.call.result.decl.id) ?? null : null; } + case "selected": return this.sim.selectedUnit(e.index); } } /** The unit, when there is one and it is still on the map. */ private *living(e: UnitExpr): Gen { const u = yield* this.unit(e); + // The unit of an on.died() turn is read as it died. + if (u && e.kind === "unitVar" && this.asDied.has(e.id)) return u; return u?.alive ? u : null; } @@ -405,16 +548,17 @@ class ProgramRun { const amount = verb.do === "damage" || verb.do === "heal" ? yield* this.amount(verb.amount) : 0; if (!u) return; switch (verb.do) { - case "kill": case "remove": this.sim.game.gone(u, verb.do === "kill"); this.sim.unitEvent(this, verb.do === "kill" ? ActionType.KillUnit : ActionType.RemoveUnit, u, at); break; - case "give": { const to = this.sim.slotOf(verb.to); if (to < 12) u.owner = to; break; } - case "order": this.sim.unitEvent(this, ActionType.Order, u, at, { location: verb.target, text: verb.order }); break; + case "kill": case "remove": this.sim.game.gone(u, verb.do === "kill", undefined, false); this.sim.unitEvent(this, verb.do === "kill" ? ActionType.KillUnit : ActionType.RemoveUnit, u, at); break; + case "give": { const to = this.sim.slotOf(verb.to); if (to < 12) this.sim.game.setOwner(u, to); break; } + // (The unit takes up the order and keeps it here: the simulator moves nothing, so it never arrives.) + case "order": u.orderId = ORDER_OF_VERB[verb.order]; this.sim.unitEvent(this, ActionType.Order, u, at, { location: verb.target, text: verb.order }); break; case "locate": this.sim.centre(verb.location, u.x, u.y); break; case "damage": case "heal": { // In the game's own units, 256 to a point, as the lowering computes it. const step = verb.percent ? Math.floor((u.maxHp * 256 * amount) / 100) : amount * 256; const raw = verb.do === "damage" ? Math.max(0, u.hp * 256 - step) : Math.min(u.maxHp * 256, u.hp * 256 + step); u.hp = Math.ceil(raw / 256); - if (u.hp === 0) { this.sim.game.gone(u, true); this.sim.unitEvent(this, ActionType.KillUnit, u, at); } + if (u.hp === 0) { this.sim.game.gone(u, true, undefined, false); this.sim.unitEvent(this, ActionType.KillUnit, u, at); } break; } } @@ -423,7 +567,7 @@ class ProgramRun { /* ── expressions ── */ /** - * A number, the way the game computes it (`python/trigscript.py`): 32 bits, given here as the + * A number, the way the game computes it (`compiler/backend/emit.ts`): 32 bits, given here as the * signed reading of them (`| 0`). + − × and the bitwise operators are the same bits whichever * way they are read; what reads them one way or the other — ÷, %, a shift right, min and max, a * comparison — says which in the IR. A divisor of 0 gives 0, as `(a / 0) | 0` does. @@ -434,6 +578,7 @@ class ProgramRun { case "var": return Number(this.read(e.id)) | 0; case "element": { const c = yield* this.cell(e.array, e.index, e.at, "it reads 0"); return c ? Number(c.cells[c.i]) | 0 : 0; } case "length": return this.arrays.get(e.array)?.cells.length ?? 0; + case "taskHandle": return this.taskNow; case "pop": return Number(this.arrays.get(e.array)?.cells.pop() ?? 0) | 0; case "unary": return -(yield* this.num(e.expr)) | 0; case "cast": return yield* this.num(e.expr); @@ -470,6 +615,13 @@ class ProgramRun { const args: number[] = []; for (const a of e.args) args.push(yield* this.num(a)); if (e.name === "abs") return Math.abs(args[0]) | 0; + if (e.name === "fmul") return fixedMul(args[0], args[1]); + if (e.name === "fdiv") return fixedDiv(args[0], args[1]); + if (e.name === "sqrt") return fixedSqrt(args[0], !!e.unsigned); + if (e.name === "atan2" || e.name === "sin" || e.name === "cos") { + const table = (this.arrays.get(e.table ?? "")?.cells ?? []).map((c) => Number(c) | 0); + return e.name === "atan2" ? fixedAtan2(args[0], args[1], table) : fixedSin(args[0], args[1], table, e.name === "cos"); + } const seen = e.unsigned ? args.map((a) => a >>> 0) : args; return (e.name === "min" ? Math.min(...seen) : Math.max(...seen)) | 0; } @@ -491,7 +643,7 @@ class ProgramRun { /* ── texts ── */ /** - * A text that was just made, as the game keeps it (`python/trigscript.py`): past `TEXT_BYTES` it is cut, and its + * A text that was just made, as the game keeps it (`compiler/backend/texts.ts`): past `TEXT_BYTES` it is cut, and its * bytes go into a block of the heap. When the heap has none, the text is empty — and both are said. */ private made(s: string, at: At, cameTo = bytesOf(s)): TextValue { @@ -522,7 +674,9 @@ class ProgramRun { private *parts(parts: TextPart[], used: TextValue[]): Gen { let out = ""; for (const p of parts) { - if (p.kind === "number") out += String(p.unsigned ? yield* this.amount(p.expr) : yield* this.num(p.expr)); + if (p.kind === "number") out += p.fixed ? fixedText(yield* this.num(p.expr)) : String(p.unsigned ? yield* this.amount(p.expr) : yield* this.num(p.expr)); + // A colour code is a character of the text, as in the game: it counts in the length, and the log shows it. + else if (p.kind === "char") { const code = (yield* this.num(p.expr)) & 0xff; if (code) out += String.fromCharCode(code); } else if (p.kind === "value") { const v = yield* this.text(p.text); used.push(v); out += v.s; } else out += this.sim.partText(p); } @@ -585,6 +739,7 @@ class ProgramRun { this.used(of); return out; } + case "built": return { s: this.sim.buffers.get(e.buffer) ?? "", room: 0, taken: false }; case "textCall": { yield* this.call(e.call); const held = e.call.result ? this.textVars.get(e.call.result.decl.id) : undefined; @@ -671,6 +826,7 @@ class ProgramRun { return false; } case "ternary": return (yield* this.bool(e.cond)) ? yield* this.bool(e.whenTrue) : yield* this.bool(e.whenFalse); + case "running": return !!this.taskOf((yield* this.num(e.task)) >>> 0); case "call": return Boolean(yield* this.call(e.call)); case "textCompare": { const a = yield* this.text(e.left); @@ -699,6 +855,14 @@ class ProgramRun { else this.store(decl.id, yield* this.init(e as NumExpr | BoolExpr, decl.kind)); } + /** A task's body, as its slot runs it: what it returns into is the task's own, and nothing reads it. */ + private *taskBody(c: Call): Exec { + if (c.result) this.declare(c.result.decl); + const flow = yield* this.block(c.body, { fn: { result: c.result?.decl } }); + if (flow === "break" || flow === "continue") throw new Error(`${flow} inside a task reached its end (line ${c.at.line}).`); + return "next"; + } + private *call(c: Call): Gen { if (c.result) this.declare(c.result.decl); const fn = c.fn ? this.program.functions?.find((f) => f.id === c.fn) : undefined; @@ -723,7 +887,14 @@ class ProgramRun { } } const ctx: Ctx = { fn: { result: c.result?.decl } }; - const flow = kept && fn ? ((yield { run: this.block(fn.body, ctx) }) as Flow) : yield* this.block(fn ? fn.body : c.body, ctx); + let flow: Flow; + try { + flow = kept && fn ? ((yield { run: this.block(fn.body, ctx) }) as Flow) : yield* this.block(fn ? fn.body : c.body, ctx); + } catch (err) { + // The error goes on out of the call; what the function holds comes back first, as it does when the function returns. + if (kept && err instanceof Thrown) this.bringBack(kept); + throw err; + } if (flow === "break" || flow === "continue") throw new Error(`${flow} inside a function reached its end (line ${c.at.line}).`); if (kept) this.bringBack(kept); return c.result && c.result.kind !== "unit" && c.result.kind !== "text" ? this.read(c.result.decl.id) : 0; @@ -731,11 +902,20 @@ class ProgramRun { /** * A call that may come back into the function it is in: what that function holds goes on the stack, as the game - * keeps it (`python/trigscript.py`) — its variables, and the handle of each array declared in it, which is "no block" + * keeps it (`compiler/backend/emit.ts`, `Emitter.keep`) — its variables, and the handle of each array declared in it, which is "no block" * for the length of the call. Past the depth the map allows the program stops, and says where. */ private keep(c: Call): Frame { const saves = c.saves!; + if (this.depth >= this.sim.stackDepth && this.program.errors) { + // A RangeError, as JavaScript's: where the call is, before anything of it is kept. + this.store(this.program.errors.thrown, ERROR_CODES.RangeError); + const old = this.textVars.get(this.program.errors.message); + if (old?.room) this.sim.heap.give(old.room); + this.textVars.set(this.program.errors.message, { s: STACK_OVERFLOW_MESSAGE, room: 0 }); + this.thrownAt = c.at; + throw new Thrown(); + } if (this.depth >= this.sim.stackDepth) { this.sim.faults.push({ cycle: this.sim.cycle, frame: this.sim.frame, program: this.index, at: c.at, message: `Stack overflow in ${saves.within}: ${this.sim.stackDepth.toLocaleString("en-US")} calls deep, which is as deep as the script's settings allow. The program has stopped, as it does in the game.` }); throw new Stopped(); @@ -819,23 +999,34 @@ class ProgramRun { // The text as it is when the loop starts: a copy of a variable's, so that assigning the variable inside changes nothing here. const over = this.owned(yield* this.text(s.of), s.at); let flow: Flow = "next"; - for (const ch of over.s) { - const old = this.textVars.get(s.decl.id); - const turn = this.made(ch, s.at); - if (old?.room) this.sim.heap.give(old.room); - this.textVars.set(s.decl.id, { s: turn.s, room: turn.room }); - flow = yield* this.block(s.body, ctx); - if (flow === "break" || flow === "return") break; + try { + for (const ch of over.s) { + const old = this.textVars.get(s.decl.id); + const turn = this.made(ch, s.at); + if (old?.room) this.sim.heap.give(old.room); + this.textVars.set(s.decl.id, { s: turn.s, room: turn.room }); + flow = yield* this.block(s.body, ctx); + if (flow === "break" || flow === "return") break; + } + } catch (err) { + // An error out of the body: the text the loop holds goes back on its way, as the game gives it back. + if (err instanceof Thrown && over.room) this.sim.heap.give(over.room); + throw err; } if (over.room) this.sim.heap.give(over.room); return flow === "return" ? flow : "next"; } case "unitLoop": { this.declare(s.decl); - // The units as they are when the loop starts, in table order; one that dies on the way is passed over. - for (const u of this.sim.matching(s.filter)) { - if (!u.alive) continue; + // The units as they are when the loop starts, in table order; one that dies on the way is passed over. Those of + // the frame's events pass (`on.created` / `on.died`): the ones that came on, and the ones that died, read as they died. + const dead = s.source === "died" || s.source === "killed"; + if (dead) this.asDied.add(s.decl.id); + if (s.was) this.declare(s.was); + for (const { u, was } of s.source ? this.sim.eventUnits(s.source, s.filter, s.event) : this.sim.matching(s.filter).map((u) => ({ u, was: 0 }))) { + if (!dead && !u.alive) continue; this.unitVars.set(s.decl.id, u); + if (s.was) this.store(s.was.id, was); const flow = yield* this.block(s.body, ctx); if (flow === "break") break; if (flow === "return") return flow; @@ -845,11 +1036,11 @@ class ProgramRun { case "unitWrite": { const u = yield* this.living(s.unit); const field = s.field; - if (field === "invincible") { const on = yield* this.bool(s.value as BoolExpr); if (u) u.invincible = on; return "next"; } + if ((UNIT_FLAGS as readonly string[]).includes(field)) { const on = yield* this.bool(s.value as BoolExpr); if (u) (u as unknown as Record)[field] = on; return "next"; } const value = yield* this.amount(s.value as NumExpr); if (!u) return "next"; (u as unknown as Record)[field] = Math.min(value, UNIT_FIELD_MAX[field as UnitNumField] ?? 0xffff_ffff); - if (field === "hp" && u.hp === 0) { this.sim.game.gone(u, true); this.sim.unitEvent(this, ActionType.KillUnit, u, s.at); } + if (field === "hp" && u.hp === 0) { this.sim.game.gone(u, true, undefined, false); this.sim.unitEvent(this, ActionType.KillUnit, u, s.at); } return "next"; } case "unitDo": yield* this.unitDo(s.unit, s.verb, s.at); return "next"; @@ -977,6 +1168,32 @@ class ProgramRun { for (let i = 0; i < n; i++) yield; return "next"; } + case "parallel": { + // Each branch a coroutine of its own: started here in order, then once a frame each one still running goes on, in + // order. A body run apart (a recursive call) inside a branch is handed up to the run's loop and its outcome back. + const branches = s.branches.map((b) => this.block(b, {})); + const done = branches.map(() => false); + for (;;) { + for (let i = 0; i < branches.length; i++) { + if (done[i]) continue; + let send: unknown; + // An error from a body the branch had run apart goes on inside the branch, which may catch it. + let thrown: Thrown | undefined; + for (;;) { + const r = thrown ? branches[i].throw(thrown) : branches[i].next(send); + send = undefined; + thrown = undefined; + if (r.done) { done[i] = true; break; } + if (r.value === undefined) break; + try { send = yield r.value; } catch (err) { if (!(err instanceof Thrown)) throw err; thrown = err; } + } + // The first to end wins a race; the others stop where they are. + if (done[i] && s.mode === "race") return "next"; + } + if (done.every(Boolean)) return "next"; + yield; + } + } case "action": { const record: ActionRecord = { ...s.record }; // A unit count is that many units (the game does the action once for each), whatever a byte could hold. @@ -992,10 +1209,108 @@ class ProgramRun { this.sim.print(this, text, s.to, s.at); return "next"; } - case "centerLocation": { const x = yield* this.amount(s.x); const y = yield* this.amount(s.y); this.sim.centre(s.location, x, y); return "next"; } + case "buildText": { + const used: TextValue[] = []; + const more = yield* this.parts(s.parts, used); + this.used(...used); + const was = s.reset ? "" : this.sim.buffers.get(s.buffer) ?? ""; + let text = was + more; + // Past what a buffer holds the text is cut, as it is in the game, and said once a buffer. + if (bytesOf(text) > BUILD_BYTES) { + if (bytesOf(was) <= BUILD_BYTES) this.sim.faults.push({ cycle: this.sim.cycle, frame: this.sim.frame, program: this.index, at: s.at, message: `This text came to more than the ${BUILD_BYTES.toLocaleString("en-US")} bytes a text put together piece by piece holds: it is cut off there, as it is in the game.` }); + text = cutTo(text, BUILD_BYTES); + } + this.sim.buffers.set(s.buffer, text); + return "next"; + } + case "select": { + const units: SimUnit[] = []; + for (const u of s.units) { const got = yield* this.unit(u); if (got?.alive && units.length < 12) units.push(got); } + this.sim.selectUnits(units, this.sim.slotOf(s.player)); + return "next"; + } + case "syncSend": { + this.sim.readAt(s.player); + let value: number; + try { value = yield* this.num(s.value); } finally { this.sim.readAt(null); } + this.sim.syncSend(s.slot, s.player, value); + return "next"; + } + case "centerLocation": { + const x = yield* this.amount(s.x); const y = yield* this.amount(s.y); + const size = s.size ? { width: yield* this.amount(s.size.width), height: yield* this.amount(s.size.height) } : undefined; + this.sim.centre(s.location, x, y, size); + return "next"; + } + case "start": { + const pool = this.pools.find((p) => p.start === s); + if (!pool) throw new Error(`The start of task ${s.task} is not one of the program's (line ${s.at.line}).`); + // The arguments as they are now, before any parameter is set. + const values: (Value | SimUnit | HeldText | null)[] = []; + for (const p of s.call.params) values.push(p.decl.kind === "unit" ? yield* this.unit(p.init as UnitExpr) : p.decl.kind === "text" ? this.owned(yield* this.text(p.init as TextExpr), s.at) : this.kept(yield* this.init(p.init as NumExpr | BoolExpr, p.decl.kind), p.decl)); + const i = pool.slots.findIndex((slot) => !slot.exec); + if (i < 0) { + for (const v of values) if (v && typeof v === "object" && "room" in v && v.room) this.sim.heap.give(v.room); + if (!pool.said) this.sim.faults.push({ cycle: this.sim.cycle, frame: this.sim.frame, program: this.index, at: s.at, message: `${s.call.name ?? "The task"} did not start: all ${s.max} of this start's tasks are running. start(…, { max: … }) lets more run at once.` }); + pool.said = true; + if (s.handle) this.store(s.handle, 0); + return "next"; + } + const slot = pool.slots[i]; + slot.gen = slot.gen >= 0xffff ? 1 : slot.gen + 1; + s.call.params.forEach((p, k) => { + this.declare(p.decl); + if (p.decl.kind === "unit") slot.units.set(p.decl.id, values[k] as SimUnit | null); + else if (p.decl.kind === "text") { const old = slot.texts.get(p.decl.id); if (old?.room) this.sim.heap.give(old.room); slot.texts.set(p.decl.id, values[k] as HeldText); } + else slot.vars.set(p.decl.id, values[k] as Value); + // (declare() put the parameter among the run's own: it is the slot's.) + this.vars.delete(p.decl.id); + this.unitVars.delete(p.decl.id); + this.textVars.delete(p.decl.id); + }); + slot.exec = this.taskBody(s.call); + slot.apart = []; + slot.ran = 0; + this.again = true; + if (s.handle) this.store(s.handle, i | (s.task << 8) | (slot.gen << 16)); + return "next"; + } + case "cancel": { + const t = this.taskOf((yield* this.num(s.task)) >>> 0); + if (t) { t.slot.exec = null; t.slot.apart.length = 0; } + return "next"; + } case "call": { yield* this.call(s.call); return "next"; } case "block": return yield* this.block(s.body, ctx); + case "try": { + try { + return yield* this.block(s.body, ctx); + } catch (err) { + if (!(err instanceof Thrown)) throw err; + return yield* this.block(s.catch, ctx); + } + } + case "throw": { + if (!s.rethrow) this.thrownAt = s.at; + throw new Thrown(); + } + case "uncaught": { + const errors = this.program.errors; + if (!errors) throw new Error(`An uncaught error, but the program keeps none (line ${s.at.line}).`); + const code = this.read(errors.thrown) as number; + const names = this.arrays.get(errors.names); + const name = names?.decl.texts?.[names.cells[code] as number] || "Error"; + const message = this.textVars.get(errors.message)?.s ?? ""; + const at = this.thrownAt ?? s.at; + this.sim.faults.push({ cycle: this.sim.cycle, frame: this.sim.frame, program: this.index, at, message: `Uncaught ${name}${message ? `: ${message}` : ""}` }); + // The game says it in red to the player it ran as (`compiler/backend/errors.ts`). + this.sim.print(this, `\x06TrigScript: uncaught ${name}: ${message}, line ${at.line}.`, 13, s.at); + this.store(errors.thrown, 0); + return "next"; + } case "remark": return "next"; + // (A map of our own's own statements, which only its backend makes and never hands the simulator.) + case "textBlock": case "showText": case "watch": throw new Error(`the simulator does not run a ${s.kind}`); } } @@ -1066,6 +1381,8 @@ export class ProgramSimulation { /** The arrays that grow inside others, by the number their handle's cell holds. */ readonly inner = new Map(); lastInner = 0; + /** What each buffer of `buildText` holds: one for the whole game, as in the game. */ + readonly buffers = new Map(); readonly heap = { cells: HEAP_CELLS, top: 1, @@ -1087,6 +1404,7 @@ export class ProgramSimulation { private readonly conditionOf?: SimulationOptions["condition"]; private readonly readOf?: ProgramSimulationOptions["read"]; private readonly nameOf: (player: number) => string; + private readonly colorOf: (player: number) => number; /** The units, the locations and who the players are: the trigger interpreter's, so both see one game. */ readonly game: World; /** What the programs wrote into the game's tables, as the cell stores it; a name as its text. */ @@ -1097,11 +1415,22 @@ export class ProgramSimulation { /** The typed-line patterns of all the programs, in the order the game tries them: the first that matches a line is the one that line is. */ private readonly chats: ChatPattern[] = []; private readonly unitByName: (lower: string) => number | undefined; - /** What the players did, for the next frame and for the one running: "key:0:F2", "click:0:left", and a typed line as the pattern it matched with its values. */ + /** What the players did, for the next frame and for the one running: "key:0:F7", "click:0:left", and a typed line as the pattern it matched with its values. */ private queued: { events: Set; lines: Map } = { events: new Set(), lines: new Map() }; private current = this.queued; + /** The keys and buttons down on the last frame, and on this one (what keyHeld() finds). */ + private down = new Set(); + private downNow = new Set(); + private readonly lastDown = new Map(); + /** Whether a program reads a held key: then a press is worked out from the held keys, as the game does. */ + private heldKeys = false; /** Where each player's mouse is, in map pixels. */ readonly mice = new Map(); + /** Where each player's screen is on the map, its top left corner in pixels (`local.camera`; B5). */ + readonly cameras = new Map(); + /** sync() (B5): what arrived this frame, "slot:player" → the value + 1; and what was sent, arriving at the next frame. */ + private readonly synced = new Map(); + private sending: [key: string, value: number][] = []; cycle = 0; /** The world is the caller's, run on the game's schedule: the programs run in its passes, and its frame is the game's. */ private readonly shared: boolean; @@ -1130,6 +1459,7 @@ export class ProgramSimulation { this.conditionOf = options.condition; this.readOf = options.read; this.nameOf = options.playerName ?? ((p) => `Player ${p + 1}`); + this.colorOf = options.playerColor ?? ((p) => SLOT_COLOR_CODES[p] ?? SLOT_COLOR_CODES[7]); this.tableOf = options.table; // A unit is made with what its type has now: what a program wrote into the game's tables, else what the caller says. const cellOf = (name: string, type: number): TableCell => { const f = TABLE_FIELDS.unit.find((x) => x.name === name)!; return { name: `unit.${name}`, base: f.base, stride: f.stride, index: type, width: f.width, ...(f.scale ? { scale: f.scale } : {}) }; }; @@ -1140,6 +1470,7 @@ export class ProgramSimulation { }; this.unitByName = options.unitByName ?? (() => undefined); for (const s of inputsOf(programs).sources) if (s.source === "chat" && !this.chats.some((c) => c.pattern === s.pattern)) this.chats.push(parseChatPattern(s.pattern)); + this.heldKeys = inputsOf(programs).sources.some((s) => s.source === "key" && s.held); this.current = { events: new Set(), lines: new Map() }; // A program runs once a frame for each of its owners who is in the game, each run with its own variables; one that // is not per player runs as the first of them. Without the map's player settings there is the one player and one run. @@ -1231,6 +1562,52 @@ export class ProgramSimulation { press(key: string, player = this.player): this { this.queued.events.add(`key:${player}:${keyName(key) ?? key}`); return this; } click(button: MouseButton = "left", player = this.player): this { this.queued.events.add(`click:${player}:${button}`); return this; } moveMouse(x: number, y: number, player = this.player): this { this.mice.set(player, { x, y }); return this; } + /** What each player has selected (B5): \`local.selected\`, \`local.selectionCount\`; select() and the tests set it. */ + readonly selections = new Map(); + /** A player selects units: what \`local.selected\` reads at their computer, and what select() makes it. */ + selectUnits(units: readonly SimUnit[], player = this.player): this { this.selections.set(player, units.slice(0, 12)); return this; } + /** The unit in a slot of the selection at the computer a program reads (\`localRead\`'s), if it is still there. */ + selectedUnit(index: number): SimUnit | null { + const u = this.selections.get(this.sendingFrom ?? this.slotOf(13))?.[index]; + return u?.alive ? u : null; + } + /** A player's screen moves: its top left corner to this place on the map, in pixels (what \`local.camera\` reads). */ + moveCamera(x: number, y: number, player = this.player): this { this.cameras.set(player, { x, y }); return this; } + + /** + * \`local.…\` (B5): what this computer knows. The simulator runs every computer's programs at once, so a program reads + * the computer of the player it runs for, and a sync()'s value is read at the computer of the player it is sent from + * (\`sendingFrom\`). + */ + private sendingFrom: number | null = null; + private localRead(name: string): number | undefined { + const at = this.sendingFrom ?? this.slotOf(13); + const camera = this.cameras.get(at) ?? { x: 0, y: 0 }; + const mouse = this.mice.get(at) ?? camera; + switch (name) { + case "local.player": return at; + case "local.camera.x": return camera.x; + case "local.camera.y": return camera.y; + case "local.mouse.x": return mouse.x - camera.x; + case "local.mouse.y": return mouse.y - camera.y; + default: { + // A selection slot: the unit's place in the table, from 1, as a kept unit's number is here; 0 for none. + const slot = /^local\.selection\[(\d+)\]$/.exec(name); + if (!slot) return undefined; + const u = this.selections.get(at)?.[Number(slot[1])]; + return u?.alive ? u.slot + 1 : 0; + } + } + } + + /** While a sync()'s value is worked out: whose computer's local values it reads. */ + readAt(player: number | null): void { this.sendingFrom = player === null ? null : this.slotOf(player); } + /** sync()'s first half: the slot's arrival cleared everywhere, and the value on its way, arriving at the next frame. */ + syncSend(slot: number, player: number, value: number): void { + const key = `${slot}:${this.slotOf(player)}`; + this.synced.delete(key); + this.sending.push([key, Math.min(Math.max(0, Math.trunc(value)), 1048574) + 1]); + } /** A player sends a line of chat: the next frame finds it, as the first of the programs' patterns it matches — or not at all. */ type(line: string, player = this.player): this { for (const c of this.chats) { @@ -1244,9 +1621,10 @@ export class ProgramSimulation { input(i: InputSource): number { const p = this.slotOf(i.player); switch (i.source) { - case "key": return this.current.events.has(`key:${p}:${i.key}`) ? 1 : 0; + case "key": return (i.held ? this.downNow : this.current.events).has(`key:${p}:${i.key}`) ? 1 : 0; case "click": return this.current.events.has(`click:${p}:${i.button}`) ? 1 : 0; case "mouse": return this.mice.get(p)?.[i.axis] ?? 0; + case "sync": return this.synced.get(`${i.slot}:${p}`) ?? 0; case "chat": { const line = this.current.lines.get(p); if (!line || line.pattern !== i.pattern) return 0; @@ -1257,9 +1635,9 @@ export class ProgramSimulation { /* ── units and tables ── */ - /** The living units a filter matches, in table order. */ + /** The living units a filter matches, in the order a program's loop comes to them (the players' lists: `World.listed`). */ matching(f: UnitFilter): SimUnit[] { - return this.game.matching(f.owner === undefined ? undefined : this.slotsOf(f.owner), f.type, f.at); + return this.game.listed(f.owner === undefined ? undefined : this.slotsOf(f.owner), f.type, f.at); } /** One of the matching units: the first, the nearest to a location's centre by |dx| + |dy| (the first of equals), or one at random. */ @@ -1279,7 +1657,7 @@ export class ProgramSimulation { } /** A location centred on a point, its size kept. */ - centre(location: number, x: number, y: number): void { this.game.centre(location, x, y); } + centre(location: number, x: number, y: number, size?: { width: number; height: number }): void { this.game.centre(location, x, y, size); } private cellKey(c: TableCell): string { return `${c.name}:${c.player ? this.slotOf(c.index) : c.index}${c.key !== undefined ? `:${c.key}` : ""}`; @@ -1287,14 +1665,72 @@ export class ProgramSimulation { /** A cell of the game's tables, in the script's units: what a program wrote, else the caller's answer, else 0. */ tableRead(c: TableCell): number { + // The game's own count of frames, which a program can read and never write (memory.ts). + if (c.name === "memory.game.frame") return this.frame; + // The vision countdown, run down by the frames (visionStep) as much as written by the programs. + if (c.name === "memory.vision.countdown") return this.countdown; + const local = c.name.startsWith("local.") ? this.localRead(c.name) : undefined; + if (local !== undefined) return local; + // A location's edge (bounds()): the location as the simulator holds it, which centerLocation() moves too. + if (c.name.startsWith("location.")) return this.locations.get(c.index + 1)?.[c.name.slice("location.".length) as keyof SimBounds] ?? 0; const stored = this.tables.get(this.cellKey(c)); if (typeof stored === "number") return Math.floor(stored / (c.scale ?? 1)); const given = this.tableOf?.(c); return given === undefined ? 0 : Math.max(0, Math.trunc(given)); } + /** + * The fog of war, as far as the simulator draws it: each player's tiles, 0 never seen, 1 seen before, 2 seen now. A + * unit lights the tiles of its sight when it is made — a unit whose type's sight a program set (\`stats(t).sight\`), + * with the shape a flying unit of that sight has in the game (FOG_SHAPES), which is what the fog library draws with — + * and the vision countdown (\`memory.vision.countdown\`) run down to 0 at the start of a frame puts every tile seen + * now back to seen before and has the units there light theirs again, as the game's frame step does. + */ + private readonly vision = new Map(); + private countdown = 0; + private visionOf(player: number): Uint8Array { + let v = this.vision.get(player); + if (!v) { v = new Uint8Array(FOG_SIZE * FOG_SIZE); this.vision.set(player, v); } + return v; + } + private reveal(u: SimUnit): void { + const sight = this.tables.get(`unit.sight:${u.type}`); + if (typeof sight !== "number" || sight < 1 || u.owner > 7) return; + const shape = FOG_SHAPES[Math.min(sight, FOG_SHAPES.length) - 1]; + const cy = shape.findIndex((r) => r.includes("@")), cx = shape[cy].indexOf("@"); + const tx = Math.floor(u.x / 32), ty = Math.floor(u.y / 32), v = this.visionOf(u.owner); + shape.forEach((row, dy) => { for (let dx = 0; dx < row.length; dx++) if (row[dx] !== ".") { const x = tx + dx - cx, y = ty + dy - cy; if (x >= 0 && y >= 0 && x < FOG_SIZE && y < FOG_SIZE) v[y * FOG_SIZE + x] = 2; } }); + } + /** At the start of a frame: the countdown, and the wipe when it runs out. */ + private visionStep(): void { + if (this.countdown <= 0) return; + if (--this.countdown > 0) return; + this.countdown = 100; + for (const v of this.vision.values()) for (let i = 0; i < v.length; i++) if (v[i] === 2) v[i] = 1; + for (const u of this.world.game.units) if (u.alive) this.reveal(u); + } + /** What a player sees of the fog, as GameSim.fog gives it from the game: "#" now, "+" before, "." never. */ + fog(player: number, area?: { x: number; y: number; width: number; height: number }): string[] { + const w = this.tableOf?.({ name: "memory.map.width", base: 0x57f1d4, stride: 0, index: 0, width: 2 }) || 128; + const h = this.tableOf?.({ name: "memory.map.height", base: 0x57f1d6, stride: 0, index: 0, width: 2 }) || 128; + const { x = 0, y = 0, width = w, height = h } = area ?? {}; + const v = this.visionOf(player), rows: string[] = []; + for (let ty = y; ty < Math.min(y + height, FOG_SIZE); ty++) { + let row = ""; + for (let tx = x; tx < Math.min(x + width, FOG_SIZE); tx++) row += ".+#"[v[ty * FOG_SIZE + tx]]; + rows.push(row); + } + return rows; + } + /** `scaled`: the value is already what the cell stores. A cell holds what its width allows and no more. */ tableWrite(c: TableCell, value: number, scaled: boolean, text?: string): void { + if (c.name === "memory.vision.countdown") this.countdown = value; + if (c.name.startsWith("location.")) { + const n = c.index + 1, b = this.locations.get(n) ?? { left: 0, top: 0, right: 0, bottom: 0 }; + this.locations.set(n, { ...b, [c.name.slice("location.".length)]: Math.min(value, cellMax(4)) }); + return; + } if (text !== undefined) { this.tables.set(this.cellKey(c), text); return; } const raw = scaled ? value : value * (c.scale ?? 1); this.tables.set(this.cellKey(c), Math.min(raw, cellMax(c.width))); @@ -1306,9 +1742,9 @@ export class ProgramSimulation { this.events.push({ cycle: this.cycle, frame: this.frame, program: run.index, player: run.player, at, action: { ...emptyAction(), type, player: u.owner, unitId: u.type, ...(extra.location ? { location: extra.location } : {}) }, text: extra.text ? `${extra.text}: ${what}` : what }); } - partText(p: Exclude): string { - // A colour is a control character in the game; the log keeps the words. - return p.kind === "text" ? p.text : p.kind === "name" ? this.nameOf(this.slotOf(p.player)) : ""; + partText(p: Exclude): string { + // A player's colour is the code of the game's texts nearest it: one character, as the game puts it there. + return p.kind === "text" ? p.text : p.kind === "name" ? this.nameOf(this.slotOf(p.player)) : p.kind === "color" ? String.fromCharCode(this.colorOf(this.slotOf(p.player))) : ""; } /** A printed text: an event like a Display Text action's, the text already filled in. */ @@ -1347,7 +1783,9 @@ export class ProgramSimulation { case ActionType.Comment: case ActionType.PreserveTrigger: return; default: { // What is done to units is done, and said like everything else. - this.game.act(a, run.player); + const made: SimUnit[] = []; + this.game.act(a, run.player, made); + for (const u of made) this.reveal(u); const ev: ProgramEvent = { cycle: this.cycle, frame: this.frame, program: run.index, player: run.player, at, action: a }; const text = programText ?? this.world.text(a.text); if (text !== undefined) ev.text = text; @@ -1356,11 +1794,70 @@ export class ProgramSimulation { } } + /** + * The frame's events pass (on.created / on.died), as the game's lowering has it: a unit is seen the first time a pass + * finds it on the map, and its death is found by the first pass after it, once. A unit made and gone between two + * passes is neither. Each list is the latest first, as the game's lists keep them. + */ + private eventsFrom = 0; + /** Each unit seen on the map, with its order and type as the pass before found them (on.order / on.morphed). */ + private readonly seen = new Map(); + private events_: Record = { created: [], died: [], killed: [], ordered: [], morphed: [] }; + private eventsPass(): void { + const out: Record = { created: [], died: [], killed: [], ordered: [], morphed: [] }; + // Latest first, as the game's lists keep them: what was seen before this pass, then what came on since. + for (const [u, last] of [...this.seen].reverse()) { + if (!u.alive) { + out.died.push({ u, was: 0 }); + if (u.killedBy !== undefined) out.killed.push({ u, was: u.killedBy }); + this.seen.delete(u); + continue; + } + if (u.orderId !== last.order) { out.ordered.push({ u, was: last.order }); last.order = u.orderId; } + if (u.type !== last.type) { out.morphed.push({ u, was: last.type }); last.type = u.type; } + } + const all = this.game.units; + for (; this.eventsFrom < all.length; this.eventsFrom++) { const u = all[this.eventsFrom]; if (u.alive) { out.created.push({ u, was: 0 }); this.seen.set(u, { order: u.orderId, type: u.type }); } } + out.created.reverse(); + this.events_ = out; + } + + /** + * What this frame's events pass found that a filter matches, each with what the unit was (the killer, the order it had, + * the type it was): the units that came on, took up an order or turned into another type and are still here, or the + * ones that died, as they died. + */ + eventUnits(source: EventSource, f: UnitFilter, event: { order?: number; by?: number } = {}): { u: SimUnit; was: number }[] { + const owners = f.owner === undefined ? undefined : this.slotsOf(f.owner); + const by = event.by === undefined ? undefined : this.slotsOf(event.by); + const dead = source === "died" || source === "killed"; + return this.events_[source].filter(({ u, was }) => (dead || u.alive) && this.game.fits(u, owners, f.type, f.at) + && (event.order === undefined || u.orderId === event.order) && (!by || by.includes(was))); + } + /** One frame: every program in order, from where it left off. */ step(): void { + this.eventsPass(); + this.visionStep(); // What was done since the last frame is what this one finds, and only this one. this.current = this.queued; this.queued = { events: new Set(), lines: new Map() }; + // What was sent arrives on this frame and only this one, as MSQC's cell holds it (B5). + this.synced.clear(); + for (const [key, value] of this.sending) this.synced.set(key, value); + this.sending = []; + // A key or a button down on this frame and the last is held, not pressed again: the game reports the frame it goes + // down (MSQC's KeyDown / MouseDown), however many frames a tap lasts. + const down = new Set([...this.current.events].filter((e) => e.startsWith("key:") || e.startsWith("click:"))); + for (const e of down) if (this.down.has(e)) this.current.events.delete(e); + this.down = down; + // keyHeld(): down now, or let go no more than HELD_GRACE frames ago — as the game's lowering has it, which covers a + // message that went missing on its way between the players' computers. + for (const e of down) this.lastDown.set(e, this.frame); + const was = this.downNow; + this.downNow = new Set([...this.lastDown].filter(([, f]) => this.frame - f <= HELD_GRACE).map(([e]) => e)); + // With a held key in the game every key goes as held, and a press is held now and not before (compiler/input.ts). + if (this.heldKeys) for (const e of [...this.current.events]) if (e.startsWith("key:") && (!this.downNow.has(e) || was.has(e))) this.current.events.delete(e); for (const run of this.runs) { this.world.current = run.player; try { run.tick(); } catch (err) { if (err instanceof Halt) throw new Error(err.message); throw err; } @@ -1375,7 +1872,7 @@ export class ProgramSimulation { } /** Whether every program has ended. */ - finished(): boolean { return this.runs.every((r) => r.done); } + finished(): boolean { return this.runs.every((r) => r.done && !r.tasksRunning); } /** A program's run: the program by its place in the list or its name, and of a per-player program the player's (the first by default). */ runOf(program: number | string = 0, player?: number): ProgramRun | undefined { diff --git a/compiler/sound.ts b/compiler/sound.ts new file mode 100644 index 0000000..fa8e966 --- /dev/null +++ b/compiler/sound.ts @@ -0,0 +1,980 @@ +/** + * Sounds a script imports or makes: `import boom from "./boom.sound"`, `tone("A4", 0.3)`. A `.sound` file is one of + * the script's files, kept in the map with the others: the samples the editor decoded when it was imported, mono, at + * the rate chosen, as IMA ADPCM — four bits a sample, the coding the game's own sounds use and the one the map gets. + * The source file is not kept, as a video's and an image's are not: what the build reads is the same everywhere. + * + * What a script gets is a `Clip` (below), one channel or two, worked on when the script is built — trimmed, made + * louder or quieter, faded, reversed, pitched, joined, mixed, repeated, panned, limited — or made from nothing by + * `tone`, `noise` and `silence`. A clip a program plays (`playWav(boom)`) is written into the map's archive when the + * script is built, named by what it holds (`staredit\wav\trigscript-.wav` or `.ogg`), once however often it is + * played; nothing else of it reaches the game. Two seconds or more is Ogg Vorbis (`clipFile`, `vorbis.ts`); shorter is + * a WAV of IMA ADPCM, a quarter of 16-bit, but for a tone the coding would make harsh, which goes in as 16-bit PCM: + * ADPCM codes each sample as a step from the last, and the jumps of a square or a saw wave come back ringing — 8 dB, + * heard as a harsh end to every note of the first sound demo (2026-10-19). See `adpcmWillDo`. + * + * What StarCraft: Remastered does with them (the sound probe, 2026-10-19): a sound starts on its mixer's 21.3 ms grid, + * up to one buffer after its frame; playing one again starts a second copy beside the first, and nothing stops one, + * so a note's length and loudness are its file's; 32 play at once; copies add, with no limiter; loudness is kept as + * written. IMA ADPCM, PCM and Ogg Vorbis play; Ogg Opus does not. + */ +import { fromBase64, toBase64 } from "./rangeCoder"; +import { chorus, compressor, curve, echo, echoTail, eq, fmVoice, plate, plateTail, pluckVoice, saturate, seeded, svf, type FilterType, type Points } from "./dsp"; + +/** The extension of a sound among a script's files. */ +export const SOUND_EXTENSION = ".sound"; +/** The longest a clip may be, in seconds. */ +export const MAX_SECONDS = 600; +/** The rate a clip is made at when nothing says otherwise. */ +export const DEFAULT_RATE = 22050; + +/** The format's version, in the file. */ +const VERSION = 1; + +interface SoundHeader { trigscript: "sound"; version: number; rate: number; frames: number; source?: string; data: string } + +/* ── IMA ADPCM, as a WAV holds it: mono blocks, each a sample and a step index, then two samples a byte ── */ + +const INDEX_STEP = [-1, -1, -1, -1, 2, 4, 6, 8, -1, -1, -1, -1, 2, 4, 6, 8]; +const STEPS = [ + 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, + 130, 143, 157, 173, 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, 724, 796, 876, 963, 1060, + 1166, 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, + 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, 15289, 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767, +]; + +/** A block's size in bytes for a rate, as the usual tools choose it: 256 at 11 kHz, 512 at 22, 1024 at 44. */ +const blockAlign = (rate: number) => 256 * Math.max(1, Math.round(rate / 11025)); +const samplesPerBlock = (align: number) => (align - 4) * 2 + 1; + +/** Samples (−1 … 1) as IMA ADPCM blocks; the last block filled out with silence. */ +export function encodeAdpcm(samples: Float32Array, rate: number): Uint8Array { + const align = blockAlign(rate), per = samplesPerBlock(align); + const blocks = Math.max(1, Math.ceil(samples.length / per)); + const out = new Uint8Array(blocks * align); + const at = (i: number) => (i < samples.length ? Math.max(-32768, Math.min(32767, Math.round(samples[i] * 32767))) : 0); + let index = 0; + for (let b = 0; b < blocks; b++) { + const base = b * align, first = b * per; + let predicted = at(first); + out[base] = predicted & 255; out[base + 1] = (predicted >> 8) & 255; out[base + 2] = index; out[base + 3] = 0; + for (let k = 1; k < per; k++) { + const step = STEPS[index]; + let diff = at(first + k) - predicted, code = 0; + if (diff < 0) { code = 8; diff = -diff; } + let delta = step >> 3; + if (diff >= step) { code |= 4; diff -= step; delta += step; } + if (diff >= step >> 1) { code |= 2; diff -= step >> 1; delta += step >> 1; } + if (diff >= step >> 2) { code |= 1; delta += step >> 2; } + predicted = Math.max(-32768, Math.min(32767, predicted + (code & 8 ? -delta : delta))); + index = Math.max(0, Math.min(88, index + INDEX_STEP[code])); + const byte = base + 4 + ((k - 1) >> 1); + out[byte] |= (k - 1) & 1 ? code << 4 : code; + } + } + return out; +} + +/** IMA ADPCM blocks back to `frames` samples (−1 … 1). */ +export function decodeAdpcm(data: Uint8Array, rate: number, frames: number): Float32Array { + const align = blockAlign(rate), per = samplesPerBlock(align), out = new Float32Array(frames); + for (let b = 0, n = 0; b * align < data.length && n < frames; b++) { + const base = b * align; + let predicted = (data[base] | (data[base + 1] << 8)) << 16 >> 16, index = Math.min(88, data[base + 2]); + out[n++] = predicted / 32767; + for (let k = 1; k < per && n < frames; k++) { + const byte = data[base + 4 + ((k - 1) >> 1)], code = (k - 1) & 1 ? byte >> 4 : byte & 15, step = STEPS[index]; + let delta = step >> 3; + if (code & 4) delta += step; + if (code & 2) delta += step >> 1; + if (code & 1) delta += step >> 2; + predicted = Math.max(-32768, Math.min(32767, predicted + (code & 8 ? -delta : delta))); + index = Math.max(0, Math.min(88, index + INDEX_STEP[code])); + out[n++] = predicted / 32767; + } + } + return out; +} + +/** A WAV of IMA ADPCM blocks: what the map's archive gets for a clip. */ +export function adpcmWav(data: Uint8Array, rate: number, frames: number): Uint8Array { + const align = blockAlign(rate), per = samplesPerBlock(align); + const out = new Uint8Array(60 + data.length), v = new DataView(out.buffer); + const text = (at: number, s: string) => { for (let i = 0; i < s.length; i++) out[at + i] = s.charCodeAt(i); }; + text(0, "RIFF"); v.setUint32(4, 52 + data.length, true); text(8, "WAVE"); + text(12, "fmt "); v.setUint32(16, 20, true); + v.setUint16(20, 0x11, true); v.setUint16(22, 1, true); v.setUint32(24, rate, true); + v.setUint32(28, Math.round((rate * align) / per), true); v.setUint16(32, align, true); v.setUint16(34, 4, true); + v.setUint16(36, 2, true); v.setUint16(38, per, true); + text(40, "fact"); v.setUint32(44, 4, true); v.setUint32(48, frames, true); + text(52, "data"); v.setUint32(56, data.length, true); + out.set(data, 60); + return out; +} + +/** A WAV of 16-bit PCM, one channel or two: what the map gets for a clip ADPCM would distort, or a short one with two sides. */ +export function pcmWav(samples: Float32Array | readonly Float32Array[], rate: number): Uint8Array { + const chans = samples instanceof Float32Array ? [samples] : samples, n = chans.length, frames = chans[0].length, size = frames * n * 2; + const out = new Uint8Array(44 + size), v = new DataView(out.buffer); + const text = (at: number, s: string) => { for (let i = 0; i < s.length; i++) out[at + i] = s.charCodeAt(i); }; + text(0, "RIFF"); v.setUint32(4, 36 + size, true); text(8, "WAVE"); + text(12, "fmt "); v.setUint32(16, 16, true); v.setUint16(20, 1, true); v.setUint16(22, n, true); + v.setUint32(24, rate, true); v.setUint32(28, rate * 2 * n, true); v.setUint16(32, 2 * n, true); v.setUint16(34, 16, true); + text(36, "data"); v.setUint32(40, size, true); + for (let i = 0; i < frames; i++) for (let c = 0; c < n; c++) v.setInt16(44 + (i * n + c) * 2, Math.round(Math.max(-1, Math.min(1, chans[c][i])) * 32767), true); + return out; +} + +/** How close ADPCM keeps samples to themselves, in dB: the samples' power over the error's. */ +export function adpcmFidelity(samples: Float32Array, rate: number, adpcm = encodeAdpcm(samples, rate)): number { + const back = decodeAdpcm(adpcm, rate, samples.length); + let signal = 0, error = 0; + for (let i = 0; i < samples.length; i++) { const c = Math.max(-1, Math.min(1, samples[i])); signal += c * c; error += (back[i] - c) ** 2; } + return error === 0 ? Infinity : signal === 0 ? -Infinity : 10 * Math.log10(signal / error); +} + +/** + * How much a clip repeats itself, 0 … 1: the best match of its middle with itself a period later, for periods from + * 4 samples to a 40 Hz note's. A tone is near 1; noise near 0. + */ +export function periodicity(samples: Float32Array, rate: number): number { + const n = Math.min(4096, samples.length), from = Math.max(0, (samples.length - n) >> 1), most = Math.min(Math.floor(rate / 40), n >> 1); + let best = 0; + for (let lag = 4; lag <= most; lag++) { + let xy = 0, xx = 0, yy = 0; + for (let i = from; i < from + n - lag; i++) { const a = samples[i], b = samples[i + lag]; xy += a * b; xx += a * a; yy += b * b; } + if (xx > 0 && yy > 0) best = Math.max(best, xy / Math.sqrt(xx * yy)); + } + return best; +} + +/** + * Below this, a tonal clip goes in the map as 16-bit. Played 2026-10-19: square-wave notes at 11.8–16.6 dB were heard + * as harsh, a plucked string's notes at 18.2–23.6 dB as clean; over noise the error is lost in the noise (a snare at + * 14.7 dB and a hat at 11.2 dB sounded fine), so noise keeps ADPCM whatever its figure. + */ +export const ADPCM_FIDELITY = 18; +/** A clip that repeats itself this much is a tone for ADPCM_FIDELITY. */ +export const TONAL = 0.5; + +/** Whether a clip goes in the map as ADPCM: unless it is a tone the coding would make harsh. */ +export const adpcmWillDo = (samples: Float32Array, rate: number, adpcm?: Uint8Array): boolean => + adpcmFidelity(samples, rate, adpcm) >= ADPCM_FIDELITY || periodicity(samples, rate) < TONAL; + +/* ── The .sound file ── */ + +/** What the editor writes from a decoded sound: mono samples (−1 … 1) at `rate`. */ +export interface SoundSamples { rate: number; samples: Float32Array; source?: string } + +/** The `.sound` file's text for these samples. */ +export function writeSound(s: SoundSamples): string { + checkRate(s.rate); + if (s.samples.length / s.rate > MAX_SECONDS) throw new Error(`A sound is at most ${MAX_SECONDS / 60} minutes long, not ${(s.samples.length / s.rate / 60).toFixed(1)}.`); + const header: SoundHeader = { trigscript: "sound", version: VERSION, rate: s.rate, frames: s.samples.length, ...(s.source ? { source: s.source } : {}), data: toBase64(encodeAdpcm(s.samples, s.rate)) }; + return JSON.stringify(header); +} + +/** A `.sound` file as the build reads it. */ +export interface SoundData { readonly rate: number; readonly frames: number; readonly source?: string; readonly adpcm: Uint8Array; readonly samples: Float32Array } + +/** Read a `.sound` file's text. Throws, in words for the person, when it is not one. */ +export function readSound(text: string): SoundData { + let h: Partial; + try { h = JSON.parse(text) as Partial; } catch { throw new Error("This is not a sound TrigScript imported: a .sound file is one line of JSON."); } + if (h?.trigscript !== "sound" || typeof h.data !== "string") throw new Error("This is not a sound TrigScript imported."); + if (h.version !== VERSION) throw new Error(`This sound was written by a newer TrigScript (format ${h.version}); update the plugin to read it.`); + const rate = Number(h.rate), frames = Number(h.frames); + checkRate(rate); + if (!(Number.isInteger(frames) && frames >= 0 && frames <= MAX_SECONDS * rate)) throw new Error("The sound's length is not a whole number of samples."); + const adpcm = fromBase64(h.data), align = blockAlign(rate); + if (adpcm.length < Math.ceil(frames / samplesPerBlock(align)) * align) throw new Error("The sound's samples end too soon: the file is cut short."); + return Object.freeze({ rate, frames, ...(typeof h.source === "string" ? { source: h.source } : {}), adpcm, samples: decodeAdpcm(adpcm, rate, frames) }); +} + +/** What the hover on an import says: "A sound, 2.4 seconds at 22 050 Hz, from boom.mp3." */ +export function describeSound(text: string): string { + try { + const h = JSON.parse(text) as Partial; + if (h?.trigscript !== "sound") return "This is not a sound TrigScript imported."; + const rate = Number(h.rate), seconds = Number(h.frames) / rate; + return `A sound, ${seconds.toFixed(seconds < 10 ? 2 : 1)} seconds at ${rate.toLocaleString("en-US")} Hz${typeof h.source === "string" ? `, from ${h.source}` : ""}.`; + } catch (err) { + return err instanceof Error ? err.message : String(err); + } +} + +function checkRate(rate: number): void { + if (!(Number.isInteger(rate) && rate >= 8000 && rate <= 48000)) throw new Error(`A sound's rate is 8 000 to 48 000 samples a second, not ${rate}.`); +} + +/* ── What a script gets ── */ + +/** + * A sound when the script is built. Its length, rate and channels are known at once; its samples are worked out only + * when something needs them — the map's file, at a build, or `peak` — so a check as the user types, which needs neither, + * runs a song's whole mix without making a sample of it. + */ +export interface Clip { + readonly __trigscript: "clip"; + readonly seconds: number; + readonly rate: number; + /** 1, or 2 for a clip with a left and a right. */ + readonly channels: 1 | 2; + /** The loudest sample, 0 … 1 (more than 1 clips when the map's file is written). Reading it works the samples out. */ + readonly peak: number; + trim(from: number, to?: number): Clip; + gain(factor: number): Clip; + fade(inSeconds: number, outSeconds?: number): Clip; + reverse(): Clip; + pitch(semitones: number): Clip; + then(...clips: Clip[]): Clip; + mix(other: Clip, at?: number): Clip; + repeat(times: number): Clip; + envelope(...points: readonly (readonly [number, number])[]): Clip; + pan(position: number): Clip; + mono(): Clip; + limit(options?: { readonly ceiling?: number; readonly release?: number }): Clip; + normalize(peak?: number): Clip; + wav(): Clip; + ogg(quality?: number): Clip; + filter(type: FilterType, cutoff: number | Points, options?: { readonly resonance?: number; readonly gain?: number; readonly q?: number }): Clip; + drive(dB: number): Clip; + delay(seconds: number, options?: { readonly feedback?: number; readonly mix?: number; readonly tone?: number; readonly pingPong?: boolean }): Clip; + reverb(options?: { readonly decay?: number; readonly damping?: number; readonly predelay?: number; readonly mix?: number; readonly dry?: number; readonly width?: number }): Clip; + chorus(options?: { readonly speed?: number; readonly depth?: number; readonly mix?: number }): Clip; + compress(options?: { readonly threshold?: number; readonly ratio?: number; readonly attack?: number; readonly release?: number; readonly knee?: number; readonly makeup?: number }): Clip; +} + +interface ClipInside { + readonly frames: number; + /** The samples, once worked out; until then, what works them out. */ + channels?: readonly Float32Array[]; + compute?: () => readonly Float32Array[]; + peak?: number; + adpcm?: Uint8Array; + wav?: boolean; + /** The Ogg Vorbis quality it goes in the map at, when it is one (`VORBIS_QUALITY` when not said). */ + quality?: number; + /** What the clip is (`keyOf`). */ + key?: string; +} +const inside = new WeakMap(); + +export const isClip = (v: unknown): v is Clip => typeof v === "object" && v !== null && (v as Clip).__trigscript === "clip"; + +const num = (v: unknown, what: string): number => { + if (typeof v !== "number" || !Number.isFinite(v)) throw new Error(`${what} is a number, not ${String(v)}.`); + return v; +}; + +/** A clip's samples, worked out the first time they are asked for. */ +function samplesOf(c: Clip): readonly Float32Array[] { + const own = inside.get(c)!; + if (!own.channels) { + const made = own.compute!(); + if (made.length !== c.channels || made.some((s) => s.length !== own.frames)) throw new Error(`Internal: a clip's samples came out ${made.map((s) => s.length).join("+")} long, not ${c.channels} × ${own.frames}.`); + own.channels = made; + // What worked them out is let go, and with it the clips they came from. + own.compute = undefined; + } + return own.channels; +} + +/* + * What a clip is, as a key: worked out from how it was made — the clip it came from, the method and its arguments — or, + * for samples from elsewhere, from the samples. Two clips made the same way have the same key: the map's file is named + * by it (so a check knows the name without the samples), and a clip made the same way twice in one build is the same + * clip, its samples worked out once (kept by a weak reference: a clip nothing holds any more is let go). + */ +const cache = new Map>(); + +/** Two 32-bit hashes of a text, as 16 hex digits. */ +function hashText(text: string): string { + let a = 0x811c9dc5, b = 0x9747b28c; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + a = Math.imul(a ^ c, 0x01000193) >>> 0; + b = Math.imul(b ^ c, 0x5bd1e995) >>> 0; b = (b ^ (b >>> 15)) >>> 0; + } + return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0"); +} + +/** A clip's key: how it was made, or its samples. */ +export function keyOf(c: Clip): string { + const own = inside.get(c)!; + if (own.key) return own.key; + let a = 0x811c9dc5, b = 0x9747b28c; + for (const s of samplesOf(c)) { + const words = new Uint32Array(s.buffer, s.byteOffset, s.length); + for (let i = 0; i < words.length; i++) { a = Math.imul(a ^ words[i], 0x01000193) >>> 0; b = Math.imul(b ^ words[i], 0x5bd1e995) >>> 0; b = (b ^ (b >>> 15)) >>> 0; } + } + own.key = `s${c.rate}:${c.channels}:${a.toString(16)}${b.toString(16)}`; + return own.key; +} + +/** The key of a clip made by `op` from these arguments (clips among them by their keys). */ +const recipe = (op: string, args: unknown): string => hashText(`${op}|${JSON.stringify(args, (_, v) => (isClip(v) ? `clip:${keyOf(v)}` : v))}`); + +/** The clip `key` names, if one is still held; else `make`'s, kept under it. */ +function made(key: string, make: () => Clip): Clip { + const hit = cache.get(key)?.deref(); + if (hit) return hit; + const c = make(), own = inside.get(c)!; + if (own.key) return c; + own.key = key; + cache.set(key, new WeakRef(c)); + if (cache.size > 8192) for (const [k, r] of cache) if (!r.deref()) cache.delete(k); + return c; +} + +/* + * Resampling by a windowed sinc: each new sample the old ones weighed by sin(πx)/(πx) under a Blackman window 16 samples + * each side, its cutoff lowered to the new rate's half when that is the lower, so a note pitched up does not fold its + * top back down as other pitches. (Straight lines between samples did both: dulled the top and folded it.) + */ +const HALF = 16, RES = 512; +const KERNEL = (() => { + const t = new Float32Array(HALF * RES + 2); + for (let i = 0; i < t.length; i++) { + const u = i / RES; + const sinc = u === 0 ? 1 : Math.sin(Math.PI * u) / (Math.PI * u); + t[i] = u >= HALF ? 0 : sinc * (0.42 + 0.5 * Math.cos((Math.PI * u) / HALF) + 0.08 * Math.cos((2 * Math.PI * u) / HALF)); + } + return t; +})(); + +/** The length `n` samples at `from` come to at `to`. */ +const resampled = (n: number, from: number, to: number) => (from === to ? n : Math.max(1, Math.round((n * to) / from))); + +/** `samples` at `rate`, as a rate of its own. */ +function resample(samples: Float32Array, from: number, to: number): Float32Array { + if (from === to) return samples; + const n = resampled(samples.length, from, to), out = new Float32Array(n), r = from / to; + const c = Math.min(1, to / from), reach = HALF / c, last = samples.length - 1; + for (let i = 0; i < n; i++) { + const p = i * r, a = Math.max(0, Math.floor(p - reach) + 1), b = Math.min(last, Math.floor(p + reach)); + let sum = 0; + for (let j = a; j <= b; j++) { + const u = Math.abs(j - p) * c * RES, k = u | 0; + sum += samples[j] * (KERNEL[k] + (KERNEL[k + 1] - KERNEL[k]) * (u - k)); + } + out[i] = sum * c; + } + return out; +} + +/** A clip's frames at `rate`. */ +const framesAt = (c: Clip, rate: number) => resampled(inside.get(c)!.frames, c.rate, rate); + +/** A clip's channels at `rate` and as many channels as `channels`: a mono one heard the same from both sides. */ +const channelsAt = (c: Clip, rate: number, channels: number): Float32Array[] => { + const own = samplesOf(c).map((s) => resample(s, c.rate, rate)); + return channels === 2 && own.length === 1 ? [own[0], own[0]] : own; +}; + +/** A plain loop over a channel: a typed array's own map() calls back far more slowly, and a song's notes come by the hundred. */ +const each = (s: Float32Array, f: (x: number, i: number) => number): Float32Array => { + const out = new Float32Array(s.length); + for (let i = 0; i < s.length; i++) out[i] = f(s[i], i); + return out; +}; +const scale = (s: Float32Array, g: Float32Array | number): Float32Array => { + const out = new Float32Array(s.length); + if (typeof g === "number") for (let i = 0; i < s.length; i++) out[i] = s[i] * g; + else for (let i = 0; i < s.length; i++) out[i] = s[i] * g[i]; + return out; +}; + +/** The clip these channels are (one, or a left and a right of the same length). */ +export function makeClip(samples: Float32Array | readonly Float32Array[], rate: number, adpcm?: Uint8Array, wav?: boolean): Clip { + const chans: readonly Float32Array[] = samples instanceof Float32Array ? [samples] : samples; + if (chans.length !== 1 && chans.length !== 2) throw new Error("A clip has one channel or two."); + const frames = chans[0].length; + if (chans.some((s) => s.length !== frames)) throw new Error("A clip's channels are the same length."); + return lazyClip(rate, frames, chans.length as 1 | 2, () => chans, { channels: chans, ...(adpcm ? { adpcm } : {}), ...(wav ? { wav } : {}) }); +} + +/** A clip `frames` long whose samples `compute` works out when they are asked for. */ +function lazyClip(rate: number, frames: number, channels: 1 | 2, compute: () => readonly Float32Array[], extra: Partial = {}): Clip { + if (frames / rate > MAX_SECONDS) throw new Error(`A clip is at most ${MAX_SECONDS / 60} minutes long, not ${(frames / rate / 60).toFixed(1)}.`); + const seconds = frames / rate, wav = extra.wav === true; + const at = (t: number) => Math.max(0, Math.min(frames, Math.round(t * rate))); + /** A clip made from this one by `op`, `n` frames and `count` channels long, its samples from these. */ + const derive = (op: string, args: unknown, n: number, count: 1 | 2, from: (chans: readonly Float32Array[]) => readonly Float32Array[], also: Partial = {}) => + made(recipe(`${keyOf(clip)}|${wav ? 1 : 0}|${op}`, args), () => lazyClip(rate, n, count, () => from(samplesOf(clip)), { ...(wav ? { wav } : {}), ...also })); + const same = (op: string, args: unknown, from: (chans: readonly Float32Array[]) => readonly Float32Array[]) => derive(op, args, frames, channels, from); + const clip: Clip = { + __trigscript: "clip", + seconds, + rate, + channels, + get peak() { + const own = inside.get(clip)!; + if (own.peak === undefined) { + let p = 0; + for (const s of samplesOf(clip)) for (let i = 0; i < s.length; i++) { const x = s[i] < 0 ? -s[i] : s[i]; if (x > p) p = x; } + own.peak = p; + } + return own.peak; + }, + trim(from, to = seconds) { + num(from, "trim()'s start"); num(to, "trim()'s end"); + if (from < 0 || to > seconds + 1e-9 || to <= from) throw new RangeError(`trim(${from}, ${to}) is not inside the clip, which is ${seconds.toFixed(3)} seconds.`); + return derive("trim", [from, to], at(to) - at(from), channels, (cs) => cs.map((s) => s.slice(at(from), at(to)))); + }, + gain(factor) { + if (num(factor, "gain()") < 0) throw new RangeError(`gain() is 0 or more (0.5 half as loud, 2 twice), not ${factor}.`); + return same("gain", [factor], (cs) => cs.map((s) => scale(s, factor))); + }, + fade(fadeIn, fadeOut = 0) { + num(fadeIn, "fade()'s in"); num(fadeOut, "fade()'s out"); + if (fadeIn < 0 || fadeOut < 0) throw new RangeError("fade() takes seconds, 0 or more."); + const a = fadeIn * rate, b = fadeOut * rate; + return same("fade", [fadeIn, fadeOut], (cs) => cs.map((s) => each(s, (x, i) => x * Math.min(1, a ? i / a : 1, b ? (frames - 1 - i) / b : 1)))); + }, + reverse() { return same("reverse", [], (cs) => cs.map((s) => s.slice().reverse())); }, + pitch(semitones) { + // Faster and higher, or slower and lower, as a sampler plays a note: the clip read at another speed. + const ratio = 2 ** (num(semitones, "pitch()") / 12); + return derive("pitch", [semitones], resampled(frames, rate * ratio, rate), channels, (cs) => cs.map((s) => resample(s, rate * ratio, rate))); + }, + then(...clips) { + for (const c of clips) if (!isClip(c)) throw new Error("then() joins clips."); + const n = Math.max(channels, ...clips.map((c) => c.channels)) as 1 | 2; + const total = frames + clips.reduce((s, c) => s + framesAt(c, rate), 0); + return derive("then", [clips], total, n, () => { + const parts = [clip, ...clips].map((c) => channelsAt(c, rate, n)); + return Array.from({ length: n }, (_, side) => { + const out = new Float32Array(total); + let o = 0; + for (const p of parts) { out.set(p[side], o); o += p[side].length; } + return out; + }); + }); + }, + mix(other, start = 0) { + if (!isClip(other)) throw new Error("mix() takes a clip."); + if (num(start, "mix()'s start") < 0) throw new RangeError("mix()'s start is 0 seconds or more."); + const n = Math.max(channels, other.channels) as 1 | 2, from = Math.round(start * rate), length = Math.max(frames, from + framesAt(other, rate)); + return derive("mix", [other, start], length, n, () => { + const mine = channelsAt(clip, rate, n), theirs = channelsAt(other, rate, n); + return mine.map((s, side) => { + const o = theirs[side], out = new Float32Array(length); + out.set(s); + for (let i = 0; i < o.length; i++) out[from + i] += o[i]; + return out; + }); + }); + }, + envelope(...points) { + // Loudness through the clip, a straight line between [seconds, level] points: the first level before the first, + // the last after the last. + if (points.length === 0) throw new Error("envelope() takes [seconds, level] points: envelope([0, 0], [0.01, 1], [0.3, 0])."); + const ps = points.map((p, k) => { + if (!Array.isArray(p) || p.length !== 2) throw new Error(`envelope()'s point ${k + 1} is [seconds, level].`); + return [num(p[0], "a point's seconds"), num(p[1], "a point's level")] as [number, number]; + }); + for (let k = 1; k < ps.length; k++) if (ps[k][0] < ps[k - 1][0]) throw new RangeError("envelope()'s points go forward in time."); + return same("envelope", ps, (cs) => { const levels = curve(ps, rate, frames); return cs.map((s) => scale(s, levels)); }); + }, + repeat(times) { + if (!Number.isInteger(num(times, "repeat()")) || times < 1) throw new RangeError(`repeat() takes a whole number of times, 1 or more, not ${times}.`); + return derive("repeat", [times], frames * times, channels, (cs) => cs.map((s) => { + const out = new Float32Array(s.length * times); + for (let k = 0; k < times; k++) out.set(s, k * s.length); + return out; + })); + }, + pan(position) { + // A balance: the far side turned down, the near one kept, so the middle (0) is the clip as it was. + if (!(num(position, "pan()") >= -1 && position <= 1)) throw new RangeError(`pan() is −1 (left) to 1 (right), 0 the middle, not ${position}.`); + const l = Math.min(1, 1 - position), r = Math.min(1, 1 + position); + return derive("pan", [position], frames, 2, (cs) => [scale(cs[0], l), scale(cs[cs.length - 1], r)]); + }, + mono() { + if (channels === 1) return clip; + return derive("mono", [], frames, 1, (cs) => [each(cs[0], (x, i) => (x + cs[1][i]) / 2)]); + }, + limit(options = {}) { + const ceiling = options.ceiling ?? LIMIT_CEILING, release = options.release ?? 0.1; + if (!(num(ceiling, "limit()'s ceiling") > 0 && ceiling <= 1)) throw new RangeError(`limit()'s ceiling is more than 0 and at most 1, not ${ceiling}.`); + if (!(num(release, "limit()'s release") >= 0)) throw new RangeError("limit()'s release is seconds, 0 or more."); + return same("limit", [ceiling, release], (cs) => { const g = limiterGain(cs, rate, ceiling, release); return cs.map((s) => scale(s, g)); }); + }, + normalize(to = LIMIT_CEILING) { + if (!(num(to, "normalize()") > 0 && to <= 1)) throw new RangeError(`normalize() makes the loudest sample more than 0 and at most 1, not ${to}.`); + return same("normalize", [to], (cs) => { const p = clip.peak; return p === 0 ? cs : cs.map((s) => scale(s, to / p)); }); + }, + ogg(quality = VORBIS_QUALITY) { + if (!(num(quality, "ogg()'s quality") >= -1 && quality <= 10)) throw new RangeError(`ogg()'s quality is −1 to 10, not ${quality}.`); + if (wav) throw new Error("This clip was made a WAV (wav()): it cannot be an Ogg as well."); + return made(`${keyOf(clip)}|ogg ${quality}`, () => lazyClip(rate, frames, channels, () => samplesOf(clip), { quality })); + }, + wav() { + if (wav) return clip; + const own = inside.get(clip)!; + return made(`${keyOf(clip)}|wav`, () => lazyClip(rate, frames, channels, () => samplesOf(clip), { wav: true, ...(own.adpcm ? { adpcm: own.adpcm } : {}) })); + }, + filter(type, cutoff, options = {}) { + const between = (v: number | undefined, d: number, lo: number, hi: number, what: string) => { + const x = v ?? d; + if (!(num(x, what) >= lo && x <= hi)) throw new RangeError(`${what} is ${lo} to ${hi}, not ${x}.`); + return x; + }; + const hzOk = (hz: unknown, what: string) => { if (!(num(hz, what) > 0)) throw new RangeError(`${what} is hertz, more than 0, not ${String(hz)}.`); }; + if (type === "lowshelf" || type === "highshelf" || type === "peak") { + if (typeof cutoff !== "number") throw new Error(`filter("${type}") takes one frequency, not points.`); + hzOk(cutoff, "filter()'s frequency"); + const gain = between(options.gain, 0, -48, 48, `filter("${type}")'s gain (dB)`), q = between(options.q, Math.SQRT1_2, 0.1, 30, "filter()'s q"); + return same("eq", [type, cutoff, gain, q], (cs) => eq(cs, rate, type, Math.min(cutoff, rate * 0.49), gain, q)); + } + if (!["lowpass", "highpass", "bandpass", "notch"].includes(type)) throw new Error(`filter()'s type is "lowpass", "highpass", "bandpass", "notch", "lowshelf", "highshelf" or "peak", not ${JSON.stringify(type)}.`); + if (typeof cutoff === "number") hzOk(cutoff, "filter()'s cutoff"); + else if (Array.isArray(cutoff) && cutoff.length > 0) cutoff.forEach((p, k) => { + if (!Array.isArray(p) || p.length !== 2) throw new Error(`filter()'s cutoff point ${k + 1} is [seconds, hertz].`); + num(p[0], "a cutoff point's seconds"); hzOk(p[1], "a cutoff point's hertz"); + }); + else throw new Error("filter()'s cutoff is hertz, or [seconds, hertz] points to sweep it."); + const resonance = between(options.resonance, 0, 0, 1, "filter()'s resonance"); + return same("filter", [type, cutoff, resonance], (cs) => svf(cs, rate, type as "lowpass", cutoff, resonance)); + }, + drive(dB) { + if (!(num(dB, "drive()") >= 0 && dB <= 48)) throw new RangeError(`drive() is 0 to 48 dB of push, not ${dB}.`); + if (dB === 0) return clip; + return same("drive", [dB], (cs) => saturate(cs, dB, (s) => resample(s, rate, rate * 2), (s) => resample(s, rate * 2, rate))); + }, + delay(seconds, options = {}) { + const feedback = options.feedback ?? 0.35, mix = options.mix ?? 0.35, toneHz = options.tone ?? 5000, pingPong = options.pingPong === true; + if (!(num(seconds, "delay()'s time") > 0 && seconds <= 5)) throw new RangeError(`delay()'s time is more than 0, at most 5 seconds, not ${seconds}.`); + if (!(num(feedback, "delay()'s feedback") >= 0 && feedback < 1)) throw new RangeError(`delay()'s feedback is 0 to less than 1, not ${feedback}.`); + if (!(num(mix, "delay()'s mix") >= 0 && mix <= 1)) throw new RangeError(`delay()'s mix is 0 to 1, not ${mix}.`); + if (!(num(toneHz, "delay()'s tone") > 0)) throw new RangeError(`delay()'s tone is hertz, more than 0, not ${toneHz}.`); + return derive("delay", [seconds, feedback, mix, toneHz, pingPong], frames + echoTail(rate, seconds, feedback), pingPong ? 2 : channels, (cs) => echo(cs, rate, seconds, feedback, mix, Math.min(toneHz, rate * 0.45), pingPong)); + }, + reverb(options = {}) { + const o = { decay: options.decay ?? 0.6, damping: options.damping ?? 0.4, predelay: options.predelay ?? 0.015, mix: options.mix ?? 0.3, dry: options.dry ?? 1, width: options.width ?? 1 }; + for (const [k, v, hi] of [["decay", o.decay, 1], ["damping", o.damping, 1], ["predelay", o.predelay, 0.5], ["mix", o.mix, 2], ["dry", o.dry, 2], ["width", o.width, 1]] as const) { + if (!(num(v, `reverb()'s ${k}`) >= 0 && v <= hi)) throw new RangeError(`reverb()'s ${k} is 0 to ${hi}, not ${v}.`); + } + const tail = Math.round(plateTail(o.decay, o.predelay) * rate); + return derive("reverb", o, frames + tail, 2, (cs) => plate(cs, rate, o, tail)); + }, + chorus(options = {}) { + const speed = options.speed ?? 0.8, depth = options.depth ?? 0.003, mix = options.mix ?? 0.5; + if (!(num(speed, "chorus()'s speed") > 0 && speed <= 20)) throw new RangeError(`chorus()'s speed is more than 0, at most 20 Hz, not ${speed}.`); + if (!(num(depth, "chorus()'s depth") >= 0 && depth <= 0.01)) throw new RangeError(`chorus()'s depth is 0 to 0.01 seconds, not ${depth}.`); + if (!(num(mix, "chorus()'s mix") >= 0 && mix <= 1)) throw new RangeError(`chorus()'s mix is 0 to 1, not ${mix}.`); + return derive("chorus", [speed, depth, mix], frames, 2, (cs) => chorus(cs, rate, speed, depth, mix)); + }, + compress(options = {}) { + const o = { threshold: options.threshold ?? -18, ratio: options.ratio ?? 4, attack: options.attack ?? 0.005, release: options.release ?? 0.12, knee: options.knee ?? 6, makeup: options.makeup ?? 0 }; + if (!(num(o.threshold, "compress()'s threshold") <= 0 && o.threshold >= -60)) throw new RangeError(`compress()'s threshold is −60 to 0 dB, not ${o.threshold}.`); + if (!(num(o.ratio, "compress()'s ratio") >= 1 && o.ratio <= 100)) throw new RangeError(`compress()'s ratio is 1 to 100, not ${o.ratio}.`); + for (const [k, v] of [["attack", o.attack], ["release", o.release], ["knee", o.knee]] as const) if (!(num(v, `compress()'s ${k}`) >= 0)) throw new RangeError(`compress()'s ${k} is 0 or more, not ${v}.`); + if (!(num(o.makeup, "compress()'s makeup") >= -24 && o.makeup <= 24)) throw new RangeError(`compress()'s makeup is −24 to 24 dB, not ${o.makeup}.`); + return same("compress", o, (cs) => compressor(cs, rate, o)); + }, + }; + inside.set(clip, { frames, compute, ...extra }); + return Object.freeze(clip); +} + +/** The level `limit` keeps a clip under unless told otherwise, and `normalize` brings it to: −1.5 dB. `limit` keeps + * the wave between the samples under it too (its true peak), and Ogg Vorbis comes back a little louder at its peaks + * than what went in, so the margin keeps a limited song from clipping in the game. (Judged by the samples alone, a + * song limited to 0.84 peaked at 0.95 between them, and a dense one decoded at 0.99.) */ +export const LIMIT_CEILING = 0.84; +/** How far ahead the limiter looks, in seconds: it turns down this long before a peak, so the peak is never over. */ +const LOOKAHEAD = 0.005; + +/** Samples a side the wave between two samples is worked out from. */ +const SIDE = 16; +/** How loud the wave is each eighth of the way from a sample to the next: windowed sinc, 16 a side. (A quarter's steps + * miss the top of a wave near 20 kHz by half a dB; 8 a side miss a wave near 22 kHz by as much.) */ +const BETWEEN = [1, 2, 3, 4, 5, 6, 7].map((k) => k / 8).map((f) => { + const taps = new Float32Array(2 * SIDE); + let sum = 0; + for (let k = 0; k < 2 * SIDE; k++) { + const x = k - (SIDE - 1) - f, w = 0.42 + 0.5 * Math.cos((Math.PI * x) / (SIDE + 0.5)) + 0.08 * Math.cos((2 * Math.PI * x) / (SIDE + 0.5)); + taps[k] = (x === 0 ? 1 : Math.sin(Math.PI * x) / (Math.PI * x)) * w; + sum += taps[k]; + } + return taps.map((t) => t / sum); +}); + +/** + * How loud each sample is with the wave around it: itself, or higher where the wave rises between it and a sample + * beside it (its true peak, as a converter plays it back). Worked out only near the loud ones: the wave goes no higher + * than twice its samples around it, so a sample and its neighbours under half `over` cannot reach it. + */ +function truePeaks(s: Float32Array, over: number): Float32Array { + const n = s.length, out = new Float32Array(n), half = over / 2; + for (let i = 0; i < n; i++) out[i] = s[i] < 0 ? -s[i] : s[i]; + for (let i = 0; i + 1 < n; i++) { + if (out[i] < half && out[i + 1] < half) continue; + for (const taps of BETWEEN) { + let v = 0; + const from = i - (SIDE - 1); + if (from >= 0 && from + 2 * SIDE <= n) for (let k = 0; k < 2 * SIDE; k++) v += taps[k] * s[from + k]; + else for (let k = 0; k < 2 * SIDE; k++) { const j = from + k; if (j >= 0 && j < n) v += taps[k] * s[j]; } + if (v < 0) v = -v; + if (v > out[i]) out[i] = v; + if (v > out[i + 1]) out[i + 1] = v; + } + } + return out; +} + +/** + * The limiter's gain each sample, the same for both sides: under the ceiling everywhere, between the samples too. What + * each sample needs (the ceiling over its loudest side's true peak, 1 at most) is taken at its lowest over the next 5 ms, then averaged over 5 ms — each + * average covers the window of every sample before the peak, so it is no higher than the peak needs, and the gain + * goes down smoothly over the 5 ms before it — and it comes back up over `release` seconds. + */ +function limiterGain(chans: readonly Float32Array[], rate: number, ceiling: number, release: number): Float32Array { + const n = chans[0].length, L = Math.max(1, Math.round(LOOKAHEAD * rate)); + const need = new Float32Array(n), peaks = chans.map((s) => truePeaks(s, ceiling)); + for (let i = 0; i < n; i++) { + let m = 0; + for (const p of peaks) if (p[i] > m) m = p[i]; + need[i] = m > ceiling ? ceiling / m : 1; + } + // The lowest over [i, i + L): a queue of indices whose needs rise, the front the lowest. + const low = new Float32Array(n), queue = new Int32Array(n + L); + let head = 0, tail = 0; + for (let j = 0; j < n + L - 1; j++) { + if (j < n) { + while (tail > head && need[queue[tail - 1]] >= need[j]) tail--; + queue[tail++] = j; + } + const i = j - L + 1; + if (i < 0) continue; + while (queue[head] < i) head++; + low[i] = need[queue[head]]; + } + // Averaged over (i − L, i], then released. (Before the first sample the need is 1: the sum starts at L ones.) + const gain = new Float32Array(n), back = release > 0 ? 1 - Math.exp(-1 / (release * rate)) : 1; + let sum = L, g = 1; + for (let i = 0; i < n; i++) { + sum += low[i] - (i >= L ? low[i - L] : 1); + const target = Math.min(1, sum / L); + g = target < g ? target : g + (target - g) * back; + gain[i] = g; + } + return gain; +} + +/** + * Many clips laid over one another, each from its own time (seconds), as one clip at the first's rate (or `rate`): + * what a song sounds like when it is mixed when the script is built. Two channels when any part has two. `seconds` + * makes it that long at least. + */ +export function mixdown(parts: unknown, seconds?: unknown, rate?: number): Clip { + if (!Array.isArray(parts) || parts.length === 0) throw new Error("mixdown() takes { clip, at } parts, one at least."); + const list = parts.map((p, k) => { + const c = (p as { clip?: unknown })?.clip, at = (p as { at?: unknown })?.at ?? 0; + if (!isClip(c)) throw new Error(`mixdown()'s part ${k + 1} has no clip.`); + if (num(at, "a part's time") < 0) throw new RangeError("A part's time is 0 seconds or more."); + return { c, at: at as number }; + }); + if (rate !== undefined) checkRate(rate); + const to = rate ?? list[0].c.rate; + const n = Math.max(...list.map((p) => p.c.channels)) as 1 | 2; + const length = Math.max(seconds === undefined ? 0 : Math.round(num(seconds, "mixdown()'s length") * to), ...list.map((p) => Math.round(p.at * to) + framesAt(p.c, to))); + if (length / to > MAX_SECONDS) throw new RangeError(`A clip is at most ${MAX_SECONDS / 60} minutes long.`); + return made(recipe("mixdown", [list.map((p) => [p.c, p.at]), length, to]), () => lazyClip(to, length, n, () => { + const out = Array.from({ length: n }, () => new Float32Array(length)); + // A clip played many times is resampled once. + const at = new Map(); + for (const p of list) { + let s = at.get(p.c); + if (!s) { s = channelsAt(p.c, to, n); at.set(p.c, s); } + const from = Math.round(p.at * to); + for (let side = 0; side < n; side++) { + const src = s[side], dst = out[side]; + for (let i = 0; i < src.length && from + i < length; i++) dst[from + i] += src[i]; + } + } + return out; + })); +} + +/** + * Clips that play together, each from its time — the bars of a song the game plays one after another and the layers + * over them — each turned down by one gain, so what they add up to never goes over `peak` (0.84, −1.5 dB), between the + * samples too, however loud each is: what a limiter does for a mix, for sounds the game adds up itself. Never turned + * up. A clip given several times is one clip back, and one file in the map. `over`: clips that may play on top, each at + * any of its times (a stinger on any bar line) — the gain leaves room for them too, and they come back after the parts, + * turned down with them. The gain is worked out when a build needs the samples, from the whole mix. + */ +export function together(parts: unknown, options?: { readonly peak?: number; readonly over?: readonly { readonly clip: Clip; readonly at: readonly number[] }[] }): Clip[] { + if (!Array.isArray(parts) || parts.length === 0) throw new Error("together() takes { clip, at } parts, one at least."); + const list = parts.map((p, k) => { + const c = (p as { clip?: unknown })?.clip, at = (p as { at?: unknown })?.at ?? 0; + if (!isClip(c)) throw new Error(`together()'s part ${k + 1} has no clip.`); + if (num(at, "a part's time") < 0) throw new RangeError("A part's time is 0 seconds or more."); + return { clip: c, at: at as number }; + }); + const peak = options?.peak ?? LIMIT_CEILING; + if (!(num(peak, "together()'s peak") > 0 && peak <= 1)) throw new RangeError(`together()'s peak is above 0 and 1 at most, not ${peak}.`); + const over = (options?.over ?? []).map((o, k) => { + if (!isClip(o?.clip) || !Array.isArray(o.at)) throw new Error(`together()'s over ${k + 1} is { clip, at: [seconds, …] }.`); + return { clip: o.clip, at: o.at.map((t) => num(t, "an over's time")) }; + }); + const whole = recipe("together", [list.map((p) => [p.clip, p.at]), peak, over.map((o) => [o.clip, o.at])]); + let gain: number | undefined; + const turn = () => { + if (gain === undefined) { + const sum = samplesOf(mixdown(list)); + let top = 0; + for (const side of sum) for (let i = 0; i < side.length; i++) { const x = side[i] < 0 ? -side[i] : side[i]; if (x > top) top = x; } + // The wave between the samples, near the loudest ones. + for (const side of sum) { const tp = truePeaks(side, top); for (let i = 0; i < tp.length; i++) if (tp[i] > top) top = tp[i]; } + // A clip that may play over them, at each time it may: the two together, between the samples too. + const rate = list[0].clip.rate; + for (const o of over) { + const extra = channelsAt(o.clip, rate, sum.length as 1 | 2); + for (const t of o.at) { + const from = Math.round(t * rate); + for (let side = 0; side < sum.length; side++) { + const both = new Float32Array(extra[side].length); + for (let i = 0; i < both.length; i++) both[i] = extra[side][i] + (sum[side][from + i] ?? 0); + const tp = truePeaks(both, top); + for (let i = 0; i < tp.length; i++) if (tp[i] > top) top = tp[i]; + } + } + } + gain = top > peak ? peak / top : 1; + } + return gain; + }; + const back = new Map(); + return [...list, ...over].map(({ clip }) => { + let c = back.get(clip); + if (!c) { + const own = inside.get(clip)!; + c = made(recipe("together part", [whole, clip]), () => lazyClip(clip.rate, own.frames, clip.channels, () => { const g = turn(); return samplesOf(clip).map((side) => { const out = new Float32Array(side.length); for (let i = 0; i < side.length; i++) out[i] = side[i] * g; return out; }); })); + back.set(clip, c); + } + return c; + }); +} + +/** A clip's samples, mono (for the compiler's own work and the editor's player, not in the declarations). */ +export const clipSamples = (c: Clip): Float32Array => { + const ch = samplesOf(c); + return ch.length === 1 ? ch[0] : each(ch[0], (x, i) => (x + ch[1][i]) / 2); +}; +/** A clip's channels as they are. */ +export const clipChannels = (c: Clip): readonly Float32Array[] => samplesOf(c); + +/** + * At this length or more a clip a program plays goes in the map as Ogg Vorbis (Remastered plays it; 1.16.1 plays only + * WAVs): a song, mixed, is a tenth the size and keeps its top and its two sides. Shorter ones stay WAVs, which start on + * their frame (the sound probe), as do an import as it was (its own ADPCM), a clip a trigger plays (`runtime.ts`), and + * one made `wav()`. One made `ogg()` is an Ogg however short. + */ +export const VORBIS_SECONDS = 2; +/** libvorbis's quality, −1 … 10: 5 is about 160 kbit/s for two channels at 44.1 kHz. */ +export const VORBIS_QUALITY = 5; +/** + * Goes into every file's name with the clip's key: a clip made the same way is the same file, so a map keeps the bytes + * of one it already has (`withSounds`). When what a method does changes, this changes, and a map's files are made again. + */ +const ENGINE = "sound 3"; + +/** What the map's archive gets for a clip: a WAV's bytes now, or the samples of an Ogg Vorbis encoded when the map is built. */ +export interface ClipFile { + path: string; + bytes?: Uint8Array; + vorbis?: { rate: number; channels: readonly Float32Array[]; quality: number }; +} + +/** + * The file the map's archive gets for a clip, and the name it goes under: named by what the clip is (its key), so it + * goes in once however often it is played, and the name is known without the samples. `contents`: the bytes (or, for + * an Ogg, the samples to encode) too — a build's; a check, which the map does not get, leaves them out and makes none. + */ +export function clipFile(c: Clip, contents = true): ClipFile { + const own = inside.get(c)!; + if (!own.wav && !own.adpcm && (c.seconds >= VORBIS_SECONDS || own.quality !== undefined)) { + // Two channels always: the game plays an Ogg of one channel 2.4 dB quieter than it was (a WAV of one at its level). + const quality = own.quality ?? VORBIS_QUALITY; + const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|vorbis ${quality} stereo|${keyOf(c)}`)}.ogg`; + if (!contents) return { path }; + const chans = samplesOf(c); + return { path, vorbis: { rate: c.rate, channels: chans.length === 1 ? [chans[0], chans[0]] : chans, quality } }; + } + const path = `staredit\\wav\\trigscript-${hashText(`${ENGINE}|wav|${keyOf(c)}`)}.wav`; + if (!contents) return { path }; + // An import as it was keeps its own ADPCM; anything else mono is ADPCM where that is faithful, 16-bit where not; two + // channels are 16-bit. + const channels = samplesOf(c); + let bytes: Uint8Array; + if (channels.length === 2) bytes = pcmWav(channels, c.rate); + else { + const coded = own.adpcm ?? encodeAdpcm(channels[0], c.rate); + bytes = own.adpcm || adpcmWillDo(channels[0], c.rate, coded) ? adpcmWav(coded, c.rate, channels[0].length) : pcmWav(channels, c.rate); + } + return { path, bytes }; +} + +/** The value an import of a `.sound` file gives, kept while the text is the same (a build reads the files each time). */ +const values = new Map(); + +export function clipValue(text: string): Clip { + const hit = values.get(text); + if (hit) return hit; + const d = readSound(text); + const clip = makeClip(d.samples, d.rate, d.adpcm); + if (values.size >= 16) values.delete(values.keys().next().value!); + values.set(text, clip); + return clip; +} + +/* ── Clips from nothing ── */ + +const NOTE = /^([A-Ga-g])([#b]?)(-?\d)$/; +const SEMITONES: Record = { c: 0, d: 2, e: 4, f: 5, g: 7, a: 9, b: 11 }; + +/** A pitch in hertz from a number or a note's name: "A4" is 440, "C#5", "Eb3". */ +export function hertz(pitch: unknown): number { + if (typeof pitch === "number") { if (!(pitch > 0 && pitch < 24000)) throw new RangeError(`A pitch is 0 to 24 000 Hz, not ${pitch}.`); return pitch; } + const m = typeof pitch === "string" ? NOTE.exec(pitch.trim()) : null; + if (!m) throw new Error(`A pitch is hertz or a note's name ("A4", "C#5", "Eb3"), not ${JSON.stringify(pitch)}.`); + const midi = 12 * (Number(m[3]) + 1) + SEMITONES[m[1].toLowerCase()] + (m[2] === "#" ? 1 : m[2] === "b" ? -1 : 0); + return 440 * 2 ** ((midi - 69) / 12); +} + +interface MakeOptions { readonly volume?: number; readonly rate?: number } +const optionsOf = (o: MakeOptions | undefined, what: string) => { + const volume = o?.volume ?? 0.5, rate = o?.rate ?? DEFAULT_RATE; + if (!(volume >= 0 && volume <= 1)) throw new RangeError(`${what}'s volume is 0 to 1, not ${String(o?.volume)}.`); + checkRate(rate); + return { volume, rate }; +}; +const length = (seconds: unknown, what: string, rate: number) => { + if (!(num(seconds, `${what}'s length`) > 0 && (seconds as number) <= MAX_SECONDS)) throw new RangeError(`${what} lasts more than 0 and at most ${MAX_SECONDS} seconds, not ${String(seconds)}.`); + return Math.max(1, Math.round((seconds as number) * rate)); +}; + +/** A pitch, or a glide, checked: hertz or a note's name, or [seconds, pitch] points (a kick's drop, a slide between notes). */ +const pitchOf = (pitch: unknown, what: string): number | [number, number][] => { + if (!Array.isArray(pitch)) return hertz(pitch); + if (pitch.length === 0) throw new Error(`${what}'s pitch points are [seconds, pitch] pairs, one at least.`); + return pitch.map((p, k) => { + if (!Array.isArray(p) || p.length !== 2) throw new Error(`${what}'s pitch point ${k + 1} is [seconds, pitch].`); + return [num(p[0], "a pitch point's seconds"), hertz(p[1])] as [number, number]; + }); +}; + +type Wave = "sine" | "square" | "triangle" | "saw"; +/** PolyBLEP: what rounds off a jump at phase 0 of a wave whose phase moves `dt` a sample. */ +function blep(t: number, dt: number): number { + if (t < dt) { t /= dt; return t + t - t * t - 1; } + if (t > 1 - dt) { t = (t - 1) / dt; return t * t + t + t + 1; } + return 0; +} + +/** One sample of a wave at phase `p` (0 … 1), `dt` the phase a sample moves: square and saw rounded off by PolyBLEP. */ +function waveAt(wave: Wave, p: number, dt: number): number { + switch (wave) { + case "sine": return Math.sin(2 * Math.PI * p); + case "square": { const q = p + 0.5; return (p < 0.5 ? 1 : -1) + blep(p, dt) - blep(q >= 1 ? q - 1 : q, dt); } + case "triangle": return 1 - 4 * Math.abs(p - 0.5); + case "saw": return 2 * p - 1 - blep(p, dt); + } +} + +/** A clip made from nothing by `op`: its arguments name it, its samples are worked out when they are asked for. */ +const source = (op: string, args: unknown, rate: number, n: number, channels: 1 | 2, compute: () => Float32Array[]) => made(recipe(op, args), () => lazyClip(rate, n, channels, compute)); + +/** + * A tone: `pitch` (hertz, a note's name, or [seconds, pitch] points to glide) for `seconds`, its wave's shape, 5 ms in + * and out so it does not click. `voices`: that many copies, spread `detune` cents from lowest to highest, each from + * its own phase — a supersaw, a wide pad — and `width` 0 … 1 spreads them left to right (two channels). + */ +export function tone(pitch: unknown, seconds: unknown, options?: MakeOptions & { readonly wave?: Wave; readonly voices?: number; readonly detune?: number; readonly width?: number }): Clip { + const { volume, rate } = optionsOf(options, "tone()"), n = length(seconds, "tone()", rate); + const wave = options?.wave ?? "sine"; + if (!["sine", "square", "triangle", "saw"].includes(wave)) throw new Error(`tone()'s wave is "sine", "square", "triangle" or "saw", not ${JSON.stringify(wave)}.`); + const voices = options?.voices ?? 1, detune = options?.detune ?? (voices > 1 ? 20 : 0), width = options?.width ?? 0; + if (!(Number.isInteger(voices) && voices >= 1 && voices <= 16)) throw new RangeError(`tone()'s voices is a whole number, 1 to 16, not ${voices}.`); + if (!(num(detune, "tone()'s detune") >= 0 && detune <= 1200)) throw new RangeError(`tone()'s detune is 0 to 1 200 cents, not ${detune}.`); + if (!(num(width, "tone()'s width") >= 0 && width <= 1)) throw new RangeError(`tone()'s width is 0 to 1, not ${width}.`); + const p = pitchOf(pitch, "tone()"), stereo = voices > 1 && width > 0; + return source("tone", [p, n, rate, wave, volume, voices, detune, width], rate, n, stereo ? 2 : 1, () => { + const hz = curve(p, rate, n, true), ramp = 0.005 * rate; + const L = new Float32Array(n), R = stereo ? new Float32Array(n) : L, random = seeded(0x51ed + voices); + const one = voices === 1 ? 1 : 1 / Math.sqrt(voices); + for (let v = 0; v < voices; v++) { + const at = voices === 1 ? 0 : v / (voices - 1) - 0.5, ratio = 2 ** ((detune * at) / 1200); + const pos = stereo ? width * 2 * at : 0, l = one * Math.min(1, 1 - pos), r = one * Math.min(1, 1 + pos); + let ph = voices === 1 ? 0 : random(); + for (let i = 0; i < n; i++) { + const dt = (hz[i] * ratio) / rate, x = waveAt(wave, ph, dt); + if (stereo) { L[i] += l * x; R[i] += r * x; } else L[i] += one * x; + ph += dt; ph -= Math.floor(ph); + } + } + for (let i = 0; i < n; i++) { + const g = volume * Math.min(1, i / ramp, (n - 1 - i) / ramp); + L[i] *= g; if (stereo) R[i] *= g; + } + return stereo ? [L, R] : [L]; + }); +} + +/** + * Two-operator FM — a sine whose phase another sine, `ratio` times its pitch, pushes by `index` (or [seconds, index] + * points: bright at the start, mellow after). 1 a warm reed, 3.5 or 7 a bell, 0.5 a bass. + */ +export function fm(pitch: unknown, seconds: unknown, options?: MakeOptions & { readonly ratio?: number; readonly index?: number | readonly (readonly [number, number])[]; readonly feedback?: number }): Clip { + const { volume, rate } = optionsOf(options, "fm()"), n = length(seconds, "fm()", rate); + const ratio = options?.ratio ?? 1, index = options?.index ?? 2, feedback = options?.feedback ?? 0; + if (!(num(ratio, "fm()'s ratio") > 0 && ratio <= 32)) throw new RangeError(`fm()'s ratio is more than 0, at most 32, not ${ratio}.`); + if (!(num(feedback, "fm()'s feedback") >= 0 && feedback <= 2)) throw new RangeError(`fm()'s feedback is 0 to 2, not ${feedback}.`); + if (typeof index !== "number" && !Array.isArray(index)) throw new Error("fm()'s index is a number or [seconds, index] points."); + const p = pitchOf(pitch, "fm()"); + return source("fm", [p, n, rate, ratio, index, feedback, volume], rate, n, 1, () => { + const out = fmVoice(curve(p, rate, n, true), rate, ratio, curve(index as number | Points, rate, n), feedback), ramp = 0.005 * rate; + for (let i = 0; i < n; i++) out[i] *= volume * Math.min(1, i / ramp, (n - 1 - i) / ramp); + return [out]; + }); +} + +/** + * A plucked string (Karplus–Strong) — a guitar, a harp, a koto. `brightness` 0 … 1 (0.5), `decay` seconds to fade away + * (2 when left out; the note ends at `seconds` regardless). + */ +export function pluck(pitch: unknown, seconds: unknown, options?: MakeOptions & { readonly brightness?: number; readonly decay?: number }): Clip { + const { volume, rate } = optionsOf(options, "pluck()"), n = length(seconds, "pluck()", rate), hz = hertz(pitch); + const brightness = options?.brightness ?? 0.5, decay = options?.decay ?? 2; + if (!(num(brightness, "pluck()'s brightness") >= 0 && brightness <= 1)) throw new RangeError(`pluck()'s brightness is 0 to 1, not ${brightness}.`); + if (!(num(decay, "pluck()'s decay") > 0)) throw new RangeError(`pluck()'s decay is seconds, more than 0, not ${decay}.`); + if (hz < 20 || hz > rate / 4) throw new RangeError(`pluck() plays 20 Hz to a quarter of its rate, not ${hz.toFixed(0)} Hz.`); + return source("pluck", [hz, n, rate, brightness, decay, volume], rate, n, 1, () => { + const out = pluckVoice(hz, rate, n, brightness, decay), ramp = 0.003 * rate; + let peak = 0; + for (let i = 0; i < n; i++) peak = Math.max(peak, Math.abs(out[i])); + for (let i = 0; i < n; i++) out[i] *= (volume / (peak || 1)) * Math.min(1, (n - 1 - i) / ramp); + return [out]; + }); +} + +/** Noise for `seconds`: every sample a random one, the same on every build. */ +export function noise(seconds: unknown, options?: MakeOptions): Clip { + const { volume, rate } = optionsOf(options, "noise()"), n = length(seconds, "noise()", rate); + return source("noise", [n, rate, volume], rate, n, 1, () => { + let seed = 0x2545f491; + const out = new Float32Array(n); + for (let i = 0; i < n; i++) { seed ^= seed << 13; seed ^= seed >>> 17; seed ^= seed << 5; out[i] = volume * ((seed >>> 0) / 0x80000000 - 1); } + return [out]; + }); +} + +/** Nothing, for `seconds`: a gap to join between clips. */ +export function silence(seconds: unknown, options?: { readonly rate?: number }): Clip { + const { rate } = optionsOf(options, "silence()"), n = length(seconds, "silence()", rate); + return source("silence", [n, rate], rate, n, 1, () => [new Float32Array(n)]); +} diff --git a/compiler/stdlib.ts b/compiler/stdlib.ts new file mode 100644 index 0000000..9a7efe7 --- /dev/null +++ b/compiler/stdlib.ts @@ -0,0 +1,31 @@ +/** + * The standard library: modules written in TrigScript that ship with the compiler, imported as "trigscript/" + * (TrigScript 4). A module joins a script's files only when the script imports it — it may hold a program of its own + * (fog's canvas runs every frame) — under the folder below, where the editor finds it too, for hovers and go to + * definition. Each is TrigScript like any script's: what it shows a map maker is how the language is used. + */ + +import { STDLIB_EXTENSIONS, STDLIB_SOURCES } from "./generated/stdlib"; +import { stdlibFile } from "./link"; + +export { STDLIB_DIR, stdlibFile } from "./link"; + +/** Each module's source, by its name after "trigscript/": `stdlib/.ts` (or `.tsx`), embedded by `npm run embed`. */ +export const STDLIB: Readonly> = STDLIB_SOURCES; +/** Each module's extension: `.tsx` for one with JSX in it. */ +export const STDLIB_EXT: Readonly> = STDLIB_EXTENSIONS; +/** Where a module of the library is among the script's files. */ +export const stdlibPath = (name: string): string => stdlibFile(name, STDLIB_EXT[name] ?? ".ts"); + +/** The library modules a set of files imports, and those they import in turn. */ +export function stdlibImports(texts: Iterable): string[] { + const found = new Set(); + const scan = (text: string) => { + for (const m of text.matchAll(/(?:from|import)\s*["']trigscript\/([A-Za-z0-9_-]+)["']/g)) { + const name = m[1]; + if (!found.has(name) && name in STDLIB) { found.add(name); scan(STDLIB[name]); } + } + }; + for (const t of texts) scan(t); + return [...found]; +} diff --git a/compiler/structured.ts b/compiler/structured.ts index c75d687..913a173 100644 --- a/compiler/structured.ts +++ b/compiler/structured.ts @@ -57,19 +57,34 @@ * expression, with a note that it ran when the script was built. */ import type * as TS from "typescript"; -import { ActionType, PlayerGroup } from "../vendor/triggers"; +import { ActionFlag, ActionType, ConditionFlag, PlayerGroup } from "../vendor/triggers"; import type { ActionRecord } from "../vendor/triggers"; import type { HoistedThunks, ProgramPlan } from "./hoist"; -import { declarationOf, libraryCallName } from "./hoist"; +import { clockCall, declarationOf, isOnCall, isScriptClass, isScriptFunction, keepsState, libraryCallName, ON_EVENTS, scriptArrow } from "./hoist"; +import { ORDER_NAMES } from "./orders"; +import { DEAF_KEYS, KEY_NAMES, keyName, parseChatPattern, type ChatPattern, type InputSource } from "./input"; import { scriptParams } from "./api"; -import { hasTextMark, isAction, isBuilder, isChat, isCondition, isDuration, isGameFunction, isGameValue, isInput, isMouse, isPrint, isRead, isReader, isTable, isTrigger, isUnitPick, isUnitQuery, playerColor, READ_ARITY, textParts, type GameFunctionValue, type InputValue, type ReadValue, type ScriptString, type TableValue, type UnitPickValue } from "./runtime"; +import { brandOfType, wholeArithmetic, wholeQuotient } from "./hoist"; +import { hasTextMark, isAction, isBuilder, isChat, isCondition, isDuration, isGameFunction, isGameValue, isInput, isMouse, isPrint, isRead, isReader, isTable, isTrigger, isUnitPick, isUnitQuery, playerColor, READ_ARITY, ScriptError, textParts, type ConditionValue, type GameFunctionValue, type InputValue, type ReadValue, type ScriptString, type TableValue, type UnitPickValue, isSelected, isSelectionCount, SELECTION_ADDRESS } from "./runtime"; import { cellMax } from "./tables"; -import { HANDLE_PARTS, Scope, TEXT_PARTS, THIS, UNIT_PARTS, type Binding, type Place, type RowField, type RowShape } from "./scope"; +import { markViewWrites, type View } from "./views"; +import { DEFAULT_COLOR, EMPTY_CELL, EMPTY_COLOR, FILLED_CELL, JSX_ELEMENTS, TEXT_COLORS, colorCode } from "./jsx"; +import { CLOSURE_CAP, countReferences, FN_BASE, makePool, type ClosurePool, type PoolMaker } from "./closures"; +import { HANDLE_PARTS, Scope, TEXT_PARTS, THIS, UNIT_PARTS, type Binding, type FnValue, type Place, type RowField, type RowShape, type TypeArgs } from "./scope"; import { ACTIONS_WITH_MODIFIER, LowerError } from "./lower"; -import { I32_MAX, I32_MIN, IR_VERSION, TEXT_BYTES, U32_MAX, UNIT_FLAGS, UNIT_NUM_FIELDS, UNIT_WRITABLE, eachCall, textHasId, type ActionVariable, type ArithOp, type ArrayDecl, type At, type BoolExpr, type Call, type CompareOp, type FuncDecl, type NumExpr, type Program, type Stmt, type TextExpr, type TextPart, type UnitExpr, type UnitFlag, type UnitNumField, type UnitVerb, type VarDecl } from "./ir"; +import { FIXED_MAX, FIXED_MIN, fixedRaw } from "./numbers"; +import { ERROR_CODES, FIRST_ERROR_CLASS, I32_MAX, I32_MIN, TASK_MAX, TEXT_BYTES, U32_MAX, UNIT_FLAGS, UNIT_NUM_FIELDS, UNIT_WRITABLE, declarations, eachCall, eachStart, programDeclarations, textHasId, type ActionVariable, type ArithOp, type ArrayDecl, type At, type BoolExpr, type Call, type CompareOp, type EventSource, type FuncDecl, type NumExpr, type Program, type Stmt, type TextExpr, type TextPart, type UnitExpr, type UnitFlag, type UnitNumField, type UnitVerb, type VarDecl } from "./ir"; + +/** Where the numbers that say which class a row of the object table is start (B9), in the pool's function column: apart from functions', for whoever reads the column. */ +const OBJECT_TAG = 1 << 22; + +/** Bindings that are there once they are made, never null or undefined: what `x !== undefined` of one is known to be. */ +const PRESENT_KINDS: ReadonlySet = new Set(["array", "records", "record", "grid", "lists", "texts", "hash", "keyed", "row"]); /** The outcome of a thunk, kept so it runs once whatever asks. */ type Outcome = { ok: true; value: unknown } | { ok: false; error: unknown }; +/** What an on.…() of units looks for, known when the script is built: the units, and on.order()'s order or on.killed()'s killer. */ +interface EventFilter { type?: number; owner?: number; at?: number; order?: number; by?: number } /** A body the walker can be in: a program's, or a `game()` function's from any file. */ export interface Body { @@ -98,6 +113,16 @@ export interface StructuredContext { error(node: TS.Node, message: string, source?: "compiler" | "script"): void; /** The body of a `game()` function, from the value the run made for it; undefined when the compiler cannot place it. */ resolve(fn: GameFunctionValue): Body | undefined; + /** The body of a class at the top of one of the script's files (`hoist.ts#planClass`); undefined when there is none. */ + classBody?(cls: TS.ClassDeclaration): Body | undefined; + /** The body of a function at the top of one of the script's files (`hoist.ts#planFunction`); undefined when there is none. */ + functionBody?(fn: TS.FunctionDeclaration | TS.ArrowFunction | TS.FunctionExpression): Body | undefined; + /** Closures over locals: the declarations a walk before found kept functions using, kept in environments this walk. */ + boxed?: ReadonlySet; + /** References (B9): the classes a walk before found the program keeping by reference, whose instances are rows of the object table this walk. */ + refs?: ReadonlySet; + /** Every class declared in the script's files, top-level or not: what a type may be an instance of. */ + classes?: readonly TS.ClassDeclaration[]; } /** A hoisted expression threw when it was evaluated: reported at the expression, as the script's own error. */ @@ -113,8 +138,55 @@ interface Ctx { /** Inside a loop (or, for `break`, a switch): what the jump statements may do here. */ canBreak?: boolean; canContinue?: boolean; - /** Inside an inlined function: what it returns. `instance`: it returns an instance — which one is settled as its body is walked, and kept here. */ - fn?: { kind: Kind | "text" | "void"; instance?: { name: string; made?: Binding } }; + /** + * Inside an inlined function: what it returns. `instance`: it returns an instance — which one is settled as its body is walked, and kept here. + * `finallies`: the `finally` blocks a `return` here leaves, innermost last (B4), each run before it; `inFinally`: this is one, which nothing leaves. + */ + fn?: { kind: Kind | "text" | "void"; instance?: { name: string; made?: Binding; list?: boolean }; finallies?: TS.Block[]; inFinally?: boolean }; + /** Inside the body of a for…of over a generator, walked where a `yield` is: `break` sets this first, and the generator then leaves. */ + breakFlag?: VarDecl; + /** The `finally` blocks a `break` or a `continue` here leaves, innermost last (B4): a loop starts with none, a switch keeps its loop's for `continue`. */ + exits?: { break: TS.Block[]; continue: TS.Block[] }; + /** Walking a `finally` block, outside any loop of its own: a `break` or `continue` here would leave it. */ + inFinally?: boolean; +} + +/** The built-in classes of error a program throws: their names. */ +type BuiltinError = keyof typeof ERROR_CODES; + +/** + * What a `catch (e)` takes (B4): the error in flight, copied into variables of the catch's own as the body reads it — + * `e.message`, `e.name`, a field of its class — so that another error thrown and caught inside the catch changes none + * of them. `entry` is the catch's first statement, filled once the whole program has been walked (`finishErrors`). + */ +interface Caught { + name: string; + at: At; + label: string; + entry: Stmt & { kind: "block" }; + /** The code of its class, for `instanceof`, `e.name` and throwing it on. */ + code?: VarDecl; + message?: VarDecl; + jsName?: VarDecl; + /** The fields of its class it was read for, by `name:kind`, each the copy of the variable the error in flight keeps it in. */ + fields: Map; + /** Something throws it on: all of it is copied, so that it goes on whole. */ + rethrown: boolean; +} + +/** The error in flight (`Program.errors`): its class's code, its message, the fields of its class by `name:kind`, the names of the classes by their code. */ +interface Flight { thrown: VarDecl; message: VarDecl; fields: Map; names: ArrayDecl } + +/** The fields of a caught error, made as they are asked for: `make` gives the binding of one, or undefined for a name no error has. */ +class CaughtFields extends Map { + private readonly make: (key: string) => Binding | undefined; + constructor(make: (key: string) => Binding | undefined) { super(); this.make = make; } + override get(key: string): Binding | undefined { + let b = super.get(key); + if (!b) { b = this.make(key); if (b) super.set(key, b); } + return b; + } + override has(key: string): boolean { return this.get(key) !== undefined; } } /** What a variable of a program holds. */ @@ -155,6 +227,27 @@ function describe(v: unknown): string { /** The player a trigger is running for, as a player number. */ const CURRENT_PLAYER = 13; +/** A view of ui.lines nothing changed is shown again after this many frames: the game's lines fade in less than six seconds. */ +const REFRESH_FRAMES = 24; + +/** JSX that is a text: an element, one that closes itself, or a fragment. */ +type JsxNode = TS.JsxElement | TS.JsxSelfClosingElement | TS.JsxFragment; +/** A colour a piece of JSX is written in: `key` tells two apart when the script is built, `part` says it in the text. */ +interface JsxColor { key: string; part: TextPart; v?: VarDecl } +/** A JSX text being lowered: its buffer, the parts not yet written, whether something was written (in the game: `started`), the colour showing. */ +interface JsxBuild { buffer: string; pending: TextPart[]; written: "no" | "yes" | "maybe"; started?: VarDecl; shown: string | null; at: At; label: string } + +/** JSX text between elements as React reads it: a line break and the spaces around it go, and lines with nothing else join with a space. */ +function jsxWhitespace(raw: string): string { + const lines = raw.split(/\r\n|\n|\r/); + if (lines.length === 1) return raw; + return lines.map((l, i) => { + let t = l.replace(/\t/g, " "); + if (i > 0) t = t.trimStart(); + if (i < lines.length - 1) t = t.trimEnd(); + return t; + }).filter((t) => t.length).join(" "); +} /** Neighbouring pieces of written text as one. */ function mergeText(parts: TextPart[]): TextPart[] { @@ -194,6 +287,14 @@ type Instance = Extract; /** The most cells an array of a program has: each is four bytes of the built map, twelve times over in a per-player program. */ const MAX_ARRAY = 4096; +/** + * A list the script worked out when it was built and a program only reads (a table: `price[level]`, a video's brushes): + * in the map as it loads, four bytes an entry, and never initialised by a trigger, so it may be far longer than an + * array of the program. What bounds it is the build's data (the IR is packed for it, `eud.ts#serializeIr`). + */ +const MAX_TABLE = 1 << 20; +/** How many text lines the game shows at once (the UI spike, 2026-10-16). */ +const TEXT_LINES = 11; const TRUE: BoolExpr = { kind: "const", value: true }; const FALSE: BoolExpr = { kind: "const", value: false }; const num = (value: number): NumExpr => ({ kind: "const", value }); @@ -218,10 +319,22 @@ const LIST_MAKERS = new Set(["map", "filter", "sort", "reverse"]); const SEARCHES = new Set(["some", "every", "find", "findLast", "findIndex", "findLastIndex", "reduce"]); /** What a call passes for a parameter: the value a parameter that is a variable is set to, or the array (the record) the parameter stands for. */ -type CallArgument = { init: NumExpr | BoolExpr | UnitExpr | TextExpr; label: string } | { binding: Binding }; +type CallArgument = { init: NumExpr | BoolExpr | UnitExpr | TextExpr; label: string; /** The instance a method is called on, by reference (B9): the classes it may be, for its `this`. */ self?: readonly TS.ClassDeclaration[] } | { binding: Binding }; /** What a call that is not written as one hands `inline`: a method's instance, and — for `new`, a getter, a setter — the arguments, that nothing comes back, and what runs first in the body. */ -interface MethodCall { self?: Binding; args?: readonly TS.Expression[]; nothing?: boolean; first?: (scope: Scope) => void; /** The call gives an instance: where the one it returns is kept, and the name one it makes is declared under. */ instance?: { name: string; made?: Binding }; /** What runs before the body, inside the call: the calls before this one in a chain (`v.add(w).scale(2)`), so that they run where the expression does. */ before?: Stmt[] } +interface MethodCall { /** A function kept as a value (B3): the scope it was written in, which its body sees. */ closure?: Scope | null; /** A generator, walked by a for…of: a copy of its body at each loop. */ generator?: boolean; self?: Binding; args?: readonly TS.Expression[]; nothing?: boolean; first?: (scope: Scope) => void; /** The call gives an instance: where the one it returns is kept, and the name one it makes is declared under. */ instance?: { name: string; made?: Binding }; /** What runs before the body, inside the call: the calls before this one in a chain (`v.add(w).scale(2)`), so that they run where the expression does. */ before?: Stmt[] } + +/** What a value of a function's parameters or result is, for a function kept as a value. */ +type ValueKind = Kind | "text"; + +/** A function kept as a value (B3): its number, the function that is called through it, and what it takes and gives. `env`: the parameter it is handed the environment it was made in through, the environment of `envScope` (closures over locals). */ +interface FnTarget { id: number; fd: FuncDecl; params: ValueKind[]; result: ValueKind | "void"; name: string; env?: VarDecl; envScope?: Scope } + +/** A variable kept in an environment: its cell there, at any environment's row, and how a row's copy of it is made from another's. */ +interface BoxedCell { home: Scope; at: (index: NumExpr) => Binding; copy: (to: NumExpr, from: NumExpr) => Stmt[] } + +/** The function a call through a value goes through, for calls of one shape: its first parameter is the number of the function to run. */ +interface Dispatcher { fd: FuncDecl; params: ValueKind[]; result: ValueKind | "void"; at: At; label: string } /** A function at one set of arrays passed: inlined where it was first met, called from then on (`fn`), or never to be (`never` says why). */ interface FunctionSite { @@ -249,6 +362,21 @@ function mentions(root: unknown, kind: string): boolean { export interface Emitted { program: Program; nodeOf(node: object): TS.Node | undefined; + /** Declarations kept functions use that this walk did not keep in environments: walk again with them in `boxed`. */ + boxes?: TS.Node[]; + /** The variables that hold an environment (a function that calls itself may not declare one yet). */ + envVars?: ReadonlySet; + /** Classes this walk found the program needs to keep by reference that it did not: walk again with them in `refs`. */ + refs?: TS.ClassDeclaration[]; +} + +/** A field of a value of the build, read as the program is lowered: a getter that refuses says why, as a call would. */ +function readField(value: unknown, name: string): unknown { + try { + return (value as Record)[name]; + } catch (err) { + throw new LowerError(err instanceof Error ? err.message : String(err)); + } } export class Structured { @@ -257,6 +385,25 @@ export class Structured { /** The body being walked: the program's, or the game function being inlined. */ private body: Body; private inlineDepth = 0; + /** An async function with no await in it (nor a for await): its name or the node to say so at; undefined for any other. */ + private neverAwaits(decl: TS.Node): TS.Node | undefined { + const { ts } = this; + if (!(ts.isFunctionDeclaration(decl) || ts.isMethodDeclaration(decl) || ts.isArrowFunction(decl) || ts.isFunctionExpression(decl)) || decl.asteriskToken || !decl.body) return undefined; + if (!ts.getModifiers(decl)?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return undefined; + let found = false; + const look = (n: TS.Node): void => { if (found || (n !== decl.body && ts.isFunctionLike(n))) return; if (ts.isAwaitExpression(n) || (ts.isForOfStatement(n) && n.awaitModifier)) { found = true; return; } ts.forEachChild(n, look); }; + look(decl.body); + return found ? undefined : (decl.name ?? decl); + } + + /** What a `yield` does in each generator a for…of is walking, innermost last. */ + private yields: ((y: TS.YieldExpression) => void)[] = []; + /** The `start` statements so far: the next one's number is one more. */ + private taskCount = 0; + /** The views of `ui.lines` in the program: their flags, and what puts each text together. */ + private readonly views: View[] = []; + /** The inline depth at which the next call is a task's (`start(wave(n))`): its parameters are copies of the arguments, never the caller's variables. */ + private copyArgsAt = -1; private scope: Scope = new Scope(null); /** The program's outermost scope: what an inlined function of the body closes over. */ private readonly topScope = this.scope; @@ -275,27 +422,186 @@ export class Structured { private readonly walkingBodies = new Map(); private readonly identities = new WeakMap(); private lastIdentity = 0; + /** A function by name (a declared one, a const of the script's) as a value: one each, so that it is the same function wherever it is named. */ + private readonly fnValues = new Map(); + /** The functions kept as values (B3): each a number, and a function that is called through it. */ + private readonly targets = new Map(); + /** What a call through a value goes through: one function a shape of call, which runs the function whose number it is handed. */ + private readonly dispatchers = new Map(); + /** Functions given a number whose bodies are still to be walked: once the program's is, so that they see every name it declares, as JavaScript's do. */ + /** How many attempts to make a function one that is called are under way (`callable`): their errors are caught. */ + private attempts = 0; + private readonly pendingTargets: { fn: FnValue; t: FnTarget; node: TS.Node; returned: TS.Type; typeArgs?: TypeArgs[] }[] = []; + /** What type parameters stand for while a generic class's instance or a generic function's call is walked: innermost last. */ + private typeArgs: TypeArgs[] = []; + + /** A type parameter as what it stands for here; any other type as it is. */ + private resolveType(t: TS.Type): TS.Type { + for (let depth = 0; depth < 8 && t.flags & this.ts.TypeFlags.TypeParameter; depth++) { + let found: TS.Type | undefined; + for (let i = this.typeArgs.length - 1; i >= 0 && !found; i--) found = t.symbol ? this.typeArgs[i].get(t.symbol) : undefined; + if (!found) break; + t = found; + } + return t; + } + + /** A union's members (or the one type), each type parameter as what it stands for. */ + private membersOf(type: TS.Type): TS.Type[] { + return (type.isUnion() ? type.types : [type]).map((t) => this.resolveType(t)); + } + + /** `fn` with these type arguments in force (none: as it is). */ + private withTypeArgs(args: TypeArgs | undefined, fn: () => T): T { + if (!args || !args.size) return fn(); + this.typeArgs.push(args); + try { return fn(); } finally { this.typeArgs.pop(); } + } + + /** What a generic class's type parameters are in `new C<…>(…)`, by what the checker says the instance is. */ + private classTypeArgs(cls: TS.ClassDeclaration, at: TS.Node): TypeArgs | undefined { + if (!cls.typeParameters?.length) return undefined; + const type = this.c.checker.getTypeAtLocation(at); + const args = (type.flags & this.ts.TypeFlags.Object) && ((type as TS.ObjectType).objectFlags & this.ts.ObjectFlags.Reference) ? this.c.checker.getTypeArguments(type as TS.TypeReference) : []; + const out: TypeArgs = new Map(); + cls.typeParameters.forEach((tp, i) => { const sym = this.c.checker.getSymbolAtLocation(tp.name); if (sym && args[i]) out.set(sym, this.resolveType(args[i])); }); + return out; + } + + /** + * What a generic function's type parameters are at a call (`shuffle(names)`), by the types the checker gave its + * parameters there: one declared `T` is the argument's type, one declared `T[]` its element's. + */ + private callTypeArgs(decl: TS.Node, call: TS.Expression): TypeArgs | undefined { + const { ts } = this; + const params = (decl as { typeParameters?: TS.NodeArray }).typeParameters; + if (!params?.length || !ts.isCallExpression(call)) return undefined; + const signature = this.c.checker.getResolvedSignature(call); + const declared = (decl as TS.SignatureDeclaration).parameters ?? []; + const out: TypeArgs = new Map(); + for (const tp of params) { + const sym = this.c.checker.getSymbolAtLocation(tp.name); + if (!sym) continue; + declared.forEach((p, i) => { + if (out.has(sym) || !p.type || !signature?.parameters[i]) return; + const given = this.c.checker.getTypeOfSymbolAtLocation(signature.parameters[i], call); + let node: TS.TypeNode = p.type; + while (ts.isTypeOperatorNode(node)) node = node.type; + if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && node.typeName.text === tp.name.text) out.set(sym, given); + else if (ts.isArrayTypeNode(node) && ts.isTypeReferenceNode(node.elementType) && ts.isIdentifier(node.elementType.typeName) && node.elementType.typeName.text === tp.name.text) { + const element = this.c.checker.getIndexTypeOfType(this.c.checker.getNonNullableType(given), ts.IndexKind.Number); + if (element) out.set(sym, element); + } + }); + } + return out; + } + + /** The scope each function declaration of the body was written in, as its block is walked. */ + private readonly declaredIn = new Map(); + + /* Closures over locals (TrigScript 4): see `closures.ts`. */ + /** The declarations kept in environments in this walk, which a walk before found kept functions using. */ + private readonly boxed: ReadonlySet; + /** Those this walk found that are not among them yet. */ + private readonly boxesFound = new Set(); + private pool: ClosurePool | null = null; + + /* References (TrigScript 4, B9). */ + /** The classes kept by reference in this walk: their instances are rows of the object table, a reference a row. */ + private readonly refs: ReadonlySet; + /** Those this walk found the program needs that are not among them yet. */ + private readonly refsFound = new Set(); + /** The object table, made when the first class kept by reference is: its columns, and each class's number in the pool's function column. */ + private objectsTable: { table: Records; tags: Map; merged: Set; columns: Set } | null = null; + /** The variable holding the environment of a run of each scope that keeps variables in one. */ + private readonly scopeEnv = new Map(); + /** What each such scope keeps, for a loop's copy of its variables at each turn. */ + private readonly scopeCells = new Map(); + /** Scopes whose environment has been made (the make emitted). */ + private readonly envMade = new Set(); + /** + * Where the code runs somewhere other than the scope above it says: the top scope of a function kept as a value (it + * runs later, handed the environment it was made in) and a function inlined there that was written outside it. `expr` + * is the environment of `env` as reached from inside. + */ + private readonly envBoundaries = new Map(); + private readonly boxedCells = new WeakMap(); + /** Copies into environments of parameters bound before their function's body is walked: made where it starts. */ + private readonly pendingBoxes = new Map void)[]>(); + /** The top scope of the function kept as a value being walked. */ + private targetTop: Scope | null = null; + private envUnreachable = false; constructor(c: StructuredContext) { - this.c = c; + // Generics: what the checker says a type is, a type parameter given as what it stands for in the instance or the + // call being walked (`typeArgs`), so a kind is worked out of `T` as of `number`. + const raw = c.checker; + const resolve = (t: TS.Type) => this.resolveType(t); + const RESOLVED = new Set(["getTypeAtLocation", "getTypeOfSymbolAtLocation", "getTypeOfSymbol", "getReturnTypeOfSignature", "getIndexTypeOfType", "getNonNullableType", "getAwaitedType", "getContextualType", "getTypeFromTypeNode", "getTypeOfPropertyOfType", "getBaseTypeOfLiteralType"]); + const checker = new Proxy(raw, { + get: (target, prop) => { + const v = (target as unknown as Record)[prop]; + if (typeof v !== "function") return v; + if (typeof prop === "string" && RESOLVED.has(prop)) return (...args: unknown[]) => { const out = (v as (...a: unknown[]) => TS.Type | undefined).apply(target, args); return out ? resolve(out) : out; }; + return (v as (...a: unknown[]) => unknown).bind(target); + }, + }); + this.c = { ...c, checker }; this.ts = c.ts; this.body = c.body; + this.boxed = c.boxed ?? new Set(); + this.refs = c.refs ?? new Set(); } run(): Emitted { const statements = this.body.plan.body.statements; const at = this.at(this.body.plan.body); - const program: Program = { version: IR_VERSION, ...(this.body.name ? { name: this.body.name } : {}), owner: this.c.owner, owners: [...this.c.owners], perPlayer: this.c.perPlayer, arrays: this.arrays, body: [], at }; + const program: Program = { ...(this.body.name ? { name: this.body.name } : {}), owner: this.c.owner, owners: [...this.c.owners], perPlayer: this.c.perPlayer, arrays: this.arrays, body: [], at }; this.nodes.set(program, this.body.plan.body); this.out = program.body; + const hooks = { boxer: Scope.boxer, resolver: Scope.resolver, created: Scope.created }; + if (this.boxed.size) { + Scope.boxer = (scope, decl, b) => this.boxBinding(scope, decl, b); + Scope.resolver = (b, _found, from) => this.resolveBoxed(b, from); + Scope.created = (scope) => this.scopeCreated(scope); + } + try { + return this.runBody(program, statements, at); + } finally { + Scope.boxer = hooks.boxer; + Scope.resolver = hooks.resolver; + Scope.created = hooks.created; + } + } + + private runBody(program: Program, statements: readonly TS.Statement[], at: At): Emitted { try { + this.declareScriptClasses(this.body.plan.root); this.block(statements, {}, this.topScope); } catch (err) { if (!(err instanceof LowerError)) throw err; this.c.error(statements[statements.length - 1] ?? this.body.plan.body, err.message); } + try { + this.walkTargets(); + } catch (err) { + if (!(err instanceof LowerError)) throw err; + this.c.error(statements[statements.length - 1] ?? this.body.plan.body, err.message); + } + this.fillDispatchers(program); + if (this.pool) { + for (const p of countReferences(program, this.functions, this.pool, this.poolMaker(), this.freshStores)) this.c.error(this.body.plan.body, p.message); + } + if (this.envUnreachable) this.c.error(statements[0] ?? this.body.plan.body, "A function kept as a value uses a variable declared after it was made: declare the variable first."); this.settleFunctions(program); - return { program, nodeOf: (node) => this.nodes.get(node) }; + this.checkTasks(program); + this.finishErrors(program); + markViewWrites(program, this.views); + const boxes = [...this.boxesFound].filter((d) => !this.boxed.has(d)); + const refs = [...this.refsFound].filter((c) => !this.refs.has(c)); + void at; + return { program, nodeOf: (node) => this.nodes.get(node), ...(boxes.length ? { boxes } : {}), ...(refs.length ? { refs } : {}), ...(this.pool ? { envVars: this.pool.envVars } : {}) }; } private lineOf(node: TS.Node): number { @@ -328,6 +634,13 @@ export class Structured { } private emit(s: T, node: TS.Node): T { + // A field written through a reference (B9) that may be none: nothing written, so that a field of none still reads 0. + const column = s.kind === "store" ? s.array : s.kind === "storeText" ? s.addr : undefined; + const index = s.kind === "store" || s.kind === "storeText" ? s.index : undefined; + if (column && index && index.kind !== "const" && !(index.kind === "var" && this.freshRows.has(index.id)) && this.objectsTable?.columns.has(column) && (s.kind === "store" || s.kind === "storeText")) { + this.out.push(this.mark({ kind: "if", cond: { kind: "compare", op: "!=", left: index, right: num(0), at: s.at, label: s.label }, then: [this.mark(s, node)], at: s.at, label: s.label }, node)); + return s; + } this.out.push(this.mark(s, node)); return s; } @@ -349,25 +662,32 @@ export class Structured { private readonly collections = new Map(); private readonly recordLists = new Map>(); - private newArray(name: string, kind: "number" | "boolean", length: number, at: At, extra: { shared?: boolean; bits?: 8 | 16; unsigned?: boolean; values?: number[] } = {}): ArrayDecl { - const a: ArrayDecl = { id: `${name}#${this.nextId++}`, name, kind, length, shared: extra.shared ?? false, ...(extra.bits ? { bits: extra.bits } : {}), ...(extra.unsigned ? { unsigned: true } : {}), ...(extra.values ? { values: extra.values } : {}), at }; + private newArray(name: string, kind: "number" | "boolean", length: number, at: At, extra: { shared?: boolean; bits?: 8 | 16; unsigned?: boolean; fixed?: boolean; values?: number[] } = {}): ArrayDecl { + const a: ArrayDecl = { id: `${name}#${this.nextId++}`, name, kind, length, shared: extra.shared ?? false, ...(extra.bits ? { bits: extra.bits } : {}), ...(extra.unsigned ? { unsigned: true } : {}), ...(extra.fixed ? { fixed: true } : {}), ...(extra.values ? { values: extra.values } : {}), at }; this.arrays.push(a); return a; } - private newVar(name: string, kind: VarDecl["kind"], at: At, extra: { shared?: boolean; bits?: 8 | 16; unsigned?: boolean; temp?: boolean; text?: "id" | "made" } = {}): VarDecl { - const v: VarDecl = { id: `${name}#${this.nextId++}`, name, kind, shared: extra.shared ?? false, ...(extra.bits ? { bits: extra.bits } : {}), ...(extra.unsigned ? { unsigned: true } : {}), ...(extra.temp ? { temp: true } : {}), ...(kind === "text" ? { text: extra.text ?? "made" } : {}), at }; + private newVar(name: string, kind: VarDecl["kind"], at: At, extra: { shared?: boolean; bits?: 8 | 16; unsigned?: boolean; fixed?: boolean; temp?: boolean; text?: "id" | "made" } = {}): VarDecl { + const v: VarDecl = { id: `${name}#${this.nextId++}`, name, kind, shared: extra.shared ?? false, ...(extra.bits ? { bits: extra.bits } : {}), ...(extra.unsigned ? { unsigned: true } : {}), ...(extra.fixed ? { fixed: true } : {}), ...(extra.temp ? { temp: true } : {}), ...(kind === "text" ? { text: extra.text ?? "made" } : {}), at }; if (kind === "text") this.textKinds.set(v.id, v.text ?? "made"); + if (extra.temp) this.temps.add(v.id); return v; } /* ── Values and bindings ── */ + private stripParens(e: TS.Expression): TS.Expression { + while (this.ts.isParenthesizedExpression(e)) e = e.expression; + return e; + } + /** Strip parentheses, `as`, `satisfies`, `!` — the wrappers that change nothing. */ private unwrap(expr: TS.Expression): TS.Expression { const { ts } = this; for (;;) { - if (ts.isParenthesizedExpression(expr) || ts.isAsExpression(expr) || ts.isTypeAssertionExpression(expr) || ts.isNonNullExpression(expr) || ts.isSatisfiesExpression(expr)) expr = expr.expression; + // `await f()` is what f gives: an async function is inlined, and where it waits the program waits. + if (ts.isParenthesizedExpression(expr) || ts.isAsExpression(expr) || ts.isTypeAssertionExpression(expr) || ts.isNonNullExpression(expr) || ts.isSatisfiesExpression(expr) || ts.isAwaitExpression(expr)) expr = expr.expression; else return expr; } } @@ -381,9 +701,22 @@ export class Structured { } /** What an identifier, or a field of a record (`p.lives`, `p["lives"]`), is bound to. */ + /** Expressions bound for the statement being walked to something else than they name: `cost[s] = 3` walked once for each field `s` may be (`fieldWrite`). */ + private pinned = new Map(); + private bindingOf(expr: TS.Expression): Binding | undefined { const { ts } = this; const e = this.unwrap(expr); + const pin = this.pinned.get(e); + if (pin) return pin; + // `new P(4).x`: the instance made where it is written, under no name, its fields then read as any instance's. + if (ts.isNewExpression(e)) { + const cls = this.classOf(e.expression); + if (!cls || this.isRefClass(cls)) return undefined; + const made = this.instantiate(e, "(made)"); + if (made) this.pinned.set(e, made); + return made ?? undefined; + } if (ts.isIdentifier(e)) { // A name taken out of a pattern (`for (const [k, v] of lost)`) is bound by its own element, not by the declaration it is part of. const raw = declarationOf(ts, this.c.checker, e); @@ -397,7 +730,19 @@ export class Structured { // `Squad.count`: a static field, which is a variable of the program's. const cls = this.classOf(e.expression); if (cls) { const field = this.memberOf(cls, (m) => ts.isPropertyDeclaration(m) && this.isStatic(m) && this.memberKey(m.name) === e.name.text); return field ? this.statics.get(field) : undefined; } - const obj = this.bindingOf(e.expression); + const inner = this.unwrap(e.expression); + // A field (not a getter: that runs the call inside it) of what a call gives. + const field = this.c.checker.getSymbolAtLocation(e.name)?.declarations?.[0]; + const isField = !!field && (ts.isPropertyDeclaration(field) || ts.isPropertySignature(field) || ts.isPropertyAssignment(field) || ts.isShorthandPropertyAssignment(field) || ts.isParameter(field)); + // Through a reference (B9): the row it is, its cells read where the reference is now. + const classes = this.refClasses(this.c.checker.getTypeAtLocation(e.expression)); + if (classes && !ts.isCallExpression(inner)) { + const self = inner.kind === ts.SyntaxKind.ThisKeyword ? this.scope.lookup(THIS) : undefined; + if (self?.kind === "record") return self.fields.get(e.name.text); + const place = this.num(inner); + return place ? this.refRow(place, classes, e)?.fields.get(e.name.text) : undefined; + } + const obj = ts.isCallExpression(inner) ? (isField ? this.madeByCall(inner) ?? undefined : undefined) : this.bindingOf(e.expression); return obj?.kind === "record" ? obj.fields.get(e.name.text) : undefined; } if (ts.isElementAccessExpression(e) && ts.isStringLiteralLike(e.argumentExpression)) { @@ -429,6 +774,12 @@ export class Structured { return undefined; } + /** Whether a call is of a function of the script's own that changes what outlives the call (`keepsState` in hoist.ts). */ + private keepsState(call: TS.CallExpression): boolean { + const decl = this.c.checker.getResolvedSignature(call)?.declaration; + return !!decl && this.ts.isFunctionLike(decl) && !!(decl as TS.FunctionLikeDeclaration).body && !decl.getSourceFile().isDeclarationFile && keepsState(this.ts, this.c.checker, decl); + } + private varOf(expr: TS.Expression): VarDecl | undefined { const b = this.bindingOf(expr); return b?.kind === "var" ? b.v : undefined; @@ -452,8 +803,17 @@ export class Structured { } if (depth > 32) return undefined; const sub = (x: TS.Expression) => this.evaluate(x, depth + 1); + // (A value of the game's — a read, a table, a pick, an input — and a condition of the game, which is a record here + // and true or false only when the map is played: nothing is worked out of either.) + const inGame = (v: unknown): boolean => isGameValue(v) || isCondition(v); if (ts.isIdentifier(e)) { const b = this.bindingOf(e); + if (!b && e.text === "undefined") return { value: undefined }; + // A constant of the body is no binding: its value — what a text read in the game (`readsMark` in hoist.ts) starts from. + if (!b) { + const decl = this.c.checker.getSymbolAtLocation(e)?.valueDeclaration; + if (decl && ts.isVariableDeclaration(decl) && this.body.plan.consts.has(decl)) return { value: this.constValue(decl) }; + } return b?.kind === "value" ? { value: b.value } : undefined; } if (e.kind === ts.SyntaxKind.TrueKeyword) return { value: true }; @@ -462,32 +822,50 @@ export class Structured { if (ts.isNumericLiteral(e)) return { value: Number(e.text) }; if (ts.isStringLiteral(e) || ts.isNoSubstitutionTemplateLiteral(e)) return { value: e.text }; if (ts.isPropertyAccessExpression(e)) { + // A field of a record of the program that is known when the script is built: what on.died({ owner: P8 }, …) says of `u.owner`. + if (ts.isIdentifier(e.expression)) { + const r = this.bindingOf(e.expression); + if (r?.kind === "record") { const f = r.fields.get(e.name.text); return f?.kind === "value" ? { value: f.value } : undefined; } + } const obj = sub(e.expression); if (!obj || obj.value === null || obj.value === undefined) return undefined; - return { value: (obj.value as Record)[e.name.text] }; + // A field of the unit selected (\`local.selected.hp\`) is read when the map is played, as any unit's is — and so is + // one of a unit picked (`first(…)?.hp`), which, worked out here, was the pick's own field: undefined. + if (isSelected(obj.value) || isSelectionCount(obj.value) || isUnitPick(obj.value)) return undefined; + // The length of a text with name() or color() in it is the game's (`readsMark` in hoist.ts). + if (typeof obj.value === "string" && hasTextMark(obj.value)) return undefined; + return { value: readField(obj.value, e.name.text) }; } if (ts.isElementAccessExpression(e)) { const obj = sub(e.expression); const key = sub(e.argumentExpression); if (!obj || !key || obj.value === null || obj.value === undefined) return undefined; - return { value: (obj.value as Record)[String(key.value)] }; + if (typeof obj.value === "string" && hasTextMark(obj.value)) return undefined; + return { value: readField(obj.value, String(key.value)) }; } if (ts.isCallExpression(e)) { const callee = sub(e.expression); if (!callee || typeof callee.value !== "function" || isGameFunction(callee.value)) return undefined; + // A function that changes what outlives the call (`keepsState`) — a closure made when the script was built, a method + // writing its instance — would change it once, here, however often the program calls it. + if (this.keepsState(e)) return undefined; const self = ts.isPropertyAccessExpression(e.expression) ? sub(e.expression.expression)?.value : undefined; const args: unknown[] = []; for (const a of e.arguments) { if (ts.isSpreadElement(a)) { const v = sub(a.expression); if (!v || !Array.isArray(v.value)) return undefined; args.push(...(v.value as unknown[])); continue; } const v = sub(a); // A read among the arguments makes the call the program's: an action with that amount, a function inlined. - if (!v || isGameValue(v.value)) return undefined; + if (!v || inGame(v.value)) return undefined; args.push(v.value); } + // A text's method over name() or color() — on one, or given one — is the game's too. + if (typeof self === "string" && [self, ...args].some((a) => typeof a === "string" && hasTextMark(a))) return undefined; // stats(x): a table's index is a plain number when the script runs; its type says which table. if (this.isLibraryCall(e, "stats") && e.arguments.length === 1) args.push(this.brandOf(e.arguments[0])); try { - return { value: (callee.value as (...a: unknown[]) => unknown).apply(self, args) }; + const value = (callee.value as (...a: unknown[]) => unknown).apply(self, args); + // (Math.abs of a whole number is the game's: 32 bits, so abs(−2 147 483 648) is −2 147 483 648, as `| 0` has it.) + return { value: callee.value === Math.abs && typeof value === "number" && Number.isInteger(value) && this.brandOf(e.arguments[0]) !== "fixed" ? value | 0 : value }; } catch (err) { throw new LowerError(err instanceof Error ? err.message : String(err)); } @@ -496,11 +874,21 @@ export class Structured { let out = e.head.text; for (const span of e.templateSpans) { const v = sub(span.expression); - if (!v || isGameValue(v.value)) return undefined; + if (!v || inGame(v.value)) return undefined; out += String(v.value) + span.literal.text; } return { value: out }; } + // `{ to: p }` where p is known when the script is built, as a handler's player is (on.chat): an object of values. + if (ts.isObjectLiteralExpression(e)) { + const out: Record = {}; + for (const p of e.properties) { + if (ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) { const v = sub(p.initializer); if (!v || inGame(v.value)) return undefined; out[p.name.text] = v.value; continue; } + if (ts.isShorthandPropertyAssignment(p)) { const v = sub(p.name); if (!v || inGame(v.value)) return undefined; out[p.name.text] = v.value; continue; } + return undefined; + } + return { value: out }; + } if (ts.isArrayLiteralExpression(e)) { const out: unknown[] = []; for (const el of e.elements) { @@ -515,9 +903,10 @@ export class Structured { if (ts.isPrefixUnaryExpression(e)) { const v = sub(e.operand); // A read has no value yet: arithmetic over it is the program's, not the script's. - if (!v || isGameValue(v.value)) return undefined; + if (!v || inGame(v.value)) return undefined; switch (e.operator) { - case ts.SyntaxKind.MinusToken: return { value: -(v.value as number) }; + // (The negative of a whole number wraps as the game's does: −(−2 147 483 648) is −2 147 483 648 — a literal past the top is a u32 by the type.) + case ts.SyntaxKind.MinusToken: return { value: typeof v.value === "number" && Number.isInteger(v.value) && !ts.isNumericLiteral(this.unwrap(e.operand)) && brandOfType(ts, this.c.checker, this.c.checker.getTypeAtLocation(e.operand)) !== "fixed" ? -(v.value) | 0 : -(v.value as number) }; case ts.SyntaxKind.PlusToken: return { value: +(v.value as number) }; case ts.SyntaxKind.TildeToken: return { value: ~(v.value as number) }; default: return undefined; @@ -527,33 +916,84 @@ export class Structured { // The class of every instance is known when the script is built, so this is a question the script answers. const b = this.bindingOf(e.left); const cls = this.classOf(e.right); + // A reference (B9) that may be of several classes is the game's question, unless they all answer it alike. + if (b?.kind === "record" && b.ref && cls) { const yes = b.ref.classes.filter((c) => this.chainOf(c).includes(cls)).length; return yes === 0 ? { value: false } : undefined; } return b?.kind === "record" && b.cls && cls ? { value: this.chainOf(b.cls).includes(cls) } : undefined; } + // `shaded !== undefined` of an optional parameter: an array, a record or a collection the call gave is there, whatever + // is in it (a unit is not one of these: it may be null while the game runs). + if (ts.isBinaryExpression(e) && [ts.SyntaxKind.EqualsEqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.EqualsEqualsToken, ts.SyntaxKind.ExclamationEqualsToken].includes(e.operatorToken.kind)) { + const nothing = (x: TS.Expression) => { const u = this.unwrap(x); return u.kind === ts.SyntaxKind.NullKeyword || (ts.isIdentifier(u) && u.text === "undefined" && !this.bindingOf(u)); }; + const other = nothing(e.left) ? e.right : nothing(e.right) ? e.left : null; + const b = other ? this.bindingOf(other) : undefined; + // (Not a record that may not be there — what find() or chatted() found — nor one kept by reference.) + if (b && PRESENT_KINDS.has(b.kind) && !(b.kind === "record" && (b.truth || b.ref))) { + const equal = e.operatorToken.kind === ts.SyntaxKind.EqualsEqualsEqualsToken || e.operatorToken.kind === ts.SyntaxKind.EqualsEqualsToken; + return { value: !equal }; + } + } + if (ts.isBinaryExpression(e) && (e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken || e.operatorToken.kind === ts.SyntaxKind.BarBarToken || e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken)) { + // `options.width ?? 3`, `a || b`, `a && b` of values known when the script is built: as JavaScript decides them, + // the right side only looked at when the left does not decide. + const l = sub(e.left); + if (!l || inGame(l.value)) return undefined; + const k = e.operatorToken.kind; + // Only of plain values: a condition or an action of the library is an object, and its truth is the game's. + const v = l.value; + const plain = v === null || v === undefined || typeof v === "number" || typeof v === "boolean" || typeof v === "string"; + if (!plain && (k !== ts.SyntaxKind.QuestionQuestionToken || isBuilder(v))) return undefined; + const decided = k === ts.SyntaxKind.QuestionQuestionToken ? v !== null && v !== undefined : k === ts.SyntaxKind.BarBarToken ? !!v : !v; + if (decided) return l; + const r = sub(e.right); + return r && !inGame(r.value) ? r : undefined; + } if (ts.isBinaryExpression(e)) { const l = sub(e.left); const r = sub(e.right); - if (!l || !r || isGameValue(l.value) || isGameValue(r.value)) return undefined; + if (!l || !r || inGame(l.value) || inGame(r.value)) return undefined; + // A text with name() or color() in it is put together with `+` here, and compared in the game (`readsMark` in hoist.ts). + const markIn = (v: unknown) => typeof v === "string" && hasTextMark(v); + if ((markIn(l.value) || markIn(r.value)) && e.operatorToken.kind !== ts.SyntaxKind.PlusToken) return undefined; + switch (e.operatorToken.kind) { + case ts.SyntaxKind.EqualsEqualsEqualsToken: return { value: l.value === r.value }; + case ts.SyntaxKind.ExclamationEqualsEqualsToken: return { value: l.value !== r.value }; + // (== and != as TypeScript lets them be written between two values of one type: the same as === and !==.) + case ts.SyntaxKind.EqualsEqualsToken: return { value: l.value === r.value }; + case ts.SyntaxKind.ExclamationEqualsToken: return { value: l.value !== r.value }; + } const a = l.value as number; const b = r.value as number; + // (Whole numbers add, subtract and multiply as the game does, in 32 bits — wrapping at the ends, or from 0 up for a + // u32 — here as there: `i + 2147483647` for i = 1 is −2 147 483 648. A fraction on either side computes as one.) + const bits = wholeArithmetic(ts, this.c.checker, e); + const whole = typeof a === "number" && typeof b === "number" && Number.isInteger(a) && Number.isInteger(b) && bits !== "fraction"; + const wrap = (v: number) => (bits === "u32" ? v >>> 0 : v | 0); switch (e.operatorToken.kind) { - case ts.SyntaxKind.PlusToken: return { value: (a as unknown as string) + (b as unknown as string) }; - case ts.SyntaxKind.MinusToken: return { value: a - b }; - case ts.SyntaxKind.AsteriskToken: return { value: a * b }; - case ts.SyntaxKind.SlashToken: return { value: a / b }; + case ts.SyntaxKind.PlusToken: return { value: whole ? wrap(a + b) : (a as unknown as string) + (b as unknown as string) }; + case ts.SyntaxKind.MinusToken: return { value: whole ? wrap(a - b) : a - b }; + case ts.SyntaxKind.AsteriskToken: return { value: whole ? wrap(Math.imul(a, b)) : a * b }; + // (Whole numbers divide as whole numbers, towards zero, here as in the game: `7 / 2` is 3, `7 / fixed(2)` 3.5. A + // fraction on either side divides as a fraction; a divisor of 0 is left to the check that names it.) + case ts.SyntaxKind.SlashToken: return { value: wholeQuotient(ts, this.c.checker, e) && Number.isInteger(a) && Number.isInteger(b) && b !== 0 ? Math.trunc(a / b) : a / b }; case ts.SyntaxKind.PercentToken: return { value: a % b }; case ts.SyntaxKind.AsteriskAsteriskToken: return { value: a ** b }; - case ts.SyntaxKind.LessThanLessThanToken: return { value: a << b }; - case ts.SyntaxKind.GreaterThanGreaterThanToken: return { value: a >> b }; - case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken: return { value: a >>> b }; + // (A shift by 32 or more, or by a number below zero, leaves nothing but the sign, as the game's does — JavaScript would shift by the remainder.) + case ts.SyntaxKind.LessThanLessThanToken: return { value: b >>> 0 >= 32 ? 0 : a << b }; + case ts.SyntaxKind.GreaterThanGreaterThanToken: return { value: b >>> 0 >= 32 ? (a < 0 ? -1 : 0) : a >> b }; + case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken: return { value: b >>> 0 >= 32 ? 0 : a >>> b }; case ts.SyntaxKind.AmpersandToken: return { value: a & b }; case ts.SyntaxKind.BarToken: return { value: a | b }; case ts.SyntaxKind.CaretToken: return { value: a ^ b }; + case ts.SyntaxKind.LessThanToken: return { value: a < b }; + case ts.SyntaxKind.LessThanEqualsToken: return { value: a <= b }; + case ts.SyntaxKind.GreaterThanToken: return { value: a > b }; + case ts.SyntaxKind.GreaterThanEqualsToken: return { value: a >= b }; default: return undefined; } } if (ts.isConditionalExpression(e)) { const c = sub(e.condition); - if (!c || isGameValue(c.value)) return undefined; + if (!c || inGame(c.value)) return undefined; return c.value ? sub(e.whenTrue) : sub(e.whenFalse); } return undefined; @@ -571,6 +1011,13 @@ export class Structured { const message = err instanceof Error ? err.message : String(err); const i = (err as { __trigscriptConst?: number } | null)?.__trigscriptConst; const decl = i !== undefined ? this.body.plan.constList[i] : undefined; + // Inside a `try`, an error the script's own code threw as it was worked out — `try { total += check(9); }` with a + // `check` that throws for 9 — is the program's, thrown where the expression stands, as JavaScript would throw it: + // the catch takes it. (0 stands where the value would have, after the throw, which nothing reaches.) + if (!decl && this.tries > 0 && err instanceof Error && !(err instanceof LowerError) && !(err instanceof ScriptError) && Object.hasOwn(ERROR_CODES, err.name)) { + this.throwBuilt(err.name as BuiltinError, err.message, at); + return 0; + } if (decl?.initializer) throw new ValueError(decl.initializer, `${message} — this constant is computed when the script is built, not in the game.`); throw new ValueError(at, `${message} — this expression is computed when the script is built, not in the game.`); } @@ -605,6 +1052,8 @@ export class Structured { } private notConstant(expr: TS.Expression, what: string) { + // Date.now(), Math.random(): the plan has said what to write instead. + if (clockCall(this.ts, this.c.checker, this.unwrap(expr))) return; const v = this.blamedVariable(expr); this.c.error(expr, v ? `${what} must be known when the script is built, but ${v} is a variable of the program. Compare or assign variables in the program's own statements instead.` @@ -616,6 +1065,9 @@ export class Structured { private block(statements: readonly TS.Statement[], ctx: Ctx, scope = new Scope(this.scope)) { const outer = this.scope; this.scope = scope; + this.flushBoxes(); + // A function declared here sees this block's variables, as JavaScript's does. + for (const s of statements) if (this.ts.isFunctionDeclaration(s)) this.declaredIn.set(s, scope); for (const s of statements) { try { this.statement(s, ctx); @@ -633,9 +1085,33 @@ export class Structured { return this.collect(() => this.statement(s, ctx)); } + /** The context of the statement being walked: what a `yield` inside an expression leaves through (B4: the generator's finally blocks). */ + private ctxNow: Ctx = {}; + private statement(s: TS.Statement, ctx: Ctx) { + const was = this.ctxNow; + const made = this.madeHere; + this.ctxNow = ctx; + this.madeHere = new Map(); + try { this.statementIn(s, ctx); this.afterLabelledJumps(s, ctx); } finally { this.ctxNow = was; this.madeHere = made; } + } + + /** What a call that gives an instance or a record gave, in the statement being walked: `v.rotate(a).x` and `.y` of the same call make it once. */ + private madeHere = new Map(); + + /** The instance or record a call gives (`v.rotate(a)`), made where the statement is, once a statement; undefined for a call that gives neither. */ + private madeByCall(e: TS.CallExpression): Binding | null | undefined { + if (this.madeHere.has(e)) return this.madeHere.get(e); + // A reference a call gives (B9): the call made once, its row what is read. + const made = this.refClasses(this.c.checker.getTypeAtLocation(e)) ? this.objectOf(e) ?? null : this.instanceCall(e, "(made)"); + if (made !== undefined) this.madeHere.set(e, made); + return made; + } + + private statementIn(s: TS.Statement, ctx: Ctx) { const { ts } = this; - if (ts.isEmptyStatement(s) || ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s) || ts.isFunctionDeclaration(s)) return; + // (An enum's members are values the script has: the plan made it where the build-time parts run.) + if (ts.isEmptyStatement(s) || ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s) || ts.isFunctionDeclaration(s) || ts.isEnumDeclaration(s)) return; if (ts.isClassDeclaration(s)) { this.declareClass(s); return; } if (ts.isVariableStatement(s)) { this.declare(s.declarationList); return; } if (ts.isBlock(s)) { const body = this.collect(() => this.block(s.statements, ctx)); this.emit({ kind: "block", body, at: this.at(s) }, s); return; } @@ -645,28 +1121,151 @@ export class Structured { if (ts.isDoStatement(s)) { this.doStatement(s, ctx); return; } if (ts.isForStatement(s)) { this.forStatement(s, ctx); return; } if (ts.isBreakStatement(s) || ts.isContinueStatement(s)) { - if (s.label) { this.c.error(s, "Labelled break / continue is not supported."); return; } - if (ts.isBreakStatement(s) ? !ctx.canBreak : !ctx.canContinue) { this.c.error(s, `${ts.isBreakStatement(s) ? "break" : "continue"} outside a loop.`); return; } - this.emit({ kind: ts.isBreakStatement(s) ? "break" : "continue", at: this.at(s), label: this.label(s) }, s); + const kind = ts.isBreakStatement(s) ? "break" : "continue"; + if (s.label) { + let frame: (typeof this.labels)[number] | undefined; + for (const f of this.labels) if (f.name === s.label.text) frame = f; + if (!frame) { this.c.error(s, `${kind} ${s.label.text} names a loop this one is not inside.`); return; } + // A jump to the loop it is directly in is a plain one; from deeper, a flag of the label's, and out a level at a time. + if (!this.labelDirect(s)) { + const flag = kind === "break" ? frame.brk! : frame.cont!; + this.emit({ kind: "assignBool", target: flag.id, value: TRUE, at: this.at(s), label: this.label(s) }, s); + this.jump("break", ctx, s); + return; + } + } + this.jump(kind, ctx, s); return; } + if (ts.isLabeledStatement(s)) { this.labelled(s, ctx); return; } if (ts.isReturnStatement(s)) { this.returnStatement(s, ctx); return; } if (ts.isSwitchStatement(s)) { this.switchStatement(s, ctx); return; } if (ts.isForOfStatement(s)) { this.forOfStatement(s, ctx); return; } if (ts.isForInStatement(s)) { this.c.error(s, "for…in is not supported in a program; for…of over a list known when the script is built is unrolled."); return; } - if (ts.isThrowStatement(s) || ts.isTryStatement(s)) { this.c.error(s, "The game has no exceptions."); return; } + if (ts.isThrowStatement(s)) { this.throwStatement(s); return; } + if (ts.isTryStatement(s)) { this.tryStatement(s, ctx); return; } this.c.error(s, "This statement is not supported in a program."); } + /** A `break` or a `continue` of the loop (or, for `break`, the switch) it is in: out through the finally blocks it leaves, the innermost first. */ + private jump(kind: "break" | "continue", ctx: Ctx, s: TS.Node) { + if (ctx.inFinally && (kind === "break" ? !ctx.canBreak : !ctx.canContinue)) { this.c.error(s, `A finally block runs on every way out of its try, an error's way too, so it does not ${kind} out of it: set a variable, and test it after the try.`); return; } + if (kind === "break" ? !ctx.canBreak : !ctx.canContinue) { this.c.error(s, `${kind} outside a loop.`); return; } + const through = (kind === "break" ? ctx.exits?.break : ctx.exits?.continue) ?? []; + for (let i = through.length - 1; i >= 0; i--) this.finallyBody(through[i], ctx.fn); + if (kind === "break" && ctx.breakFlag) this.emit({ kind: "assignBool", target: ctx.breakFlag.id, value: TRUE, at: this.at(s), label: this.label(s) }, s); + this.emit({ kind, at: this.at(s), label: this.label(s) }, s); + } + + /** + * The labels around the statement being walked (`outer: for …`). A `break outer` or a `continue outer` from a loop or a + * switch inside the labelled loop sets the label's flag and breaks out of where it is; after each loop or switch it + * leaves, the flag is tested and broken out of again, until the labelled loop's own body, where it breaks or continues + * that loop and the flag is cleared. `brk` and `cont` are made only when such a jump is there. + */ + private labels: { name: string; loop: TS.IterationStatement; jumps: (TS.BreakStatement | TS.ContinueStatement)[]; brk?: VarDecl; cont?: VarDecl }[] = []; + + /** The loop or switch a statement is in (`loops` false: the loop), within its function; a label is passed through. */ + private enclosing(n: TS.Node, loops = false): TS.Node | undefined { + const { ts } = this; + for (let p = n.parent; p; p = p.parent) { + if (ts.isIterationStatement(p, false) || (!loops && ts.isSwitchStatement(p))) return p; + if (ts.isFunctionLike(p) || ts.isClassLike(p) || ts.isSourceFile(p)) return undefined; + } + return undefined; + } + + /** The loop a labelled statement labels: the statement under its labels (`a: b: for …`). */ + private labelTarget(s: TS.LabeledStatement): TS.Statement { + let inner: TS.Statement = s.statement; + while (this.ts.isLabeledStatement(inner)) inner = inner.statement; + return inner; + } + + /** Whether a labelled jump is to the loop it is directly in: `break` with no loop or switch between, `continue` with no loop. */ + private labelDirect(s: TS.BreakStatement | TS.ContinueStatement): boolean { + let named: TS.Node | undefined = s.parent; + while (named && !(this.ts.isLabeledStatement(named) && named.label.text === s.label!.text)) named = named.parent; + if (!named) return false; + return this.enclosing(s, this.ts.isContinueStatement(s)) === this.labelTarget(named as TS.LabeledStatement); + } + + private labelled(s: TS.LabeledStatement, ctx: Ctx) { + const { ts } = this; + const loop = this.labelTarget(s); + if (!ts.isIterationStatement(loop, false)) { this.c.error(s.label, "A label names a loop: outer: for (…) { … break outer; }. A block of its own is a function that returns."); return; } + const jumps: (TS.BreakStatement | TS.ContinueStatement)[] = []; + const scan = (n: TS.Node): void => { + if (ts.isFunctionLike(n) || ts.isClassLike(n)) return; + if ((ts.isBreakStatement(n) || ts.isContinueStatement(n)) && n.label?.text === s.label.text && !this.labelDirect(n)) jumps.push(n); + // A label of the same name inside names its own loop. + if (ts.isLabeledStatement(n) && n !== s && n.label.text === s.label.text) return; + ts.forEachChild(n, scan); + }; + scan(s.statement); + const at = this.at(s), label = this.label(s); + const frame: (typeof this.labels)[number] = { name: s.label.text, loop, jumps }; + if (jumps.some((j) => ts.isBreakStatement(j))) { frame.brk = this.newVar(`(break ${frame.name})`, "boolean", at); this.emit({ kind: "declare", decl: frame.brk, init: FALSE, at, label }, s); } + if (jumps.some((j) => ts.isContinueStatement(j))) { frame.cont = this.newVar(`(continue ${frame.name})`, "boolean", at); this.emit({ kind: "declare", decl: frame.cont, init: FALSE, at, label }, s); } + this.labels.push(frame); + try { this.statement(loop, ctx); } finally { this.labels.pop(); } + } + + /** After a loop or a switch inside labelled loops: a labelled jump out of it goes on out, or, in the labelled loop's own body, does what it says. */ + private afterLabelledJumps(s: TS.Statement, ctx: Ctx) { + const { ts } = this; + if (!this.labels.length || !(ts.isIterationStatement(s, false) || ts.isSwitchStatement(s))) return; + const inside = (j: TS.Node) => { for (let p: TS.Node | undefined = j.parent; p; p = p.parent) if (p === s) return true; return false; }; + const near = this.enclosing(s); + const at = this.at(s), label = this.label(s); + for (const f of this.labels) { + if (f.loop === s) continue; + const out = f.jumps.filter(inside); + if (!out.length) continue; + const brk = out.some((j) => ts.isBreakStatement(j)) ? f.brk : undefined; + const cont = out.some((j) => ts.isContinueStatement(j)) ? f.cont : undefined; + if (near === f.loop) { + for (const [flag, kind] of [[brk, "break"], [cont, "continue"]] as const) { + if (!flag) continue; + const then = this.collect(() => { this.emit({ kind: "assignBool", target: flag.id, value: FALSE, at, label }, s); this.jump(kind, ctx, s); }); + this.emit({ kind: "if", cond: { kind: "var", id: flag.id }, then, at, label }, s); + } + } else { + const flags = [brk, cont].filter((x): x is VarDecl => !!x).map((x): BoolExpr => ({ kind: "var", id: x.id })); + const cond: BoolExpr = flags.length === 1 ? flags[0] : { kind: "or", items: flags }; + this.emit({ kind: "if", cond, then: this.collect(() => this.jump("break", ctx, s)), at, label }, s); + } + } + } + private returnStatement(s: TS.ReturnStatement, ctx: Ctx) { - if (!ctx.fn) { this.c.error(s, "return outside a function."); return; } + if (!ctx.fn) { + // `if (!raider) return;` at the top of a program: the program ends there, for good. + if (s.expression) { this.c.error(s.expression, "A program gives nothing back: return; alone ends it."); return; } + this.emit({ kind: "return", at: this.at(s), label: this.label(s) }, s); + return; + } const { fn } = ctx; + if (fn.inFinally) { this.c.error(s, "A finally block runs on every way out of its try, an error's way too, so it does not return: return after the try."); return; } + if (fn.finallies?.length) { this.returnThrough(s, ctx); return; } if (fn.instance || (fn.kind === "void" && s.expression && this.classOfType(this.c.checker.getTypeAtLocation(s.expression)))) { // `return this`, `return new Wave(n)`, `return other`: which instance the call gives, settled here. A call that does nothing with it only leaves. + if (fn.instance?.list && s.expression) { + // `return xs`: the array the call gives, one of the function's own or one it was handed; every return the same one. + const given = this.unwrap(s.expression); + const { ts } = this; + const made = ts.isArrayLiteralExpression(given) ? this.returnedArray(given, fn.instance.name) : ts.isCallExpression(given) ? this.listOf(given) ?? this.instanceCall(given, fn.instance.name) : this.bindingOf(given); + if (!made || !Structured.LIST_KINDS.has(made.kind)) { if (made !== null) this.c.error(s.expression, "A function gives an array it made (const xs: number[] = []; …; return xs), one written out, or one it was handed."); return; } + if (fn.instance.made && fn.instance.made !== made) { this.c.error(s, "Every return of this function has to give the same array: which one a call gives is settled when the script is built. Fill one array and return it."); return; } + fn.instance.made = made; + this.emit({ kind: "return", at: this.at(s), label: this.label(s) }, s); + return; + } if (fn.instance && s.expression) { const given = this.unwrap(s.expression); - const made = this.ts.isNewExpression(given) && this.classOf(given.expression) ? this.instantiate(given, fn.instance.name) : this.ts.isCallExpression(given) ? this.instanceCall(given, fn.instance.name) : this.bindingOf(given); - if (made?.kind !== "record") { if (made !== null) this.c.error(s.expression, "A function gives an instance it made (return new Wave(…)), itself (return this), or one it was handed."); return; } + const made = this.madeInstance(given, fn.instance.name); + if (made?.kind !== "record") { if (made !== null) this.c.error(s.expression, "A function gives a record or an instance it made (return { x, y }, return new Wave(…)), itself (return this), or one it was handed."); return; } + if (fn.instance.made && fn.instance.made !== made && made.cls && this.needRef(this.instanceClasses(this.c.checker.getTypeAtLocation(s.expression)) ?? [made.cls])) { this.c.error(s, "This function gives one instance or another: their class is kept by reference."); return; } if (fn.instance.made && fn.instance.made !== made) { this.c.error(s, "Every return of this function has to give the same instance: which one a call gives is settled when the script is built. Give the place of a row of an array instead, and let the caller take the row."); return; } fn.instance.made = made; } @@ -703,6 +1302,562 @@ export class Structured { } } + /* ── Errors (B4) ── */ + + /** The error in flight, made when the program first throws or catches; null while it does neither. */ + private flight: Flight | null = null; + /** The program's classes of error that something throws, each with its code. */ + private readonly errorCodes = new Map(); + /** What a caught error's record is: `instanceof`, `e.name` and `throw e` look it up here. */ + private readonly caughtRecords = new WeakMap(); + /** The texts of the classes' names as `e.name` gives them, by code; made when a catch first reads `e.name`. */ + private jsNames: ArrayDecl | null = null; + /** What is filled in once the whole program has been walked, when every class that is thrown and every field is known. */ + private readonly errorFills: (() => void)[] = []; + /** Each `throw` of a new error, for its `stack` — made there only when a catch reads one: the name, and where. */ + private readonly stackSites: { block: Stmt & { kind: "block" }; name: string; at: At; label: string }[] = []; + private stackRead = false; + /** The same for what a finally puts aside, after the others: those may still add a field to the error in flight. */ + private readonly keepFills: (() => void)[] = []; + /** Each `await t` of a task: what it fills in once the program is known to throw — a mark that it waits, and the task's error thrown on. */ + /** The `sync()` slots of this program, 0, 1, …; the compiler numbers them across programs afterwards (`numberSyncSlots`). */ + private syncSlots = 0; + private readonly awaitedTasks: { h: VarDecl; watch: Stmt & { kind: "block" }; check: Stmt & { kind: "block" }; at: At; label: string }[] = []; + /** What an error leaving a statement has to undo on its way (a loop through a Map counted out): put in a `try` once the program is known to throw. */ + private readonly unwinds: { held: Stmt & { kind: "block" }; cleanup: Stmt[] }[] = []; + /** The `try` around each handler of an event that runs within the frame: a block again when the program throws nothing. */ + private readonly guards: Stmt[] = []; + + private flightOf(at: TS.Node): Flight { + if (this.flight) return this.flight; + const where = this.at(at); + const thrown = this.newVar("(error thrown)", "number", where, { shared: true }); + const message = this.newVar("(error message)", "text", where, { shared: true, text: "made" }); + const names = this.newArray("(error classes)", "number", 1, where, { shared: true, values: [0] }); + names.texts = ["Error"]; + return (this.flight = { thrown, message, fields: new Map(), names }); + } + + /** The variable the error in flight keeps a field of its class in: one for each name and kind, whichever class it is of. */ + private flightField(key: string, kind: VarDecl["kind"], at: At): VarDecl { + const f = this.flight!; + const k = `${key}:${kind}`; + let v = f.fields.get(k); + if (!v) { v = this.newVar(`(error ${key})`, kind, at, { shared: true }); f.fields.set(k, v); } + return v; + } + + /** "RangeError: too far", or the name alone when the message is empty: what JavaScript writes an error as. */ + private errorText(name: TextExpr, message: TextExpr, at: At, label: string): TextExpr { + const rest: TextExpr = { kind: "textTernary", cond: { kind: "compare", op: ">", left: { kind: "textLength", of: message, at, label }, right: num(0), at, label }, whenTrue: { kind: "template", parts: [{ kind: "text", text: ": " }, { kind: "value", text: message }], at, label }, whenFalse: { kind: "text", text: "" }, at, label }; + return { kind: "template", parts: [{ kind: "value", text: name }, { kind: "value", text: rest }], at, label }; + } + + /** `to = from`, whatever the two hold. */ + private copyOf(to: VarDecl, from: VarDecl, at: At, label: string): Stmt { + switch (to.kind) { + case "number": return { kind: "assign", target: to.id, value: varRef(from), at, label }; + case "boolean": return { kind: "assignBool", target: to.id, value: boolRef(from), at, label }; + case "unit": return { kind: "assignUnit", target: to.id, value: unitRef(from), at, label }; + default: return { kind: "assignText", target: to.id, value: { kind: "textVar", id: from.id }, at, label }; + } + } + + /** `Error`, `RangeError` or `TypeError` of the language, when the expression names one. */ + private builtinError(expr: TS.Expression): BuiltinError | undefined { + const { ts } = this; + const e = this.unwrap(expr); + if (!ts.isIdentifier(e) || !Object.hasOwn(ERROR_CODES, e.text)) return undefined; + const decl = this.c.checker.getSymbolAtLocation(e)?.declarations?.[0]; + const sf = decl?.getSourceFile(); + return sf && (sf.hasNoDefaultLib || /(^|\/)lib(\.[^/]*)?\.d\.ts$/.test(sf.fileName)) ? (e.text as BuiltinError) : undefined; + } + + /** The built-in class a class's own `extends` names: `class OutOfGold extends Error`. */ + private extendsBuiltin(cls: TS.ClassDeclaration): BuiltinError | undefined { + const clause = cls.heritageClauses?.find((h) => h.token === this.ts.SyntaxKind.ExtendsKeyword)?.types[0]; + return clause ? this.builtinError(clause.expression) : undefined; + } + + /** The built-in class of error a class is one of, through what it extends; undefined for a class that is not an error. */ + private builtinBase(cls: TS.ClassDeclaration): BuiltinError | undefined { + for (const c of this.chainOf(cls)) { const b = this.extendsBuiltin(c); if (b) return b; } + return undefined; + } + + private errorCode(cls: TS.ClassDeclaration): number { + let code = this.errorCodes.get(cls); + if (code === undefined) { code = FIRST_ERROR_CLASS + this.errorCodes.size; this.errorCodes.set(cls, code); } + return code; + } + + /** What `e.name` is for an error of a class: the `name` it gives itself (`name = "OutOfGold"`, `this.name = …` in its constructor), else what it extends gives, as JavaScript has it. */ + private errorName(cls: TS.ClassDeclaration): string { + const { ts } = this; + for (const c of this.chainOf(cls)) { + for (const m of c.members) { + if (ts.isPropertyDeclaration(m) && !this.isStatic(m) && this.memberKey(m.name) === "name" && m.initializer && ts.isStringLiteralLike(m.initializer)) return m.initializer.text; + if (ts.isConstructorDeclaration(m) && m.body) { + for (const st of m.body.statements) { + const x = ts.isExpressionStatement(st) ? st.expression : undefined; + if (x && ts.isBinaryExpression(x) && x.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(x.left) && x.left.expression.kind === ts.SyntaxKind.ThisKeyword && x.left.name.text === "name" && ts.isStringLiteralLike(x.right)) return x.right.text; + } + } + } + } + return this.builtinBase(cls) ?? "Error"; + } + + /** + * What `super(message)` of a class that extends `Error` makes, and `new OutOfGold("…")` of one with no constructor: the + * instance's `message` — the text handed over, or nothing — and its `name`. + */ + private errorFields(self: Instance, message: TS.Expression | undefined, name: string, at: TS.Node): boolean { + const where = this.at(at), label = this.label(at); + if (this.onRow.has(self)) { this.c.error(at, "An error is made where it is thrown — throw new OutOfGold(…) — and not kept in an array."); return false; } + const text = message ? this.text(message) : { kind: "text" as const, text: "" }; + if (!text) return false; + const m = this.newVar(`${name}.message`, "text", where, { text: "made" }); + this.emit({ kind: "declare", decl: m, init: text, at: where, label }, at); + self.fields.set("message", { kind: "var", v: m }); + const n = this.newVar(`${name}.name`, "text", where, { text: "made" }); + this.emit({ kind: "declare", decl: n, init: { kind: "text", text: self.cls ? this.errorName(self.cls) : "Error" }, at: where, label }, at); + self.fields.set("name", { kind: "var", v: n }); + return true; + } + + /** The kind of a field that a class of error declares under that name, in any class of error the program has; undefined when none does. */ + private errorFieldKind(key: string): VarDecl["kind"] | undefined { + const { ts } = this; + for (const cls of this.classBodies.keys()) { + if (!this.builtinBase(cls)) continue; + for (const m of cls.members) { + const node = ts.isPropertyDeclaration(m) && !this.isStatic(m) && this.memberKey(m.name) === key ? m.name + : ts.isConstructorDeclaration(m) ? m.parameters.find((p) => ts.getModifiers(p)?.length && ts.isIdentifier(p.name) && p.name.text === key)?.name : undefined; + if (!node) continue; + const type = this.c.checker.getTypeAtLocation(node); + if (this.isTextType(type)) return "text"; + const kind = this.kindOf(type); + if (kind) return kind; + } + } + return undefined; + } + + /** A field of a caught error as the catch's body asks for it: a copy of its own, made at the catch's start. */ + private caughtField(caught: Caught, key: string): Binding | undefined { + const at = caught.at; + if (key === "message") return { kind: "var", v: (caught.message ??= this.newVar(`${caught.name}.message`, "text", at, { text: "made" })) }; + if (key === "name") { + caught.code ??= this.newVar(`${caught.name} (class)`, "number", at); + return { kind: "var", v: (caught.jsName ??= this.newVar(`${caught.name}.name`, "text", at, { text: "made" })) }; + } + if (key === "stack") this.stackRead = true; + const kind = key === "stack" ? "text" : this.errorFieldKind(key); + if (!kind) return undefined; + const k = `${key}:${kind}`; + let v = caught.fields.get(k); + if (!v) { v = this.newVar(`${caught.name}.${key}`, kind, at); caught.fields.set(k, v); } + return { kind: "var", v }; + } + + /** + * `e.describe()`, `e.short` of a caught error: the method or getter of the class the error is, which is known only while + * the map is played. Every class of error it may be (the type `instanceof` narrowed `e` to, and what extends it) is + * asked for the member; when they all have the same one it is inlined, `this` the caught error, and when they do not, + * each is, behind a test of the error's code. Undefined when `e` is no caught error, or no class has the member. + */ + private caughtMember(call: TS.Expression, access: TS.PropertyAccessExpression, getter: boolean): Call | null | undefined { + const { ts } = this; + const self = this.bindingOf(access.expression); + const caught = self && this.caughtRecords.get(self); + if (!self || !caught) return undefined; + const name = access.name.text; + const pick = (m: TS.ClassElement) => !this.isStatic(m) && (getter ? ts.isGetAccessorDeclaration(m) : ts.isMethodDeclaration(m)) && this.memberKey(m.name) === name; + const narrowed = this.classOfType(this.c.checker.getTypeAtLocation(access.expression)); + const groups = new Map(); + for (const cls of this.classBodies.keys()) { + if (!this.builtinBase(cls) || (narrowed && !this.chainOf(cls).includes(narrowed))) continue; + const m = this.memberOf(cls, pick); + if (m) groups.set(m, [...(groups.get(m) ?? []), cls]); + } + if (!groups.size) return undefined; + const make = (m: TS.ClassElement): Call | undefined => { + const owner = m.parent as TS.ClassDeclaration; + const target = this.classBodies.get(owner) ?? this.body; + const full = `${this.className(owner)}.${name}`; + if (getter) return this.inline(call, [], (m as TS.GetAccessorDeclaration).body, target, full, m, { self, args: [] }); + const method = m as TS.MethodDeclaration; + if (!method.body) { this.c.error(call, `${full} has no body here: it is abstract.`); return undefined; } + return this.inline(call, method.parameters, method.body, target, full, method, { self }); + }; + if (groups.size === 1) return make([...groups.keys()][0]) ?? null; + const at = this.at(call), label = this.label(call); + const code = (caught.code ??= this.newVar(`${caught.name} (class)`, "number", caught.at)); + const made = [...groups].map(([m, classes]) => ({ classes, c: make(m) })); + if (made.some((x) => !x.c)) return null; + const first = made[0].c!; + const result = first.result ? { decl: this.newVar(`(${name})`, first.result.decl.kind, at, first.result.decl.kind === "text" ? { text: "made" } : {}), kind: first.result.kind } : undefined; + let chain: Stmt[] = []; + for (const { classes, c } of made.reverse()) { + const test: Extract = { kind: "or", items: [] }; + this.errorFills.push(() => { + const codes = classes.flatMap((k) => (this.errorCodes.has(k) ? [this.errorCodes.get(k)!] : [])); + test.items = codes.length ? codes.map((k): BoolExpr => ({ kind: "compare", op: "==", left: varRef(code), right: num(k), at, label })) : [FALSE]; + }); + const then: Stmt[] = [{ kind: "call", call: c!, at, label }, ...(result && c!.result ? [this.copyOf(result.decl, c!.result.decl, at, label)] : [])]; + chain = [{ kind: "if", cond: test, then, ...(chain.length ? { else: chain } : {}), at, label }]; + } + return this.mark({ name: `${caught.name}.${name}`, at, label, params: [], ...(result ? { result } : {}), body: chain }, call); + } + + /** The start of a catch: the copies its body reads (all of it when it throws the error on), then nothing in flight. */ + private caughtEntry(caught: Caught): Stmt[] { + const f = this.flight!; + const { at, label } = caught; + if (caught.rethrown) { + caught.code ??= this.newVar(`${caught.name} (class)`, "number", at); + caught.message ??= this.newVar(`${caught.name}.message`, "text", at, { text: "made" }); + for (const [k, v] of f.fields) if (!caught.fields.has(k)) caught.fields.set(k, this.newVar(`${caught.name}.${k.split(":")[0]}`, v.kind, at)); + } + const out: Stmt[] = []; + if (caught.code) out.push({ kind: "declare", decl: caught.code, init: varRef(f.thrown), at, label }); + if (caught.jsName) out.push({ kind: "declare", decl: caught.jsName, init: { kind: "textOf", array: this.jsNamesArray(at).id, index: varRef(caught.code!), at }, at, label }); + if (caught.message) out.push({ kind: "declare", decl: caught.message, init: { kind: "textVar", id: f.message.id }, at, label }); + for (const [k, v] of caught.fields) { + const [key, kind] = k.split(":") as [string, VarDecl["kind"]]; + out.push({ kind: "declare", decl: v, init: refOf(this.flightField(key, kind, at)), at, label }); + } + out.push({ kind: "assign", target: f.thrown.id, value: num(0), at, label }); + return out; + } + + private jsNamesArray(at: At): ArrayDecl { + if (!this.jsNames) { this.jsNames = this.newArray("(error names)", "number", 1, at, { shared: true, values: [0] }); this.jsNames.texts = ["Error"]; } + return this.jsNames; + } + + /** `e instanceof RangeError`, `e instanceof OutOfGold` of a caught error: its code is one of the class's or of a class that extends it. */ + private caughtIs(caught: Caught, right: TS.Expression, node: TS.Node): BoolExpr { + const builtin = this.builtinError(right); + const cls = builtin ? undefined : this.classOf(right); + if (builtin === "Error") return TRUE; + if (!builtin && !(cls && this.builtinBase(cls))) { this.c.error(right, "What a catch takes is an error: instanceof tells apart Error, RangeError, TypeError and the classes that extend them."); return FALSE; } + const code = (caught.code ??= this.newVar(`${caught.name} (class)`, "number", caught.at)); + const at = this.at(node), label = this.label(node); + const test: Extract = { kind: "or", items: [] }; + this.errorFills.push(() => { + const codes = builtin + ? [ERROR_CODES[builtin], ...[...this.errorCodes].filter(([c]) => this.builtinBase(c) === builtin).map(([, k]) => k)] + : [...this.errorCodes].filter(([c]) => this.chainOf(c).includes(cls!)).map(([, k]) => k); + test.items = codes.length ? codes.map((k): BoolExpr => ({ kind: "compare", op: "==", left: varRef(code), right: num(k), at, label })) : [FALSE]; + }); + return test; + } + + /** + * `throw new Error("…")`, `throw new OutOfGold(need)`, `throw e`: the error is put in flight — its message, its class's + * fields, its class's code last — and goes to the nearest `try` around it, out through the calls it is in. + */ + /** How many `try` bodies the walk is inside: an error a build-time call throws there is the program's (`force`). */ + private tries = 0; + + /** A built-in error thrown where `node` stands, with its message: what `throw new Error("…")` emits. */ + private throwBuilt(name: BuiltinError, message: string, node: TS.Node) { + const at = this.at(node), label = this.label(node); + const f = this.flightOf(node); + this.emit({ kind: "assignText", target: f.message.id, value: { kind: "text", text: message }, at, label }, node); + const block: Stmt & { kind: "block" } = { kind: "block", body: [], at }; + this.stackSites.push({ block, name, at, label }); + this.emit(block, node); + this.emit({ kind: "assign", target: f.thrown.id, value: num(ERROR_CODES[name]), at, label }, node); + this.emit({ kind: "throw", at, label }, node); + } + + private throwStatement(s: TS.ThrowStatement) { + const { ts } = this; + const at = this.at(s), label = this.label(s); + const f = this.flightOf(s); + const e = this.unwrap(s.expression); + const go = (code: number, name: string) => { + const block: Stmt & { kind: "block" } = { kind: "block", body: [], at }; + this.stackSites.push({ block, name, at, label }); + this.emit(block, s); + this.emit({ kind: "assign", target: f.thrown.id, value: num(code), at, label }, s); + this.emit({ kind: "throw", at, label }, s); + }; + const b = ts.isIdentifier(e) || ts.isPropertyAccessExpression(e) || e.kind === ts.SyntaxKind.ThisKeyword ? this.bindingOf(e) : undefined; + const caught = b ? this.caughtRecords.get(b) : undefined; + if (caught) { + // Thrown on: the copies the catch took go back in flight, its class's code last. + caught.rethrown = true; + const restore: Stmt & { kind: "block" } = { kind: "block", body: [], at }; + this.emit(restore, s); + this.errorFills.push(() => { + const out: Stmt[] = [this.copyOf(f.message, caught.message!, at, label)]; + for (const [k, v] of caught.fields) { const [key, kind] = k.split(":") as [string, VarDecl["kind"]]; out.push(this.copyOf(this.flightField(key, kind, at), v, at, label)); } + out.push(this.copyOf(f.thrown, caught.code!, at, label)); + restore.body = out; + }); + this.emit({ kind: "throw", rethrow: true, at, label }, s); + return; + } + // `new Error("…")` the script made when it was built. + const h = !b ? this.evaluate(s.expression) : undefined; + if (h) { + const v = h.value as { name?: unknown; message?: unknown } | null; + if (v && typeof v === "object" && typeof v.name === "string" && Object.hasOwn(ERROR_CODES, v.name) && typeof v.message === "string") { + this.emit({ kind: "assignText", target: f.message.id, value: { kind: "text", text: v.message }, at, label }, s); + go(ERROR_CODES[v.name as BuiltinError], v.name); + return; + } + this.c.error(s.expression, `throw takes an error — throw new Error("…"), or an instance of a class that extends Error — and this is ${describe(h.value)}.`); + return; + } + const builtin = (ts.isNewExpression(e) || ts.isCallExpression(e)) ? this.builtinError(e.expression) : undefined; + if (builtin && (ts.isNewExpression(e) || ts.isCallExpression(e))) { + if (e.arguments && e.arguments.length > 1) { this.c.error(e.arguments[1], "An error carries its message and its class's fields; a cause is not kept. Put what it needs in a field of a class that extends Error."); return; } + const arg = e.arguments?.[0]; + const message = arg ? this.text(arg) : { kind: "text" as const, text: "" }; + if (!message) return; + this.emit({ kind: "assignText", target: f.message.id, value: message, at, label }, s); + go(ERROR_CODES[builtin], builtin); + return; + } + const cls = ts.isNewExpression(e) ? this.classOf(e.expression) : undefined; + const made = ts.isNewExpression(e) && cls ? this.instantiate(e, `(${this.className(cls)})`) : b; + if (made?.kind !== "record" || !made.cls || !this.builtinBase(made.cls)) { + if (made !== null) this.c.error(s.expression, "throw takes an error: throw new Error(\"…\"), new RangeError(\"…\"), or an instance of a class that extends Error."); + return; + } + for (const [key, field] of made.fields) { + if (key === "name") continue; + if (field.kind === "var") { this.emit(this.copyOf(key === "message" ? f.message : this.flightField(key, field.v.kind, at), field.v, at, label), s); continue; } + if (field.kind === "value" && (typeof field.value === "number" || typeof field.value === "boolean" || typeof field.value === "string")) { + const v = field.value; + const to = key === "message" ? f.message : this.flightField(key, typeof v === "string" ? "text" : typeof v === "number" ? "number" : "boolean", at); + this.emit(typeof v === "string" ? { kind: "assignText", target: to.id, value: { kind: "text", text: v }, at, label } : typeof v === "number" ? { kind: "assign", target: to.id, value: num(v), at, label } : { kind: "assignBool", target: to.id, value: { kind: "const", value: v }, at, label }, s); + continue; + } + this.c.error(s.expression, `${this.className(made.cls)}'s ${key} is not a number, a boolean, a unit or a text: an error carries those, which is what a catch can take.`); + return; + } + go(this.errorCode(made.cls), this.errorName(made.cls)); + } + + /** + * `try { … } catch (e) { … } finally { … }`. The finally runs on every way out: copied before each `break`, `continue` + * and `return` that leaves the try, after it on the way out at its end, and — on an error's way — inside a `try` around + * the whole, which puts the error aside, runs the finally, and throws it on. + */ + private tryStatement(s: TS.TryStatement, ctx: Ctx) { + const at = this.at(s), label = this.label(s); + this.flightOf(s); + // A finally is walked once for every way out; what it may not do is said once, and it is then left out. + const leaving = s.finallyBlock && this.leavesFinally(s.finallyBlock); + if (leaving) this.c.error(leaving, `A finally block runs on every way out of its try, an error's way too, so it does not ${this.ts.isReturnStatement(leaving) ? "return: return after the try" : `${this.ts.isBreakStatement(leaving) ? "break" : "continue"} out of it: set a variable, and test it after the try`}.`); + const fin = leaving ? undefined : s.finallyBlock; + const inner: Ctx = fin + ? { ...ctx, ...(ctx.fn ? { fn: { ...ctx.fn, finallies: [...(ctx.fn.finallies ?? []), fin] } } : {}), exits: { break: [...(ctx.exits?.break ?? []), fin], continue: [...(ctx.exits?.continue ?? []), fin] } } + : ctx; + this.tries++; + let body: Stmt[]; + try { body = this.collect(() => this.block(s.tryBlock.statements, inner)); } finally { this.tries--; } + const guarded: Stmt = s.catchClause ? { kind: "try", body, catch: this.catchBody(s.catchClause, inner), at, label } : { kind: "block", body, at }; + if (!fin) { this.emit(guarded, s); return; } + const { save, restore } = this.keepFlight(at, label); + const onError = this.collect(() => { + this.out.push(save); + this.finallyBody(fin, ctx.fn); + this.out.push(restore); + this.emit({ kind: "throw", rethrow: true, at, label }, s); + }); + this.emit({ kind: "try", body: [guarded], catch: onError, at, label }, s); + this.finallyBody(fin, ctx.fn); + } + + /** + * Two blocks filled once the program is walked: `save` puts the error in flight aside — another error thrown and caught + * meanwhile would write over it — and leaves nothing in flight; `restore` puts it back, its class's code last. + */ + private keepFlight(at: At, label: string): { save: Stmt & { kind: "block" }; restore: Stmt & { kind: "block" } } { + const save: Stmt & { kind: "block" } = { kind: "block", body: [], at }, restore: Stmt & { kind: "block" } = { kind: "block", body: [], at }; + this.keepFills.push(() => { + const f = this.flight!; + const kept = [f.thrown, f.message, ...f.fields.values()].map((v) => ({ v, mine: this.newVar(`(kept ${v.name})`, v.kind, at) })); + save.body = [...kept.map(({ v, mine }): Stmt => ({ kind: "declare", decl: mine, init: refOf(v), at, label })), { kind: "assign", target: f.thrown.id, value: num(0), at, label }]; + restore.body = [...kept.slice(1), kept[0]].map(({ v, mine }) => this.copyOf(v, mine, at, label)); + }); + return { save, restore }; + } + + /** A `return`, `break` or `continue` that would leave a finally block; undefined when there is none. */ + private leavesFinally(f: TS.Block): TS.Statement | undefined { + const { ts } = this; + let found: TS.Statement | undefined; + const walk = (n: TS.Node, loop: boolean, breakable: boolean): void => { + if (found || (n !== f && ts.isFunctionLike(n)) || ts.isClassLike(n)) return; + if (ts.isReturnStatement(n) || (ts.isBreakStatement(n) && !breakable) || (ts.isContinueStatement(n) && !loop)) { found = n; return; } + const isLoop = ts.isIterationStatement(n, false); + ts.forEachChild(n, (c) => walk(c, loop || isLoop, breakable || isLoop || ts.isSwitchStatement(n))); + }; + walk(f, false, false); + return found; + } + + private catchBody(c: TS.CatchClause, ctx: Ctx): Stmt[] { + const { ts } = this; + const at = this.at(c), label = this.label(c); + const d = c.variableDeclaration; + const caught: Caught = { name: d && ts.isIdentifier(d.name) ? d.name.text : "(caught)", at, label, entry: { kind: "block", body: [], at }, fields: new Map(), rethrown: false }; + const scope = new Scope(this.scope); + if (d) { + if (!ts.isIdentifier(d.name)) this.c.error(d.name, "catch takes the error whole — catch (e) { … e.message … } — not taken apart."); + else { + const record: Instance = { kind: "record", fields: new CaughtFields((key) => this.caughtField(caught, key)) }; + this.caughtRecords.set(record, caught); + scope.bind(d, record); + } + } + this.errorFills.push(() => { caught.entry.body = this.caughtEntry(caught); }); + return this.collect(() => { + this.out.push(caught.entry); + this.block(c.block.statements, ctx, scope); + }); + } + + /** + * `await t` of a task that ended on an error throws that error, as awaiting a promise that was rejected does. Every slot + * of every start keeps, beside its task, the error it ended on: the task's number (`failed`), the error's class, its + * message and its class's fields, in arrays a cell a slot, found from a task's number through `offsets` (where each + * start's slots begin). A task that ends on an error stores it there, and says it in red unless something is waiting + * for it (`watched`), as an unhandled rejection is said. Gives what the end of a task's body does with its error. + */ + private taskFailures(program: Program, f: Flight): (at: At) => Stmt[] { + const at = program.at, label = "(errors)"; + const starts: Extract[] = []; + eachStart([program.body, program.functions ?? []], (st) => starts.push(st)); + const top = Math.max(0, ...starts.map((st) => st.task)); + const offsets: number[] = new Array(top + 1).fill(0); + let total = 0; + for (const st of [...starts].sort((a, b) => a.task - b.task)) { offsets[st.task] = total; total += st.max; } + total = Math.max(1, total); + const array = (name: string, values?: number[]) => this.newArray(`(task errors ${name})`, "number", values?.length ?? total, at, { shared: true, ...(values ? { values } : {}) }); + const offsetOf = array("offsets", offsets); + const failedAt = array("failed"), watched = array("watched"), code = array("class"); + const text = (name: string) => ({ addr: array(`${name} addr`), block: array(`${name} block`), chars: array(`${name} chars`) }); + const message = text("message"); + // Each field of the error in flight: a column, or three for a unit or a text. + type Columns = { cell: ArrayDecl } | { ptr: ArrayDecl; epd: ArrayDecl; uid: ArrayDecl } | { addr: ArrayDecl; block: ArrayDecl; chars: ArrayDecl }; + const columns = [...f.fields].map(([k, v]): { v: VarDecl; cols: Columns } => ({ v, cols: v.kind === "text" ? text(k) : v.kind === "unit" ? { ptr: array(`${k} ptr`), epd: array(`${k} epd`), uid: array(`${k} uid`) } : { cell: array(k) } })); + const bits = (h: NumExpr, shift: number): NumExpr => ({ kind: "binary", op: "&", left: shift ? { kind: "binary", op: ">>>", left: h, right: num(shift), at, label } : h, right: num(255), at, label }); + const indexOf = (h: NumExpr): NumExpr => ({ kind: "binary", op: "+", left: { kind: "element", array: offsetOf.id, index: bits(h, 8), at }, right: bits(h, 0), at, label }); + const el = (a: ArrayDecl, i: VarDecl): NumExpr => ({ kind: "element", array: a.id, index: varRef(i), at }); + for (const site of this.awaitedTasks) { + const i = this.newVar("(awaited slot)", "number", site.at); + site.watch.body = [ + { kind: "declare", decl: i, init: indexOf(varRef(site.h)), at: site.at, label: site.label }, + { kind: "if", cond: { kind: "running", task: varRef(site.h), at: site.at }, then: [{ kind: "store", array: watched.id, index: varRef(i), value: varRef(site.h), at: site.at, label: site.label }], at: site.at, label: site.label }, + ]; + const back: Stmt[] = [{ kind: "assignText", target: f.message.id, value: { kind: "textAt", addr: message.addr.id, block: message.block.id, chars: message.chars.id, index: varRef(i), at: site.at }, at: site.at, label: site.label }]; + for (const { v, cols } of columns) { + if ("cell" in cols) back.push(v.kind === "boolean" ? { kind: "assignBool", target: v.id, value: { kind: "test", expr: el(cols.cell, i), at: site.at, label: site.label }, at: site.at, label: site.label } : { kind: "assign", target: v.id, value: el(cols.cell, i), at: site.at, label: site.label }); + else if ("ptr" in cols) back.push({ kind: "assignUnit", target: v.id, value: { kind: "unitAt", ptr: el(cols.ptr, i), epd: el(cols.epd, i), uid: el(cols.uid, i), at: site.at }, at: site.at, label: site.label }); + else back.push({ kind: "assignText", target: v.id, value: { kind: "textAt", addr: cols.addr.id, block: cols.block.id, chars: cols.chars.id, index: varRef(i), at: site.at }, at: site.at, label: site.label }); + } + back.push({ kind: "assign", target: f.thrown.id, value: el(code, i), at: site.at, label: site.label }, { kind: "throw", at: site.at, label: site.label }); + const cmp = (left: NumExpr, op: CompareOp, right: NumExpr): BoolExpr => ({ kind: "compare", op, left, right, at: site.at, label: site.label }); + site.check.body = [{ kind: "if", cond: { kind: "and", items: [cmp(varRef(site.h), "!=", num(0)), cmp(el(failedAt, i), "==", varRef(site.h))] }, then: back, at: site.at, label: site.label }]; + } + return (where: At): Stmt[] => { + const me = this.newVar("(this task)", "number", where); + const i = this.newVar("(this task's slot)", "number", where); + const keep: Stmt[] = [ + { kind: "declare", decl: me, init: { kind: "taskHandle", at: where }, at: where, label }, + { kind: "declare", decl: i, init: indexOf(varRef(me)), at: where, label }, + { kind: "store", array: failedAt.id, index: varRef(i), value: varRef(me), at: where, label }, + { kind: "store", array: code.id, index: varRef(i), value: varRef(f.thrown), at: where, label }, + { kind: "storeText", addr: message.addr.id, block: message.block.id, chars: message.chars.id, index: varRef(i), value: { kind: "textVar", id: f.message.id }, at: where, label }, + ]; + for (const { v, cols } of columns) { + if ("cell" in cols) keep.push({ kind: "store", array: cols.cell.id, index: varRef(i), value: v.kind === "boolean" ? { kind: "ternary", cond: boolRef(v), whenTrue: num(1), whenFalse: num(0), at: where, label } : varRef(v), at: where, label }); + else if ("ptr" in cols) for (const part of ["ptr", "epd", "uid"] as const) keep.push({ kind: "store", array: cols[part].id, index: varRef(i), value: { kind: "unitPart", unit: unitRef(v), part, at: where }, at: where, label }); + else keep.push({ kind: "storeText", addr: cols.addr.id, block: cols.block.id, chars: cols.chars.id, index: varRef(i), value: { kind: "textVar", id: v.id }, at: where, label }); + } + keep.push({ kind: "if", cond: { kind: "compare", op: "==", left: { kind: "element", array: watched.id, index: varRef(i), at: where }, right: varRef(me), at: where, label }, then: [{ kind: "assign", target: f.thrown.id, value: num(0), at: where, label }], else: [{ kind: "uncaught", at: where, label }], at: where, label }); + return keep; + }; + } + + /** A finally block, where the walk is: nothing in it leaves it. */ + private finallyBody(f: TS.Block, fn: Ctx["fn"]) { + const body = this.collect(() => this.block(f.statements, { ...(fn ? { fn: { ...fn, finallies: [], inFinally: true } } : {}), inFinally: true })); + this.emit({ kind: "block", body, at: this.at(f) }, f); + } + + /** `return …` out through a try's finally: the value worked out first, as JavaScript does, then each finally, the innermost first, then the return. */ + private returnThrough(s: TS.ReturnStatement, ctx: Ctx) { + const fn = ctx.fn!; + const through = fn.finallies ?? []; + const outer: Ctx = { ...ctx, fn: { ...fn, finallies: [] } }; + const run = () => { for (let i = through.length - 1; i >= 0; i--) this.finallyBody(through[i], outer.fn); }; + if (!s.expression || fn.instance || fn.kind === "void") { run(); this.returnStatement(s, outer); return; } + const at = this.at(s), label = this.label(s); + const kind = fn.kind; + const init = kind === "number" ? this.num(s.expression) : kind === "unit" ? this.unitExpr(s.expression) : kind === "text" ? this.text(s.expression) : this.boolValue(s.expression); + if (!init) return; + const v = this.newVar("(returned)", kind, at, kind === "text" ? { text: "made" } : {}); + this.emit({ kind: "declare", decl: v, init, at, label }, s); + run(); + this.emit({ kind: "return", value: refOf(v), at, label }, s); + } + + /** + * Once the whole program is walked: every class that is thrown is known, and every field that goes through the error in + * flight. The catches' copies are filled in, the names of the classes, the variables declared at the start, and the + * program, every task and every handler of an event inside a `try` that says what nothing caught. + */ + private finishErrors(program: Program) { + const f = this.flight; + if (!f) { + for (const g of this.guards) { const t = g as Extract; Object.assign(g, { kind: "block", body: t.body }); delete (g as Partial).catch; } + return; + } + if (this.stackRead) { + // "OutOfGold: need 20 more\n at main.ts:12": made where each error is thrown, in the field a caught one copies. + const stack = this.flightField("stack", "text", program.at); + for (const site of this.stackSites) { + const where = `\n at ${site.at.file}:${site.at.line}:${site.at.column}`; + site.block.body = [{ kind: "assignText", target: stack.id, value: { kind: "template", parts: [{ kind: "value", text: this.errorText({ kind: "text", text: site.name }, { kind: "textVar", id: f.message.id }, site.at, site.label) }, { kind: "text", text: where }], at: site.at, label: site.label }, at: site.at, label: site.label }]; + } + } + for (const fill of [...this.errorFills, ...this.keepFills]) fill(); + for (const { held, cleanup } of this.unwinds) { + const { at } = held; + if (mentions(held.body, "throw") || mentions(held.body, "call")) held.body = [{ kind: "try", body: held.body, catch: [...cleanup, { kind: "throw", rethrow: true, at, label: "(errors)" }], at, label: "(errors)" }]; + } + const top = Math.max(ERROR_CODES.TypeError, ...this.errorCodes.values()); + const names = (named: (code: number) => string | undefined) => Array.from({ length: top + 1 }, (_, code) => named(code) ?? "Error"); + const builtins = new Map(Object.entries(ERROR_CODES).map(([k, v]) => [v, k])); + const byCode = new Map([...this.errorCodes].map(([c, k]) => [k, c])); + const fill = (a: ArrayDecl, texts: string[]) => { a.texts = texts; a.values = texts.map((_, i) => i); a.length = texts.length; }; + fill(f.names, names((code) => builtins.get(code) ?? (byCode.get(code) ? this.className(byCode.get(code)!) : undefined))); + if (this.jsNames) fill(this.jsNames, names((code) => builtins.get(code) ?? (byCode.get(code) ? this.errorName(byCode.get(code)!) : undefined))); + // (settleFunctions kept only the arrays something named then; these are named by what was filled in since.) + for (const a of [f.names, this.jsNames]) if (a && !this.arrays.includes(a)) this.arrays.push(a); + // (newArray puts the tasks' error arrays in as they are made.) + const at = program.at, label = "(errors)"; + const declares: Stmt[] = [f.thrown, f.message, ...f.fields.values()].map((v) => ({ + kind: "declare", decl: v, at, label, + init: v.kind === "number" ? num(0) : v.kind === "boolean" ? FALSE : v.kind === "unit" ? NO_UNIT : { kind: "text", text: "" }, + })); + const guard = (body: Stmt[], at: At, caught: Stmt[] = [{ kind: "uncaught", at, label }]): Stmt[] => [{ kind: "try", body, catch: caught, at, label }]; + const failed = this.awaitedTasks.length ? this.taskFailures(program, f) : undefined; + eachStart([program.body, program.functions ?? []], (st) => { st.call.body = guard(st.call.body, st.at, failed?.(st.at)); }); + program.body = [...declares, ...guard(program.body, at)]; + program.errors = { thrown: f.thrown.id, message: f.message.id, names: f.names.id }; + } + private declare(list: TS.VariableDeclarationList) { const { ts } = this; for (const d of list.declarations) { @@ -711,19 +1866,51 @@ export class Structured { if (!ts.isIdentifier(d.name)) { // `const { x, y } = mouse(P1)`, `const [a, b] = pair`: every name a variable of its own. if (!d.initializer) { this.c.error(d, "A pattern is taken from something: const { x, y } = p."); continue; } + let given: TS.Expression = d.initializer; + while (ts.isParenthesizedExpression(given)) given = given.expression; + const all = ts.isAwaitExpression(given) ? this.unwrap(given.expression) : undefined; + if (all && ts.isCallExpression(all) && this.promiseCall(all) === "all" && ts.isArrayBindingPattern(d.name)) { this.allValues(d.name, all, d); continue; } + // `const [x, y] = await sync(P1, a, b)` (B5): every value sent on the same frame, a name for each. + if (all && ts.isCallExpression(all) && this.isLibraryCall(all, "sync") && ts.isArrayBindingPattern(d.name)) { + const vars = this.syncValues(all); + if (vars) this.bindPattern(d.name, d, vars.map((v) => ({ kind: "var", v }) as Binding), this.scope); + continue; + } const from = this.patternSource(d.initializer, d); if (from) this.bindPattern(d.name, d, from, this.scope); continue; } if (!d.initializer) { this.c.error(d, `Give ${d.name.text} an initial value: let ${d.name.text} = 0 or = false.`); continue; } + // `const f = (x: number) => x + 1` (B3): the function itself, known when the script is built, so a call of f is a call of its body. + if (list.flags & ts.NodeFlags.Const) { + const written = this.unwrap(d.initializer); + if (ts.isArrowFunction(written) || ts.isFunctionExpression(written)) { + const fn = this.fnValueOf(written); + if (fn) { fn.name = d.name.text; this.scope.bind(d, { kind: "fn", fn }); } + continue; + } + } + // `const count = layout.count` in a game function whose `layout` is known when the script is built: a value too, as + // a const the plan computed is (a number, a boolean or a text: an object's fields may still be assigned). + if (list.flags & ts.NodeFlags.Const) { + const known = this.evaluate(d.initializer); + const v = known?.value; + if (known && (typeof v === "number" || typeof v === "boolean" || typeof v === "string")) { this.scope.bind(d, { kind: "value", value: v }); continue; } + } const init = this.unwrap(d.initializer); + if (ts.isCallExpression(init) && ts.isIdentifier(init.expression)) { + const g = this.gameDeclaration(init.expression); + if (g && ts.isFunctionDeclaration(g) && g.asteriskToken) { this.c.error(init, `${init.expression.text} is a generator: a program walks one with for…of — for (const w of ${init.expression.text}(…)) { … }.`); continue; } + } + if (ts.isCallExpression(init) && this.isLibraryCall(init, "sync") && init.arguments.length > 2) { this.c.error(init, "sync() of several values gives one for each: take them apart where it is awaited — const [x, y] = await sync(P1, a, b)."); continue; } + if (ts.isAwaitExpression(this.stripParens(d.initializer)) && ts.isCallExpression(init) && this.promiseCall(init) === "all") { this.c.error(init, "Promise.all() gives a value for each item: take them apart where it is awaited — const [a, b] = await Promise.all([…])."); continue; } if (ts.isObjectLiteralExpression(init) && !this.keyedForm(init, this.c.checker.getTypeAtLocation(d.name))) { const record = this.declareRecord(d.name.text, init, this.c.checker.getTypeAtLocation(d.name), d); if (record) this.scope.bind(d, record); continue; } - // `const s = new Squad(P1)`: an instance, which is a record whose class is known. - if (ts.isNewExpression(init) && this.classOf(init.expression)) { + // `const s = new Squad(P1)`: an instance, which is a record whose class is known. One kept by reference (B9) is a number below. + if (ts.isNewExpression(init) && this.classOf(init.expression) && !this.isRefClass(this.classOf(init.expression))) { const instance = this.instantiate(init, d.name.text); if (instance) this.scope.bind(d, instance); continue; @@ -733,16 +1920,40 @@ export class Structured { const given = this.instanceCall(init, d.name.text); if (given !== undefined) { if (given) this.scope.bind(d, given); continue; } } + // `const r = c ? { x: 1 } : { x: 2 }`, `const v = c ? a.add(b) : a` (B9): a record made empty, and one or the other copied in. + if (ts.isConditionalExpression(init)) { + const type = this.c.checker.getTypeAtLocation(d.name); + const cls = this.classOfType(type); + if ((this.plainRecordType(type) || (cls && !this.refClasses(type) && this.isValueClass(cls))) && !this.kindOf(type)) { + const record = this.zeroRecord(d.name.text, type, d, cls); + if (record) { this.assignWhole(record, init, d); this.scope.bind(d, record); } + continue; + } + } // `const boss = s`, `const p = this.pos`: another name for the same instance, as it is for an object. if (ts.isIdentifier(init) || ts.isPropertyAccessExpression(init) || init.kind === ts.SyntaxKind.ThisKeyword) { const same = this.bindingOf(init); if (same?.kind === "inner" || same?.kind === "innerUnits") { this.scope.bind(d, this.takenCopy(same, d.name.text, d)); continue; } if (same?.kind === "record" && !same.truth) { - if (!(list.flags & ts.NodeFlags.Const) && this.assigns(this.body.plan.body, d)) { this.c.error(d, `${d.name.text} is another name for a record, and a record is not assigned whole: declare it with const, or copy the fields it needs.`); continue; } + // Given whole somewhere — this name, or the one it is taken from (B9): a copy of its own, since a record given whole + // is copied into. A copy is what JavaScript shows only while nobody writes a field of either: said otherwise. + const from = ts.isIdentifier(init) ? declarationOf(ts, this.c.checker, init) : undefined; + const mine = !(list.flags & ts.NodeFlags.Const) && this.assigns(this.body.plan.body, d); + const theirs = !!from && this.assigns(this.body.plan.body, from); + if ((mine || theirs) && !same.ref) { + if (!same.cls && (this.fieldsWritten(d) || (from && this.fieldsWritten(from)))) { this.c.error(d, `${d.name.text} is another name for a record that is given whole, and a field of one is written: the two would not stay one record. Copy the fields it needs ({ ...${init.getText(this.body.sf)} }), or keep one name.`); continue; } + this.scope.bind(d, this.recordCopy(same, d.name.text, d)); + continue; + } this.scope.bind(d, same); continue; } } + // `const t = enemies.find((e) => e.hp > 0)`: the row found, and whether one was, as what chatted() found is. + if (ts.isCallExpression(init) && ts.isPropertyAccessExpression(init.expression) && (init.expression.name.text === "find" || init.expression.name.text === "findLast")) { + const over = this.overOf(init.expression.expression); + if (over?.kind === "records") { const row = this.foundRow(init, over, d.name.text, d); if (row) this.scope.bind(d, row); continue; } + } if (ts.isCallExpression(init) && (this.isLibraryCall(init, "mouse") || this.isLibraryCall(init, "chatted"))) { const record = this.declareInput(d.name.text, init, d); if (record) this.scope.bind(d, record); @@ -800,6 +2011,13 @@ export class Structured { if (squad) this.scope.bind(d, squad); continue; } + // An array of a type more than one class fits, or that may hold none (B9): its classes kept by reference. + { + const isList = this.c.checker.isArrayType(type) || this.c.checker.isTupleType(type); + const el = isList ? this.c.checker.getIndexTypeOfType(type, ts.IndexKind.Number) : undefined; + const wanted = el && !this.kindOf(el) ? this.refWanted(el) : null; + if (wanted && this.needRef(wanted)) { this.c.error(d, `${d.name.text} holds ${this.c.checker.typeToString(el!)}, which more than one class fits or which may be none: its classes are kept by reference.`); continue; } + } const rowClass = this.rowClass(type, d); if (rowClass === null) continue; const why: string[] = []; @@ -838,11 +2056,22 @@ export class Structured { continue; } const kind = this.kindOf(type); + // `let best: number | undefined`, `const x = xs.find(…)` (B9): the value and whether there is one. + const maybe = kind ? null : this.maybeKind(type); + if (maybe) { const b = this.declareMaybe(d.name.text, maybe, type, d.initializer, d); if (b) this.scope.bind(d, b); continue; } // `const x = xs.find(…)` would be a number or undefined: searchCall says what to write instead. if (!kind && ts.isCallExpression(init) && ts.isPropertyAccessExpression(init.expression) && (init.expression.name.text === "find" || init.expression.name.text === "findLast")) { const over = this.overOf(init.expression.expression); if (over) { this.searchCall(init, over, init.expression.name.text, over.kind === "array" ? over.a.kind : "number"); continue; } } + // `const v = await Promise.race([wave(), sleep(…)])` is a number or nothing. + const race = !kind ? this.promiseCall(init) : undefined; + if (race && race !== "all") { this.c.error(d, `${d.name.text} would be ${this.c.checker.typeToString(type)}: Promise.${race} gives the value of the branch that ends first, so every branch gives one. Give a sleep() or an until() a value with .then(() => …).`); continue; } + // `let target: Enemy | null`, `let s: Shape = …` (B9): a variable that may be none, or one of several classes — they are kept by reference. + if (!kind) { const wanted = this.instanceClasses(type); if (wanted && this.needRef(wanted)) { this.c.error(d, `${d.name.text} is ${this.c.checker.typeToString(type)}: its classes are kept by reference.`); continue; } } + // `const both = command(…) && bring(…)`: conditions of the game, kept to be asked where the name is tested, as + // `const there = bring(…)` is (the script hoists that one itself; `&&` of two is the program's). + if (!kind) { const conds = this.conditionsOf(init); if (conds) { this.scope.bind(d, { kind: "value", value: conds }); continue; } } if (!kind) { this.c.error(d, `Variables hold numbers, booleans, texts, units of the game or records of them ({ lives: 3 }); ${d.name.text} is ${this.c.checker.typeToString(type)}.`); continue; } // `let total = shared(0)`: one cell for every player of a per-player program, initialised with the argument. const shared = ts.isCallExpression(init) && this.isLibraryCall(init, "shared") ? init : null; @@ -914,9 +2143,79 @@ export class Structured { } /** `squad.push(u)`, `squad.pop()` standing on their own; the three arrays move together. */ + /** + * The rows of a list from `start` on: `remove` of them taken out (their blocks given back, when `owner` says its rows + * own any), room made for `insert` new ones in their place, the rest moved up or down to close or open the gap — as + * JavaScript's `splice` has it, `start` counted from the end when below zero and both kept inside the list. The new + * rows are left empty (their cells 0) for the caller to fill; what comes back is the place of the first. + */ + private spliceRows(columns: ArrayDecl[], owner: Records | null, start: NumExpr, remove: NumExpr | null, insert: number, e: TS.Node): VarDecl { + const b = this.irAt(e); + for (const a of columns) a.dynamic = true; + const len = b.num("(length)"), s0 = b.num("(start)"), d = b.num("(removed)"), k = b.num("(row)"), g = b.num("(gap)"); + const length = (): NumExpr => ({ kind: "length", array: columns[0].id, at: b.at }); + const move = (to: NumExpr, from: NumExpr): Stmt[] => columns.map((a): Stmt => ({ kind: "store", array: a.id, index: to, value: { kind: "element", array: a.id, index: from, at: b.at }, at: b.at, label: b.label })); + const min = (x: NumExpr, y: NumExpr): NumExpr => ({ kind: "intrinsic", name: "min", args: [x, y], at: b.at, label: b.label }); + const max = (x: NumExpr, y: NumExpr): NumExpr => ({ kind: "intrinsic", name: "max", args: [x, y], at: b.at, label: b.label }); + this.emit(b.declare(len, length()), e); + this.emit(b.declare(s0, { kind: "ternary", cond: b.cmp("<", start, b.n(0)), whenTrue: max(b.add(b.ref(len), start), b.n(0)), whenFalse: min(start, b.ref(len)), at: b.at, label: b.label }), e); + this.emit(b.declare(d, remove ? max(b.n(0), min(remove, b.sub(b.ref(len), b.ref(s0)))) : b.sub(b.ref(len), b.ref(s0))), e); + if (owner && this.owns(owner)) { + this.emit(b.declare(k, b.ref(s0)), e); + this.out.push(b.loop(b.cmp("<", b.ref(k), b.add(b.ref(s0), b.ref(d))), [...this.collect(() => this.releaseRow(owner, b.ref(k), e)), b.set(k, b.add(b.ref(k), b.n(1)))])); + } else this.emit(b.declare(k, b.n(0)), e); + // Fewer rows: those after the gap move down, and the end is cut off (what it held has moved, so nothing goes back). + this.out.push(b.when(b.cmp(">", b.ref(d), b.n(insert)), [ + b.declare(g, b.sub(b.ref(d), b.n(insert))), + b.set(k, b.add(b.ref(s0), b.n(insert))), + b.loop(b.cmp("<", b.add(b.ref(k), b.ref(g)), b.ref(len)), [...move(b.ref(k), b.add(b.ref(k), b.ref(g))), b.set(k, b.add(b.ref(k), b.n(1)))]), + ...columns.map((a): Stmt => ({ kind: "setLength", array: a.id, value: b.sub(b.ref(len), b.ref(g)), at: b.at, label: b.label })), + ])); + // More rows: as many pushed at the end, and those after the gap move up into them, the last first. + this.out.push(b.when(b.cmp("<", b.ref(d), b.n(insert)), [ + b.declare(g, b.sub(b.n(insert), b.ref(d))), + b.set(k, b.n(0)), + b.loop(b.cmp("<", b.ref(k), b.ref(g)), [...columns.map((a): Stmt => ({ kind: "push", array: a.id, value: num(0), at: b.at, label: b.label })), b.set(k, b.add(b.ref(k), b.n(1)))]), + b.set(k, b.sub(b.add(b.ref(len), b.ref(g)), b.n(1))), + b.loop(b.cmp(">=", b.ref(k), b.add(b.ref(s0), b.n(insert))), [...move(b.ref(k), b.sub(b.ref(k), b.ref(g))), b.set(k, b.sub(b.ref(k), b.n(1)))]), + ])); + // The new rows hold copies of rows that moved: emptied, so that filling them gives nothing of those back. + for (let i = 0; i < insert; i++) for (const a of columns) this.emit({ kind: "store", array: a.id, index: b.add(b.ref(s0), b.n(i)), value: a.kind === "boolean" ? FALSE : num(0), at: b.at, label: b.label }, e); + return s0; + } + + /** A place counted from the end when below zero: `at(-1)` is the last. */ + private fromEnd(i: NumExpr, length: NumExpr, e: TS.Node): NumExpr { + if (i.kind === "const") return i.value >= 0 ? i : { kind: "binary", op: "+", left: length, right: i, at: this.at(e), label: this.label(e) }; + const t = this.temp(i, e); + return { kind: "ternary", cond: { kind: "compare", op: "<", left: t, right: num(0), at: this.at(e), label: this.label(e) }, whenTrue: { kind: "binary", op: "+", left: length, right: t, at: this.at(e), label: this.label(e) }, whenFalse: t, at: this.at(e), label: this.label(e) }; + } + + /** `start` and `deleteCount` of a `splice`, and the items after them; `shift` and `unshift` are splices too. Null with a diagnostic. */ + private spliceArgs(e: TS.CallExpression, method: string): { start: NumExpr; remove: NumExpr | null; items: readonly TS.Expression[] } | null { + const args = e.arguments; + if (args.some((a) => this.ts.isSpreadElement(a))) { this.c.error(e, `${method}() of a program takes its items one by one.`); return null; } + if (method === "shift") return args.length ? (this.c.error(e, "shift() takes nothing."), null) : { start: num(0), remove: num(1), items: [] }; + if (method === "unshift") return { start: num(0), remove: num(0), items: args }; + if (!args.length) return { start: num(0), remove: num(0), items: [] }; + const start = this.num(args[0]); + const remove = args[1] ? this.num(args[1]) : null; + if (!start || (args[1] && !remove)) return null; + return { start: this.temp(start, e), remove, items: args.slice(2) }; + } + private unitsCall(e: TS.CallExpression, of: Extract, method: string) { const at = this.at(e); const label = this.label(e); + if (method === "shift" || method === "unshift" || method === "splice") { + const sp = this.spliceArgs(e, method); + if (!sp) return; + const units: UnitExpr[] = []; + for (const x of sp.items) { const u = this.unitExpr(x); if (!u) return; units.push(this.unitTemp(u, x)); } + const s0 = this.spliceRows(this.unitArrays(of).map(([a]) => a), null, sp.start, sp.remove, units.length, e); + units.forEach((unit, i) => { for (const [a, part] of this.unitArrays(of)) this.emit({ kind: "store", array: a.id, index: { kind: "binary", op: "+", left: varRef(s0), right: num(i), at, label }, value: { kind: "unitPart", unit, part, at }, at, label }, e); }); + return; + } if (method === "push" && e.arguments.length > 0) { for (const arg of e.arguments) { const found = this.unitExpr(arg); @@ -930,7 +2229,7 @@ export class Structured { for (const [a] of this.unitArrays(of)) { a.dynamic = true; this.emit({ kind: "pop", array: a.id, at, label }, e); } return; } - this.c.error(e, `An array of units has push(unit), pop(), length, for…of, and forEach, filter, some, every, find, findIndex, sort and reverse; ${method}() is not one of them.`); + this.c.error(e, `An array of units has push(unit), pop(), shift(), unshift(unit), splice(…), length, for…of, and forEach, filter, some, every, find, findIndex, sort and reverse; ${method}() is not one of them.`); } /** A number of the program as something that can be read twice without being worked out twice: itself when it is a constant or a variable, else a temporary holding it. */ @@ -948,7 +2247,9 @@ export class Structured { const checker = this.c.checker; if (!checker.isArrayType(type) && !checker.isTupleType(type)) return null; const element = checker.getIndexTypeOfType(type, ts.IndexKind.Number); - if (!element || this.kindOf(element) || !(element.flags & ts.TypeFlags.Object) || checker.isArrayType(element) || checker.isTupleType(element)) return null; + // `Event[]` of `{ kind: "hit"; dmg: number } | { kind: "heal"; amt: number }` (B9): a row holds every kind's fields. + const recordUnion = !!element?.isUnion() && element.types.every((t) => (t.flags & ts.TypeFlags.Object) !== 0 && !checker.isArrayType(t) && !checker.isTupleType(t) && !this.instanceClasses(t)); + if (!element || this.kindOf(element) || (!(element.flags & ts.TypeFlags.Object) && !recordUnion) || checker.isArrayType(element) || checker.isTupleType(element)) return null; return this.shapeOf(element, why); } @@ -963,14 +2264,30 @@ export class Structured { const fields: RowShape = new Map(); const no = (what: string) => { why?.push(what); return null; }; if (depth > 4) return no("it holds itself, and a row has every one of its cells when the script is built"); - for (const p of checker.getPropertiesOfType(element)) { - const d = p.valueDeclaration; + // A union of records (B9): every member's fields, one column each; a field of one kind only is empty in the others' rows. + if (element.isUnion() && element.types.every((t) => (t.flags & ts.TypeFlags.Object) !== 0)) { + for (const t of element.types) { + const one = this.shapeOf(t, why, depth); + if (!one) return null; + for (const [k, f] of one) { + const had = fields.get(k); + if (had && JSON.stringify(had) !== JSON.stringify(f)) return no(`${k} is of two kinds in the records it may be`); + fields.set(k, f); + } + } + this.unionShapes.add(fields); + return fields; + } + for (const p of checker.getPropertiesOfType(element)) { + const d = p.valueDeclaration; if (d && (ts.isMethodDeclaration(d) || ts.isAccessor(d))) continue; const t = checker.getTypeOfSymbol(p); // `#hp` is kept under the name it is written with, not the one the checker makes of it. const key = d && (ts.isPropertyDeclaration(d) || ts.isParameter(d)) ? this.memberKey(d.name as TS.PropertyName) ?? p.name : p.name; const kind = this.kindOf(t); if (kind === "number" || kind === "boolean") { fields.set(key, { kind, width: kind === "number" ? this.widthOf(t) : {} }); continue; } + const maybe = kind ? null : this.maybeKind(t); + if (maybe) { fields.set(key, { kind: "maybe", of: maybe, width: maybe === "number" ? this.widthOf(checker.getNonNullableType(t)) : {} }); continue; } if (kind === "unit") { fields.set(key, { kind: "unit" }); continue; } if (this.isTextType(t)) { fields.set(key, { kind: "text" }); continue; } if (checker.isArrayType(t) || checker.isTupleType(t)) { @@ -978,9 +2295,11 @@ export class Structured { const of = el ? this.kindOf(el) : null; if (of === "unit") fields.set(key, { kind: "squad" }); else if (of) fields.set(key, { kind: "list", of, width: of === "number" && el ? this.widthOf(el) : {} }); + else if (el && this.instanceClasses(el) && this.needRef(this.instanceClasses(el)!)) return no(`${key} is an array of instances, which are kept by reference`); else return no(`${key} is an array of something other than numbers, booleans or units`); continue; } + { const wanted = this.refWanted(t); if (wanted && this.needRef(wanted)) return no(`${key} is ${checker.typeToString(t)}, whose classes are kept by reference`); } if (!(t.flags & ts.TypeFlags.Object)) return no(`${key} is ${checker.typeToString(t)}`); const inner = this.shapeOf(t, why, depth + 1); if (!inner) return null; @@ -1006,6 +2325,7 @@ export class Structured { case "text": return TEXT_PARTS.map((part) => ({ key: `${key} ${part}`, kind: "number" as const, width: { unsigned: true } })); case "squad": return UNIT_PARTS.flatMap((part) => handle(`${key} ${part}`)); case "record": return this.columnsOf(f.shape, `${key} `); + case "maybe": return [{ key, kind: f.of, width: f.width }, { key: `${key} there`, kind: "boolean" as const, width: {} }]; default: return [{ key, kind: f.kind, width: f.width }]; } }); @@ -1088,6 +2408,8 @@ export class Structured { const { ts } = this; if (!this.c.checker.isArrayType(type) && !this.c.checker.isTupleType(type)) return undefined; const element = this.c.checker.getIndexTypeOfType(type, ts.IndexKind.Number); + // An array of references (B9) is an array of numbers. + if (element && this.refClasses(element)) return undefined; const declared = element && this.classOfType(element); if (!declared) return undefined; const made = new Set(); @@ -1102,9 +2424,10 @@ export class Structured { if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isElementAccessExpression(n.left) && mine(n.left.expression)) take(n.right); ts.forEachChild(n, walk); }; - walk(this.body.plan.arrow.body); + walk(this.body.plan.root); const init = d.initializer && this.unwrap(d.initializer); if (init && ts.isArrayLiteralExpression(init)) init.elements.forEach(take); + if (made.size > 1 && this.needRef([...made, ...(this.instanceClasses(element) ?? [])])) { this.c.error(d, `${d.name.getText(this.body.sf)} holds instances of several classes, so they are kept by reference.`); return null; } if (made.size > 1) { this.c.error(d, `${d.name.getText(this.body.sf)} would hold ${[...made].map((c) => this.className(c)).join(" and ")}: an array of instances holds one class, since a row has the fields of one. Keep an array for each.`); return null; } return [...made][0] ?? declared; } @@ -1156,7 +2479,8 @@ export class Structured { const out: RowValues = { cells: new Map(), fill: [] }; if (!this.rowField(row.of.name, key, f, src, out, at)) return false; // An array's handle and a text's cells are nothing in a new row, and are filled below: only what is a value is stored. - if (f.kind !== "list" && f.kind !== "squad" && f.kind !== "text") for (const [column, value] of out.cells) this.emit({ kind: "store", array: row.of.fields.get(column)!.id, index: row.index, value, at: this.at(at), label: this.label(at) }, at); + const fresh = row.index.kind === "var" && this.freshRows.has(row.index.id); + if (f.kind !== "list" && f.kind !== "squad" && f.kind !== "text") for (const [column, value] of out.cells) { const st = this.emit({ kind: "store", array: row.of.fields.get(column)!.id, index: row.index, value, at: this.at(at), label: this.label(at) }, at); if (fresh) this.freshStores.add(st); } return out.fill.every((fill) => fill(row.of, row.index)); } @@ -1196,13 +2520,23 @@ export class Structured { let v: NumExpr | BoolExpr | null; if ("value" in src) { if (f.kind === "boolean") { if (typeof src.value !== "boolean") { this.c.error(where, `${name}: ${field} is true or false, got ${describe(src.value)}.`); return false; } v = { kind: "const", value: src.value }; } - else { const n = this.asInteger({ value: src.value }, where); v = n === null ? null : num(n); } + else v = this.constOf({ value: src.value }, where); } else if ("expr" in src) v = f.kind === "number" ? this.num(src.expr) : this.boolValue(src.expr); else v = src.binding.kind === "record" ? null : (this.valueOf(src.binding, f.kind, where) as NumExpr | BoolExpr | null); if (!v) return false; out.cells.set(key, v); return true; } + case "maybe": { + let got: { value: NumExpr | BoolExpr; there: BoolExpr } | null; + if ("value" in src) got = src.value === undefined || src.value === null ? { value: f.of === "number" ? num(0) : FALSE, there: FALSE } : (() => { const v = f.of === "number" ? this.constOf({ value: src.value }, where) : typeof src.value === "boolean" ? { kind: "const" as const, value: src.value } : null; return v ? { value: v, there: TRUE } : null; })(); + else if ("expr" in src) got = this.maybeValue(src.expr, f.of); + else got = src.binding.kind === "maybe" ? { value: this.valueOf(src.binding.value, f.of, where) as NumExpr | BoolExpr, there: this.thereOf(src.binding, where) } : (() => { const v = this.valueOf(src.binding, f.of, where) as NumExpr | BoolExpr | null; return v ? { value: v, there: TRUE } : null; })(); + if (!got) return false; + out.cells.set(key, got.value); + out.cells.set(`${key} there`, got.there); + return true; + } case "unit": { let u: UnitExpr | null; if ("value" in src) { if (src.value !== null && src.value !== undefined) { this.c.error(where, `${name}: ${field} is a unit of the game, got ${describe(src.value)}.`); return false; } u = NO_UNIT; } @@ -1380,6 +2714,7 @@ export class Structured { case "text": return [name, this.textCells(of, key, index)]; case "squad": { const [ptr, epd, uid] = UNIT_PARTS.map((part) => this.listsAt(of, `${key} ${part}`, { of: "number", width: { unsigned: true } })); return [name, { kind: "innerUnits", name: `${of.name}[…].${key.replace(/ /g, ".")}`, ptr, epd, uid, index }]; } case "record": return [name, { kind: "record", fields: build(f.shape, `${key} `), ...(f.cls ? { cls: f.cls } : {}) }]; + case "maybe": return [name, { kind: "maybe", value: { kind: "cell", a: of.fields.get(key)!, index }, there: { kind: "cell", a: of.fields.get(`${key} there`)!, index } }]; default: return [name, { kind: "cell", a: of.fields.get(key)!, index }]; } })); @@ -1446,7 +2781,8 @@ export class Structured { if (!from) return null; const out: RowValues = { cells: new Map(), fill: [] }; for (const [field, f] of shape) { - const src = from(field); + // An optional field left out (B9) is undefined; a field of another kind of a union of records, empty. + const src = from(field) ?? (f.kind === "maybe" ? { value: undefined } : this.unionShapes.has(shape) ? this.emptyField(f) : undefined); if (!src) { this.c.error(literal, `${name}: a record has ${[...shape.keys()].join(", ")}; ${field} is missing.`); return null; } if (!this.rowField(name, field, f, src, out, literal)) return null; } @@ -1507,17 +2843,51 @@ export class Structured { const label = this.label(e); if (method === "push") { if (e.arguments.length === 0) { this.c.error(e, "push() takes the record to add."); return; } - for (const arg of e.arguments) { + // `q.push(hit ? { kind: "hit", n } : { kind: "heal", h })`: the one the condition picks, each written out. + const pushOne = (arg: TS.Expression): boolean => { const literal = this.unwrap(arg); - if (!ts.isObjectLiteralExpression(literal) && !ts.isNewExpression(literal)) { this.c.error(arg, of.cls ? `${of.name}.push(new ${this.className(of.cls)}(…)) takes an instance made there: a row is the instance, so one kept in a variable would be copied, and changing it afterwards would not change the row.` : `${of.name}.push({ … }) takes a record written out.`); return; } + if (ts.isConditionalExpression(literal)) { + const cond = this.boolValue(literal.condition); + let ok = true; + const then = this.collect(() => { ok = pushOne(literal.whenTrue) && ok; }); + const otherwise = this.collect(() => { ok = pushOne(literal.whenFalse) && ok; }); + this.emit({ kind: "if", cond, then, else: otherwise, at, label }, e); + return ok; + } + if (!ts.isObjectLiteralExpression(literal) && !ts.isNewExpression(literal) && of.cls && this.needRef(this.instanceClasses(this.instanceType(of.cls)) ?? [of.cls])) { this.c.error(arg, `${of.name} is given an instance kept elsewhere: its class is kept by reference.`); return false; } + if (!ts.isObjectLiteralExpression(literal) && !ts.isNewExpression(literal)) { this.c.error(arg, of.cls ? `${of.name}.push(new ${this.className(of.cls)}(…)) takes an instance made there: a row is the instance, so one kept in a variable would be copied, and changing it afterwards would not change the row.` : `${of.name}.push({ … }) takes a record written out, or c ? { … } : { … }.`); return false; } const shape = this.rowShape(of); const row = ts.isNewExpression(literal) ? this.newRow(of, literal, shape, of) : this.rowValues(of.name, literal, shape); - if (!row) return; + if (!row) return false; const place = row.fill.length ? this.newVar(`(row of ${of.name})`, "number", at, { temp: true }) : undefined; if (place) this.emit({ kind: "declare", decl: place, init: { kind: "length", array: [...of.fields.values()][0].id, at }, at, label }, e); for (const [key, a] of of.fields) { a.dynamic = true; this.emit({ kind: "push", array: a.id, value: row.cells.get(key)!, at, label }, e); } - for (const fill of row.fill) if (!fill(of, varRef(place!))) return; - } + for (const fill of row.fill) if (!fill(of, varRef(place!))) return false; + return true; + }; + for (const arg of e.arguments) if (!pushOne(arg)) return; + return; + } + if (method === "shift" || method === "unshift" || method === "splice") { + const sp = this.spliceArgs(e, method); + if (!sp) return; + const shape = this.rowShape(of); + const rows: RowValues[] = []; + for (const arg of sp.items) { + const literal = this.unwrap(arg); + if (!ts.isObjectLiteralExpression(literal) && !ts.isNewExpression(literal)) { this.c.error(arg, `${of.name}.${method}(…) takes records written out, or instances made there.`); return; } + const row = ts.isNewExpression(literal) ? this.newRow(of, literal, shape, of) : this.rowValues(of.name, literal, shape); + if (!row) return; + // Worked out before anything moves, as JavaScript's arguments are. + const held = new Map([...row.cells].map(([key, v]) => [key, this.temp(v as NumExpr, arg, true) as NumExpr | BoolExpr])); + rows.push({ ...row, cells: held }); + } + const s0 = this.spliceRows([...of.fields.values()], of, sp.start, sp.remove, rows.length, e); + rows.forEach((row, i) => { + const place = this.temp({ kind: "binary", op: "+", left: varRef(s0), right: num(i), at, label }, e, true); + for (const [key, a] of of.fields) this.emit({ kind: "store", array: a.id, index: place, value: row.cells.get(key)!, at, label }, e); + for (const fill of row.fill) fill(of, place); + }); return; } if (method === "pop" && e.arguments.length === 0) { @@ -1530,7 +2900,7 @@ export class Structured { else this.out.push(...pops); return; } - this.c.error(e, `An array of records has push({ … }), pop(), length, for…of, and forEach, filter, some, every, findIndex, reduce, sort and reverse; ${method}() is not one of them. (pop() stands on its own: read ${of.name}[${of.name}.length - 1] first for what it takes off.)`); + this.c.error(e, `An array of records has push({ … }), pop(), shift(), unshift({ … }), splice(…), length, for…of, and forEach, filter, some, every, findIndex, reduce, sort and reverse; ${method}() is not one of them. (pop() stands on its own: read ${of.name}[${of.name}.length - 1] first for what it takes off.)`); } /** The `__kind` a branded type of the library carries ("unit", "player", …), if it carries one. */ @@ -1574,13 +2944,19 @@ export class Structured { const args = as === "record" ? [] : checker.getTypeArguments(type as TS.TypeReference); const keyType = as === "record" ? checker.getIndexInfosOfType(type).find((i) => this.brandOfType(i.keyType))?.keyType : args[0]; const valueType = as === "record" ? checker.getIndexInfosOfType(type).find((i) => this.brandOfType(i.keyType))?.type : as === "map" ? args[1] : undefined; - const key = keyType ? this.brandOfType(keyType) : undefined; - const domain = key ? KEY_DOMAINS[key] : undefined; + // `Map<"red" | "blue", number>`: texts of a union, a cell each. + const literals = keyType ? this.literalTexts(keyType) : undefined; + const key = literals ? "text" : keyType ? this.brandOfType(keyType) : undefined; + const domain = literals ? { size: literals.length, what: literals.map((l) => JSON.stringify(l)).join(" | ") } : key ? KEY_DOMAINS[key] : undefined; + // `new Set()` (B9): instances as keys are references, numbers. + const instanceKeys = keyType ? this.instanceClasses(keyType) : null; + if ((!key || !domain) && instanceKeys && this.needRef(instanceKeys)) { this.c.error(at, `${name}'s keys are instances: they are kept by reference.`); return null; } if (!key || !domain) { - this.c.error(at, `${name}'s keys have to be numbers, or ids of the game — UnitType, Player, Location, Switch, Weapon, Upgrade or Tech: ${as === "set" ? "new Set()" : as === "map" ? "new Map()" : "Record"}. For numbers of your own, an array does it.`); + this.c.error(at, `${name}'s keys have to be numbers, ids of the game — UnitType, Player, Location, Switch, Weapon, Upgrade or Tech — or texts of a union known when the script is built: ${as === "set" ? "new Set()" : as === "map" ? "new Map(), new Map<\"red\" | \"blue\", number>()" : "Record"}. For numbers of your own, an array does it.`); return null; } const kind = valueType ? this.kindOf(valueType) : null; + if (as !== "set" && kind !== "number" && kind !== "boolean" && valueType && this.instanceClasses(valueType) && this.needRef(this.instanceClasses(valueType)!)) { this.c.error(at, `${name} holds instances: they are kept by reference.`); return null; } if (as !== "set" && kind !== "number" && kind !== "boolean") { this.c.error(at, `${name} holds numbers or booleans.`); return null; } const where = this.sourceOf(at); const atIr = this.at(at); @@ -1588,7 +2964,7 @@ export class Structured { const values = as === "set" ? undefined : this.newArray(name, kind as "number" | "boolean", domain.size, where, kind === "number" ? this.widthOf(valueType!) : {}); const present = as === "record" ? undefined : this.newArray(`${name} (has)`, "boolean", domain.size, where); const size = as === "record" ? undefined : this.newVar(`${name}.size`, "number", where); - const keyed: Keyed = { kind: "keyed", as, name, domain: domain.size, key, ...(values ? { values } : {}), ...(present ? { present } : {}), ...(size ? { size } : {}) }; + const keyed: Keyed = { kind: "keyed", as, name, domain: domain.size, key, ...(values ? { values } : {}), ...(present ? { present } : {}), ...(size ? { size } : {}), ...(literals ? { literals } : {}) }; if (values) this.emit({ kind: "declareArray", array: values.id, fill: values.kind === "number" ? num(0) : FALSE, at: atIr, label }, at); if (present) this.emit({ kind: "declareArray", array: present.id, fill: FALSE, at: atIr, label }, at); if (size) this.emit({ kind: "declare", decl: size, init: num(0), at: atIr, label }, at); @@ -1600,7 +2976,7 @@ export class Structured { const index = this.keyConstant(keyed, typeof k === "string" && /^\d+$/.test(k) ? Number(k) : k, init); if (index === null) continue; if (values) { - const value: NumExpr | BoolExpr | null = values.kind === "boolean" ? (typeof v === "boolean" ? { kind: "const", value: v } : null) : (() => { const n = this.asInteger({ value: v }, init); return n === null ? null : num(n); })(); + const value: NumExpr | BoolExpr | null = values.kind === "boolean" ? (typeof v === "boolean" ? { kind: "const", value: v } : null) : this.constOf({ value: v }, init); if (!value) { if (values.kind === "boolean") this.c.error(init, `Expected true or false, got ${describe(v)}.`); continue; } this.emit({ kind: "store", array: values.id, index: num(index), value, at: atIr, label }, at); } @@ -1672,6 +3048,11 @@ export class Structured { /** A key known when the script is built, as a cell's number; null, with a diagnostic, when it is not one of the kind's ids. */ private keyConstant(b: Keyed, value: unknown, at: TS.Node): number | null { + if (b.literals) { + const i = typeof value === "string" ? b.literals.indexOf(value) : -1; + if (i < 0) { this.c.error(at, `${b.name}'s keys are ${b.literals.map((l) => JSON.stringify(l)).join(", ")}; got ${describe(value)}.`); return null; } + return i; + } const what = KEY_DOMAINS[b.key].what; if (typeof value !== "number" || !Number.isInteger(value)) { this.c.error(at, `${b.name}'s keys are ${what}; got ${describe(value)}.`); return null; } if (b.key === "player" && value === PlayerGroup.CurrentPlayer) { this.c.error(at, `CurrentPlayer is not a key of ${b.name}: it would be one cell for everybody. In a program of every player a plain variable is already one per player.`); return null; } @@ -1683,9 +3064,87 @@ export class Structured { private keyIndex(b: Keyed, e: TS.Expression): NumExpr | null { const h = this.evaluate(e); if (h && !isGameValue(h.value)) { const k = this.keyConstant(b, h.value, e); return k === null ? null : num(k); } + if (b.literals) return this.literalPlace(b.literals, e); return this.num(e); } + /** The texts of a union of string literals (`"red" | "blue"`, or one alone), in the order the type has them; undefined for any other type. */ + private literalTexts(type: TS.Type): string[] | undefined { + const parts = type.isUnion() ? type.types : [type]; + if (!parts.length || !parts.every((t) => t.isStringLiteral())) return undefined; + return parts.map((t) => (t as TS.StringLiteralType).value); + } + + /** + * `costs[state]`, `table[state]()`, `NAMES[team]`: a record (of the program's, or one the script has) read at a text of + * the program whose type is a union of texts, each of them a field. Each field, and the test that picks it (the last + * one the field taken when none of the others is). Undefined when the expression is not of that form. + */ + private fieldAtText(e: TS.Expression): { field: Binding; when?: BoolExpr }[] | null | undefined { + const { ts } = this; + if (!ts.isElementAccessExpression(e) || this.evaluate(e.argumentExpression)) return undefined; + const literals = this.literalTexts(this.c.checker.getTypeAtLocation(e.argumentExpression)); + if (!literals) return undefined; + const b = this.bindingOf(e.expression); + const known = b ? undefined : this.evaluate(e.expression); + const obj = known && known.value && typeof known.value === "object" && !Array.isArray(known.value) && !isGameValue(known.value) ? known.value as Record : undefined; + if (b?.kind !== "record" && !obj) return undefined; + const fields: Binding[] = []; + for (const l of literals) { + const f = b?.kind === "record" ? b.fields.get(l) : obj && l in obj ? { kind: "value", value: obj[l] } as Binding : undefined; + if (!f) { this.c.error(e, `${e.expression.getText(this.body.sf)} has no field ${JSON.stringify(l)}, which ${e.argumentExpression.getText(this.body.sf)} may be.`); return null; } + fields.push(f); + } + const given = this.text(e.argumentExpression); + if (!given) return null; + const of = this.textTemp(given, e); + const at = this.at(e), label = this.label(e); + return fields.map((field, i) => (i === fields.length - 1 ? { field } : { field, when: { kind: "textCompare", op: "==", left: of, right: { kind: "text", text: literals[i] }, at, label } })); + } + + /** + * `cost[state] = 3`, `cost[state]++`, `open[door] = true`: the statement walked once for each field the text may be, + * the left side that field there, each under its test. False when the statement is not of that form. + */ + private fieldWrite(e: TS.Expression, again: () => void): boolean { + const { ts } = this; + const assigns = ts.isBinaryExpression(e) && e.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && e.operatorToken.kind <= ts.SyntaxKind.LastAssignment; + const steps = (ts.isPostfixUnaryExpression(e) || ts.isPrefixUnaryExpression(e)) && (e.operator === ts.SyntaxKind.PlusPlusToken || e.operator === ts.SyntaxKind.MinusMinusToken); + const target = assigns ? this.unwrap((e as TS.BinaryExpression).left) : steps ? this.unwrap((e as TS.PostfixUnaryExpression).operand) : undefined; + if (!target || !ts.isElementAccessExpression(target) || this.pinned.has(target)) return false; + const picked = this.fieldAtText(target); + if (picked === undefined) return false; + if (picked === null) return true; + const at = this.at(e), label = this.label(e); + const walk = (field: Binding) => this.collect(() => { this.pinned.set(target, field); try { again(); } finally { this.pinned.delete(target); } }); + let chain = walk(picked[picked.length - 1].field); + for (let i = picked.length - 2; i >= 0; i--) chain = [{ kind: "if", cond: picked[i].when!, then: walk(picked[i].field), else: chain, at, label } as Stmt]; + for (const st of chain) this.emit(st, e); + return true; + } + + /** What fieldAtText found, as one value: the fields' values chosen by their tests. */ + private pickField(picked: { field: Binding; when?: BoolExpr }[], value: (b: Binding) => T | null, choose: (cond: BoolExpr, a: T, b: T) => T): T | null { + let acc = value(picked[picked.length - 1].field); + for (let i = picked.length - 2; i >= 0 && acc !== null; i--) { + const v = value(picked[i].field); + if (v === null) return null; + acc = choose(picked[i].when!, v, acc); + } + return acc; + } + + /** The place of a text of the program among a union's texts: the first it equals, worked out as a number (as a switch over texts is). */ + private literalPlace(literals: readonly string[], e: TS.Expression): NumExpr | null { + const given = this.text(e); + if (!given) return null; + const of = this.textTemp(given, e); + const at = this.at(e), label = this.label(e); + let which: NumExpr = num(0); + for (let i = literals.length - 1; i >= 1; i--) which = { kind: "ternary", cond: { kind: "textCompare", op: "==", left: of, right: { kind: "text", text: literals[i] }, at, label }, whenTrue: num(i), whenFalse: which, at, label }; + return which; + } + /** * A method of a keyed table: `get`, `set`, `has`, `delete`, `clear` of a Map; `add`, `has`, `delete`, `clear` of a Set. * What comes back is as `arrayCall`'s. A key that is more than a constant or a variable is worked out once, into a temporary. @@ -1788,16 +3247,21 @@ export class Structured { return this.mark({ kind: "pop", array: a.id, at }, e); } case "fill": { - if (as !== "statement" || e.arguments.length !== 1) return wrong(`${a.name}.fill(value) stands on its own and takes one value.`); + if (as !== "statement" || e.arguments.length < 1 || e.arguments.length > 3) return wrong(`${a.name}.fill(value, start?, end?) stands on its own.`); if (a.values) return wrong(`${a.name} was computed when the script was built and is only read in a program.`); const value = one(e.arguments[0]); if (!value) return null; - // The value once, then every cell: a loop the game runs, as for…of is. + // The value once, then every cell: a loop the game runs, as for…of is. With `start` and `end` (JavaScript's, + // counted from the end when negative, cut to the array), from the one to before the other. const v = this.newVar(`(fill of ${a.name})`, a.kind, at, { temp: true, ...(a.bits ? { bits: a.bits } : {}), ...(a.unsigned ? { unsigned: true } : {}) }); const i = this.newVar(`(index of ${a.name})`, "number", at, { temp: true }); + const length: NumExpr = { kind: "length", array: a.id, at }; + const from = e.arguments[1] ? this.sliceEnd(e.arguments[1], length, e.arguments[1]) : num(0); + const to = e.arguments[2] ? this.sliceEnd(e.arguments[2], length, e.arguments[2]) : length; + if (!from || !to) return null; this.emit({ kind: "declare", decl: v, init: value, at, label }, e); - this.emit({ kind: "declare", decl: i, init: num(0), at, label }, e); - this.emit({ kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: { kind: "length", array: a.id, at }, at, label }, update: [{ kind: "assign", target: i.id, value: { kind: "binary", op: "+", left: varRef(i), right: num(1), at, label }, at, label }], + this.emit({ kind: "declare", decl: i, init: from, at, label }, e); + this.emit({ kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: to, at, label }, update: [{ kind: "assign", target: i.id, value: { kind: "binary", op: "+", left: varRef(i), right: num(1), at, label }, at, label }], body: [{ kind: "store", array: a.id, index: varRef(i), value: a.kind === "number" ? varRef(v) : boolRef(v), at, label }], at, label }, e); return true; } @@ -1825,8 +3289,52 @@ export class Structured { }; return this.mark({ kind: "call", call }, e); } + case "at": { + if (e.arguments.length !== 1) return wrong("at() takes the place: from the end when below zero."); + if (as === "statement") return wrong(`${a.name}.at(…) is a value: use it or store it.`); + if ((as === "number") !== (a.kind === "number")) return wrong(`${a.name} holds ${a.kind}s.`); + const i = this.num(e.arguments[0]); + if (!i) return null; + return this.mark({ kind: "element", array: a.id, index: this.fromEnd(i, { kind: "length", array: a.id, at }, e), at }, e); + } + case "lastIndexOf": { + if (e.arguments.length !== 1) return wrong("lastIndexOf() takes the value to look for."); + if (as !== "number") return wrong(`${a.name}.lastIndexOf(…) is a number: the place of the last such value, or -1.`); + const wanted = one(e.arguments[0]); + if (!wanted) return null; + const b = this.irAt(e); + const w = this.newVar(`(lastIndexOf of ${a.name})`, a.kind, at, { temp: true }); + const i = b.num(`(index of ${a.name})`); + const cell = { kind: "element" as const, array: a.id, index: varRef(i), at }; + const same: BoolExpr = a.kind === "number" ? b.cmp("==", cell, varRef(w)) + : b.or(b.and(cell, boolRef(w)), b.and(b.not(cell), b.not(boolRef(w)))); + const call: Call = { name: `${a.name}.lastIndexOf`, at, label, params: [{ decl: w, init: wanted, label }], result: { decl: b.num(`(${a.name}.lastIndexOf result)`), kind: "number" }, + body: [b.declare(i, b.sub({ kind: "length", array: a.id, at }, b.n(1))), b.loop(b.cmp(">=", b.ref(i), b.n(0)), [b.when(same, [b.ret(b.ref(i))]), b.set(i, b.sub(b.ref(i), b.n(1)))]), b.ret(b.n(-1))] }; + return this.mark({ kind: "call", call }, e); + } + case "shift": case "unshift": case "splice": { + if (!this.grows(a, e)) return null; + const sp = this.spliceArgs(e, method); + if (!sp) return null; + if (method === "splice" && as !== "statement") return wrong(`${a.name}.splice(…) of a program stands on its own: read what it takes out first (${a.name}.slice(start, end)).`); + if (method === "shift" && as !== "statement" && (as === "number") !== (a.kind === "number")) return wrong(`${a.name} holds ${a.kind}s.`); + if (method === "unshift" && as === "boolean") return wrong(`${a.name}.unshift(…) gives the new length, a number.`); + const items: (NumExpr | BoolExpr)[] = []; + for (const x of sp.items) { const v = one(x); if (!v) return null; items.push(a.kind === "number" ? this.temp(v as NumExpr, x, true) : v); } + const b = this.irAt(e); + const first = method === "shift" && as !== "statement" ? this.newVar(`(${a.name}.shift)`, a.kind, at, { temp: true }) : null; + const body = this.collect(() => { + if (first) this.emit(b.declare(first, { kind: "element", array: a.id, index: num(0), at }), e); + const s0 = this.spliceRows([a], null, sp.start, sp.remove, items.length, e); + items.forEach((v, i) => this.emit({ kind: "store", array: a.id, index: b.add(b.ref(s0), b.n(i)), value: v, at, label }, e)); + }); + if (as === "statement") { this.out.push(...body); return true; } + const value: NumExpr | BoolExpr = first ? (a.kind === "number" ? varRef(first) : boolRef(first)) : { kind: "length", array: a.id, at }; + const call: Call = { name: `${a.name}.${method}`, at, label, params: [], result: { decl: this.newVar(`(${a.name}.${method} result)`, a.kind === "boolean" && first ? "boolean" : "number", at, { temp: true }), kind: a.kind === "boolean" && first ? "boolean" : "number" }, body: [...body, b.ret(value)] }; + return this.mark({ kind: "call", call }, e); + } default: - return wrong(`An array of a program has push, pop, fill, includes, indexOf, length, for…of, and the methods that take a function (forEach, map, filter, some, every, find, findIndex, reduce, sort) with reverse, and the copies (slice, concat, toSorted, toReversed); ${method}() is not one of them.`); + return wrong(`An array of a program has push, pop, shift, unshift, splice, fill, at, includes, indexOf, lastIndexOf, join, length, for…of, and the methods that take a function (forEach, map, filter, some, every, find, findIndex, reduce, sort) with reverse, and the copies (slice, concat, toSorted, toReversed); ${method}() is not one of them.`); } } @@ -1935,7 +3443,7 @@ export class Structured { else if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isPropertyAccessExpression(n.left) && n.left.name.text === "length" && rowOfDecl(n.left.expression)) found = true; else ts.forEachChild(n, walk); }; - walk(this.body.plan.arrow.body); + walk(this.body.plan.root); return found; } @@ -1949,7 +3457,7 @@ export class Structured { } ts.forEachChild(n, walk); }; - walk(this.body.plan.arrow.body); + walk(this.body.plan.root); return out; } @@ -2091,7 +3599,7 @@ export class Structured { if (!Array.isArray(h.value)) { this.c.error(e, `Expected a list here, got ${describe(h.value)}.`); return false; } for (const v of h.value as unknown[]) { if (of === "boolean") { if (typeof v !== "boolean") { this.c.error(e, `Expected true or false, got ${describe(v)}.`); return false; } this.emit({ kind: "push", array: into.id, value: { kind: "const", value: v }, at, label }, e); } - else { const n = this.asInteger({ value: v }, e); if (n === null) return false; this.emit({ kind: "push", array: into.id, value: num(n), at, label }, e); } + else { const n = this.constOf({ value: v }, e); if (n === null) return false; this.emit({ kind: "push", array: into.id, value: n, at, label }, e); } } return true; } @@ -2140,7 +3648,7 @@ export class Structured { if (Array.isArray(row)) { for (const v of row) { if (shape.kind === "boolean") { if (typeof v !== "boolean") { this.c.error(init, `Expected true or false, got ${describe(v)}.`); return null; } this.emit({ kind: "push", array: inner.id, value: { kind: "const", value: v }, at: this.at(at), label: this.label(at) }, at); } - else { const n = this.asInteger({ value: v }, init); if (n === null) return null; this.emit({ kind: "push", array: inner.id, value: num(n), at: this.at(at), label: this.label(at) }, at); } + else { const n = this.constOf({ value: v }, init); if (n === null) return null; this.emit({ kind: "push", array: inner.id, value: n, at: this.at(at), label: this.label(at) }, at); } } } else if (!this.fillInner(inner, shape.kind, row)) return null; } @@ -2212,6 +3720,7 @@ export class Structured { } if (ts.isObjectLiteralExpression(e)) return this.declareRecord("(taken)", e, this.c.checker.getTypeAtLocation(e), at); if (ts.isCallExpression(e) && (this.isLibraryCall(e, "mouse") || this.isLibraryCall(e, "chatted"))) return this.declareInput("(taken)", e, at); + if (ts.isCallExpression(e)) { const given = this.instanceCall(e, "(taken)"); if (given !== undefined) return given; } const b = this.listOf(init); if (b) return b; this.c.error(init, "Only a record, an array, or what mouse() and chatted() give can be taken apart in a program."); @@ -2231,11 +3740,12 @@ export class Structured { /** A field of a record of the program from whatever a spread gave it: always a variable of its own, since a field can be assigned. */ private fieldCopy(b: Binding, name: string, at: TS.Node): Binding { if (b.kind === "record") return { kind: "record", fields: new Map([...b.fields].map(([field, inner]) => [field, this.fieldCopy(inner, `${name}.${field}`, at)])) }; + if (b.kind === "maybe") return { kind: "maybe", value: this.fieldCopy(b.value, name, at), there: this.fieldCopy(b.there, `${name} (there)`, at) }; if (b.kind !== "value") return this.takenCopy(b, name, at); const kind: Kind = typeof b.value === "boolean" ? "boolean" : "number"; const v = this.newVar(name, kind, this.sourceOf(at)); - const n = kind === "number" ? this.asInteger({ value: b.value }, at) : null; - this.emit({ kind: "declare", decl: v, init: kind === "boolean" ? { kind: "const", value: b.value as boolean } : num(n ?? 0), at: this.at(at), label: this.label(at) }, at); + const n = kind === "number" ? this.constOf({ value: b.value }, at) : null; + this.emit({ kind: "declare", decl: v, init: kind === "boolean" ? { kind: "const", value: b.value as boolean } : n ?? num(0), at: this.at(at), label: this.label(at) }, at); return { kind: "var", v }; } @@ -2267,6 +3777,8 @@ export class Structured { private patternDefault(el: TS.BindingElement, what: string): Binding | null { const { ts } = this; const name = ts.isIdentifier(el.name) ? el.name.text : "(taken)"; + // A name whose type says it may be undefined (an optional prop left out) is that, a value the script knows: `note ?? ""`. + if (!el.initializer && ts.isIdentifier(el.name) && (this.c.checker.getTypeAtLocation(el.name).flags & ts.TypeFlags.Undefined || this.c.checker.getTypeAtLocation(el.name).isUnion() && (this.c.checker.getTypeAtLocation(el.name) as TS.UnionType).types.some((t) => t.flags & ts.TypeFlags.Undefined))) return { kind: "value", value: undefined }; if (!el.initializer) { this.c.error(el, `${what}, and ${name} has no default: it would be undefined, which does not exist when the map is played.`); return null; } const h = this.evaluate(el.initializer); if (h) return { kind: "value", value: h.value }; @@ -2320,11 +3832,12 @@ export class Structured { if (from.kind === "value") return Array.isArray(from.value) && i < from.value.length ? { kind: "value", value: (from.value as unknown[])[i] } : undefined; if (from.kind === "array") return from.a.dynamic || i < from.a.length ? { kind: "cell", a: from.a, index: num(i) } : undefined; if (from.kind === "records") return this.rowOf(from, num(i)); + if (from.kind === "texts") return this.textAtIndex(from, num(i)); if (from.kind === "grid") return from.dims[0] === 0 || i < from.dims[0] ? this.partAt(from, num(i), pattern) : undefined; if (from.kind === "lists") return from.ptr.dynamic || i < from.ptr.length ? { kind: "inner", lists: from, index: num(i) } : undefined; return undefined; }; - if (!Array.isArray(from) && from.kind !== "value" && from.kind !== "array" && from.kind !== "records" && from.kind !== "units" && from.kind !== "grid" && from.kind !== "lists") { this.c.error(pattern, "[ … ] takes the items of an array."); return false; } + if (!Array.isArray(from) && from.kind !== "value" && from.kind !== "array" && from.kind !== "records" && from.kind !== "texts" && from.kind !== "units" && from.kind !== "grid" && from.kind !== "lists") { this.c.error(pattern, "[ … ] takes the items of an array."); return false; } pattern.elements.forEach((el, i) => { if (ts.isOmittedExpression(el)) return; if (el.dotDotDotToken) { @@ -2456,14 +3969,45 @@ export class Structured { const b = this.arrayOf(expr); if (b) return b; const e = this.unwrap(expr); + if (this.isProgramLiteral(e)) { const a = this.declareArray(`(${e.getText(this.body.sf).replace(/\s+/g, " ")})`, e, this.c.checker.getTypeAtLocation(e), e); return a ? { kind: "array", a } : { kind: "array", a: this.newArray("(not made)", "number", 1, this.sourceOf(e)) }; } + if (ts.isCallExpression(e) && this.isSplit(e)) return this.splitTexts(e, "(split)", e) ?? undefined; if (ts.isCallExpression(e) && this.makesList(e)) { // Refused, and said so: an array of nothing stands in, so the one mistake is the one message. return this.madeList(e, undefined, e) ?? { kind: "array", a: this.newArray("(not made)", "number", 1, this.sourceOf(e)) }; } if (ts.isCallExpression(e) && this.copiesList(e)) return this.copiedList(e, undefined, e) ?? { kind: "array", a: this.newArray("(not made)", "number", 1, this.sourceOf(e)) }; + if (ts.isCallExpression(e) && !this.body.plan.index.has(e)) { + const made = this.madeByCall(e); + if (made && Structured.LIST_KINDS.has(made.kind)) return made; + } return undefined; } + /** + * `[n, 2, 8]` written where a list is used, a value of the program among it, numbers or booleans: an array of the + * program made there, as `const xs = [n, 2, 8]` would make one. (One known when the script is built is the script's.) + */ + private isProgramLiteral(e: TS.Expression): e is TS.ArrayLiteralExpression { + const { ts } = this; + if (!ts.isArrayLiteralExpression(e) || e.elements.length === 0) return false; + try { if (this.evaluate(e)) return false; } catch { return false; } + const element = this.c.checker.getIndexTypeOfType(this.c.checker.getTypeAtLocation(e), ts.IndexKind.Number); + const kind = element ? this.kindOf(element) : null; + return kind === "number" || kind === "boolean"; + } + + /** Whether a list is made where it is used: written out (`isProgramLiteral`), or given by `map`, `filter`, `slice`, …. */ + private listMadeHere(expr: TS.Expression): boolean { + const { ts } = this; + const e = this.unwrap(expr); + return this.isProgramLiteral(e) || (ts.isCallExpression(e) && (this.makesList(e) || this.copiesList(e))); + } + + /** A list made where it is used (`listMadeHere`), made: what `arrayOf` is for a variable. */ + private listHere(expr: TS.Expression): Binding | undefined { + return this.listMadeHere(expr) ? this.listOf(expr) : undefined; + } + /** The methods that give a copy: a new array with what another holds, or a Map's keys or values as one. */ private static readonly COPIERS = new Set(["slice", "concat", "toSorted", "toReversed", "keys", "values"]); @@ -2490,6 +4034,7 @@ export class Structured { let h: Hoisted | undefined; try { h = this.evaluate(e); } catch { return undefined; } if (h) return this.isPlainList(h.value) ? "array" : undefined; + if (this.isProgramLiteral(e)) return "array"; if (!ts.isCallExpression(e)) return undefined; if (this.copiesList(e)) { if (!ts.isPropertyAccessExpression(e.expression)) return "array"; @@ -2502,7 +4047,7 @@ export class Structured { } private isPlainList(v: unknown): v is (number | boolean)[] { - return Array.isArray(v) && v.length > 0 && v.length <= MAX_ARRAY && (v.every((x) => typeof x === "number" && Number.isInteger(x)) || v.every((x) => typeof x === "boolean")); + return Array.isArray(v) && v.length > 0 && v.length <= MAX_TABLE && (v.every((x) => typeof x === "number" && (Number.isInteger(x) || (Number.isFinite(x) && x >= FIXED_MIN && x <= FIXED_MAX))) || v.every((x) => typeof x === "boolean")); } /** A list the script has, as the array nothing writes that a program reads it through: once a list. */ @@ -2512,8 +4057,9 @@ export class Structured { let a = this.tables.get(list); if (!a) { const booleans = typeof list[0] === "boolean"; - const values = list.map((v) => (typeof v === "boolean" ? (v ? 1 : 0) : v)); - a = this.newArray(expr.getText(this.body.sf).replace(/\s+/g, " "), booleans ? "boolean" : "number", values.length, this.at(expr), { shared: true, values, ...(values.some((v) => v > I32_MAX) ? { unsigned: true } : {}) }); + const cells = this.listCells(list.map((v) => (typeof v === "boolean" ? (v ? 1 : 0) : v))); + const values = cells.values; + a = this.newArray(expr.getText(this.body.sf).replace(/\s+/g, " "), booleans ? "boolean" : "number", values.length, this.at(expr), { shared: true, values, ...(cells.fixed ? { fixed: true } : {}), ...(values.some((v) => v > I32_MAX) ? { unsigned: true } : {}) }); this.tables.set(list, a); } return { kind: "array", a }; @@ -2553,6 +4099,43 @@ export class Structured { body: pairs.map(([to, of]): Stmt => ({ kind: "push", array: to.id, value: { kind: "element", array: of.id, index: varRef(i), at }, at, label })), at, label }, where); } + /** + * Conditions of the game known when the script is built, joined by `&&` — `command(…) && bring(…)` — each as the + * script made it. Null when the expression is anything else (a `!`, a comparison of a variable: the program's own). + */ + private conditionsOf(e: TS.Expression): ConditionValue[] | null { + const { ts } = this; + const x = this.unwrap(e); + if (ts.isBinaryExpression(x) && x.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + const l = this.conditionsOf(x.left), r = this.conditionsOf(x.right); + return l && r ? [...l, ...r] : null; + } + let h: Hoisted | undefined; + try { h = this.evaluate(x); } catch { return null; } + if (!h) return null; + if (isCondition(h.value)) return [h.value]; + return Array.isArray(h.value) && h.value.length > 0 && h.value.every(isCondition) ? (h.value as ConditionValue[]) : null; + } + + /** + * `a ** n` and `Math.pow(a, n)`: the base multiplied into itself, `n` known when the script is built (the game has + * no power of a variable — a loop of products says it). `a ** 0` is 1. + */ + private power(baseE: TS.Expression, expE: TS.Expression, e: TS.Expression): NumExpr | null { + const h = this.evaluate(expE); + const n = h ? this.asInteger(h, expE) : null; + if (n === null) { if (!h) this.c.error(expE, "The power is known when the script is built: a ** 2, Math.pow(a, 3). For one the game works out, multiply in a loop."); return null; } + if (n < 0) { this.c.error(expE, "A negative power is a fraction below 1, which a whole number of the game has no room for."); return null; } + const base = this.num(baseE); + if (!base) return null; + if (n === 0) return num(1); + const at = this.at(e), label = this.label(e); + const x = n > 1 ? this.temp(base, baseE) : base; + let acc: NumExpr = x; + for (let i = 1; i < n; i++) acc = { kind: "binary", op: "*", left: acc, right: x, at, label }; + return this.mark(acc, e); + } + /** A place a `slice` is given, as JavaScript reads it: below zero it counts from the end, and it stays inside 0 … length. */ private sliceEnd(given: TS.Expression, length: NumExpr, where: TS.Node): NumExpr | null { const n = this.num(given); @@ -2626,6 +4209,8 @@ export class Structured { private overOf(expr: TS.Expression): Over | undefined { const b = this.listOf(expr); if (b?.kind === "array" || b?.kind === "records" || b?.kind === "units" || b?.kind === "grid" || b?.kind === "lists") return b; + // A list the script has, handed in as a value (a component's prop, a parameter): unrolled. + if (b?.kind === "value" && Array.isArray(b.value)) return { kind: "values", name: expr.getText(this.body.sf).replace(/\s+/g, " ").slice(0, 40), items: b.value as unknown[] }; if (b) return undefined; const h = this.evaluate(expr); if (!h) return undefined; @@ -2645,23 +4230,28 @@ export class Structured { const receiver = this.unwrap(e.expression.expression); if (this.bindingOf(receiver)) { const b = this.bindingOf(receiver)!; return b.kind === "array" || b.kind === "records" || b.kind === "units" || b.kind === "grid" || b.kind === "row" || b.kind === "lists" || b.kind === "inner"; } if (ts.isCallExpression(receiver) && (this.makesList(receiver) || this.copiesList(receiver))) return true; + if (this.isProgramLiteral(receiver)) return true; const h = this.evaluate(receiver); return !!h && (isUnitQuery(h.value) || Array.isArray(h.value)); } /** The function a method is given: written there, or the name of one declared in the body. */ - private functionOf(arg: TS.Expression | undefined, method: string, e: TS.Node): { parameters: readonly TS.ParameterDeclaration[]; body: TS.Block | TS.Expression; closure: Scope | null; name?: string } | null { + private functionOf(arg: TS.Expression | undefined, method: string, e: TS.Node, waits = false): { parameters: readonly TS.ParameterDeclaration[]; body: TS.Block | TS.Expression; closure: Scope | null; name?: string } | null { const { ts } = this; if (!arg) { this.c.error(e, `${method}() takes a function: xs.${method}((x) => …).`); return null; } const fn = this.unwrap(arg); if (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) { - if (fn.asteriskToken || fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) { this.c.error(fn, "Generators and async functions are not supported in a program."); return null; } + if (fn.asteriskToken) { this.c.error(fn, "Generators are not supported in a program yet."); return null; } + if (!waits && fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) { this.c.error(fn, `An async function given to ${method}(): the method is one loop within the frame, and nothing waits for what the function gives. A for…of over the same list can await between its turns.`); return null; } return { parameters: fn.parameters, body: fn.body, closure: this.scope }; } if (ts.isIdentifier(fn)) { const decl = this.gameDeclaration(fn); if (decl && ts.isFunctionDeclaration(decl) && decl.body) return { parameters: decl.parameters, body: decl.body, closure: this.body === this.c.body ? this.topScope : null, name: decl.name?.text }; } + // `xs.forEach(f)` with a const or a parameter that is a function (B3). + const known = this.fnValueOf(fn); + if (known && !known.decl.asteriskToken) return { parameters: known.parameters, body: known.body, closure: known.closure, name: this.functionName(known) }; this.c.error(arg, `A function is not a value when the map is played: write it where it is used — xs.${method}((x) => …) — or name one declared with function in the program.`); return null; } @@ -2671,9 +4261,10 @@ export class Structured { * its place, the list. A variable it uses from outside is the program's own cell, so nothing is captured and nothing * is made when the map is played. `kind` is what it has to give back; "any" takes what it says it returns. */ - private callback(arg: TS.Expression | undefined, method: string, bound: Binding[], kind: Kind | "void" | "any", e: TS.CallExpression): Call | undefined { + private callback(arg: TS.Expression | undefined, method: string, bound: Binding[], kind: Kind | "void" | "any", e: TS.CallExpression, waits = false): Call | undefined { const { ts } = this; - const fn = this.functionOf(arg, method, e); + // `waits`: an async arrow called where it is written, whose body is the program's own and may wait. + const fn = this.functionOf(arg, method, e, waits); if (!fn) return undefined; if (kind === "any") { const signature = this.c.checker.getSignaturesOfType(this.c.checker.getTypeAtLocation(arg!), ts.SignatureKind.Call)[0]; @@ -2716,8 +4307,8 @@ export class Structured { scope.bind(p, b); }); if (!ok) return undefined; - this.inCallback++; - try { out.body = this.walkFunction(fn.body, kind, this.body, scope, () => patterns.forEach((take) => take())); } finally { this.inCallback--; } + if (!waits) this.inCallback++; + try { out.body = this.walkFunction(fn.body, kind, this.body, scope, () => patterns.forEach((take) => take())); } finally { if (!waits) this.inCallback--; } return out; } @@ -2770,7 +4361,7 @@ export class Structured { if (b.kind === "unitAt" && kind === "unit") return this.unitAtPlaces(b, at); if (b.kind === "value") { if (kind === "boolean" && typeof b.value === "boolean") return { kind: "const", value: b.value }; - if (kind === "number") { const n = this.asInteger({ value: b.value }, at); return n === null ? null : num(n); } + if (kind === "number") return this.constOf({ value: b.value }, at); } this.c.error(at, `Expected a ${kind} here.`); return null; @@ -2863,7 +4454,7 @@ export class Structured { * is worked out again wherever the expression is, a loop's condition included. `otherwise` is what `find` gives when * nothing is found (`xs.find(…) ?? 0`). */ - private searchCall(e: TS.CallExpression, over: Over, method: string, as: Kind, otherwise?: TS.Expression): NumExpr | BoolExpr | UnitExpr | null { + private searchCall(e: TS.CallExpression, over: Over, method: string, as: Kind, otherwise?: TS.Expression, found?: VarDecl): NumExpr | BoolExpr | UnitExpr | null { const at = this.at(e); const label = this.label(e); const name = `${this.overName(over)}.${method}`; @@ -2910,7 +4501,8 @@ export class Structured { if (as !== holds) return wrong(`${name}(…) gives what ${this.overName(over)} holds: a ${holds}.`); // A unit that was not found is no unit, as first() gives; a number has nothing of the kind. let none: NumExpr | BoolExpr | UnitExpr | null = holds === "unit" ? NO_UNIT : null; - if (holds !== "unit") { + if (holds !== "unit" && found) none = holds === "number" ? num(0) : FALSE; + else if (holds !== "unit") { if (!otherwise) return wrong(`${name}(…) is undefined when nothing is found, and there is no undefined when the map is played: say what it is then — ${name}(…) ?? 0 — or take the place with ${method === "find" ? "findIndex" : "findLastIndex"}(…), which is -1.`); none = holds === "number" ? this.num(otherwise) : this.boolValue(otherwise); if (!none) return null; @@ -2920,8 +4512,10 @@ export class Structured { this.loopOver(over, e, (item, index) => { const p = this.predicate(fn, method, this.handed(over, item, index), e); if (!p) { ok = false; return; } - this.emit({ kind: "if", cond: p, then: ret(this.valueOf(item, holds, e)), at, label }, e); + // Kept as a number or nothing (B9): whether one was found, beside it. + this.emit({ kind: "if", cond: p, then: [...(found ? [{ kind: "assignBool", target: found.id, value: TRUE, at, label } as Stmt] : []), ...ret(this.valueOf(item, holds, e))], at, label }, e); }, method === "findLast"); + if (found) this.out.push({ kind: "assignBool", target: found.id, value: FALSE, at, label }); this.out.push(...ret(none)); }); break; @@ -3217,11 +4811,11 @@ export class Structured { * it moved up one. It keeps the order of equals, as JavaScript's sort does, and is quick on a list nearly in order; * a list in no order costs its length squared, which is what the hint is about. */ - private sortList(e: TS.CallExpression, list: List | Grid | Lists): boolean { + private sortList(e: TS.CallExpression, list: List | Grid | Lists, outOfOrder?: (before: Binding, held: Binding) => BoolExpr | null): boolean { const at = this.at(e); const label = this.label(e); const name = this.overName(list); - if (!e.arguments[0]) { this.c.error(e, `sort() wants its function — ${name}.sort((a, b) => a - b): without one JavaScript sorts numbers as text, 10 before 9.`); return false; } + if (!e.arguments[0] && !outOfOrder) { this.c.error(e, `sort() wants its function — ${name}.sort((a, b) => a - b): without one JavaScript sorts numbers as text, 10 before 9.`); return false; } const ops = this.rowOps(list, e); if (!ops) return false; const i = this.newVar(`(index of ${name})`, "number", at, { temp: true }); @@ -3233,10 +4827,17 @@ export class Structured { this.emit({ kind: "declare", decl: j, init: { kind: "binary", op: "-", left: varRef(i), right: num(1), at, label }, at, label }, e); const inner = this.collect(() => { const before = ops.row(varRef(j)); - const call = this.callback(e.arguments[0], "sort", [before, held.binding], "any", e); - if (!call) { ok = false; return; } - if (call.result?.kind !== "number") { this.c.error(e.arguments[0], "sort()'s function gives a number — below 0 when a goes first, above when b does: (a, b) => a - b."); ok = false; return; } - const after: BoolExpr = { kind: "compare", op: ">", left: this.mark({ kind: "call", call }, e), right: num(0), at, label }; + let after: BoolExpr; + if (outOfOrder) { + const cond = outOfOrder(before, held.binding); + if (!cond) { ok = false; return; } + after = cond; + } else { + const call = this.callback(e.arguments[0], "sort", [before, held.binding], "any", e); + if (!call) { ok = false; return; } + if (call.result?.kind !== "number") { this.c.error(e.arguments[0], "sort()'s function gives a number — below 0 when a goes first, above when b does: (a, b) => a - b."); ok = false; return; } + after = { kind: "compare", op: ">", left: this.mark({ kind: "call", call }, e), right: num(0), at, label }; + } this.emit({ kind: "if", cond: { kind: "not", expr: after }, then: [{ kind: "break", at, label }], at, label }, e); this.out.push(...ops.move(next, varRef(j)), { kind: "assign", target: j.id, value: { kind: "binary", op: "-", left: varRef(j), right: num(1), at, label }, at, label }); }); @@ -3252,8 +4853,13 @@ export class Structured { /** `hp[i] = v`, `hp[i] += v`: a store into a cell. An index that holds a call is evaluated once for the read and once for the store, as two reads of `i` would be. */ private storeElement(e: TS.BinaryExpression, el: { a: ArrayDecl; index: NumExpr }, op: TS.SyntaxKind) { const { ts } = this; - const { a, index } = el; + const { a } = el; if (a.values) { this.c.error(e.left, `${a.name} was computed when the script was built and is only read in a program; declare it with let inside the program to write to it.`); return; } + // (`xs[n++] = n * 10`: the index before the value, as JavaScript has it — an index with a call or a pop in it is worked out first.) + const calls = (x: unknown): boolean => !!x && typeof x === "object" && (Array.isArray(x) ? x.some(calls) : (x as { kind?: string }).kind === "call" || (x as { kind?: string }).kind === "pop" || Object.values(x).some(calls)); + // (And one read by the index when the value calls: the call may change what the index reads.) + const callsIn = (n: TS.Node): boolean => ts.isCallExpression(n) || ts.isNewExpression(n) || !!ts.forEachChild(n, (c) => callsIn(c) || undefined); + const index = calls(el.index) || (el.index.kind !== "const" && callsIn(e.right)) ? this.temp(el.index, e.left) : el.index; const emit = (value: NumExpr | BoolExpr) => this.emit({ kind: "store", array: a.id, index, value, at: this.at(e), label: this.label(e) }, e); if (a.kind === "boolean") { if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "Booleans take = only."); return; } @@ -3311,8 +4917,7 @@ export class Structured { }; const constant = (v: unknown, where: TS.Node): NumExpr | BoolExpr | null => { if (kind === "boolean") { if (typeof v === "boolean") return { kind: "const", value: v }; this.c.error(where, `Expected true or false, got ${describe(v)}.`); return null; } - const n = this.asInteger({ value: v }, where); - return n === null ? null : num(n); + return this.constOf({ value: v }, where); }; const h = this.evaluate(init); if (h) { @@ -3371,7 +4976,7 @@ export class Structured { if (!Array.isArray(h.value)) { this.c.error(x, `... spreads a list, got ${describe(h.value)}.`); return null; } for (const v of h.value as unknown[]) { if (kind === "boolean") { if (typeof v !== "boolean") { this.c.error(x, `Expected true or false, got ${describe(v)}.`); return null; } parts.push({ value: { kind: "const", value: v } }); } - else { const n = this.asInteger({ value: v }, x); if (n === null) return null; parts.push({ value: num(n) }); } + else { const n = this.constOf({ value: v }, x); if (n === null) return null; parts.push({ value: n }); } } continue; } @@ -3440,15 +5045,22 @@ export class Structured { a = this.tables.get(list); if (!a) { const booleans = list.length > 0 && list.every((v) => typeof v === "boolean"); - const values: number[] = []; + const raw: number[] = []; for (const v of list as unknown[]) { + if (!booleans && typeof v === "number" && Number.isFinite(v) && !Number.isInteger(v)) { + if (v < FIXED_MIN || v > FIXED_MAX) { this.c.error(e.expression, `${v} has a fraction, which makes it a fixed number, and a fixed number holds −32 768 … 32 767.99998.`); return null; } + raw.push(v); + continue; + } const n = booleans ? (v ? 1 : 0) : this.asInteger({ value: v }, e.expression); if (n === null) return null; - values.push(n); + raw.push(n); } - if (values.length < 1 || values.length > MAX_ARRAY) { this.c.error(e.expression, `A list a program looks a value up in has 1 to ${MAX_ARRAY} entries (got ${values.length}).`); return null; } + const cells = this.listCells(raw); + const values = cells.values; + if (values.length < 1 || values.length > MAX_TABLE) { this.c.error(e.expression, `A list a program looks a value up in has 1 to ${MAX_TABLE.toLocaleString("en-US")} entries (got ${values.length.toLocaleString("en-US")}).`); return null; } const name = e.expression.getText(this.body.sf).replace(/\s+/g, " "); - a = this.newArray(name, booleans ? "boolean" : "number", values.length, this.at(e.expression), { shared: true, values, ...(values.some((v) => v > I32_MAX) ? { unsigned: true } : {}) }); + a = this.newArray(name, booleans ? "boolean" : "number", values.length, this.at(e.expression), { shared: true, values, ...(cells.fixed ? { fixed: true } : {}), ...(values.some((v) => v > I32_MAX) ? { unsigned: true } : {}) }); this.tables.set(list, a); } } @@ -3499,7 +5111,7 @@ export class Structured { const { ts } = this; const inner = init && this.unwrap(init); if (inner && ts.isObjectLiteralExpression(inner)) return this.declareRecord(full, inner, ft, p); - if (inner && ts.isNewExpression(inner) && this.classOf(inner.expression)) return this.instantiate(inner, full); + if (inner && ts.isNewExpression(inner) && this.classOf(inner.expression) && !this.isRefClass(this.classOf(inner.expression))) return this.instantiate(inner, full); // `counts = new Map()`: a table the record's name leads to. const keyedAs = inner ? this.keyedForm(inner, ft) : null; if (inner && keyedAs && keyedAs !== "record") return this.anyNumberKeys(ft) ? this.declareHash(full, keyedAs, inner, ft, p) : this.declareKeyed(full, keyedAs, inner, ft, p); @@ -3511,18 +5123,29 @@ export class Structured { if (gridShape) return this.declareGrid(full, init, gridShape, p) ?? this.declareLists(full, init, gridShape, p) ?? null; if (element && this.kindOf(element) === "unit") return this.declareUnits(full, init, p); if (element && this.isTextType(element)) return this.declareTexts(full, init, p); + // `members: Soldier[]` (B9): an array of instances inside an instance holds them by reference. + { const wanted = element && !this.kindOf(element) ? this.instanceClasses(element) : null; if (wanted && this.needRef(wanted)) { this.c.error(p, `${full} is an array of instances inside a record or an instance: they are kept by reference.`); return null; } } const a = this.declareArray(full, init, ft, p); return a ? { kind: "array", a } : null; } if (this.isTextType(ft)) { // `name: "Boss"`: a text variable the record's name leads to, kept by what `p.name = …` anywhere gives it. - const keptAs = prop ? this.textKept(init, (left) => ts.isPropertyAccessExpression(left) && this.c.checker.getSymbolAtLocation(left.name) === prop) : "made"; + // Handed to a function (`say(P1, talk, …)`), the record may be given a made text there, where this cannot look: made. + const handed = this.handedToCall(at); + // A field of a class is written in the class's own methods too, wherever the class is declared. + const cls = prop?.valueDeclaration?.parent; + const keptAs = prop && !handed ? this.textKept(init, (left) => ts.isPropertyAccessExpression(left) && this.c.checker.getSymbolAtLocation(left.name) === prop, cls && ts.isClassDeclaration(cls) ? cls : undefined) : "made"; if (init) return { kind: "var", v: this.declareText(full, init, keptAs, this.sourceOf(nameNode), at) }; const v = this.newVar(full, "text", this.sourceOf(nameNode), { text: keptAs }); this.emit({ kind: "declare", decl: v, init: { kind: "text", text: "" }, at: this.at(at), label: this.label(at) }, at); return { kind: "var", v }; } const kind = this.kindOf(ft); + // `cooldown?: number` (B9): the value and whether there is one. + const maybe = kind ? null : this.maybeKind(ft); + if (maybe) return this.declareMaybe(full, maybe, ft, init, at); + // `next: Node | null`, `target: Unit | Building` (B9): their classes kept by reference. + if (!kind) { const wanted = this.instanceClasses(ft); if (wanted && this.needRef(wanted)) { this.c.error(p, `${full} is ${this.c.checker.typeToString(ft)}: its classes are kept by reference.`); return null; } } if (!kind) { this.c.error(p, `A record's fields hold numbers, booleans, texts, units, arrays of them, records or instances; ${full} is ${this.c.checker.typeToString(ft)}${init ? "" : " and has no first value"}.`); return null; } const v = this.newVar(full, kind, this.sourceOf(nameNode), kind === "number" ? this.widthOf(ft) : {}); if (init) this.emitDeclare(v, init, at); @@ -3574,7 +5197,8 @@ export class Structured { } /** What a `u8` / `u16` / `u32` annotation declares, read off the brand in the type; nothing for a plain number, which is signed. */ - private widthOf(type: TS.Type): { bits?: 8 | 16; unsigned?: boolean } { + private widthOf(type: TS.Type): { bits?: 8 | 16; unsigned?: boolean; fixed?: boolean } { + type = this.awaited(type); for (const t of type.isIntersection() ? type.types : [type]) { const p = t.getProperty("__kind"); if (!p) continue; @@ -3583,22 +5207,78 @@ export class Structured { if (names.includes("u8")) return { bits: 8 }; if (names.includes("u16")) return { bits: 16 }; if (names.includes("u32")) return { unsigned: true }; + if (names.includes("fixed")) return { fixed: true }; } + // `fixed[]` read as its element's type is the caller's to ask; a `number` is whole until something stores a fraction into it. return {}; } + /** What a promise gives once it is awaited (an async function's `Promise` is a number); any other type as it is. */ + private awaited(type: TS.Type): TS.Type { + return this.c.checker.getAwaitedType(type) ?? type; + } + private kindOf(type: TS.Type): Kind | null { const { ts } = this; + // What start() gives back: a number of the program that stands for the task. + if (this.isTaskType(type)) return "number"; + type = this.awaited(type); // `c ? 1 : 0` is `0 | 1`: a union of numbers is a number. - const isNumber = (t: TS.Type): boolean => (t.flags & ts.TypeFlags.NumberLike) !== 0 || (t.isIntersection() && t.types.some(isNumber)) || (t.isUnion() && t.types.every(isNumber)); + const isNumber = (t: TS.Type): boolean => { t = this.resolveType(t); return (t.flags & ts.TypeFlags.NumberLike) !== 0 || (t.isIntersection() && t.types.some(isNumber)) || (t.isUnion() && t.types.every(isNumber)); }; if (type.flags & ts.TypeFlags.BooleanLike) return "boolean"; if (isNumber(type)) return "number"; // `Unit | null`: a unit of the game, or none. - const bare = (type.isUnion() ? type.types : [type]).filter((t) => (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0); + const bare = this.membersOf(type).filter((t) => (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0); + if (bare.length > 0 && bare.every((t) => (t.flags & ts.TypeFlags.BooleanLike) !== 0)) return "boolean"; if (bare.length > 0 && bare.every((t) => !!t.getProperty("__unit"))) return "unit"; + // A function (B3): kept as a number, which function it is; `f?: () => void` is one or undefined, 0. + if (bare.length > 0 && bare.every((t) => this.isFunctionType(t))) return "number"; + // An instance of a class kept by reference (B9): the row of the object table it is; none is 0. + if (this.refClasses(type)) return "number"; return null; } + /** Whether `undefined` or `null` here stands for no function: as written (`undefined as (() => void) | undefined`), or where it goes (`let f: Fn | undefined = undefined`). */ + private noFunction(expr: TS.Expression): boolean { + const { checker } = this.c; + const fits = (t: TS.Type | undefined) => !!t && (this.isFunctionType(checker.getNonNullableType(t)) || !!this.refClasses(t)); + return fits(checker.getTypeAtLocation(expr)) || fits(checker.getContextualType(expr)); + } + + /** + * The reference an optional chain asks about first (B9): `target` of `target?.hp`, `a.next` of `a.next?.value`. Undefined + * when the expression is no optional chain through a reference. + */ + /** In `t?.hp`, `t?.pos.x`: whether there is a `t`, when it is a record that may not be there (what find() or chatted() found). */ + private foundBase(expr: TS.Expression): VarDecl | undefined { + const { ts } = this; + const e = this.unwrap(expr); + const inner = ts.isCallExpression(e) ? this.unwrap(e.expression) : e; + if (!ts.isPropertyAccessExpression(inner) && !ts.isElementAccessExpression(inner)) return undefined; + if (inner.questionDotToken) { const b = this.bindingOf(inner.expression); if (b?.kind === "record" && b.truth) return b.truth; } + return this.foundBase(inner.expression); + } + + private optionalBase(expr: TS.Expression): TS.Expression | undefined { + const { ts } = this; + const e = this.unwrap(expr); + if (!(ts.isPropertyAccessExpression(e) || ts.isCallExpression(e) || ts.isElementAccessExpression(e))) return undefined; + const inner = ts.isCallExpression(e) ? this.unwrap(e.expression) : e; + if (!ts.isPropertyAccessExpression(inner) && !ts.isElementAccessExpression(inner)) return undefined; + if (inner.questionDotToken && this.refClasses(this.c.checker.getTypeAtLocation(inner.expression))) return inner.expression; + return this.optionalBase(inner.expression); + } + + /** Whether a type is a function a program may keep: something called, not a game() function or a unit. */ + private isFunctionType(type: TS.Type): boolean { + return type.getCallSignatures().length > 0 && (type.flags & this.ts.TypeFlags.Any) === 0 && !type.getProperty("__game") && !type.getProperty("__unit") && !type.getProperty("__task"); + } + + /** Whether a type is the Task start() gives back. */ + private isTaskType(type: TS.Type): boolean { + return !!type.getProperty("__task") && (type.flags & this.ts.TypeFlags.Any) === 0; + } + private isUnitTyped(e: TS.Expression): boolean { return this.kindOf(this.c.checker.getTypeAtLocation(e)) === "unit"; } @@ -3635,11 +5315,15 @@ export class Structured { } private emitAction(a: ActionRecord, at: TS.Node) { - if (a.type === ActionType.PreserveTrigger) return; // Every generated trigger is preserved already. + // Every generated trigger is preserved already: a word, and nothing emitted. + if (a.type === ActionType.PreserveTrigger) { this.emit({ kind: "remark", short: "does nothing in a program", text: "preserve() keeps a trigger running after it has fired. A program runs on its own until it ends, so this does nothing here and can go.", at: this.at(at) }, at); return; } + if (a.flags & ActionFlag.Disabled) { this.c.error(at, "disabled() switches an action of a trigger off, which the game then skips. In a program, leave the action out instead."); return; } const s = a.text > 0 ? this.c.strings[a.text - 1] : undefined; if (s && "text" in s && hasTextMark(s.text)) { // name() or color() in the text: only displayText has a form the game fills in. if (a.type !== ActionType.DisplayText) { this.c.error(at, "name() and color() are filled in while the game runs, which displayText() and print() can do; this action's text is fixed when the script is built."); return; } + // (A print is always displayed: the flag an action carries has no place in it.) + if (!(a.flags & ActionFlag.AlwaysDisplay)) this.emit({ kind: "remark", short: "always displayed", text: "A text with name() or color() in it is filled in while the game runs, as print() fills one, and such a text is always displayed: the false here changes nothing.", at: this.at(at) }, at); this.emitPrint(textParts(s.text), CURRENT_PLAYER, "chat", at); return; } @@ -3656,7 +5340,8 @@ export class Structured { /** Whether a type is a string's: `string`, a text written out, a union of those (`undefined` beside it is what `at()` adds, and is nothing here). */ private isTextType(type: TS.Type): boolean { const { ts } = this; - const bare = (type.isUnion() ? type.types : [type]).filter((t) => (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0); + type = this.awaited(type); + const bare = this.membersOf(type).filter((t) => (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) === 0); return bare.length > 0 && bare.every((t) => (t.flags & ts.TypeFlags.StringLike) !== 0); } @@ -3726,18 +5411,35 @@ export class Structured { * its first value and every `=` in the body — and as a text that is made otherwise (`+=` makes one). `isTarget` says * whether the left of an assignment is this variable. */ - private textKept(initializer: TS.Expression | undefined, isTarget: (left: TS.Expression) => boolean): "id" | "made" { + /** Whether a declared variable is handed to a call anywhere in the body, as itself (`f(x)`). */ + private handedToCall(decl: TS.Node): boolean { + const { ts } = this; + if (!ts.isVariableDeclaration(decl) || !ts.isIdentifier(decl.name)) return false; + let found = false; + const walk = (n: TS.Node) => { + if (found) return; + if ((ts.isCallExpression(n) || ts.isNewExpression(n)) && n.arguments?.some((a) => ts.isIdentifier(a) && declarationOf(ts, this.c.checker, a) === decl)) { found = true; return; } + if (ts.isJsxAttribute(n) && n.initializer && ts.isJsxExpression(n.initializer) && n.initializer.expression && ts.isIdentifier(n.initializer.expression) && declarationOf(ts, this.c.checker, n.initializer.expression) === decl) { found = true; return; } + ts.forEachChild(n, walk); + }; + walk(this.body.plan.root); + return found; + } + + private textKept(initializer: TS.Expression | undefined, isTarget: (left: TS.Expression) => boolean, also?: TS.Node): "id" | "made" { const { ts } = this; if (initializer && !this.hasId(initializer)) return "made"; let made = false; const walk = (n: TS.Node) => { if (made) return; if (ts.isBinaryExpression(n) && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && n.operatorToken.kind <= ts.SyntaxKind.LastAssignment && isTarget(this.unwrap(n.left))) { - if (n.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !this.hasId(n.right)) made = true; + // What a parameter is (`this.value = v`) is known only at a call: made, which takes any text. + if (n.operatorToken.kind !== ts.SyntaxKind.EqualsToken || !this.hasId(n.right) || (ts.isIdentifier(this.unwrap(n.right)) && ts.isParameter(declarationOf(ts, this.c.checker, this.unwrap(n.right) as TS.Identifier) ?? n))) made = true; } ts.forEachChild(n, walk); }; walk(this.body.plan.body); + if (also && !made) walk(also); return made ? "made" : "id"; } @@ -3802,7 +5504,7 @@ export class Structured { return this.mark({ kind: "ternary", cond: { kind: "compare", op: "<", left: i, right: num(0), at: this.at(at), label: this.label(at) }, whenTrue: whenBelow, whenFalse: i, at: this.at(at), label: this.label(at) }, at); } - private static readonly TEXT_METHODS = "slice, substring, at, charAt, indexOf, includes, startsWith, endsWith, padStart, padEnd, repeat, concat, codePointAt, toString and length"; + private static readonly TEXT_METHODS = "slice, substring, at, charAt, indexOf, lastIndexOf, includes, startsWith, endsWith, padStart, padEnd, repeat, concat, trim, trimStart, trimEnd, replace, replaceAll, toUpperCase, toLowerCase, split, codePointAt, toString and length"; /** A method of a text that gives a text. Null with a diagnostic. */ private textCall(e: TS.CallExpression, receiver: TS.Expression, method: string): TextExpr | null { @@ -3858,12 +5560,202 @@ export class Structured { const count = this.num(args[0]); return of && count ? this.mark({ kind: "textRepeat", of, count, at: this.at(e), label: this.label(e) }, e) : null; } + case "trim": case "trimStart": case "trimEnd": { + if (!wants(0, 0, "nothing")) return null; + const of = this.text(receiver); + return of ? this.textTrim(of, method === "trim" ? "both" : method === "trimStart" ? "start" : "end", e) : null; + } + case "replace": case "replaceAll": { + if (!wants(2, 2, "the text to find and the text to put in its place")) return null; + if (this.ts.isRegularExpressionLiteral(this.unwrap(args[0]))) { this.c.error(args[0], `${method}() of a program finds a text, not a regular expression: ${method}("a", "b").`); return null; } + if (this.isFunctionType(this.c.checker.getTypeAtLocation(args[1]))) { this.c.error(args[1], `${method}() of a program puts a text in place of what it finds, not what a function gives.`); return null; } + const of = this.text(receiver), find = this.text(args[0]), by = this.text(args[1]); + return of && find && by ? this.textReplace(of, find, by, method === "replaceAll", e) : null; + } + case "toUpperCase": case "toLowerCase": case "toLocaleUpperCase": case "toLocaleLowerCase": { + if (!wants(0, method.includes("Locale") ? 1 : 0, "nothing")) return null; + const of = this.text(receiver); + return of ? this.textCase(of, method.includes("Upper"), e) : null; + } default: this.c.error(e, `${method}() is not something a text of a program does; it has ${Structured.TEXT_METHODS}. A text the script has (nothing of the program in it) takes any method JavaScript has.`); return null; } } + /* ── The compiler's own functions over texts: trim, replace, case, lastIndexOf, split, parseInt ── */ + + /** What writing IR by hand takes, for the compiler's own functions: at one place, under one label. */ + private irAt(e: TS.Node) { + const at = this.at(e); + const label = this.label(e); + const n = (v: number): NumExpr => num(v); + return { + at, label, n, + num: (name: string) => this.newVar(name, "number", at, { temp: true }), + txt: (name: string) => this.newVar(name, "text", at, { temp: true, text: "made" }), + ref: (v: VarDecl): NumExpr => varRef(v), + tref: (v: VarDecl): TextExpr => ({ kind: "textVar", id: v.id }), + declare: (decl: VarDecl, init: NumExpr | BoolExpr | UnitExpr | TextExpr): Stmt => ({ kind: "declare", decl, init, at, label }), + set: (v: VarDecl, value: NumExpr): Stmt => ({ kind: "assign", target: v.id, value, at, label }), + setText: (v: VarDecl, value: TextExpr): Stmt => ({ kind: "assignText", target: v.id, value, at, label }), + add: (left: NumExpr, right: NumExpr): NumExpr => ({ kind: "binary", op: "+", left, right, at, label }), + sub: (left: NumExpr, right: NumExpr): NumExpr => ({ kind: "binary", op: "-", left, right, at, label }), + mul: (left: NumExpr, right: NumExpr): NumExpr => ({ kind: "binary", op: "*", left, right, at, label }), + cmp: (op: CompareOp, left: NumExpr, right: NumExpr): BoolExpr => ({ kind: "compare", op, left, right, at, label }), + and: (...items: BoolExpr[]): BoolExpr => ({ kind: "and", items }), + or: (...items: BoolExpr[]): BoolExpr => ({ kind: "or", items }), + not: (expr: BoolExpr): BoolExpr => ({ kind: "not", expr }), + loop: (cond: BoolExpr, body: Stmt[]): Stmt => ({ kind: "while", cond, body, at, label }), + when: (cond: BoolExpr, then: Stmt[], otherwise?: Stmt[]): Stmt => ({ kind: "if", cond, then, ...(otherwise ? { else: otherwise } : {}), at, label }), + ret: (value?: NumExpr | BoolExpr | UnitExpr | TextExpr): Stmt => ({ kind: "return", ...(value ? { value } : {}), at, label }), + brk: (): Stmt => ({ kind: "break", at, label }), + len: (of: TextExpr): NumExpr => ({ kind: "textLength", of, at, label }), + code: (of: TextExpr, index: NumExpr): NumExpr => ({ kind: "textCode", of, index, at, label }), + find: (of: TextExpr, find: TextExpr, from?: NumExpr): NumExpr => ({ kind: "textIndexOf", of, find, ...(from ? { from } : {}), at, label }), + slice: (of: TextExpr, start: NumExpr, end?: NumExpr): TextExpr => ({ kind: "textSlice", of, start, ...(end ? { end } : {}), at, label }), + join: (...parts: TextExpr[]): TextExpr => ({ kind: "template", parts: parts.map((t): TextPart => ({ kind: "value", text: t })), at, label }), + /** A call inlined where the value is: its parameters copies of what it is handed, its body, its result. */ + call: (name: string, kind: K, params: [VarDecl, NumExpr | BoolExpr | TextExpr][], body: Stmt[]): Call => + ({ name, at, label, params: params.map(([decl, init]) => ({ decl, init, label })), result: { decl: this.newVar(`(${name})`, kind, at, { temp: true, ...(kind === "text" ? { text: "made" as const } : {}) }), kind }, body }), + }; + } + + /** A space as `trim` takes one: a space, a tab, a line's end. */ + private isSpaceCode(c: NumExpr, b: ReturnType): BoolExpr { + return b.or(b.cmp("==", c, b.n(32)), b.cmp("==", c, b.n(9)), b.cmp("==", c, b.n(10)), b.cmp("==", c, b.n(13)), b.cmp("==", c, b.n(11)), b.cmp("==", c, b.n(12))); + } + + /** `s.trim()`, `trimStart()`, `trimEnd()`: the characters between the spaces at either end. */ + private textTrim(of: TextExpr, side: "both" | "start" | "end", e: TS.Node): TextExpr { + const b = this.irAt(e); + const s = b.txt("(trimmed)"), len = b.num("(length)"), a = b.num("(start)"), z = b.num("(end)"); + const body: Stmt[] = [b.declare(len, b.len(b.tref(s))), b.declare(a, b.n(0)), b.declare(z, b.ref(len))]; + if (side !== "end") body.push(b.loop(b.and(b.cmp("<", b.ref(a), b.ref(len)), this.isSpaceCode(b.code(b.tref(s), b.ref(a)), b)), [b.set(a, b.add(b.ref(a), b.n(1)))])); + if (side !== "start") body.push(b.loop(b.and(b.cmp(">", b.ref(z), b.ref(a)), this.isSpaceCode(b.code(b.tref(s), b.sub(b.ref(z), b.n(1))), b)), [b.set(z, b.sub(b.ref(z), b.n(1)))])); + body.push(b.ret(b.slice(b.tref(s), b.ref(a), b.ref(z)))); + return this.mark({ kind: "textCall", call: b.call(`${side === "both" ? "trim" : side === "start" ? "trimStart" : "trimEnd"}`, "text", [[s, of]], body) }, e); + } + + /** `s.replace(find, by)`: the first match, if any, by the other text; `replaceAll`, every one, left to right, none overlapping. An empty `find` matches before every character (and at the end), as JavaScript's does. */ + private textReplace(of: TextExpr, find: TextExpr, by: TextExpr, all: boolean, e: TS.Node): TextExpr { + const b = this.irAt(e); + const s = b.txt("(text)"), f = b.txt("(find)"), r = b.txt("(by)"), out = b.txt("(replaced)"); + const fl = b.num("(length found)"), i = b.num("(found at)"), from = b.num("(from)"); + const body: Stmt[] = [b.declare(fl, b.len(b.tref(f))), b.declare(i, b.find(b.tref(s), b.tref(f)))]; + if (!all) { + body.push(b.when(b.cmp("<", b.ref(i), b.n(0)), [b.ret(b.tref(s))])); + body.push(b.ret(b.join(b.slice(b.tref(s), b.n(0), b.ref(i)), b.tref(r), b.slice(b.tref(s), b.add(b.ref(i), b.ref(fl)))))); + } else { + const n = b.num("(length)"), k = b.num("(place)"); + body.push(b.declare(out, { kind: "text", text: "" }), b.declare(from, b.n(0))); + // Nothing to find: the other text before every character, and at the end. + body.push(b.when(b.cmp("==", b.ref(fl), b.n(0)), [ + b.declare(n, b.len(b.tref(s))), b.declare(k, b.n(0)), + b.loop(b.cmp("<", b.ref(k), b.ref(n)), [b.setText(out, b.join(b.tref(out), b.tref(r), b.slice(b.tref(s), b.ref(k), b.add(b.ref(k), b.n(1))))), b.set(k, b.add(b.ref(k), b.n(1)))]), + b.ret(b.join(b.tref(out), b.tref(r))), + ])); + body.push(b.loop(b.cmp(">=", b.ref(i), b.n(0)), [ + b.setText(out, b.join(b.tref(out), b.slice(b.tref(s), b.ref(from), b.ref(i)), b.tref(r))), + b.set(from, b.add(b.ref(i), b.ref(fl))), + b.set(i, b.find(b.tref(s), b.tref(f), b.ref(from))), + ])); + body.push(b.ret(b.join(b.tref(out), b.slice(b.tref(s), b.ref(from))))); + } + return this.mark({ kind: "textCall", call: b.call(all ? "replaceAll" : "replace", "text", [[s, of], [f, find], [r, by]], body) }, e); + } + + /** `toUpperCase()` / `toLowerCase()`: the letters A to Z, a to z changed; every other character as it is. */ + private textCase(of: TextExpr, upper: boolean, e: TS.Node): TextExpr { + const b = this.irAt(e); + const s = b.txt("(text)"), out = b.txt("(changed)"), n = b.num("(length)"), k = b.num("(place)"), c = b.num("(character)"); + const [lo, hi] = upper ? [97, 122] : [65, 90]; + const body: Stmt[] = [ + b.declare(out, { kind: "text", text: "" }), b.declare(n, b.len(b.tref(s))), b.declare(k, b.n(0)), + b.loop(b.cmp("<", b.ref(k), b.ref(n)), [ + b.declare(c, b.code(b.tref(s), b.ref(k))), + b.when(b.and(b.cmp(">=", b.ref(c), b.n(lo)), b.cmp("<=", b.ref(c), b.n(hi))), + [b.setText(out, b.join(b.tref(out), b.slice({ kind: "text", text: upper ? "ABCDEFGHIJKLMNOPQRSTUVWXYZ" : "abcdefghijklmnopqrstuvwxyz" }, b.sub(b.ref(c), b.n(lo)), b.sub(b.ref(c), b.n(lo - 1)))))], + [b.setText(out, b.join(b.tref(out), b.slice(b.tref(s), b.ref(k), b.add(b.ref(k), b.n(1)))))]), + b.set(k, b.add(b.ref(k), b.n(1))), + ]), + b.ret(b.tref(out)), + ]; + return this.mark({ kind: "textCall", call: b.call(upper ? "toUpperCase" : "toLowerCase", "text", [[s, of]], body) }, e); + } + + /** `s.lastIndexOf(find)`: the place of the last match, or −1. */ + private textLastIndexOf(of: TextExpr, find: TextExpr, e: TS.Node): NumExpr { + const b = this.irAt(e); + const s = b.txt("(text)"), f = b.txt("(find)"), i = b.num("(found at)"), last = b.num("(last)"); + const body: Stmt[] = [ + b.declare(last, b.n(-1)), b.declare(i, b.find(b.tref(s), b.tref(f))), + // Nothing to find is found at the end. + b.when(b.cmp("==", b.len(b.tref(f)), b.n(0)), [b.ret(b.len(b.tref(s)))]), + b.loop(b.cmp(">=", b.ref(i), b.n(0)), [b.set(last, b.ref(i)), b.set(i, b.find(b.tref(s), b.tref(f), b.add(b.ref(i), b.n(1))))]), + b.ret(b.ref(last)), + ]; + return this.mark({ kind: "call", call: b.call("lastIndexOf", "number", [[s, of], [f, find]], body) }, e); + } + + /** + * `parseInt(s)`, `parseInt(s, radix)`, `Number(s)`, `Number.parseInt(s)`: the whole number a text starts with, after + * any spaces and a sign. `digits`: whether it had any — what `isNaN(parseInt(s))` asks, where JavaScript would give + * NaN; a number of a program is always one, and a text with no digits gives 0. + */ + private parsedNumber(of: TextExpr, radix: number, digits: boolean, e: TS.Node): Call { + const b = this.irAt(e); + const s = b.txt("(text)"), n = b.num("(length)"), i = b.num("(place)"), neg = b.num("(minus)"), v = b.num("(value)"), c = b.num("(character)"), d = b.num("(digit)"), count = b.num("(digits)"); + const code = () => b.code(b.tref(s), b.ref(i)); + const step = b.set(i, b.add(b.ref(i), b.n(1))); + const letters: Stmt[] = radix > 10 + ? [b.when(b.and(b.cmp(">=", b.ref(c), b.n(97)), b.cmp("<=", b.ref(c), b.n(122))), [b.set(d, b.sub(b.ref(c), b.n(87)))], + [b.when(b.and(b.cmp(">=", b.ref(c), b.n(65)), b.cmp("<=", b.ref(c), b.n(90))), [b.set(d, b.sub(b.ref(c), b.n(55)))])])] + : []; + const body: Stmt[] = [ + b.declare(n, b.len(b.tref(s))), b.declare(i, b.n(0)), b.declare(neg, b.n(0)), b.declare(v, b.n(0)), b.declare(count, b.n(0)), + b.loop(b.and(b.cmp("<", b.ref(i), b.ref(n)), this.isSpaceCode(code(), b)), [step]), + b.when(b.cmp("<", b.ref(i), b.ref(n)), [ + b.declare(c, code()), + b.when(b.cmp("==", b.ref(c), b.n(45)), [b.set(neg, b.n(1)), step], [b.when(b.cmp("==", b.ref(c), b.n(43)), [step])]), + ]), + b.loop(b.cmp("<", b.ref(i), b.ref(n)), [ + b.declare(c, code()), + b.declare(d, b.n(99)), + b.when(b.and(b.cmp(">=", b.ref(c), b.n(48)), b.cmp("<=", b.ref(c), b.n(57))), [b.set(d, b.sub(b.ref(c), b.n(48)))], letters.length ? letters : undefined), + b.when(b.cmp(">=", b.ref(d), b.n(radix)), [b.brk()]), + b.set(v, b.add(b.mul(b.ref(v), b.n(radix)), b.ref(d))), + b.set(count, b.add(b.ref(count), b.n(1))), + step, + ]), + digits ? b.ret(b.cmp("==", b.ref(count), b.n(0))) : b.ret({ kind: "ternary", cond: b.cmp("==", b.ref(neg), b.n(1)), whenTrue: { kind: "unary", op: "-", expr: b.ref(v), at: b.at }, whenFalse: b.ref(v), at: b.at, label: b.label }), + ]; + return b.call(digits ? "isNaN(parseInt)" : "parseInt", digits ? "boolean" : "number", [[s, of]], body); + } + + /** `parseInt(s)` / `Number(s)` of a text of the program, or `isNaN(…)` of one: undefined when the call is not one. */ + private parseCall(e: TS.CallExpression, digits: boolean): Call | null | undefined { + const { ts } = this; + const callee = this.unwrap(e.expression); + const name = ts.isIdentifier(callee) ? callee.text : ts.isPropertyAccessExpression(callee) && ts.isIdentifier(callee.expression) && callee.expression.text === "Number" ? `Number.${callee.name.text}` : ""; + if (!["parseInt", "Number", "Number.parseInt"].includes(name) || !this.isGlobal(callee) || !e.arguments[0] || !this.isTextTyped(e.arguments[0]) || this.evaluate(e.arguments[0])) return undefined; + let radix = 10; + if (e.arguments[1]) { + const r = this.evaluate(e.arguments[1]); + if (!r || typeof r.value !== "number" || !Number.isInteger(r.value) || r.value < 2 || r.value > 36) { this.c.error(e.arguments[1], "parseInt()'s radix is a whole number from 2 to 36 the script knows when it is built."); return null; } + radix = r.value; + } + const of = this.text(e.arguments[0]); + return of ? this.parsedNumber(of, radix, digits, e) : null; + } + + /** Whether a name is JavaScript's own (declared in its library), not the script's or TrigScript's. */ + private isGlobal(e: TS.Expression): boolean { + const decl = this.c.checker.getSymbolAtLocation(this.ts.isPropertyAccessExpression(e) ? e.expression : e)?.declarations?.[0]; + const sf = decl?.getSourceFile(); + return !!sf && (sf.hasNoDefaultLib || /(^|\/)lib(\.[^/]*)?\.d\.ts$/.test(sf.fileName)); + } + /** * A text of the program: one written out, a variable, a template or texts joined with `+`, `c ? a : b`, a method that * gives a text, `String(n)`, a function's result, a text looked up in a list the script has. Null, with a diagnostic. @@ -3871,6 +5763,7 @@ export class Structured { private text(expr: TS.Expression): TextExpr | null { const { ts } = this; const e = this.unwrap(expr); + if (ts.isJsxElement(e) || ts.isJsxSelfClosingElement(e) || ts.isJsxFragment(e)) return this.jsx(e); const h = this.evaluate(expr); if (h && !isGameValue(h.value)) { const v = h.value; @@ -3879,6 +5772,20 @@ export class Structured { this.c.error(e, `Expected a text, got ${describe(v)}.`); return null; } + // `NAMES[team]`: the text of the field the team is. + const picked = this.fieldAtText(e); + if (picked === null) return null; + if (picked) { + const at = this.at(e), label = this.label(e); + const textOfField = (b: Binding): TextExpr | null => { + if (b.kind === "value" && typeof b.value === "string") return this.literalText(b.value, e); + if (b.kind === "var" && b.v.kind === "text") return { kind: "textVar", id: b.v.id }; + if (b.kind === "textAt") return this.textAtCells(b, e); + this.c.error(e, `Expected a text in every field ${e.getText(this.body.sf)} may be.`); + return null; + }; + return this.pickField(picked, textOfField, (cond, whenTrue, whenFalse) => ({ kind: "textTernary", cond, whenTrue, whenFalse, at, label })); + } const got = ts.isCallExpression(e) ? this.methodCall(e) : this.getterCall(e); if (got !== undefined) { if (got && got.result?.kind !== "text") { const parts = this.textOf(e); return parts ? this.textFrom(parts, e) : null; } @@ -3887,6 +5794,22 @@ export class Structured { if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression)) { const of = this.bindingOf(e.expression.expression); if (of?.kind === "texts" && e.expression.name.text === "join") return this.textsJoin(e, of); + if (of?.kind === "texts" && e.expression.name.text === "at") { + if (e.arguments.length !== 1) { this.c.error(e, "at() takes the place: from the end when below zero."); return null; } + const i = this.num(e.arguments[0]); + return i ? this.textAtCells(this.textAtIndex(of, this.temp(this.fromEnd(i, { kind: "length", array: [...of.rows.fields.values()][0].id, at: this.at(e) }, e), e)), e) : null; + } + if (of?.kind === "texts" && e.expression.name.text === "shift" && !e.arguments.length) { + // The first text, a copy taken before the row goes. + const first = this.newVar(`(${of.name}.shift)`, "text", this.at(e), { temp: true, text: "made" }); + this.emit({ kind: "declare", decl: first, init: this.textAtCells(this.textAtIndex(of, num(0)), e), at: this.at(e), label: this.label(e) }, e); + this.textsCall(e, of, "shift"); + return { kind: "textVar", id: first.id }; + } + if (e.expression.name.text === "join" && (of?.kind === "array" || (!of && this.listMadeHere(e.expression.expression)))) { + const list = of ?? this.listOf(e.expression.expression); + return list?.kind === "array" ? this.arrayJoin(e, list.a) : null; + } if (of?.kind === "texts" && e.expression.name.text === "pop" && e.arguments.length === 0) { // What it takes off, kept — a copy, since the row's own goes back with the row. const at = this.at(e); @@ -3899,6 +5822,12 @@ export class Structured { return { kind: "textVar", id: last.id }; } } + if (ts.isElementAccessExpression(e) && ts.isCallExpression(this.unwrap(e.expression)) && this.isSplit(this.unwrap(e.expression) as TS.CallExpression)) { + // `s.split(",")[1]`: the piece at that place. + const pieces = this.splitTexts(this.unwrap(e.expression) as TS.CallExpression, "(split)", e); + const i = pieces ? this.num(e.argumentExpression) : null; + return pieces && i ? this.textAtCells(this.textAtIndex(pieces, this.temp(i, e)), e) : null; + } if (ts.isIdentifier(e) || ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) { const b = this.bindingOf(e); if (b?.kind === "textAt") return this.textAtCells(b, e); @@ -3910,6 +5839,8 @@ export class Structured { return parts ? this.textFrom(parts, e) : null; } if (ts.isConditionalExpression(e)) { + const known = this.knownCondition(e.condition); + if (known !== undefined) return this.text(known ? e.whenTrue : e.whenFalse); const cond = this.bool(e.condition); const whenTrue = this.text(e.whenTrue); const whenFalse = this.text(e.whenFalse); @@ -3941,11 +5872,9 @@ export class Structured { // `n.toString()`: the number's digits. if (e.expression.name.text === "toString" && e.arguments.length === 0) { const parts = this.textOf(receiver); return parts ? this.textFrom(parts, e) : null; } } - let call: Call | undefined; - if (ts.isIdentifier(e.expression)) { - const decl = this.gameDeclaration(e.expression); - if (decl && ts.isFunctionDeclaration(decl)) call = this.inline(e, decl.parameters, decl.body, this.body, decl.name?.text, decl); - } + const user = this.userCall(e); + if (user === null) return null; + let call: Call | undefined = user; if (!call) { const callee = this.evaluate(e.expression)?.value; if (isGameFunction(callee)) call = this.gameCall(e, callee); @@ -4011,6 +5940,11 @@ export class Structured { const { ts } = this; if (!ts.isPropertyAccessExpression(e.expression) || !this.isTextTyped(e.expression.expression)) return undefined; const method = e.expression.name.text; + if (method === "lastIndexOf") { + if (e.arguments.length !== 1) { this.c.error(e, "lastIndexOf() of a program takes the text to find."); return null; } + const of = this.text(e.expression.expression), find = this.text(e.arguments[0]); + return of && find ? this.textLastIndexOf(of, find, e) : null; + } if (method !== "indexOf" && method !== "codePointAt" && method !== "charCodeAt") return undefined; const of = this.text(e.expression.expression); if (!of) return null; @@ -4076,8 +6010,26 @@ export class Structured { const r = this.textOf(e.right); return l && r ? [...l, ...r] : null; } - if (this.isTextTyped(e)) { const t = this.text(e); return t ? this.partsOf(t) : null; } - if (this.kindOf(this.c.checker.getTypeAtLocation(e)) === "boolean") { this.c.error(e, "A boolean has no text of its own: write flag ? \"yes\" : \"no\" with both texts known when the script is built, or show a number."); return null; } + // A variable of the program holds its own kind, whatever TypeScript has narrowed its name to. + const held = ts.isIdentifier(e) || ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e) ? this.bindingOf(e) : undefined; + const ownKind = held?.kind === "var" && held.v.kind !== "text"; + if (this.isTextTyped(e) && !ownKind) { const t = this.text(e); return t ? this.partsOf(t) : null; } + // A text held where the type says more (a component's `children`, typed `Children`): the text it holds. + const heldText = ts.isIdentifier(e) || ts.isPropertyAccessExpression(e) ? this.bindingOf(e) : undefined; + if (heldText?.kind === "var" && heldText.v.kind === "text") return [{ kind: "value", text: { kind: "textVar", id: heldText.v.id } }]; + // `${e}`, String(e) of an error a catch took: "RangeError: too far", as JavaScript writes one. + const bound = ts.isIdentifier(e) || ts.isPropertyAccessExpression(e) ? this.bindingOf(e) : undefined; + const caught = bound && this.caughtRecords.get(bound); + if (caught) { + const name = this.caughtField(caught, "name"), message = this.caughtField(caught, "message"); + if (name?.kind !== "var" || message?.kind !== "var") return null; + return [{ kind: "value", text: this.errorText({ kind: "textVar", id: name.v.id }, { kind: "textVar", id: message.v.id }, this.at(e), this.label(e)) }]; + } + // `${flag}`, String(flag): "true" or "false", as JavaScript writes one. + if (this.kindOf(this.c.checker.getTypeAtLocation(e)) === "boolean") { + const at = this.at(e), label = this.label(e); + return [{ kind: "value", text: { kind: "textTernary", cond: this.boolValue(e), whenTrue: { kind: "text", text: "true" }, whenFalse: { kind: "text", text: "false" }, at, label } }]; + } const value = this.num(e); return value ? [{ kind: "number", expr: value }] : null; } @@ -4106,96 +6058,694 @@ export class Structured { /** `centerLocation(locations.Cursor, at.x, at.y)`: the location known when the script is built, the point the program's. */ private centerLocation(e: TS.CallExpression) { - if (e.arguments.length !== 3) { this.c.error(e, "centerLocation() takes the location and the point: centerLocation(locations.Cursor, x, y)."); return; } + if (e.arguments.length !== 3 && e.arguments.length !== 5) { this.c.error(e, "centerLocation() takes the location and the point, and a width and a height to size it too: centerLocation(locations.Cursor, x, y) or centerLocation(locations.Cursor, x, y, 32, 32)."); return; } const h = this.evaluate(e.arguments[0]); if (!h || isGameValue(h.value)) { this.notConstant(e.arguments[0], "The location"); return; } const location = h.value; if (typeof location !== "number" || !Number.isInteger(location) || location < 1 || location > 255 || location === 64) { this.c.error(e.arguments[0], "centerLocation() takes one of locations.*, which is moved (not Anywhere)."); return; } const x = this.num(e.arguments[1]); const y = this.num(e.arguments[2]); - if (x && y) this.emit({ kind: "centerLocation", location, x, y, at: this.at(e), label: this.label(e) }, e); + const width = e.arguments.length === 5 ? this.num(e.arguments[3]) : undefined; + const height = e.arguments.length === 5 ? this.num(e.arguments[4]) : undefined; + if (e.arguments.length === 5 && (!width || !height)) return; + const size = width && height ? { size: { width, height } } : {}; + if (x && y) this.emit({ kind: "centerLocation", location, x, y, ...size, at: this.at(e), label: this.label(e) }, e); + } + + /** `sleep(…)`, or `nextFrame()` — which is `sleep(frames(1))`, said the way a frame loop reads. */ + private isSleep(e: TS.CallExpression): boolean { + return this.isLibraryCall(e, "sleep") || this.isLibraryCall(e, "nextFrame"); } /** `sleep(seconds(2))`: the duration is a build-time value; what it makes of it is the target's. */ private sleepStatement(call: TS.CallExpression) { if (this.inCallback > 0) { this.c.error(call, "sleep() inside a function given to an array method: the method is one loop within the frame. A for…of over the same list can sleep between its turns."); return; } + if (this.isLibraryCall(call, "nextFrame")) { + if (call.arguments.length) { this.c.error(call, "nextFrame() takes nothing: await nextFrame(); waits for the next frame."); return; } + this.emit({ kind: "sleep", cycles: 1, at: this.at(call), label: this.label(call) }, call); + return; + } if (call.arguments.length !== 1) { this.c.error(call, "sleep() takes one duration: sleep(seconds(2)), sleep(minutes(1)) or sleep(frames(5))."); return; } const h = this.evaluate(call.arguments[0]); - if (!h) { this.notConstant(call.arguments[0], "A duration"); return; } + if (!h) { this.variableSleep(call, call.arguments[0]); return; } if (!isDuration(h.value)) { this.c.error(call.arguments[0], `sleep() takes a duration from seconds(), minutes() or frames(), got ${describe(h.value)}.`); return; } const d = h.value; this.emit({ kind: "sleep", ...(d.cycles !== undefined ? { cycles: d.cycles } : {}), ...(d.ms !== undefined ? { ms: d.ms } : {}), at: this.at(call), label: this.label(call) }, call); } - private expressionStatement(expr: TS.Expression) { + /** + * `sleep(frames(n))`, `sleep(seconds(t))` of a number the game holds: a frame at a time, counted down — the frame after + * at the least, as a duration known when the script is built waits. Twenty-four frames a second. + */ + private variableSleep(call: TS.CallExpression, arg: TS.Expression) { + const { ts } = this; + const e = this.unwrap(arg); + const unit = ts.isCallExpression(e) && e.arguments.length === 1 ? ["frames", "cycles", "seconds", "minutes"].find((n) => this.isLibraryCall(e, n)) : undefined; + if (!unit || !ts.isCallExpression(e)) { this.notConstant(arg, "A duration"); return; } + if (unit === "seconds" && this.widthOf(this.c.checker.getTypeAtLocation(e.arguments[0])).fixed) { this.c.error(e.arguments[0], "seconds() of a fixed number the game holds: say it in frames — sleep(frames(n)), 24 a second."); return; } + const n = this.num(e.arguments[0]); + if (!n) return; + const at = this.at(call), label = this.label(call); + const frames: NumExpr = unit === "seconds" ? { kind: "binary", op: "*", left: n, right: num(24), at, label } : unit === "minutes" ? { kind: "binary", op: "*", left: n, right: num(1440), at, label } : n; + const left = this.newVar("(frames left)", "number", at); + this.emit({ kind: "declare", decl: left, init: frames, at, label }, call); + this.emit({ kind: "do", body: [ + { kind: "sleep", cycles: 1, at, label }, + { kind: "assign", target: left.id, value: { kind: "binary", op: "-", left: varRef(left), right: num(1), at, label }, at, label }, + ], cond: { kind: "compare", op: ">", left: varRef(left), right: num(0), at, label }, condLabel: label, at, label }, call); + } + + /** `await until(() => condition)`: the program waits, asking again every frame, until the condition holds — not at all when it holds now. */ + private untilStatement(call: TS.CallExpression) { + const { ts } = this; + if (this.inCallback > 0) { this.c.error(call, "until() inside a function given to an array method: the method is one loop within the frame. A for…of over the same list can await between its turns."); return; } + if (call.arguments.length !== 1) { this.c.error(call, "until() takes a function that answers true or false: await until(() => countUnits(P8, units.ZergZergling) === 0)."); return; } + const arg = call.arguments[0]; + const fn = this.unwrap(arg); + let cond: BoolExpr | null; + if (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) cond = this.predicate(arg, "until", [], call); + else if (isCondition(this.evaluate(arg)?.value)) cond = this.bool(arg); + else { this.c.error(arg, "until() takes a function, which is asked again every frame: until(() => …). A condition — until(bring(…)) — is asked again on its own."); return; } + if (!cond) return; + const at = this.at(call), label = this.label(call); + this.emit({ kind: "while", cond: { kind: "not", expr: cond }, body: [{ kind: "sleep", cycles: 1, at, label }], at, label }, call); + } + + /** `await sync(player, value)` (B5): one value, as a number. Several are taken apart where they are awaited (`syncValues`). */ + private syncValue(e: TS.CallExpression): NumExpr | null { + if (e.arguments.length > 2) { this.c.error(e, "sync() of several values gives one for each: take them apart where it is awaited — const [x, y] = await sync(P1, a, b)."); return null; } + const out = this.syncValues(e); + return out ? varRef(out[0]) : null; + } + + /** + * `await sync(player, value, …)` (B5): `syncSend` for each value on the same frame — on every computer its slot's + * arrival cleared, on the player's own the value put to be sent — then, a frame at a time, each value caught on the + * frame it arrives (MSQC's cell holds it for that frame only) until all have come, or the player is no person or has + * left (0 for what has not come). The values may be `Local` ones; what comes back is not. A variable for each. + */ + private syncValues(e: TS.CallExpression): VarDecl[] | null { + const { ts } = this; + let up: TS.Node = e.parent; + while (ts.isParenthesizedExpression(up)) up = up.parent; + if (!ts.isAwaitExpression(up)) { this.c.error(e, "sync() gives a promise: await it where it is assigned — const x = await sync(P1, local.camera.x)."); return null; } + let host: TS.Node = up.parent; + while (ts.isParenthesizedExpression(host)) host = host.parent; + const assigned = ts.isVariableDeclaration(host) || ts.isExpressionStatement(host) || (ts.isBinaryExpression(host) && host.operatorToken.kind === ts.SyntaxKind.EqualsToken); + if (!assigned) { this.c.error(e, "sync() waits, and its value is kept where it is awaited: const x = await sync(P1, local.camera.x), or x = await sync(…) on a line of its own."); return null; } + if (!this.inAsync(e)) { this.c.error(e, "sync() waits, and a program waits in an async function: program(async () => { … await sync(P1, local.camera.x); … })."); return null; } + if (this.inCallback > 0) { this.c.error(e, "sync() inside a function given to an array method: the method is one loop within the frame. A for…of over the same list can await between its turns."); return null; } + if (e.arguments.length < 2 || e.arguments.length > 5) { this.c.error(e, "sync() takes the player whose values they are and one to four values: await sync(P1, local.camera.x), const [x, y] = await sync(P1, a, b)."); return null; } + const who = this.evaluate(e.arguments[0])?.value; + if (typeof who !== "number" || !Number.isInteger(who) || (who !== CURRENT_PLAYER && (who < 0 || who >= 8))) { this.c.error(e.arguments[0], "sync() sends from one player's computer: P1 … P8, or CurrentPlayer, known when the script is built."); return null; } + const values: NumExpr[] = []; + for (const a of e.arguments.slice(1)) { const v = this.num(a); if (!v) return null; values.push(v); } + const at = this.at(e), label = this.label(e); + const slots = values.map(() => this.syncSlots++); + // All sent on this frame: read here, together, and on their way together. + slots.forEach((slot, i) => this.emit({ kind: "syncSend", slot, player: who, value: values[i], at, label }, e)); + const arrived = (slot: number): NumExpr => ({ kind: "input", input: { source: "sync", slot, player: who }, at, label }); + // Nothing yet is 0 (cleared by syncSend) or 0xFFFFFFFF (MSQC's cell on a frame with nothing, as the chat's): read + // signed, anything below 1 or past what val carries. + const came = (slot: number): BoolExpr => ({ kind: "and", items: [ + { kind: "compare", op: ">=", left: arrived(slot), right: num(1), at, label }, + { kind: "compare", op: "<=", left: arrived(slot), right: num(1048575), at, label }, + ] }); + const out = slots.map((_, i) => this.newVar(values.length > 1 ? `(synced ${i + 1})` : "(synced)", "number", at)); + const got = slots.map((_, i) => this.newVar(`(sync ${i + 1} came)`, "boolean", at, { temp: true })); + out.forEach((v, i) => { this.emit({ kind: "declare", decl: v, init: num(0), at, label }, e); this.emit({ kind: "declare", decl: got[i], init: FALSE, at, label }, e); }); + const latch = (): Stmt[] => slots.map((slot, i) => ({ kind: "if", cond: came(slot), then: [ + { kind: "assign", target: out[i].id, value: { kind: "binary", op: "-", left: arrived(slot), right: num(1), at, label }, at, label }, + { kind: "assignBool", target: got[i].id, value: TRUE, at, label }, + ], at, label })); + const fact = (f: "slot" | "left"): NumExpr => ({ kind: "read", read: { source: "player", fact: f, player: who }, at, label }); + const waiting: BoolExpr = { kind: "and", items: [ + { kind: "or", items: got.map((g) => ({ kind: "not", expr: { kind: "var", id: g.id } }) as BoolExpr) }, + { kind: "compare", op: "==", left: fact("slot"), right: num(2), at, label }, + { kind: "compare", op: "!=", left: fact("left"), right: num(1), at, label }, + ] }; + for (const st of latch()) this.emit(st, e); + this.emit({ kind: "while", cond: waiting, body: [{ kind: "sleep", cycles: 1, at, label }, ...latch()], at, label }, e); + return out; + } + + /** `local.selectionCount` (B5): this computer's selection slots that hold a unit, counted. */ + private selectionCount(e: TS.Expression): NumExpr { + const at = this.at(e), label = this.label(e); + let sum: NumExpr | null = null; + for (let i = 0; i < 12; i++) { + const cell: NumExpr = { kind: "tableRead", cell: { name: `local.selection[${i}]`, base: SELECTION_ADDRESS + i * 4, stride: 0, index: 0, width: 4 }, at, label }; + const one: NumExpr = { kind: "ternary", cond: { kind: "compare", op: "!=", left: cell, right: num(0), at, label }, whenTrue: num(1), whenFalse: num(0), at, label }; + sum = sum ? { kind: "binary", op: "+", left: sum, right: one, at, label } : one; + } + return this.mark(sum!, e); + } + + /** + * `select(player, units)` (B5): what the player has selected becomes these units, at their computer and in the orders + * every computer runs for them — a unit, or a list of up to twelve written out. + */ + private selectStatement(e: TS.CallExpression) { + const { ts } = this; + if (e.arguments.length !== 2) { this.c.error(e, "select() takes the player and what they will have selected: select(P1, u) or select(P1, [a, b])."); return; } + const who = this.evaluate(e.arguments[0])?.value; + if (typeof who !== "number" || !Number.isInteger(who) || (who !== CURRENT_PLAYER && (who < 0 || who >= 8))) { this.c.error(e.arguments[0], "select() is for one player: P1 … P8, or CurrentPlayer, known when the script is built."); return; } + const given = this.unwrap(e.arguments[1]); + const written = ts.isArrayLiteralExpression(given) ? [...given.elements] : [given]; + if (written.length < 1 || written.length > 12) { this.c.error(given, "A player has one to twelve units selected: select(P1, [a, b, c])."); return; } + const units: UnitExpr[] = []; + for (const w of written) { + if (ts.isSpreadElement(w)) { this.c.error(w, "select() takes the units written out: select(P1, [a, b]), up to twelve."); return; } + const u = this.unitExpr(w); + if (!u) return; + units.push(u); + } + this.emit({ kind: "select", player: who, units, at: this.at(e), label: this.label(e) }, e); + } + + /** `Promise.all(…)`, `Promise.race(…)`, `Promise.any(…)`: which one, when the call is one of the language's own Promise. */ + private promiseCall(e: TS.Expression): "all" | "race" | "any" | undefined { + const { ts } = this; + if (!ts.isCallExpression(e) || !ts.isPropertyAccessExpression(e.expression) || !ts.isIdentifier(e.expression.expression) || e.expression.expression.text !== "Promise") return undefined; + if (this.gameDeclaration(e.expression.expression)) return undefined; + const name = e.expression.name.text; + return name === "all" || name === "race" || name === "any" ? name : undefined; + } + + /** Whether an expression's type is a promise: what an async function, sleep(), until() and Promise.all give. */ + private isPromise(e: TS.Expression): boolean { + const type = this.c.checker.getTypeAtLocation(e); + return this.awaited(type) !== type; + } + + /** Whether a node is inside an async function: `program(async () => …)`, an `async function`, an async arrow called where it is written. */ + private inAsync(node: TS.Node): boolean { + const { ts } = this; + for (let n = node.parent; n; n = n.parent) if (ts.isFunctionLike(n)) return !!ts.getModifiers(n as TS.FunctionLikeDeclaration)?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword); + // A listener at the top of a file is a program of its own, and an async one. + return ts.isExpressionStatement(this.body.plan.root); + } + + /** A promise as a statement on its own: in a program, what is not awaited never runs beside it. */ + private notAwaited(e: TS.CallExpression) { + const text = e.getText(); + const mode = this.promiseCall(e); + const what = this.isSleep(e) ? "sleep()" : this.isLibraryCall(e, "until") ? "until()" : mode ? `Promise.${mode}()` : `${this.unwrap(e.expression).getText()}()`; + if (this.inAsync(e)) this.c.error(e, `${what} gives a promise, and nothing waits for it here: await ${text}${mode || what === "sleep()" || what === "until()" ? "" : ` — or, to leave it running beside the program, start(${text})`}.`); + else this.c.error(e, `${what} waits, and a program waits in an async function: program(async () => { … await ${text}; … }).`); + } + + /** + * What is awaited, as statements of the program — or of a branch, inside Promise.all / race: sleep(), until(), an async + * call, an async arrow called where it is written, a Promise.all / race inside another, `….then(() => value)`. `into`: + * the result of a race, which the branch sets as it ends. + */ + private task(expr: TS.Expression, into?: { decl: VarDecl; kind: "number" | "boolean" }) { const { ts } = this; const e = this.unwrap(expr); - const h = this.evaluate(expr); - if (h) { this.hoistedStatement(e, h); return; } - if (ts.isBinaryExpression(e)) { - const op = e.operatorToken.kind; - // `i++, j--`, as a for's update has it: one after the other. - if (op === ts.SyntaxKind.CommaToken) { this.expressionStatement(e.left); this.expressionStatement(e.right); return; } - if (op === ts.SyntaxKind.EqualsToken && (ts.isArrayLiteralExpression(this.unwrap(e.left)) || ts.isObjectLiteralExpression(this.unwrap(e.left)))) { - // `[a, b] = [b, a]`, `({ x, y } = p)`: every value taken first, then every store. - const from = this.patternSource(e.right, e); - if (!from) return; - const stores: (() => void)[] = []; - if (this.assignPattern(this.unwrap(e.left), from, stores)) stores.forEach((store) => store()); + const valueless = (what: string) => { if (into) this.c.error(e, `${what} gives no value, and the value of this item is kept: give it one with .then(() => …).`); }; + const keep = (call: Call | undefined, node: TS.Node) => { + if (!call) return; + this.emit({ kind: "call", call, at: call.at, label: call.label }, node); + if (!into) return; + if (!call.result || call.result.kind !== into.kind) { this.c.error(node, `This item gives ${call.result ? `a ${call.result.kind}` : "no value"}, and what is kept from it is a ${into.kind}.`); return; } + const value = { kind: "var" as const, id: call.result.decl.id }; + this.emit(into.kind === "number" ? { kind: "assign", target: into.decl.id, value, at: call.at, label: call.label } : { kind: "assignBool", target: into.decl.id, value, at: call.at, label: call.label }, node); + }; + if (this.isTaskType(this.c.checker.getTypeAtLocation(e))) { + // `await t`, `await start(…)`: until the task has ended — and, when it ended on an error (B4), that error thrown here. + const t = this.num(e); + if (!t) return; + const at = this.at(e), label = this.label(e); + const h = this.newVar("(awaited)", "number", at); + this.emit({ kind: "declare", decl: h, init: t, at, label }, e); + const site = { h, watch: { kind: "block", body: [], at } as Stmt & { kind: "block" }, check: { kind: "block", body: [], at } as Stmt & { kind: "block" }, at, label }; + this.awaitedTasks.push(site); + this.emit(site.watch, e); + this.emit({ kind: "while", cond: { kind: "running", task: varRef(h), at }, body: [{ kind: "sleep", cycles: 1, at, label }], at, label }, e); + this.emit(site.check, e); + valueless("A task"); + return; + } + if (ts.isCallExpression(e)) { + if (this.isSleep(e)) { this.sleepStatement(e); valueless("sleep()"); return; } + if (this.isLibraryCall(e, "until")) { this.untilStatement(e); valueless("until()"); return; } + if (this.isLibraryCall(e, "sync")) { this.syncValues(e); return; } + const mode = this.promiseCall(e); + if (mode) { + if (mode === "all") valueless("Promise.all()"); + this.parallel(e, mode, mode === "all" ? undefined : into); return; } - if (op >= ts.SyntaxKind.FirstAssignment && op <= ts.SyntaxKind.LastAssignment) { - const member = this.unitMember(e.left); - if (member) { this.unitAssign(e, member, op); return; } - const cell = this.evaluate(e.left)?.value; - if (isTable(cell)) { this.tableAssign(e, cell, op); return; } - const left = this.unwrap(e.left); - if (ts.isPropertyAccessExpression(left) && left.name.text === "length") { - const b = this.arrayOf(left.expression); - if (b?.kind === "lists") { - // The rows that go give their blocks back first. - if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "An array's length takes = only: xs.length = 0 empties it."); return; } - for (const a of this.handlesOf(b)) if (!this.grows(a, e.left)) return; - const given = this.num(e.right); - if (!given) return; - const n = this.temp(given, e, true); - this.releaseRows(b, n, e); - for (const a of this.handlesOf(b)) this.emit({ kind: "setLength", array: a.id, value: n, at: this.at(e), label: this.label(e) }, e); - return; - } - if (b?.kind === "grid") { - // Whole rows: the flat array's length is the rows' by the cells of a row. - if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "An array's length takes = only: xs.length = 0 empties it."); return; } - if (b.offset) { this.c.error(e, `${b.name} is a part of a larger array of arrays; its length is the whole's to set.`); return; } - if (!this.grows(b.a, e.left)) return; - const rows = this.num(e.right); - if (rows) this.emit({ kind: "setLength", array: b.a.id, value: this.scaled(rows, b.dims.slice(1).reduce((n, d) => n * d, 1), null, e), at: this.at(e), label: this.label(e) }, e); - return; - } - const sized = b?.kind === "texts" ? b.rows : b; - if (sized?.kind === "records" || sized?.kind === "units") { - const b = sized; - if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "An array's length takes = only: xs.length = 0 empties it."); return; } - const value = this.num(e.right); - if (!value) return; - const n = this.temp(value, e, b.kind === "records" && !!b.shape); - if (b.kind === "records") this.releaseFrom(b, n, e); - for (const a of b.kind === "units" ? [b.ptr, b.epd, b.uid] : b.fields.values()) { a.dynamic = true; this.emit({ kind: "setLength", array: a.id, value: n, at: this.at(e), label: this.label(e) }, e); } - return; - } - if (b?.kind === "array") { - if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "An array's length takes = only: xs.length = 0 empties it."); return; } - if (!this.grows(b.a, e.left)) return; - const value = this.num(e.right); - if (value) this.emit({ kind: "setLength", array: b.a.id, value, at: this.at(e), label: this.label(e) }, e); - return; - } + if (ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === "then" && this.isPromise(e.expression.expression)) { + if (e.arguments.length !== 1) { this.c.error(e, ".then() takes one function: sleep(seconds(90)).then(() => false)."); return; } + const fn = this.unwrap(e.arguments[0]); + if ((ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) && fn.parameters.length) { + // `wave(n).then((cleared) => …)`: what the promise gives, in a variable the function's parameter is. + const before = e.expression.expression; + const kind = this.kindOf(this.c.checker.getTypeAtLocation(before)); + if (fn.parameters.length > 1 || (kind !== "number" && kind !== "boolean")) { this.c.error(fn, `The function given to .then() takes what the promise gives${kind ? "" : ", and this one gives nothing"}: wave(n).then((cleared) => …), for a promise of a number or a boolean.`); return; } + const at = this.at(before), label = this.label(before); + const value = this.newVar(`(${fn.parameters[0].name.getText()})`, kind, at, kind === "number" ? this.widthOf(this.c.checker.getTypeAtLocation(before)) : {}); + this.emit({ kind: "declare", decl: value, init: kind === "number" ? num(0) : FALSE, at, label }, before); + this.task(before, { decl: value, kind }); + keep(this.callback(e.arguments[0], "then", [{ kind: "var", v: value }], into?.kind ?? "any", e), e); + return; } - if (ts.isElementAccessExpression(left)) { - // `grid[y] = [0, 0, 0]`: the row's cells, every one read before any is stored. - const row = this.bindingOf(left); - if (row?.kind === "inner") { - // `buckets[i] = [a, b]`: the row starts over with these — what it held goes back first. (Worked out before that, so `[...]` of its own cells would be lost: a row is not assigned from itself.) + this.task(e.expression.expression); + keep(this.callback(e.arguments[0], "then", [], into?.kind ?? "any", e), e); + return; + } + const callee = this.unwrap(e.expression); + if (ts.isArrowFunction(callee) || ts.isFunctionExpression(callee)) { + if (e.arguments.length || callee.parameters.length) { this.c.error(e, "An async arrow called where it is written takes nothing: (async () => { … })()."); return; } + keep(this.callback(e.expression, "an async function", [], into?.kind ?? "any", e, true), e); + return; + } + } + if (into) { + const value = into.kind === "number" ? this.num(expr) : this.boolValue(expr); + if (!value) return; + const at = this.at(e), label = this.label(e); + this.emit(into.kind === "number" ? { kind: "assign", target: into.decl.id, value: value as NumExpr, at, label } : { kind: "assignBool", target: into.decl.id, value: value as BoolExpr, at, label }, e); + return; + } + this.expressionStatement(expr, true); + } + + /** + * `await Promise.all([…])` / `race` / `any`: each item a branch of its own, side by side (`parallel` in the IR). Nothing in + * a program fails, so the first branch to end is the first to succeed, and `any` is a race. + */ + private parallel(call: TS.CallExpression, mode: "all" | "race" | "any", into?: { decl: VarDecl; kind: "number" | "boolean" }, intos?: ({ decl: VarDecl; kind: "number" | "boolean" } | undefined)[]) { + const { ts } = this; + const name = `Promise.${mode}`; + if (this.inCallback > 0) { this.c.error(call, `${name}() inside a function given to an array method: the method is one loop within the frame.`); return; } + const list = call.arguments.length === 1 ? this.unwrap(call.arguments[0]) : undefined; + if (!list || !ts.isArrayLiteralExpression(list)) { this.c.error(call, `${name}() takes a list written where it is called: ${name}([wave(1), sleep(seconds(90))]).`); return; } + if (!list.elements.length) { this.c.error(list, mode === "all" ? "Promise.all([]) has nothing to wait for." : `${name}([]) never ends.`); return; } + if (intos && intos.length > list.elements.length) { this.c.error(call, `Promise.all() here has ${list.elements.length} item${list.elements.length === 1 ? "" : "s"}, so it gives ${list.elements.length} value${list.elements.length === 1 ? "" : "s"}, not ${intos.length}.`); return; } + const branches: Stmt[][] = []; + for (const [i, el] of list.elements.entries()) { + if (ts.isSpreadElement(el) || ts.isOmittedExpression(el)) { this.c.error(el, `Each item of ${name}() is written out: what runs side by side is settled when the script is built.`); return; } + branches.push(this.collect(() => this.task(el, intos ? intos[i] : into))); + } + this.emit({ kind: "parallel", mode: mode === "all" ? "all" : "race", branches, at: this.at(call), label: this.label(call) }, call); + } + + /** `const [a, b] = await Promise.all([…])`: each name the value of its branch, which the branch sets as it ends. */ + private allValues(pattern: TS.ArrayBindingPattern, call: TS.CallExpression, d: TS.VariableDeclaration) { + const { ts } = this; + const intos: ({ decl: VarDecl; kind: "number" | "boolean" } | undefined)[] = []; + const from: Binding[] = []; + for (const el of pattern.elements) { + if (ts.isOmittedExpression(el)) { intos.push(undefined); from.push({ kind: "value", value: undefined }); continue; } + if (el.dotDotDotToken || el.initializer || !ts.isIdentifier(el.name)) { this.c.error(el, "Each value of Promise.all() goes to a name of its own: const [a, b] = await Promise.all([…]), with a hole — [, b] — for one you do not keep."); return; } + const type = this.c.checker.getTypeAtLocation(el.name); + const kind = this.kindOf(type); + if (kind !== "number" && kind !== "boolean") { + const none = (type.flags & ts.TypeFlags.Void) !== 0; + this.c.error(el, none ? `${el.name.text} would be void: its item gives no value. Give it one with .then(() => …), or leave a hole for it — [, b].` : `${el.name.text} would be ${this.c.checker.typeToString(type)}: what Promise.all() gives a name here is a number or a boolean.`); + return; + } + const at = this.at(el), label = this.label(el); + const v = this.newVar(el.name.text, kind, at, kind === "number" ? this.widthOf(type) : {}); + this.emit({ kind: "declare", decl: v, init: kind === "number" ? num(0) : FALSE, at, label }, el); + intos.push({ decl: v, kind }); + from.push({ kind: "var", v }); + } + this.parallel(call, "all", undefined, intos); + this.bindPattern(pattern, d, from, this.scope); + } + + /** `const first = await Promise.race([…])`: the value of the branch that ends first, in a variable of its own. */ + private raceValue(e: TS.CallExpression, kind: "number" | "boolean"): NumExpr | BoolExpr | null { + const { ts } = this; + const mode = this.promiseCall(e)!; + if (mode === "all") { this.c.error(e, "Promise.all() gives a value for each item: take them apart where it is awaited — const [a, b] = await Promise.all([…])."); return null; } + let up: TS.Node = e.parent; + while (ts.isParenthesizedExpression(up)) up = up.parent; + if (!ts.isAwaitExpression(up)) { this.c.error(e, `Promise.${mode}() gives a promise: await it — const v = await Promise.${mode}([…]).`); return null; } + const at = this.at(e), label = this.label(e); + const t = this.newVar(`(Promise.${mode} result)`, kind, at, { temp: true }); + this.emit({ kind: "declare", decl: t, init: kind === "number" ? num(0) : FALSE, at, label }, e); + this.parallel(e, mode, { decl: t, kind }); + return kind === "number" ? varRef(t) : { kind: "var", id: t.id }; + } + + /** `start(…)`'s `{ max }`: how many of its tasks can run at once, 16 unless said. */ + private startMax(arg: TS.Expression | undefined): number | null { + if (!arg) return TASK_MAX; + const h = this.evaluate(arg); + const max = h && typeof h.value === "object" && h.value !== null ? (h.value as { max?: unknown }).max : undefined; + if (!h || (max !== undefined && (typeof max !== "number" || !Number.isInteger(max) || max < 1 || max > 255))) { + this.c.error(arg, "start()'s options are written out, as { max: 40 }: how many of this start's tasks can run at once, a whole number from 1 to 255."); + return null; + } + return (max as number | undefined) ?? TASK_MAX; + } + + /** `start(task, options?)` as a statement, or into `handle` — the variable that takes the number the task is known by. */ + private startCall(call: TS.CallExpression, handle?: VarDecl) { + if (call.arguments.length < 1 || call.arguments.length > 2) { this.c.error(call, "start() takes a task, and options if you like: start(wave(3)), start(async () => { … }, { max: 4 })."); return; } + const max = this.startMax(call.arguments[1]); + if (max === null) return; + this.startTask(call.arguments[0], call, max, handle); + } + + /** `const t = start(…)`: the task's number, in a variable of its own. */ + private startValue(e: TS.CallExpression): NumExpr { + const at = this.at(e), label = this.label(e); + const t = this.newVar("(task)", "number", at, { temp: true }); + this.emit({ kind: "declare", decl: t, init: num(0), at, label }, e); + this.startCall(e, t); + return varRef(t); + } + + /** + * A task, started here and left running (`start` in the IR): `start(wave(n))` — the async function's body inlined for + * this start alone, its parameters copies of the arguments as they are now — or `start(async () => { … })`. The body's + * variables and arrays are the task's: a cell for each of its slots, so tasks of the same start keep theirs apart. + */ + private startTask(expr: TS.Expression, node: TS.Node, max: number, handle?: VarDecl) { + const { ts } = this; + const e = this.unwrap(expr); + const task = ++this.taskCount; + const refuse = () => this.c.error(e, `start() takes a call of an async function — start(wave(3)) — or an async function: start(async () => { … }). For anything else, start an async function that awaits it: start(async () => { await ${e.getText()}; }).`); + let call: Call | undefined; + const fn = ts.isCallExpression(e) ? this.unwrap(e.expression) : e; + const caught = (ts.isArrowFunction(fn) || ts.isFunctionExpression(fn)) ? this.loopCapture(fn, node) : undefined; + if (caught) { this.c.error(caught, `This task reads ${caught.text}, which the loop around the start() changes: JavaScript gives each turn its own, and here the task would find it as it is when it reads it. Hand it over as an argument instead — an async function that takes it, start(f(${caught.text})).`); return; } + if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) { + if (e.parameters.length) { this.c.error(e, "The function start() runs takes nothing: start(async () => { … })."); return; } + if (!ts.isCallExpression(node)) { refuse(); return; } + call = this.callback(expr, "start", [], "any", node, true); + } else if (ts.isCallExpression(e) && this.isPromise(e)) { + // The call's arguments are worked out at the start: the parameters are copies, whatever the function does with them. + this.copyArgsAt = this.inlineDepth; + let stmts: Stmt[]; + try { stmts = this.collect(() => this.task(e)); } finally { this.copyArgsAt = -1; } + // Only a call of a function of the script is a body to run as a task; sleep(), until(), Promise.all and .then() are not. + call = stmts.length === 1 && stmts[0].kind === "call" && !stmts[0].call.fn ? stmts[0].call : undefined; + if (!call) { if (stmts.length) refuse(); return; } + } else { refuse(); return; } + if (!call) return; + this.claimTask(call, task); + this.emit({ kind: "start", task, max, call, ...(handle ? { handle: handle.id } : {}), at: this.at(node), label: this.label(node) }, node); + } + + /** + * A task's body may use the program's variables, its own, and those of the tasks it starts — not those of a task it is + * started from: which slot of that one's is in its cells is settled when that one runs, not when this one does. + */ + private checkTasks(program: Program) { + const owner = new Map(); + for (const d of programDeclarations(program)) if (d.task !== undefined) owner.set(d.id, { task: d.task, name: d.name }); + for (const a of this.arrays) if (a.task !== undefined) owner.set(a.id, { task: a.task, name: a.name }); + eachStart(program.body, (st) => { + const mine = new Set([st.task]); + eachStart(st.call.body, (inner) => mine.add(inner.task)); + let found: string | undefined; + const visit = (root: unknown): void => { + if (found || !root || typeof root !== "object") return; + if (Array.isArray(root)) { root.forEach(visit); return; } + const o = root as Record; + for (const key of ["id", "target", "array", "handle"]) { + const v = o[key]; + const of = typeof v === "string" ? owner.get(v) : undefined; + if (of && !mine.has(of.task)) { found = of.name; return; } + } + Object.values(o).forEach(visit); + }; + visit(st.call.body); + const node = this.nodes.get(st); + if (found && node) this.c.error(node, `This task uses ${found}, a variable of the task it is started from: which of that task's copies it would find is not settled. Hand it over as an argument instead — start(f(${found})).`); + }); + } + + /** A variable of the program, declared inside a loop around `at`, that `fn` reads: what a task must not close over. */ + private loopCapture(fn: TS.ArrowFunction | TS.FunctionExpression, at: TS.Node): TS.Identifier | undefined { + const { ts } = this; + const loops: TS.Node[] = []; + for (let n: TS.Node | undefined = at.parent; n; n = n.parent) { + if (ts.isIterationStatement(n, false)) loops.push(n); + if (ts.isFunctionLike(n)) break; + } + if (!loops.length) return undefined; + let found: TS.Identifier | undefined; + const visit = (n: TS.Node): void => { + if (found) return; + if (ts.isIdentifier(n)) { + const d = this.c.checker.getSymbolAtLocation(n)?.valueDeclaration; + if (d && ts.isVariableDeclaration(d) && !(d.pos >= fn.pos && d.end <= fn.end) && loops.some((l) => d.pos >= l.pos && d.end <= l.end) && this.bindingOf(n)?.kind === "var") found = n; + } + ts.forEachChild(n, visit); + }; + visit(fn.body); + return found; + } + + /** + * `for (const w of waves(…))`, waves a generator (`function*`, or `async function*` with `for await`): the generator's + * body inlined, and at each `yield` the loop's body, its variable what was yielded — a value the script knows as it + * is, anything else in a variable of the turn. `continue` goes on after the yield; `break` leaves the generator. + */ + /** + * The generator a for…of walks, when it walks one: a generator function of the program or at the top of a file, a + * generator method (`squad.alive()`), or what an instance's `*[Symbol.iterator]()` gives (`for (const u of squad)`). + */ + private generatorOf(expr: TS.Expression): { fn: TS.FunctionDeclaration | TS.MethodDeclaration; target: Body; name: string; self?: Binding; call: TS.Expression } | null | undefined { + const { ts } = this; + const e = this.unwrap(expr); + if (ts.isCallExpression(e) && ts.isIdentifier(e.expression)) { + const own = this.gameDeclaration(e.expression); + if (own && ts.isFunctionDeclaration(own) && own.asteriskToken) return { fn: own, target: this.body, name: own.name?.text ?? "generator", call: e }; + const decl = declarationOf(ts, this.c.checker, e.expression); + if (decl && isScriptFunction(ts, decl) && decl.asteriskToken && !this.body.plan.game.has(decl)) { + const target = this.c.functionBody?.(decl); + if (!target) return undefined; + return { fn: decl, target, name: decl.name!.text, call: e }; + } + return undefined; + } + if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(this.unwrap(e.expression))) { + const callee = this.unwrap(e.expression) as TS.PropertyAccessExpression; + const found = this.memberAt(callee, (m, name) => ts.isMethodDeclaration(m) && !!m.asteriskToken && this.memberKey(m.name) === name, true); + if (!found) return found; + if (found.virtual) { this.c.error(e, `${found.name} is a generator of several classes: which one runs is the game's, and a generator is walked where it is written. Walk it on an instance of one class.`); return null; } + const m = found.member as TS.MethodDeclaration; + return { fn: m, target: this.classBodies.get(m.parent as TS.ClassDeclaration) ?? this.body, name: found.name, ...(found.self ? { self: found.self } : {}), call: e }; + } + // `for (const x of thing)` of an instance whose class has `*[Symbol.iterator]()`. + const type = this.c.checker.getTypeAtLocation(expr); + const cls = this.classOfType(this.c.checker.getNonNullableType(type)) ?? this.instanceClasses(type)?.[0]; + if (!cls) return undefined; + const iterator = this.memberOf(cls, (m) => ts.isMethodDeclaration(m) && !!m.asteriskToken && ts.isComputedPropertyName(m.name) && m.name.expression.getText() === "Symbol.iterator") as TS.MethodDeclaration | undefined; + if (!iterator) return undefined; + const self = this.objectOf(expr); + if (self?.kind !== "record") return undefined; + if (self.ref && self.ref.classes.length > 1 && new Set(self.ref.classes.map((c) => this.memberOf(c, (m) => m === iterator || (ts.isMethodDeclaration(m) && ts.isComputedPropertyName(m.name) && m.name.expression.getText() === "Symbol.iterator")))).size > 1) { + this.c.error(expr, "This may be an instance of several classes, each walked its own way: which one is the game's, and a generator is walked where it is written. Walk an instance of one class."); + return null; + } + return { fn: iterator, target: this.classBodies.get(iterator.parent as TS.ClassDeclaration) ?? this.body, name: `${this.className(iterator.parent as TS.ClassDeclaration)}[Symbol.iterator]`, self, call: expr }; + } + + private generatorLoop(s: TS.ForOfStatement, decl: TS.VariableDeclaration, ctx: Ctx): boolean { + const { ts } = this; + const source = this.generatorOf(s.expression); + if (source === null) return true; + if (!source) return false; + const fn = source.fn; + let returns: TS.Node | undefined; + const look = (n: TS.Node): void => { if (returns || ts.isFunctionLike(n)) return; if (ts.isReturnStatement(n)) { returns = n; return; } ts.forEachChild(n, look); }; + look(s.statement); + if (returns) { this.c.error(returns, "return inside a for…of over a generator is not supported yet: break out of the loop, and return after it."); return true; } + const loopBody = this.body, loopScope = this.scope; + const at = this.at(s), label = this.label(s); + const stop = this.newVar("(loop left)", "boolean", at); + this.emit({ kind: "declare", decl: stop, init: FALSE, at, label }, s); + // A generator with a try in it (B4): an error of the loop's body is not the generator's to catch. It is put aside, the + // generator leaves through its finally blocks, and the error is thrown on after it, as JavaScript's return() does. + let tries = false; + const look2 = (n: TS.Node): void => { if (tries || (n !== fn && ts.isFunctionLike(n))) return; if (ts.isTryStatement(n)) { tries = true; return; } ts.forEachChild(n, look2); }; + look2(fn); + const pending = tries ? this.newVar("(loop threw)", "boolean", at) : undefined; + const kept = tries ? (this.flightOf(s), this.keepFlight(at, label)) : undefined; + if (pending) this.emit({ kind: "declare", decl: pending, init: FALSE, at, label }, s); + const kind = this.kindOf(this.c.checker.getTypeAtLocation(decl.name)); + const handler = (y: TS.YieldExpression) => { + // `yield* other()`: the other generator walked here, its yields this loop's too; then, if the loop was left, this one leaves. + if (y.asteriskToken) { + const inner = y.expression ? this.generatorOf(y.expression) : undefined; + if (inner === null) return; + if (!inner) { + // `yield* [a, b]`, `yield* xs`: each item yielded in turn. + const over = y.expression ? this.overOf(y.expression) : undefined; + if (!over || !y.expression) { this.c.error(y, "yield* walks another generator, or a list: yield* waves(), yield* [1, 2], yield* xs."); return; } + this.loopOf(over, y, "(item)", (item) => { deliver(item, y); }); + return; + } + const call = this.inline(inner.call, inner.fn.parameters, inner.fn.body, inner.target, inner.name, inner.fn, { generator: true, nothing: true, ...(inner.self ? { self: inner.self } : {}), ...(ts.isCallExpression(inner.call) ? {} : { args: [] }) }); + if (call) this.emit({ kind: "call", call, at: call.at, label: call.label }, y); + leaveIfLeft(y); + return; + } + if (!y.expression) { this.c.error(y, "yield gives the loop a value here: yield 3, yield { count: 4 }."); return; } + // The value, worked out where the yield is. + const h = this.evaluate(y.expression); + let item: Binding | null = null; + if (h && !isGameValue(h.value)) item = { kind: "value", value: h.value }; + else if (kind) { + const v = this.newVar(ts.isIdentifier(decl.name) ? decl.name.text : "(item)", kind, this.sourceOf(decl.name), kind === "number" ? this.widthOf(this.c.checker.getTypeAtLocation(decl.name)) : {}); + const init = kind === "number" ? this.num(y.expression) : kind === "unit" ? this.unitExpr(y.expression) : this.boolValue(y.expression); + if (!init) return; + this.emit({ kind: "declare", decl: v, init, at: this.at(y), label: this.label(y) }, y); + item = { kind: "var", v }; + } else if (ts.isObjectLiteralExpression(this.unwrap(y.expression))) { + item = this.declareRecord(ts.isIdentifier(decl.name) ? decl.name.text : "(item)", this.unwrap(y.expression) as TS.ObjectLiteralExpression, this.c.checker.getTypeAtLocation(decl.name), y); + } else item = this.bindingOf(y.expression) ?? null; + if (!item) { this.c.error(y.expression, "A generator walked by a program yields numbers, booleans, units, records of them, or values the script knows."); return; } + deliver(item, y); + }; + /** The loop's body for one item, where the yield is; then, if the loop was left, the generator leaves. */ + const deliver = (item: Binding, y: TS.Node) => { + // The loop's body, where the loop is: its scope, its file, and a yield in it is the generator's around the loop, if any. + const saved = this.enterBody(loopBody); + const inner = this.scope; + const mine = this.yields.pop()!; + const scope = new Scope(loopScope); + this.scope = scope; + let body: Stmt[] = []; + try { + const bound = ts.isIdentifier(decl.name) ? (scope.bind(decl, item), true) : this.bindPattern(decl.name, decl, item, scope); + if (bound) body = this.collect(() => this.block([s.statement], { fn: ctx.fn, canBreak: true, canContinue: true, breakFlag: stop }, new Scope(scope))); + } finally { + this.yields.push(mine); + this.scope = inner; + this.leaveBody(saved); + } + const where = this.at(y), what = this.label(y); + if (pending && kept) body = [{ kind: "try", body, catch: [kept.save, { kind: "assignBool", target: stop.id, value: TRUE, at: where, label: what }, { kind: "assignBool", target: pending.id, value: TRUE, at: where, label: what }], at: where, label: what }]; + this.emit({ kind: "do", body, cond: FALSE, at: where, label: what, condLabel: what }, y); + leaveIfLeft(y); + }; + const leaveIfLeft = (y: TS.Node) => { + const where = this.at(y), what = this.label(y); + // The loop was left: the generator returns, through the finally blocks around this yield. + const through = this.ctxNow.fn?.finallies ?? []; + const outer = this.ctxNow.fn ? { ...this.ctxNow.fn, finallies: [] } : undefined; + const leave = this.collect(() => { for (let i = through.length - 1; i >= 0; i--) this.finallyBody(through[i], outer); }); + this.emit({ kind: "if", cond: { kind: "var", id: stop.id }, then: [...leave, { kind: "return", at: where, label: what }], at: where, label: what }, y); + }; + this.yields.push(handler); + try { + const call = this.inline(source.call, fn.parameters, fn.body, source.target, source.name, fn, { generator: true, nothing: true, ...(source.self ? { self: source.self } : {}), ...(ts.isCallExpression(source.call) ? {} : { args: [] }) }); + if (call) this.emit({ kind: "call", call, at: call.at, label: call.label }, s); + if (call && pending && kept) this.emit({ kind: "if", cond: { kind: "var", id: pending.id }, then: [kept.restore, { kind: "throw", rethrow: true, at, label }], at, label }, s); + } finally { + this.yields.pop(); + } + return true; + } + + /** `yield v;` in a generator a for…of walks: the loop's body, there. */ + private yieldStatement(y: TS.YieldExpression) { + const handler = this.yields[this.yields.length - 1]; + if (!handler) { this.c.error(y, "yield is for a generator that a for…of walks: for (const w of waves()) { … }."); return; } + handler(y); + } + + /** `t.cancel()`: the task stops where it is. */ + private cancelTask(e: TS.CallExpression, receiver: TS.Expression) { + if (e.arguments.length) { this.c.error(e, "cancel() takes nothing."); return; } + const task = this.num(receiver); + if (task) this.emit({ kind: "cancel", task, at: this.at(e), label: this.label(e) }, e); + } + + private expressionStatement(expr: TS.Expression, awaited = false) { + const { ts } = this; + let bare = expr; + while (ts.isParenthesizedExpression(bare)) bare = bare.expression; + if (this.fieldWrite(bare, () => this.expressionStatement(expr, awaited))) return; + if (ts.isAwaitExpression(bare)) { this.task(bare.expression); return; } + if (ts.isYieldExpression(bare)) { this.yieldStatement(bare); return; } + // `void wave(3)`: started, and nothing waits for it — what JavaScript does with a promise nobody keeps. + if (ts.isVoidExpression(bare) && this.isPromise(bare.expression)) { this.startTask(bare.expression, bare, TASK_MAX); return; } + if (ts.isCallExpression(bare) && this.isLibraryCall(bare, "start")) { this.startCall(bare); return; } + if (ts.isCallExpression(bare) && this.isLibraryCall(bare, "lines")) { this.uiLines(bare); return; } + if (ts.isCallExpression(bare) && isOnCall(ts, this.c.checker, bare)) { this.onEvent(bare); return; } + if (ts.isCallExpression(bare) && ts.isPropertyAccessExpression(bare.expression) && bare.expression.name.text === "cancel" && this.isTaskType(this.c.checker.getTypeAtLocation(bare.expression.expression))) { this.cancelTask(bare, bare.expression.expression); return; } + // A promise nothing waits for. sleep() in a function that is not async is the language before 4: it pauses there. + // A Task a function gives (`every(30, f)`, which starts one) runs beside the program: nothing is to wait for it. + if (!awaited && ts.isCallExpression(bare) && this.isPromise(bare) && !(this.isSleep(bare) && !this.inAsync(bare)) && !this.isTaskType(this.c.checker.getTypeAtLocation(bare))) { this.notAwaited(bare); return; } + const e = this.unwrap(expr); + const h = this.evaluate(expr); + if (h) { this.hoistedStatement(e, h); return; } + if (ts.isBinaryExpression(e)) { + const op = e.operatorToken.kind; + // `i++, j--`, as a for's update has it: one after the other. + if (op === ts.SyntaxKind.CommaToken) { this.expressionStatement(e.left); this.expressionStatement(e.right); return; } + if (op === ts.SyntaxKind.EqualsToken && (ts.isArrayLiteralExpression(this.unwrap(e.left)) || ts.isObjectLiteralExpression(this.unwrap(e.left)))) { + // `[a, b] = [b, a]`, `({ x, y } = p)`: every value taken first, then every store. + const from = this.patternSource(e.right, e); + if (!from) return; + const stores: (() => void)[] = []; + if (this.assignPattern(this.unwrap(e.left), from, stores)) stores.forEach((store) => store()); + return; + } + if (op >= ts.SyntaxKind.FirstAssignment && op <= ts.SyntaxKind.LastAssignment) { + const member = this.unitMember(e.left); + if (member) { this.unitAssign(e, member, op); return; } + const cell = this.evaluate(e.left)?.value; + if (isTable(cell)) { this.tableAssign(e, cell, op); return; } + const left = this.unwrap(e.left); + if (ts.isPropertyAccessExpression(left) && left.name.text === "length") { + const b = this.arrayOf(left.expression); + if (b?.kind === "lists") { + // The rows that go give their blocks back first. + if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "An array's length takes = only: xs.length = 0 empties it."); return; } + for (const a of this.handlesOf(b)) if (!this.grows(a, e.left)) return; + const given = this.num(e.right); + if (!given) return; + const n = this.temp(given, e, true); + this.releaseRows(b, n, e); + for (const a of this.handlesOf(b)) this.emit({ kind: "setLength", array: a.id, value: n, at: this.at(e), label: this.label(e) }, e); + return; + } + if (b?.kind === "grid") { + // Whole rows: the flat array's length is the rows' by the cells of a row. + if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "An array's length takes = only: xs.length = 0 empties it."); return; } + if (b.offset) { this.c.error(e, `${b.name} is a part of a larger array of arrays; its length is the whole's to set.`); return; } + if (!this.grows(b.a, e.left)) return; + const rows = this.num(e.right); + if (rows) this.emit({ kind: "setLength", array: b.a.id, value: this.scaled(rows, b.dims.slice(1).reduce((n, d) => n * d, 1), null, e), at: this.at(e), label: this.label(e) }, e); + return; + } + const sized = b?.kind === "texts" ? b.rows : b; + if (sized?.kind === "records" || sized?.kind === "units") { + const b = sized; + if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "An array's length takes = only: xs.length = 0 empties it."); return; } + const value = this.num(e.right); + if (!value) return; + const n = this.temp(value, e, b.kind === "records" && !!b.shape); + if (b.kind === "records") this.releaseFrom(b, n, e); + for (const a of b.kind === "units" ? [b.ptr, b.epd, b.uid] : b.fields.values()) { a.dynamic = true; this.emit({ kind: "setLength", array: a.id, value: n, at: this.at(e), label: this.label(e) }, e); } + return; + } + if (b?.kind === "array") { + if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "An array's length takes = only: xs.length = 0 empties it."); return; } + if (!this.grows(b.a, e.left)) return; + const value = this.num(e.right); + if (value) this.emit({ kind: "setLength", array: b.a.id, value, at: this.at(e), label: this.label(e) }, e); + return; + } + } + if (ts.isElementAccessExpression(left)) { + // `grid[y] = [0, 0, 0]`: the row's cells, every one read before any is stored. + const row = this.bindingOf(left); + if (row?.kind === "inner") { + // `buckets[i] = [a, b]`: the row starts over with these — what it held goes back first. (Worked out before that, so `[...]` of its own cells would be lost: a row is not assigned from itself.) if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "A row takes = only."); return; } const inner = this.innerOf(row.lists, row.index, e); this.emit({ kind: "declareArray", array: inner.id, init: [], at: this.at(e), label: this.label(e) }, e); @@ -4258,10 +6808,18 @@ export class Structured { if (from) this.emit({ kind: "assign", target: rowVar.place.id, value: from, at: this.at(e), label: this.label(e) }, e); return; } + if (!target && (() => { const b = this.bindingOf(e.left); return b?.kind === "maybe"; })()) { this.assignMaybe(e, this.bindingOf(e.left) as Extract, op); return; } if (!target) { const b = this.bindingOf(e.left); if (b?.kind === "array") { this.c.error(e.left, `An array is assigned cell by cell: ${b.a.name}[i] = 3.`); return; } - if (b?.kind === "record") this.c.error(e.left, "A record is assigned field by field: p.lives = 3."); + // `p = p.add(q)`, `r = { x: 1, y: 2 }`, `r = c ? a : b` (B9): a record, or an instance of a class nothing changes in + // place, given whole is a copy of its fields. + const given = b?.kind === "record" && b.cls ? this.instanceClasses(this.c.checker.getTypeAtLocation(e.right)) : null; + if (b?.kind === "record" && !b.truth && op === ts.SyntaxKind.EqualsToken && (!b.cls || (this.isValueClass(b.cls) && given?.length === 1 && given[0] === b.cls))) { this.assignWhole(b, e.right, e); return; } + // `boss = other` of an instance: which instance it is is the game's, so its class is kept by reference. + const wanted = b?.kind === "record" && b.cls ? this.instanceClasses(this.c.checker.getTypeAtLocation(e.left)) : null; + if (wanted && this.needRef(wanted)) this.c.error(e.left, "This variable is given another instance: its class is kept by reference."); + else if (b?.kind === "record") this.c.error(e.left, "A record is assigned field by field: p.lives = 3."); else if ((ts.isPropertyAccessExpression(this.unwrap(e.left)) || ts.isElementAccessExpression(this.unwrap(e.left))) && this.evaluate(e.left)) this.c.error(e.left, "This object is computed when the script is built. Declare it with let inside the program to make it a record of variables."); else this.c.error(e.left, "Only the program's let variables can be assigned."); return; @@ -4317,6 +6875,16 @@ export class Structured { this.emit({ kind: "store", array: el.a.id, index: el.index, value: this.mark({ kind: "binary", op, left: now, right: num(1), at: this.at(e), label: this.label(e) }, e), at: this.at(e), label: this.label(e) }, e); return; } + // `count!++` of a number or nothing (B9): the value moves; whether there is one stays. + const maybe = this.bindingOf(e.operand); + if (maybe?.kind === "maybe" && (maybe.value.kind === "var" || maybe.value.kind === "cell")) { + const now = this.valueOf(maybe.value, "number", e) as NumExpr | null; + if (!now) return; + const value = this.mark({ kind: "binary", op, left: now, right: num(1), at: this.at(e), label: this.label(e) }, e); + if (maybe.value.kind === "var") this.emit({ kind: "assign", target: maybe.value.v.id, value, at: this.at(e), label: this.label(e) }, e); + else this.emit({ kind: "store", array: maybe.value.a.id, index: maybe.value.index, value, at: this.at(e), label: this.label(e) }, e); + return; + } const target = this.varOf(e.operand); if (!target || target.kind !== "number") { this.c.error(e, "++ / -- apply to number variables."); return; } const value = this.mark({ kind: "binary", op: e.operator === ts.SyntaxKind.PlusPlusToken ? "+" : "-", left: varRef(target), right: num(1), at: this.at(e), label: this.label(e) }, e); @@ -4335,11 +6903,13 @@ export class Structured { if (method !== undefined) { if (method) this.emit({ kind: "call", call: method, at: method.at, label: method.label }, e); return; } if (ts.isIdentifier(e.expression)) { const decl = this.gameDeclaration(e.expression); - if (decl) { - if (ts.isFunctionDeclaration(decl)) { const call = this.inline(e, decl.parameters, decl.body, this.body, decl.name?.text, decl); if (call) this.emit({ kind: "call", call, at: call.at, label: call.label }, e); return; } - this.c.error(e, `${e.expression.text} is not a function.`); - return; - } + const call = this.userCall(e); + if (call !== undefined) { if (call) this.emit({ kind: "call", call, at: call.at, label: call.label }, e); return; } + if (decl) { this.c.error(e, `${e.expression.text} is not a function.`); return; } + } else if (!ts.isPropertyAccessExpression(e.expression)) { + // `handlers[i](p)`, `make()()` (B3): a function the program keeps, called through its number. + const call = this.userCall(e); + if (call !== undefined) { if (call) this.emit({ kind: "call", call, at: call.at, label: call.label }, e); return; } } if (ts.isPropertyAccessExpression(e.expression)) { const method = e.expression.name.text; @@ -4349,7 +6919,7 @@ export class Structured { if (over) { this.c.error(e, `${this.overName(over)}.${method}(…) is a value: use it in an if or store it.`); return; } } if (LIST_MAKERS.has(method) && this.makesList(e)) { this.madeList(e, undefined, e); return; } - const list = this.arrayOf(e.expression.expression); + const list = this.arrayOf(e.expression.expression) ?? this.listHere(e.expression.expression); if (list?.kind === "grid") { this.gridCall(e, list, method); return; } if (list?.kind === "lists") { this.listsCall(e, list, method); return; } if (list?.kind === "array") { this.arrayCall(e, list.a, e.expression.name.text, "statement"); return; } @@ -4372,14 +6942,18 @@ export class Structured { if (made) { this.keyedCall(e, made, e.expression.name.text, "statement"); return; } const member = this.unitMember(e.expression); if (member) { this.unitCall(e, member); return; } + // `ops.add(1, 2)`, `this.onDone()` (B3): a function kept in a field. + const call = this.userCall(e); + if (call !== undefined) { if (call) this.emit({ kind: "call", call, at: call.at, label: call.label }, e); return; } } if (this.isLibraryCall(e, "random")) { this.c.error(e, "random() does nothing on its own; test it in an if, or assign it to a variable."); return; } - if (this.isLibraryCall(e, "sleep")) { this.sleepStatement(e); return; } + if (this.isSleep(e)) { this.sleepStatement(e); return; } if (this.isLibraryCall(e, "rose") || this.isLibraryCall(e, "once")) { this.c.error(e, "rose() / once() are conditions: test them in an if."); return; } if (this.isLibraryCall(e, "shared")) { this.c.error(e, "shared() goes on a declaration: let total = shared(0)."); return; } if (this.isLibraryCall(e, "print")) { this.printStatement(e); return; } + if (this.isLibraryCall(e, "select")) { this.selectStatement(e); return; } if (this.isLibraryCall(e, "centerLocation")) { this.centerLocation(e); return; } - if (this.isLibraryCall(e, "keyPressed") || this.isLibraryCall(e, "clicked") || this.isLibraryCall(e, "mouse") || this.isLibraryCall(e, "chatted")) { this.c.error(e, "This asks what a player did and does nothing on its own: test it in an if, or keep it in a const."); return; } + if (this.isLibraryCall(e, "keyPressed") || this.isLibraryCall(e, "keyHeld") || this.isLibraryCall(e, "clicked") || this.isLibraryCall(e, "mouse") || this.isLibraryCall(e, "chatted")) { this.c.error(e, "This asks what a player did and does nothing on its own: test it in an if, or keep it in a const."); return; } const callee = this.evaluate(e.expression)?.value; if (isGameFunction(callee)) { const call = this.gameCall(e, callee); if (call) this.emit({ kind: "call", call, at: call.at, label: call.label }, e); return; } if (isReader(callee) || (isBuilder(callee) && callee.kind === "condition" && READ_ARITY.get(callee.ident) === e.arguments.length)) { this.c.error(e, "This reads a value and does nothing on its own: assign it to a variable, or compare it in an if."); return; } @@ -4394,6 +6968,11 @@ export class Structured { this.c.error(e, "This is a condition; test it in an if or a while."); return; } + // `xs.flatMap((x) => …)`: a method of the program's that takes a function, which this one is not. + if (ts.isPropertyAccessExpression(e.expression) && e.arguments.some((a) => ts.isArrowFunction(this.unwrap(a)) || ts.isFunctionExpression(this.unwrap(a)))) { + this.c.error(e, `${e.expression.name.text}() is not one of the methods a program has that take a function: forEach, map, filter, find, findIndex, findLast, findLastIndex, some, every, reduce and sort.`); + return; + } this.notConstant(e, "A call's arguments"); } @@ -4401,8 +6980,13 @@ export class Structured { /** A condition known when the script is built (a hoisted boolean, number or text): true, false, or undefined when it must be tested in the game. */ private knownCondition(expr: TS.Expression): boolean | undefined { + const narrowed = this.knownTypeof(expr) ?? this.knownIn(expr); + if (narrowed !== undefined) return narrowed; + // A function known when the script is built is there (B3); an optional one left out is undefined, below. + if (this.fnValueOf(expr)) return true; const h = this.evaluate(expr); if (!h) return undefined; + if (h.value === undefined || h.value === null) return false; const v = h.value; if (typeof v === "boolean") return v; if (typeof v === "number") return v !== 0; @@ -4410,6 +6994,51 @@ export class Structured { return undefined; } + /** + * `typeof shape === "number"`: known when the script is built, whatever `shape` is bound to — a value known then + * (its own typeof), or a variable of the program (its kind). So a function written for two kinds of argument, as + * TypeScript narrows them, is inlined with only the side its argument takes (`fog.draw(P1, x, y, 5)` and + * `fog.draw(P1, x, y, heart)` are one function). + */ + private knownTypeof(expr: TS.Expression): boolean | undefined { + const { ts } = this; + const e = this.unwrap(expr); + if (!ts.isBinaryExpression(e)) return undefined; + const op = e.operatorToken.kind; + const equal = op === ts.SyntaxKind.EqualsEqualsEqualsToken || op === ts.SyntaxKind.EqualsEqualsToken; + if (!equal && op !== ts.SyntaxKind.ExclamationEqualsEqualsToken && op !== ts.SyntaxKind.ExclamationEqualsToken) return undefined; + const left = this.unwrap(e.left), right = this.unwrap(e.right); + const [of, word] = ts.isTypeOfExpression(left) && ts.isStringLiteralLike(right) ? [left, right.text] : ts.isTypeOfExpression(right) && ts.isStringLiteralLike(left) ? [right, left.text] : [null, ""]; + if (!of) return undefined; + const kind = this.typeofOf(of.expression); + return kind === undefined ? undefined : (kind === word) === equal; + } + + /** + * `"brushes" in shape`: known when the script is built when `shape` is a value known then — the other way TypeScript + * narrows a union of objects (a picture or a video). A variable of the program has no fields to ask about. + */ + private knownIn(expr: TS.Expression): boolean | undefined { + const { ts } = this; + const e = this.unwrap(expr); + if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.InKeyword) return undefined; + const key = this.unwrap(e.left); + if (!ts.isStringLiteralLike(key)) return undefined; + const b = this.bindingOf(e.right); + const v = b?.kind === "value" ? b.value : b ? undefined : this.evaluate(e.right)?.value; + return typeof v === "object" && v !== null && !isGameValue(v) ? key.text in v : undefined; + } + + /** What `typeof` would say of an expression, when the compiler knows: a value known when the script is built, or a binding of the program. */ + private typeofOf(expr: TS.Expression): string | undefined { + const b = this.bindingOf(expr); + if (b?.kind === "value") return typeof b.value; + if (b?.kind === "var") return b.v.kind === "text" ? "string" : b.v.kind === "unit" ? "object" : b.v.kind; + if (b) return "object"; + const h = this.evaluate(expr); + return h && !isGameValue(h.value) ? typeof h.value : undefined; + } + private ifStatement(s: TS.IfStatement, ctx: Ctx) { const known = this.knownCondition(s.expression); if (known !== undefined) { @@ -4424,6 +7053,14 @@ export class Structured { if (s.elseStatement) this.statement(s.elseStatement, ctx); return; } + // Two numbers known once compiled — a parameter of an inlined function bound to a build-time value, compared with + // one (`if (size == 3)` in a library's function): only the side that runs is compiled, as for a known condition. + const folded = foldedCompare(cond); + if (folded !== undefined) { + const live = folded ? s.thenStatement : s.elseStatement; + if (live) this.statement(live, ctx); + return; + } const then = this.sub(s.thenStatement, ctx); const otherwise = s.elseStatement ? this.sub(s.elseStatement, ctx) : undefined; this.emit({ kind: "if", cond, then, ...(otherwise ? { else: otherwise } : {}), at: this.at(s), label: this.label(s) }, s); @@ -4519,7 +7156,9 @@ export class Structured { if (this.neverRuns(s.condition)) { this.scope = outer; return; } const cond = this.loopCondition(s.condition); const body = this.sub(s.statement, { fn: ctx.fn, canBreak: true, canContinue: true }); - const update = s.incrementor ? this.collect(() => this.expressionStatement(s.incrementor!)) : []; + // A `let` of the loop a kept function uses is one for each turn, as JavaScript's is: a copy of the environment before the update. + const renew = this.scopeEnv.has(this.scope) ? this.renewEnv(this.scope, s) : []; + const update = [...renew, ...(s.incrementor ? this.collect(() => this.expressionStatement(s.incrementor!)) : [])]; this.scope = outer; this.emit({ kind: "for", ...(cond ? { cond } : {}), update, body, at: this.at(s), label: this.label(s) }, s); } @@ -4579,6 +7218,7 @@ export class Structured { if (table.as === "record" || !table.present) { this.c.error(s.expression, `${table.name} is a Record: it has a value for every key, so there is nothing to go through. A Map or a Set knows which keys it has.`); return true; } part ??= table.as === "map" ? "entries" : "keys"; if ((part !== "keys" && table.as === "set") || (part !== "keys" && !table.values)) part = "keys"; + if (table.literals) { this.literalKeyedLoop(s, decl, table as Keyed & { literals: readonly string[] }, part, ctx); return true; } const at = this.at(s); const label = this.label(s); const i = this.newVar(`(key of ${table.name})`, "number", at, { temp: true }); @@ -4610,10 +7250,40 @@ export class Structured { return true; } + /** `for (const [k, v] of m)` over a Map keyed by texts of a union: unrolled, a key each, its body for the keys present — the key a text known when built. */ + private literalKeyedLoop(s: TS.ForOfStatement, decl: TS.VariableDeclaration, table: Keyed & { literals: readonly string[] }, part: "keys" | "values" | "entries", ctx: Ctx) { + const { ts } = this; + const at = this.at(s), label = this.label(s); + const names: { node: TS.Node; of: "key" | "value" }[] = []; + if (ts.isIdentifier(decl.name)) { + if (part === "entries") { this.c.error(decl.name, `A Map gives a key and a value a turn: for (const [key, value] of ${table.name}).`); return; } + names.push({ node: decl, of: part === "keys" ? "key" : "value" }); + } else if (ts.isArrayBindingPattern(decl.name) && part === "entries") { + const [k, v] = decl.name.elements; + if (k && ts.isBindingElement(k) && ts.isIdentifier(k.name)) names.push({ node: k, of: "key" }); + if (v && ts.isBindingElement(v) && ts.isIdentifier(v.name)) names.push({ node: v, of: "value" }); + } else { this.c.error(decl.name, `for…of over ${table.name} takes ${table.as === "map" ? "[key, value]" : "one variable"}.`); return; } + const iterations: Stmt[][] = table.literals.map((text, i) => { + const scope = new Scope(this.scope); + const declared: Stmt[] = []; + for (const n of names) { + if (n.of === "key") { scope.bind(n.node, { kind: "value", value: text }); continue; } + const a = table.values!; + const v = this.newVar((n.node as TS.NamedDeclaration).name!.getText(this.body.sf), a.kind, this.sourceOf(n.node), { ...(a.bits ? { bits: a.bits } : {}), ...(a.unsigned ? { unsigned: true } : {}) }); + declared.push({ kind: "declare", decl: v, init: { kind: "element", array: a.id, index: num(i), at }, at, label }); + scope.bind(n.node, { kind: "var", v }); + } + const body = this.collect(() => { for (const d of declared) this.emit(d, s); this.block([s.statement], { fn: ctx.fn, canBreak: true, canContinue: true }, scope); }); + return [{ kind: "if", cond: { kind: "element", array: table.present!.id, index: num(i), at }, then: body, at, label } as Stmt]; + }); + this.emit({ kind: "unrolled", iterations, at, label }, s); + } + private forOfStatement(s: TS.ForOfStatement, ctx: Ctx) { const { ts } = this; - if (s.awaitModifier) { this.c.error(s, "for await is not supported in a program."); return; } const decl = ts.isVariableDeclarationList(s.initializer) && s.initializer.declarations.length === 1 ? s.initializer.declarations[0] : undefined; + if (decl && this.generatorLoop(s, decl, ctx)) return; + if (s.awaitModifier) { this.c.error(s, "for await walks a generator here: for await (const w of waves()) { … }, waves an async function*."); return; } if (decl && this.keyedLoop(s, decl, ctx)) return; if (!decl) { this.c.error(s.initializer, "for…of takes one variable: for (const w of waves) { … }."); return; } if (!ts.isIdentifier(decl.name)) { @@ -4729,10 +7399,9 @@ export class Structured { */ private switchStatement(s: TS.SwitchStatement, ctx: Ctx) { const { ts } = this; - if (this.isTextTyped(s.expression) && !this.evaluate(s.expression)) { this.textSwitch(s, ctx); return; } + if (this.isTextTyped(s.expression)) { this.textSwitch(s, ctx); return; } const value = this.num(s.expression); if (!value) return; - if (value.kind === "const") { this.c.error(s.expression, "switch over a value known when the script is built: write the case that applies."); return; } const clauses = s.caseBlock.clauses; // A case whose value could not be read keeps its body (it can be fallen into) but no test: NaN stands for that. const values: (number | null)[] = clauses.map((c) => { @@ -4742,8 +7411,25 @@ export class Structured { const n = this.asInteger(h, c.expression); return n === null ? Number.NaN : n; }); + // Over a value known when the script is built (a size a library's function was given): only the case that applies, + // and those it falls through into, are built, each with the value itself as its test, so the first is the one taken. + let from = 0; + if (value.kind === "const") { + const v = value.value | 0; + from = values.findIndex((n) => n !== null && (n | 0) === v); + if (from < 0) from = values.indexOf(null); + if (from < 0) return; + } + // (Those after the one that applies only as far as it falls through: a clause ending in break or return ends it.) + let to = clauses.length; + if (value.kind === "const") { + const ends = (c: TS.CaseOrDefaultClause) => { const l = c.statements[c.statements.length - 1]; return !!l && (ts.isBreakStatement(l) || ts.isReturnStatement(l) || ts.isContinueStatement(l) || ts.isThrowStatement(l)); }; + to = from; + while (to < clauses.length && !ends(clauses[to])) to++; + to = Math.min(to + 1, clauses.length); + } const scope = new Scope(this.scope); - const cases = clauses.map((c, i) => ({ value: values[i], body: this.collect(() => this.block(c.statements, { fn: ctx.fn, canBreak: true, canContinue: ctx.canContinue }, scope)) })); + const cases = clauses.slice(from, to).map((c, i) => ({ value: value.kind === "const" ? value.value | 0 : values[from + i], body: this.collect(() => this.block(c.statements, { fn: ctx.fn, canBreak: true, canContinue: ctx.canContinue, exits: { break: [], continue: ctx.exits?.continue ?? [] } }, scope)) })); this.emit({ kind: "switch", value, cases, at: this.at(s), label: this.label(s) }, s); } @@ -4752,7 +7438,10 @@ export class Structured { const { ts } = this; const given = this.text(s.expression); if (!given) return; - const of = this.textTemp(given, s.expression); + // (A text known when the script is built — `switch (MODE)` of a const — is matched now: the case it falls into is + // the one taken, and no text is compared in the game.) + const known = given.kind === "text" ? given.text : undefined; + const of = known === undefined ? this.textTemp(given, s.expression) : given; const clauses = s.caseBlock.clauses; const at = this.at(s), label = this.label(s); let which: NumExpr = num(-1); @@ -4762,10 +7451,11 @@ export class Structured { if (ts.isDefaultClause(c)) continue; const h = this.evaluate(c.expression); if (!h || typeof h.value !== "string" || hasTextMark(h.value)) { this.notConstant(c.expression, "A case's text"); values[i] = Number.NaN; continue; } + if (known !== undefined) { if (h.value === known) which = num(i); continue; } which = { kind: "ternary", cond: { kind: "textCompare", op: "==", left: of, right: { kind: "text", text: h.value }, at, label }, whenTrue: num(i), whenFalse: which, at, label }; } const scope = new Scope(this.scope); - const cases = clauses.map((c, i) => ({ value: values[i], body: this.collect(() => this.block(c.statements, { fn: ctx.fn, canBreak: true, canContinue: ctx.canContinue }, scope)) })); + const cases = clauses.map((c, i) => ({ value: values[i], body: this.collect(() => this.block(c.statements, { fn: ctx.fn, canBreak: true, canContinue: ctx.canContinue, exits: { break: [], continue: ctx.exits?.continue ?? [] } }, scope)) })); this.emit({ kind: "switch", value: which, cases, at, label }, s); } @@ -4779,6 +7469,7 @@ export class Structured { private declareTexts(name: string, initializer: TS.Expression, at: TS.Node): Texts | null { const { ts } = this; const init = this.unwrap(initializer); + if (ts.isCallExpression(init) && this.isSplit(init)) return this.splitTexts(init, name, at); const shape: RowShape = new Map([[TEXT_FIELD, { kind: "text" }]]); const rows: RowValues[] = []; const whole = this.evaluate(init); @@ -4799,11 +7490,89 @@ export class Structured { return { kind: "texts", name, rows: records }; } + /** Whether a call is `s.split(…)` of a text of the program. */ + private isSplit(e: TS.CallExpression): boolean { + return this.ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === "split" && this.isTextTyped(e.expression.expression) && !this.evaluate(e); + } + + /** + * `s.split(sep)`, `s.split(sep, limit)`: an array of texts, the pieces between the matches, as JavaScript gives them — + * an empty separator gives each character, none gives the text whole, and `limit` keeps the first so many. + */ + private splitTexts(e: TS.CallExpression, name: string, at: TS.Node): Texts | null { + const receiver = (e.expression as TS.PropertyAccessExpression).expression; + const [sepArg, limitArg] = e.arguments; + if (sepArg && this.ts.isRegularExpressionLiteral(this.unwrap(sepArg))) { this.c.error(sepArg, "split() of a program takes a text to split at, not a regular expression."); return null; } + const of = this.text(receiver); + const sep = sepArg ? this.text(sepArg) : null; + const limit = limitArg ? this.num(limitArg) : null; + if (!of || (sepArg && !sep) || (limitArg && !limit)) return null; + const rows = this.recordsFrom(name, new Map([[TEXT_FIELD, { kind: "text" }]]), [], e, at, true); + if (!rows) return null; + const texts: Texts = { kind: "texts", name, rows }; + const columns = [...rows.fields.values()]; + const b = this.irAt(e); + const s = b.txt("(split text)"), sp = b.txt("(separator)"), n = b.num("(length)"), sl = b.num("(separator length)"), lim = b.num("(limit)"); + const i = b.num("(found at)"), from = b.num("(from)"), k = b.num("(place)"); + const push = (t: TextExpr): Stmt[] => this.collect(() => { + const place = b.num(`(row of ${name})`); + this.out.push(b.when(b.cmp("<", { kind: "length", array: columns[0].id, at: b.at }, b.ref(lim)), this.collect(() => { + this.emit(b.declare(place, { kind: "length", array: columns[0].id, at: b.at }), e); + for (const a of columns) this.emit({ kind: "push", array: a.id, value: num(0), at: b.at, label: b.label }, e); + this.storeTextAt(this.textAtIndex(texts, varRef(place)), t, e); + }))); + }); + this.emit(b.declare(s, of), e); + this.emit(b.declare(lim, limit ?? b.n(I32_MAX)), e); + if (!sep) { this.out.push(...push(b.tref(s))); return texts; } + this.emit(b.declare(sp, sep), e); + this.emit(b.declare(n, b.len(b.tref(s))), e); + this.emit(b.declare(sl, b.len(b.tref(sp))), e); + this.emit(b.declare(from, b.n(0)), e); + this.emit(b.declare(i, b.n(0)), e); + this.emit(b.declare(k, b.n(0)), e); + this.out.push(b.when(b.cmp("==", b.ref(sl), b.n(0)), [ + b.loop(b.cmp("<", b.ref(k), b.ref(n)), [...push(b.slice(b.tref(s), b.ref(k), b.add(b.ref(k), b.n(1)))), b.set(k, b.add(b.ref(k), b.n(1)))]), + ], [ + b.set(i, b.find(b.tref(s), b.tref(sp))), + b.loop(b.cmp(">=", b.ref(i), b.n(0)), [...push(b.slice(b.tref(s), b.ref(from), b.ref(i))), b.set(from, b.add(b.ref(i), b.ref(sl))), b.set(i, b.find(b.tref(s), b.tref(sp), b.ref(from)))]), + ...push(b.slice(b.tref(s), b.ref(from))), + ])); + return texts; + } + /** `names[i]` as the text kept there. */ private textAtIndex(of: Texts, index: NumExpr): Extract { return this.textCells(of.rows, TEXT_FIELD, index); } + /** + * `names.sort()`: in the order of the texts' characters, as JavaScript sorts texts (by their UTF-16 code units — "B" + * before "a"); `names.sort((a, b) => …)` by the function. A row is moved whole, the block of the heap it owns with it. + */ + private sortTexts(e: TS.CallExpression, of: Texts) { + const at = this.at(e), label = this.label(e); + const textIn = (b: Binding): Binding => (b.kind === "record" ? b.fields.get(TEXT_FIELD)! : b); + const textOf = (b: Binding): TextExpr | null => { + const t = textIn(b); + if (t.kind === "textAt") return this.textAtCells(t, e); + if (t.kind === "var" && t.v.kind === "text") return { kind: "textVar", id: t.v.id }; + this.c.error(e, "sort() of texts: a row that is not a text."); + return null; + }; + const fn = e.arguments[0]; + this.sortList(e, of.rows, (before, held) => { + if (!fn) { + const a = textOf(before), b = textOf(held); + return a && b ? { kind: "textCompare", op: ">", left: a, right: b, at, label } : null; + } + const call = this.callback(fn, "sort", [textIn(before), textIn(held)], "any", e); + if (!call) return null; + if (call.result?.kind !== "number") { this.c.error(fn, "sort()'s function gives a number — below 0 when a goes first, above when b does."); return null; } + return { kind: "compare", op: ">", left: this.mark({ kind: "call", call }, e), right: num(0), at, label }; + }); + } + /** `names.push(t)`, `names.pop()` standing on their own. */ private textsCall(e: TS.CallExpression, of: Texts, method: string) { const at = this.at(e); @@ -4825,11 +7594,28 @@ export class Structured { return; } if (method === "pop" && e.arguments.length === 0) { this.recordsCall(e, of.rows, "pop"); return; } - this.c.error(e, `An array of texts has push(text), pop(), length, [i], for…of, forEach, includes, indexOf and join; ${method}() is not one of them.`); + if (method === "reverse") { this.reverseList(e, of.rows); return; } + if (method === "sort") { this.sortTexts(e, of); return; } + if (method === "shift" || method === "unshift" || method === "splice") { + const sp = this.spliceArgs(e, method); + if (!sp) return; + const held: VarDecl[] = []; + for (const x of sp.items) { + const value = this.text(x); + if (!value) return; + const v = this.newVar("(item)", "text", at, { temp: true, text: "made" }); + this.emit({ kind: "declare", decl: v, init: value, at, label }, x); + held.push(v); + } + const s0 = this.spliceRows(columns, of.rows, sp.start, sp.remove, held.length, e); + held.forEach((v, i) => this.storeTextAt(this.textAtIndex(of, this.temp({ kind: "binary", op: "+", left: varRef(s0), right: num(i), at, label }, e, true)), { kind: "textVar", id: v.id }, e)); + return; + } + this.c.error(e, `An array of texts has push(text), pop(), shift(), unshift(text), splice(…), sort(), reverse(), length, [i], at(i), for…of, forEach, includes, indexOf, lastIndexOf and join; ${method}() is not one of them.`); } /** `names.includes(t)`, `names.indexOf(t)`: a function of the compiler's own, a loop over the texts that compares each. */ - private textsSearch(e: TS.CallExpression, of: Texts, method: "includes" | "indexOf"): NumExpr | BoolExpr | null { + private textsSearch(e: TS.CallExpression, of: Texts, method: "includes" | "indexOf" | "lastIndexOf"): NumExpr | BoolExpr | null { const at = this.at(e); const label = this.label(e); if (e.arguments.length !== 1) { this.c.error(e, `${method}() takes the text to look for.`); return null; } @@ -4842,62 +7628,917 @@ export class Structured { const call: Call = { name: `${of.name}.${method}`, at, label, params: [{ decl: w, init: wanted, label }], result: { decl: result, kind: method === "includes" ? "boolean" : "number" }, body: [ - { kind: "declare", decl: i, init: num(0), at, label }, - { kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: { kind: "length", array: [...of.rows.fields.values()][0].id, at }, at, label }, update: [{ kind: "assign", target: i.id, value: { kind: "binary", op: "+", left: varRef(i), right: num(1), at, label }, at, label }], body: [{ kind: "if", cond: same, then: [{ kind: "return", value: method === "includes" ? TRUE : varRef(i), at, label }], at, label }], at, label }, + ...(method === "lastIndexOf" ? [ + { kind: "declare", decl: i, init: { kind: "binary", op: "-", left: { kind: "length", array: [...of.rows.fields.values()][0].id, at }, right: num(1), at, label }, at, label }, + { kind: "while", cond: { kind: "compare", op: ">=", left: varRef(i), right: num(0), at, label }, body: [{ kind: "if", cond: same, then: [{ kind: "return", value: varRef(i), at, label }], at, label }, { kind: "assign", target: i.id, value: { kind: "binary", op: "-", left: varRef(i), right: num(1), at, label }, at, label }], at, label }, + ] as Stmt[] : [ + { kind: "declare", decl: i, init: num(0), at, label }, + { kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: { kind: "length", array: [...of.rows.fields.values()][0].id, at }, at, label }, update: [{ kind: "assign", target: i.id, value: { kind: "binary", op: "+", left: varRef(i), right: num(1), at, label }, at, label }], body: [{ kind: "if", cond: same, then: [{ kind: "return", value: method === "includes" ? TRUE : varRef(i), at, label }], at, label }], at, label }, + ] as Stmt[]), { kind: "return", value: method === "includes" ? FALSE : num(-1), at, label }, ], }; return this.mark({ kind: "call", call }, e); } - /** `names.join(", ")`: the texts one after another with the separator between, made where the call stands. */ - private textsJoin(e: TS.CallExpression, of: Texts): TextExpr | null { - const at = this.at(e); - const label = this.label(e); - const between = e.arguments[0] ? this.text(e.arguments[0]) : { kind: "text" as const, text: "," }; - if (!between) return null; - const sep = this.newVar("(separator)", "text", at, { temp: true, text: "made" }); - const out = this.newVar(`(${of.name}.join)`, "text", at, { temp: true, text: "made" }); - const i = this.newVar(`(index of ${of.name})`, "number", at, { temp: true }); - const add = (t: TextExpr): Stmt => ({ kind: "assignText", target: out.id, value: { kind: "template", parts: [{ kind: "value", text: { kind: "textVar", id: out.id } }, { kind: "value", text: t }], at, label }, at, label }); - this.emit({ kind: "declare", decl: sep, init: between, at, label }, e); - this.emit({ kind: "declare", decl: out, init: { kind: "text", text: "" }, at, label }, e); - this.emit({ kind: "declare", decl: i, init: num(0), at, label }, e); - this.emit({ kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: { kind: "length", array: [...of.rows.fields.values()][0].id, at }, at, label }, update: [{ kind: "assign", target: i.id, value: { kind: "binary", op: "+", left: varRef(i), right: num(1), at, label }, at, label }], - body: [{ kind: "if", cond: { kind: "compare", op: ">", left: varRef(i), right: num(0), at, label }, then: [add({ kind: "textVar", id: sep.id })], at, label }, add(this.textAtCells(this.textAtIndex(of, varRef(i)), e))], at, label }, e); - return { kind: "textVar", id: out.id }; + /* ── JSX: a text as elements, put together a piece at a time ── */ + + /** + * `…`, `<>…`: the text put together where the expression stands, into a buffer of its own + * (`buildText`), and read where it is used (`built`) — never copied on the way, however many pieces it has. + * A line break goes before every `` but the first thing written; `{…}` is the program's value; `cond && …`, + * `cond ? … : …` and `xs.map((x) => …)` are the program's if and loop. A colour is put back lazily: an element + * that changed it leaves the next piece of its parent to say the parent's again. + */ + private jsx(e: JsxNode): TextExpr | null { + const { ts } = this; + // The buffer is used up within the frame: nothing in the text may wait. + const waits = (n: TS.Node): TS.Node | undefined => (ts.isAwaitExpression(n) || ts.isYieldExpression(n) ? n : ts.isFunctionLike(n) ? undefined : ts.forEachChild(n, waits)); + const waiting = waits(e); + if (waiting) { this.c.error(waiting, "A text is put together within the frame it is shown in: nothing inside it can wait. Await before it, into a variable, and write the variable."); return null; } + const at = this.at(e), label = this.label(e); + const buffer = `jsx L${this.line(e)}#${this.nextId++}`; + const b: JsxBuild = { buffer, pending: [], written: "no", shown: null, at, label }; + if (this.jsxFlowsLines(e)) { + b.started = this.newVar("(something written)", "number", at, { temp: true }); + this.emit({ kind: "declare", decl: b.started, init: num(0), at, label }, e); + } + this.emit({ kind: "buildText", buffer, parts: [], reset: true, at, label }, e); + this.jsxNode(e, b, { key: `c${DEFAULT_COLOR}`, part: { kind: "text", text: String.fromCharCode(DEFAULT_COLOR) } }); + this.jsxFlush(b, e); + return this.mark({ kind: "built", buffer }, e); + } + + /** Whether a JSX text writes something inside a `{…}` — a condition or a loop — so whether a line break is due is known only in the game. */ + private jsxFlowsLines(e: TS.Node): boolean { + const { ts } = this; + // A component counts as braces: what it writes is worked out in its body, which may loop or choose. + const component = (n: TS.Node): boolean => (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) && /^[A-Z]/.test(n.tagName.getText(this.body.sf)) && !Object.hasOwn(JSX_ELEMENTS, n.tagName.getText(this.body.sf)); + const inside = (n: TS.Node): boolean => (ts.isJsxExpression(n) && !!n.expression && !ts.isJsxAttribute(n.parent)) || component(n) || !!ts.forEachChild(n, (c) => inside(c) || undefined); + return inside(e); } - /** A loop over an array of texts: `turn` is called, where the body goes, with the text at each place and the place. As many as there are when the loop starts. */ - private textsLoop(of: Texts, node: TS.Node, turn: (item: Binding, index: VarDecl) => void) { - const at = this.at(node); - const label = this.label(node); - const i = this.newVar(`(index of ${of.name})`, "number", at, { temp: true }); - const n = this.newVar(`(length of ${of.name})`, "number", at, { temp: true }); - const body = this.collect(() => turn(this.textAtIndex(of, varRef(i)), i)); - this.emit({ kind: "declare", decl: n, init: { kind: "length", array: [...of.rows.fields.values()][0].id, at }, at, label }, node); - this.emit({ kind: "declare", decl: i, init: num(0), at, label }, node); - this.emit({ kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: varRef(n), at, label }, update: [{ kind: "assign", target: i.id, value: { kind: "binary", op: "+", left: varRef(i), right: num(1), at, label }, at, label }], body, at, label }, node); + /** The pieces waiting, written as one `buildText`. */ + private jsxFlush(b: JsxBuild, node: TS.Node) { + if (!b.pending.length) return; + this.emit({ kind: "buildText", buffer: b.buffer, parts: b.pending, at: b.at, label: b.label }, node); + b.pending = []; } - /* ── A Map and a Set over any number ── */ + /** Something visible is written: a line break first when the element is a line and something was written before it. */ + private jsxWrites(b: JsxBuild, node: TS.Node, line: boolean) { + const { at, label } = b; + if (line && b.written === "yes") b.pending.push({ kind: "text", text: "\n" }); + if (line && b.written === "maybe" && b.started) { + this.jsxFlush(b, node); + this.emit({ kind: "if", cond: { kind: "test", expr: varRef(b.started), at, label }, then: [{ kind: "buildText", buffer: b.buffer, parts: [{ kind: "text", text: "\n" }], at, label }], at, label }, node); + } + if (b.started && b.written !== "yes") this.emit({ kind: "assign", target: b.started.id, value: num(1), at, label }, node); + b.written = "yes"; + } - /** Where each such table was declared: where its functions say they are from. */ - private readonly hashNodes = new WeakMap(); + /** The colour of the element a piece is in, said unless it is the one showing. */ + private jsxColor(b: JsxBuild, color: JsxColor) { + if (b.shown === color.key) return; + b.pending.push(structuredClone(color.part)); + b.shown = color.key; + } - /** - * `new Map()`, `new Set()`: keys that are any number, where a table keyed by ids of the game has - * a cell for every id there is. The entries are kept in the order they went in — three arrays that grow, a key, a - * value and whether it is still there — which is the order JavaScript goes through a Map in: a key set again stays - * where it was, one deleted and set again goes to the end, one added while a loop goes through is reached by it. A - * key is found through `slots`, open addressing over a power of two of cells, made again at twice the size when three - * quarters of it is taken. The work is in functions of the table's own (`hashFunction`), called where it is used. - */ - private declareHash(name: string, as: "map" | "set", init: TS.Expression, type: TS.Type, at: TS.Node): Hash | null { - const { ts } = this; + /** A colour attribute: known when the script is built (a name or a code), or the program's number. Null with a diagnostic. */ + private jsxColorOf(value: TS.Expression | TS.StringLiteral, tag: string): JsxColor | { dynamic: NumExpr } | null { + const h = this.evaluate(value as TS.Expression); + if (h && !isGameValue(h.value)) { + const c = colorCode(h.value); + if (c === null) { this.c.error(value, `<${tag}>'s color is one of ${Object.keys(TEXT_COLORS).join(", ")}, or a colour code from 0 to 255; got ${describe(h.value)}.`); return null; } + return { key: `c${c}`, part: { kind: "text", text: c ? String.fromCharCode(c) : "" } }; + } + const n = this.colorNumber(value as TS.Expression, tag); + return n ? { dynamic: n } : null; + } + + /** A colour worked out in the game as its code: a number, or `cond ? "cyan" : "white"` of names (`??` and nesting too). */ + private colorNumber(expr: TS.Expression, tag: string): NumExpr | null { + const { ts } = this; + const e = this.unwrap(expr); + const h = this.evaluate(e); + if (h && !isGameValue(h.value)) { + const c = colorCode(h.value); + if (c === null) { this.c.error(e, `<${tag}>'s color is one of ${Object.keys(TEXT_COLORS).join(", ")}, or a colour code from 0 to 255; got ${describe(h.value)}.`); return null; } + return num(c); + } + if (ts.isConditionalExpression(e)) { + const known = this.knownCondition(e.condition); + if (known !== undefined) return this.colorNumber(known ? e.whenTrue : e.whenFalse, tag); + const cond = this.bool(e.condition); + const a = this.colorNumber(e.whenTrue, tag), b = this.colorNumber(e.whenFalse, tag); + return a && b ? this.mark({ kind: "ternary", cond, whenTrue: a, whenFalse: b, at: this.at(e), label: this.label(e) }, e) : null; + } + return this.num(e); + } + + /** A colour of the program's held in a variable of its own, so that saying it again reads the same number. */ + private jsxHeldColor(n: NumExpr, node: TS.Node): JsxColor { + const at = this.at(node), label = this.label(node); + const v = this.newVar("(colour)", "number", at, { temp: true }); + this.emit({ kind: "declare", decl: v, init: n, at, label }, node); + return { key: `v${v.id}`, part: { kind: "char", expr: varRef(v) }, v }; + } + + /** An element's attributes by name: a string's text, or the expression in braces. Null with a diagnostic. */ + private jsxAttributes(e: TS.JsxElement | TS.JsxSelfClosingElement, tag: string): Map | null { + const { ts } = this; + const attributes = ts.isJsxElement(e) ? e.openingElement.attributes : e.attributes; + const out = new Map(); + for (const a of attributes.properties) { + if (!ts.isJsxAttribute(a) || !ts.isIdentifier(a.name)) { this.c.error(a, `<${tag}> takes its attributes one by one: ${JSX_ELEMENTS[tag].join(", ")}.`); return null; } + const name = a.name.text; + if (!JSX_ELEMENTS[tag].includes(name)) { this.c.error(a, JSX_ELEMENTS[tag].length ? `<${tag}> takes ${JSX_ELEMENTS[tag].join(", ")}; not ${name}.` : `<${tag}> takes no attributes.`); return null; } + const init = a.initializer; + if (!init) { this.c.error(a, `${name} needs a value: ${name}={…}.`); return null; } + if (ts.isStringLiteral(init)) out.set(name, init); + else if (ts.isJsxExpression(init) && init.expression) out.set(name, init.expression); + else { this.c.error(a, `${name} needs a value: ${name}={…}.`); return null; } + } + return out; + } + + /** An element, a fragment, or text between them, written in colour `color` (its parent's). */ + private jsxNode(e: TS.JsxChild | JsxNode, b: JsxBuild, color: JsxColor) { + const { ts } = this; + if (ts.isJsxText(e)) { + const text = jsxWhitespace(e.text); + if (!text) return; + this.jsxWrites(b, e, false); + this.jsxColor(b, color); + b.pending.push({ kind: "text", text }); + return; + } + if (ts.isJsxExpression(e)) { if (e.expression) this.jsxChild(e.expression, b, color); return; } + if (ts.isJsxFragment(e)) { for (const c of e.children) this.jsxNode(c, b, color); return; } + const opening = ts.isJsxElement(e) ? e.openingElement : e; + const tag = opening.tagName.getText(this.body.sf); + if (!Object.hasOwn(JSX_ELEMENTS, tag) && /^[A-Z]/.test(tag)) { + const done = this.jsxComponent(e as TS.JsxElement | TS.JsxSelfClosingElement, b, color); + if (done !== undefined) return; + } + if (!Object.hasOwn(JSX_ELEMENTS, tag)) { + this.c.error(opening.tagName, /^[A-Z]/.test(tag) + ? `<${tag}>: a function of your own is called in braces in a text — {${tag[0].toLowerCase()}${tag.slice(1)}(…)} — and gives back a text. Elements are ${Object.keys(JSX_ELEMENTS).map((t) => `<${t}>`).join(", ")}.` + : `<${tag}> is not an element of a text: they are ${Object.keys(JSX_ELEMENTS).map((t) => `<${t}>`).join(", ")}.`); + return; + } + const attrs = this.jsxAttributes(e as TS.JsxElement | TS.JsxSelfClosingElement, tag); + if (!attrs) return; + const children: readonly TS.JsxChild[] = ts.isJsxElement(e) ? e.children : []; + const own = (fallback: JsxColor | null): JsxColor | null | undefined => { + const v = attrs.get("color"); + if (!v) return fallback; + const c = this.jsxColorOf(v, tag); + if (!c) return undefined; + return "dynamic" in c ? this.jsxHeldColor(c.dynamic, e) : c; + }; + switch (tag) { + case "line": { + const c = own({ key: `c${DEFAULT_COLOR}`, part: { kind: "text", text: String.fromCharCode(DEFAULT_COLOR) } }); + if (!c) return; + this.jsxWrites(b, e, true); + // A line says its colour at its start, whatever was showing at the end of the one before. + b.shown = null; + this.jsxColor(b, c); + for (const child of children) this.jsxNode(child, b, c); + return; + } + case "span": { + const c = own(color); + if (!c) return; + for (const child of children) this.jsxNode(child, b, c); + return; + } + case "cell": { + if (children.length) { this.c.error(e, " is one square and holds nothing: ."); return; } + this.jsxWrites(b, e, false); + const v = attrs.get("color"); + const c = v ? this.jsxColorOf(v, tag) : { key: "c0", part: { kind: "text" as const, text: "" } }; + if (!c) return; + if (!("dynamic" in c)) { + // Known when the script is built: filled in its colour, or empty. + const filled = c.key !== "c0"; + this.jsxColor(b, filled ? c : { key: `c${EMPTY_COLOR}`, part: { kind: "text", text: String.fromCharCode(EMPTY_COLOR) } }); + b.pending.push({ kind: "text", text: filled ? FILLED_CELL : EMPTY_CELL }); + return; + } + // The program's colour: 0 is an empty square in the empty colour, anything else a filled one in it. + const held = this.jsxHeldColor(c.dynamic, e); + const at = this.at(e), label = this.label(e); + const empty: BoolExpr = { kind: "compare", op: "==", left: varRef(held.v!), right: num(0), at, label }; + b.pending.push({ kind: "char", expr: { kind: "ternary", cond: empty, whenTrue: num(EMPTY_COLOR), whenFalse: varRef(held.v!), at, label } }); + b.pending.push({ kind: "value", text: { kind: "textTernary", cond: structuredClone(empty), whenTrue: { kind: "text", text: EMPTY_CELL }, whenFalse: { kind: "text", text: FILLED_CELL }, at, label } }); + b.shown = null; + return; + } + case "bar": { + if (children.length) { this.c.error(e, " holds nothing: ."); return; } + const at = this.at(e), label = this.label(e); + const value = attrs.get("value"), max = attrs.get("max"); + if (!value || !max) { this.c.error(e, " takes value and max: ."); return; } + const w = attrs.get("width"); + const width = w ? this.evaluate(w as TS.Expression)?.value : 10; + if (typeof width !== "number" || !Number.isInteger(width) || width < 1 || width > 60) { this.c.error(w ?? e, "'s width is known when the script is built: a whole number from 1 to 60."); return; } + const c = own({ key: `c${TEXT_COLORS.green}`, part: { kind: "text", text: String.fromCharCode(TEXT_COLORS.green) } }); + const v = this.num(value as TS.Expression), m = this.num(max as TS.Expression); + if (!c || !v || !m) return; + this.jsxWrites(b, e, false); + // How many are filled: value × width / max, from 0 to width; none when max is 0 or less. + const top = this.newVar("(bar's max)", "number", at, { temp: true }); + const n = this.newVar("(filled)", "number", at, { temp: true }); + this.emit({ kind: "declare", decl: top, init: m, at, label }, e); + const share: NumExpr = { kind: "binary", op: "/", left: { kind: "binary", op: "*", left: v, right: num(width), at, label }, right: varRef(top), at, label }; + this.emit({ kind: "declare", decl: n, init: { kind: "ternary", cond: { kind: "compare", op: "<=", left: varRef(top), right: num(0), at, label }, whenTrue: num(0), whenFalse: share, at, label }, at, label }, e); + this.emit({ kind: "if", cond: { kind: "compare", op: ">", left: varRef(n), right: num(width), at, label }, then: [{ kind: "assign", target: n.id, value: num(width), at, label }], at, label }, e); + this.emit({ kind: "if", cond: { kind: "compare", op: "<", left: varRef(n), right: num(0), at, label }, then: [{ kind: "assign", target: n.id, value: num(0), at, label }], at, label }, e); + b.shown = null; + this.jsxColor(b, c); + b.pending.push({ kind: "value", text: { kind: "textRepeat", of: { kind: "text", text: FILLED_CELL }, count: varRef(n), at, label } }); + b.pending.push({ kind: "text", text: String.fromCharCode(EMPTY_COLOR) }); + b.pending.push({ kind: "value", text: { kind: "textRepeat", of: { kind: "text", text: EMPTY_CELL }, count: { kind: "binary", op: "-", left: num(width), right: varRef(n), at, label }, at, label } }); + b.shown = `c${EMPTY_COLOR}`; + return; + } + } + } + + /** What is inside braces among the children: an element, a condition, a loop, or a value of the program written as text. */ + private jsxChild(expr: TS.Expression, b: JsxBuild, color: JsxColor) { + const { ts } = this; + const e = this.unwrap(expr); + if (ts.isJsxElement(e) || ts.isJsxSelfClosingElement(e) || ts.isJsxFragment(e)) { this.jsxNode(e, b, color); return; } + const at = this.at(e), label = this.label(e); + const h = this.evaluate(e); + // React's rule: nothing is written for true, false, null and undefined. + if (h && (h.value === null || h.value === undefined || typeof h.value === "boolean")) return; + if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) { + const known = this.knownCondition(e.left); + if (known !== undefined) { if (known) this.jsxChild(e.right, b, color); return; } + this.jsxBranches(e, b, this.bool(e.left), [() => this.jsxChild(e.right, b, color)], at, label); + return; + } + if (ts.isConditionalExpression(e)) { + const known = this.knownCondition(e.condition); + if (known !== undefined) { this.jsxChild(known ? e.whenTrue : e.whenFalse, b, color); return; } + this.jsxBranches(e, b, this.bool(e.condition), [() => this.jsxChild(e.whenTrue, b, color), () => this.jsxChild(e.whenFalse, b, color)], at, label); + return; + } + if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === "map" && this.jsxMap(e, b, color)) return; + // A value: its digits, its text. Anything its working out runs comes before what is waiting is written. + const mark = this.out.length; + const parts = this.textOf(e); + if (!parts) return; + if (this.out.length > mark && b.pending.length) { + this.out.splice(mark, 0, this.mark({ kind: "buildText", buffer: b.buffer, parts: b.pending, at: b.at, label: b.label }, e)); + b.pending = []; + } + this.jsxWrites(b, e, false); + this.jsxColor(b, color); + b.pending.push(...parts); + } + + /** How many components are being walked inside one another: a component that uses itself stops somewhere. */ + private componentDepth = 0; + + /** + * ``, `…`: a function of your own that gives a text, used as an + * element. Its props are a record of what the element says (`children`, the text between its tags, made first), and + * its text is written into the text it stands in, as React has it — its ``s are lines of that text. A component + * is a function declared in the program, a const holding an arrow, or a `game()` function of a module; its body is an + * expression, or statements that end with the one `return <…/>`. Undefined when the tag names none of those. + */ + private jsxComponent(e: TS.JsxElement | TS.JsxSelfClosingElement, b: JsxBuild, color: JsxColor): true | null | undefined { + const { ts } = this; + const opening = ts.isJsxElement(e) ? e.openingElement : e; + const tagExpr = opening.tagName as TS.Expression; + const name = tagExpr.getText(this.body.sf); + let parameters: readonly TS.ParameterDeclaration[] | undefined; + let body: TS.Block | TS.Expression | undefined; + let target: Body = this.body; + let closure: Scope | null = this.body === this.c.body ? this.topScope : null; + const decl = ts.isIdentifier(tagExpr) ? this.gameDeclaration(tagExpr) : undefined; + if (decl && ts.isFunctionDeclaration(decl) && decl.body) { parameters = decl.parameters; body = decl.body; } + else { + const fn = this.fnValueOf(tagExpr); + if (fn) { parameters = fn.parameters; body = fn.body; target = fn.target as Body; closure = fn.closure; } + else { + const h = this.evaluate(tagExpr); + if (h && isGameFunction(h.value)) { + const found = this.c.resolve(h.value); + const arrow = found?.plan.arrow; + if (found && arrow) { parameters = arrow.parameters; body = found.plan.expression ?? (arrow.body as TS.Block); target = found; closure = null; } + } + } + } + if (!parameters || !body) return undefined; + if (parameters.length > 1) { this.c.error(opening.tagName, `<${name}>: a component takes one parameter, its props.`); return null; } + if (this.componentDepth >= 16) { this.c.error(opening.tagName, `<${name}> uses itself sixteen deep: a component's text is worked out when the script is built, so it cannot use itself without end.`); return null; } + // What it ends with: the expression of an arrow, or the one return at the end of its statements. + let returned: TS.Expression; + let before: readonly TS.Statement[] = []; + if (ts.isBlock(body)) { + const last = body.statements[body.statements.length - 1]; + const others = (n: TS.Node): boolean => (n !== last && ts.isReturnStatement(n)) || (!ts.isFunctionLike(n) && !!ts.forEachChild(n, (c) => others(c) || undefined)); + if (!last || !ts.isReturnStatement(last) || !last.expression || body.statements.slice(0, -1).some(others)) { + this.c.error(opening.tagName, `<${name}>: a component's statements end with the one return of its text — return …. For a text worked out with returns along the way, call it in braces: {${name[0].toLowerCase()}${name.slice(1)}(…)}.`); + return null; + } + returned = last.expression; + before = body.statements.slice(0, -1); + } else returned = body; + // The props: each attribute, and the children as a text. + const fields = new Map(); + for (const attr of opening.attributes.properties) { + if (ts.isJsxSpreadAttribute(attr)) { const spread = this.spreadFields(attr.expression); if (!spread) return null; for (const [k, v] of spread) fields.set(k, v); continue; } + const key = attr.name.getText(this.body.sf); + const init = attr.initializer; + if (!init) { fields.set(key, { kind: "value", value: true }); continue; } + if (ts.isStringLiteral(init)) { fields.set(key, { kind: "value", value: init.text }); continue; } + if (!ts.isJsxExpression(init)) { this.c.error(init, `${key}: a prop is a text in quotes or a value in braces.`); return null; } + if (!init.expression) continue; + const prop = this.propBinding(init.expression, key); + if (!prop) return null; + fields.set(key, prop); + } + if (ts.isJsxElement(e) && e.children.some((c) => !ts.isJsxText(c) || jsxWhitespace(c.text))) { + const kids = this.jsxChildrenText(e); + if (!kids) return null; + const v = this.newVar(`(${name} children)`, "text", this.at(e), { temp: true, text: "made" }); + this.jsxFlush(b, e); + this.emit({ kind: "declare", decl: v, init: kids, at: this.at(e), label: this.label(e) }, e); + fields.set("children", { kind: "var", v }); + } + const p = parameters[0]; + // A prop the element leaves out is undefined, which the component can ask about when the script is built (`note ?? ""`). + if (p) for (const prop of this.c.checker.getTypeAtLocation(p.name).getProperties()) if (!fields.has(prop.name) && prop.flags & ts.SymbolFlags.Optional) fields.set(prop.name, { kind: "value", value: undefined }); + const props: Binding = { kind: "record", fields }; + const scope = new Scope(closure); + const saved = this.enterBody(target); + const outer = this.scope; + this.componentDepth++; + try { + this.scope = scope; + if (p) { + if (ts.isIdentifier(p.name)) scope.bind(p, props); + else if (!this.bindPattern(p.name, p, props, scope)) return null; + } + this.flushBoxes(); + // Its statements run before what follows is written: what is waiting goes into the buffer first. + if (before.length) { this.jsxFlush(b, e); this.block(before, {}, scope); } + this.jsxChild(returned, b, color); + } finally { + this.componentDepth--; + this.scope = outer; + this.leaveBody(saved); + } + return true; + } + + /** What a prop of a component is: a value the script has, a function, the program's record or array as itself, else its value now. */ + private propBinding(expr: TS.Expression, key: string): Binding | null { + const h = this.evaluate(expr); + if (h && !isGameValue(h.value)) return { kind: "value", value: h.value }; + const fn = this.fnValueOf(expr); + if (fn) return { kind: "fn", fn }; + const held = this.bindingOf(expr); + if (held && held.kind !== "var" && held.kind !== "cell" && held.kind !== "value") return held; + const at = this.at(expr), label = this.label(expr); + const type = this.c.checker.getTypeAtLocation(expr); + if (this.isTextType(type)) { + const t = this.text(expr); + if (!t) return null; + const v = this.newVar(`(${key})`, "text", at, { temp: true, text: "made" }); + this.emit({ kind: "declare", decl: v, init: t, at, label }, expr); + return { kind: "var", v }; + } + const k = this.kindOf(type); + if (!k) { this.c.error(expr, `${key}: a prop is a number, a boolean, a text, a unit, a function, or a record or an array of the program — not ${this.c.checker.typeToString(type)}.`); return null; } + const value = k === "number" ? this.num(expr) : k === "unit" ? this.unitExpr(expr) : this.boolValue(expr); + if (!value) return null; + const v = this.newVar(`(${key})`, k, at, { temp: true, ...(k === "number" ? this.widthOf(type) : {}) }); + this.emit({ kind: "declare", decl: v, init: value, at, label }, expr); + return { kind: "var", v }; + } + + /** The text between a component's tags, put together into a buffer of its own: what its `children` is. */ + private jsxChildrenText(e: TS.JsxElement): TextExpr | null { + const at = this.at(e), label = this.label(e); + const buffer = `jsx L${this.line(e)} children#${this.nextId++}`; + const kids: JsxBuild = { buffer, pending: [], written: "no", shown: null, at, label }; + this.emit({ kind: "buildText", buffer, parts: [], reset: true, at, label }, e); + for (const c of e.children) this.jsxNode(c, kids, { key: `c${DEFAULT_COLOR}`, part: { kind: "text", text: String.fromCharCode(DEFAULT_COLOR) } }); + this.jsxFlush(kids, e); + return this.mark({ kind: "built", buffer }, e); + } + + /** `cond && <…/>`, `cond ? <…/> : <…/>`: an if, each side written into the same buffer. */ + private jsxBranches(e: TS.Node, b: JsxBuild, cond: BoolExpr, sides: (() => void)[], at: At, label: string) { + this.jsxFlush(b, e); + const before = { written: b.written, shown: b.shown }; + const after: JsxBuild["written"][] = []; + const bodies = sides.map((side) => this.collect(() => { + b.written = before.written; + b.shown = before.shown; + side(); + this.jsxFlush(b, e); + after.push(b.written); + })); + if (sides.length === 1) after.push(before.written); + b.written = after.every((w) => w === "yes") ? "yes" : after.every((w) => w === "no") ? "no" : "maybe"; + b.shown = null; + this.emit({ kind: "if", cond, then: bodies[0], ...(bodies[1] ? { else: bodies[1] } : {}), at, label }, e); + } + + /** `{xs.map((x, i) => <…/>)}`: the program's loop over the list, the function's element written at each turn. False when it is not a list this can loop over. */ + private jsxMap(e: TS.CallExpression, b: JsxBuild, color: JsxColor): boolean { + const { ts } = this; + const receiver = (e.expression as TS.PropertyAccessExpression).expression; + const texts = this.listOf(receiver); + if (texts?.kind === "texts") { + // An array of texts of the program: a loop, each text and its place handed to the function. + const fn = this.functionOf(e.arguments[0], "map", e); + if (!fn) return true; + const body = ts.isBlock(fn.body) ? (fn.body.statements.length === 1 && ts.isReturnStatement(fn.body.statements[0]) ? fn.body.statements[0].expression : undefined) : fn.body; + if (!body) { this.c.error(fn.body, "In a text, the function given to map gives back what is written for each item, and does nothing else: (x) => {x}."); return true; } + this.jsxFlush(b, e); + if (b.written === "no") b.written = "maybe"; + b.shown = null; + this.textsLoop(texts, e, (item, i) => { + const scope = new Scope(fn.closure); + if (fn.parameters[0]) scope.bind(fn.parameters[0], item); + if (fn.parameters[1]) scope.bind(fn.parameters[1], { kind: "var", v: i }); + const outer = this.scope; + this.scope = scope; + try { this.jsxChild(body, b, color); this.jsxFlush(b, e); b.shown = null; } finally { this.scope = outer; } + }); + return true; + } + const over = this.overOf(receiver); + if (!over) return false; + const fn = this.functionOf(e.arguments[0], "map", e); + if (!fn) return true; + let body: TS.Expression | undefined = ts.isBlock(fn.body) ? undefined : fn.body; + if (ts.isBlock(fn.body) && fn.body.statements.length === 1 && ts.isReturnStatement(fn.body.statements[0])) body = fn.body.statements[0].expression; + if (!body) { this.c.error(fn.body, "In a text, the function given to map gives back what is written for each item, and does nothing else: (x) => ."); return true; } + for (const p of fn.parameters) if (!ts.isIdentifier(p.name)) { this.c.error(p, "In a text, map's function takes the item and its place by name: (x, i) => …."); return true; } + // A list the script has is unrolled, its turns one after another; the program's list is a loop, run any number of times. + const unrolled = over.kind === "values"; + if (!unrolled) { + this.jsxFlush(b, e); + if (b.written === "no") b.written = "maybe"; + b.shown = null; + } + this.loopOver(over, e, (item, index) => { + const scope = new Scope(fn.closure); + const handed = this.handed(over, item, index); + fn.parameters.forEach((p, i) => { if (handed[i]) scope.bind(p, handed[i]); }); + const outer = this.scope; + this.scope = scope; + try { + this.jsxChild(body, b, color); + if (!unrolled) { this.jsxFlush(b, e); b.shown = null; } + } finally { this.scope = outer; } + }); + return true; + } + + /** `ui.lines(P1, () => <…/>)`: a task that shows the function's text in the player's lines, again every frame. */ + private uiLines(call: TS.CallExpression, handle?: VarDecl) { + const { ts } = this; + const at = this.at(call), label = this.label(call); + if (call.arguments.length !== 2) { this.c.error(call, "ui.lines() takes the player and a function that gives the text: ui.lines(P1, () => …)."); return; } + if (!this.inAsync(call)) { this.c.error(call, "ui.lines() runs beside the program as a task: inside program(async () => { … })."); return; } + const h = this.evaluate(call.arguments[0]); + const to = h && typeof h.value === "number" ? h.value : undefined; + const groups: number[] = [CURRENT_PLAYER, PlayerGroup.AllPlayers, PlayerGroup.Force1, PlayerGroup.Force2, PlayerGroup.Force3, PlayerGroup.Force4]; + if (to === undefined || !Number.isInteger(to) || ((to < 0 || to > 7) && !groups.includes(to))) { this.c.error(call.arguments[0], "ui.lines()'s player is known when the script is built: P1 … P8, CurrentPlayer, AllPlayers or a force."); return; } + const fn = this.unwrap(call.arguments[1]); + if (!ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn)) { this.c.error(fn, "ui.lines() takes a function written where it is used: ui.lines(P1, () => …)."); return; } + if (fn.parameters.length) { this.c.error(fn, "ui.lines()'s function takes nothing: () => …."); return; } + if (fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) { this.c.error(fn, "ui.lines()'s function gives its text at once, every frame: it is not async."); return; } + let view: TS.Expression | undefined = ts.isBlock(fn.body) ? undefined : fn.body; + if (ts.isBlock(fn.body) && fn.body.statements.length === 1 && ts.isReturnStatement(fn.body.statements[0])) view = fn.body.statements[0].expression; + if (!view) { this.c.error(fn.body, "ui.lines()'s function gives back the text and does nothing else: () => <>…."); return; } + const caught = this.loopCapture(fn, call); + if (caught) { this.c.error(caught, `This text reads ${caught.text}, which the loop around ui.lines() changes; the task would find it as it is when it reads it. Keep it in a variable the program changes instead.`); return; } + const task = ++this.taskCount; + const shown = view; + // Put together again when something it reads was written (`views.ts` sets the flag after every such write), and shown + // again once a second whatever happens, before the game's lines fade: the text it last put together, which stays in + // its buffer (put together again each time, a view of eleven lines was tens of thousands of records a second). + // Declared at the top of the program (`views.ts`): a write before this line already sets it. + const dirty = this.newVar("(redraw)", "number", at, { temp: true }); + // The lines after the view, each a space, so that every showing is all the text lines there are: the one before is + // pushed out of sight (it stayed above, the newest lines being the lowest, until it faded). + const fill = this.newVar("(lines to fill)", "number", at, { temp: true }); + this.fillVar = fill; + let text: TextExpr | null = null; + const build = this.collect(() => { text = this.text(shown); }); + if (!text) return; + const body = this.collect(() => { + const age = this.newVar("(frames since shown)", "number", at, { temp: true }); + this.emit({ kind: "declare", decl: age, init: num(REFRESH_FRAMES), at, label }, call); + const loop = this.collect(() => { + // Put together, and then shown this frame. + this.emit({ kind: "if", cond: { kind: "test", expr: varRef(dirty), at, label }, then: [ + { kind: "assign", target: dirty.id, value: num(0), at, label }, + { kind: "block", body: build, at }, + ...this.fillLines(text!, at, label), + { kind: "assign", target: age.id, value: num(REFRESH_FRAMES - 1), at, label }, + ], at, label }, call); + this.emit({ kind: "if", cond: { kind: "compare", op: ">=", left: varRef(age), right: num(REFRESH_FRAMES - 1), at, label }, then: [ + { kind: "print", parts: [{ kind: "value", text: text! }, { kind: "value", text: { kind: "textRepeat", of: { kind: "text", text: "\n " }, count: varRef(fill), at, label } }], to, position: "chat", at, label }, + { kind: "assign", target: age.id, value: num(0), at, label }, + ], else: [{ kind: "assign", target: age.id, value: { kind: "binary", op: "+", left: varRef(age), right: num(1), at, label }, at, label }], at, label }, call); + this.emit({ kind: "sleep", cycles: 1, at, label }, call); + }); + this.emit({ kind: "while", body: loop, at, label }, call); + }); + this.views.push({ dirty: dirty.id, declare: this.mark({ kind: "declare", decl: dirty, init: num(1), at, label }, call), build }); + const run: Call = this.mark({ name: "ui.lines", at, label, params: [], body }, call); + for (const d of declarations(body)) d.task ??= task; + this.emit({ kind: "start", task, max: 1, call: run, ...(handle ? { handle: handle.id } : {}), at, label }, call); + } + + private fillVar: VarDecl | null = null; + + /** How many lines a view lacks of the game's eleven text lines: its line breaks counted, into `fillVar`. */ + private fillLines(text: TextExpr, at: At, label: string): Stmt[] { + const fill = this.fillVar!; + const i = this.newVar("(line break at)", "number", at, { temp: true }); + const nl: TextExpr = { kind: "text", text: "\n" }; + return [ + { kind: "declare", decl: fill, init: num(TEXT_LINES - 1), at, label }, + { kind: "declare", decl: i, init: { kind: "textIndexOf", of: text, find: nl, at, label }, at, label }, + { kind: "while", cond: { kind: "compare", op: ">=", left: varRef(i), right: num(0), at, label }, body: [ + { kind: "assign", target: fill.id, value: { kind: "binary", op: "-", left: varRef(fill), right: num(1), at, label }, at, label }, + { kind: "assign", target: i.id, value: { kind: "textIndexOf", of: text, find: nl, from: { kind: "binary", op: "+", left: varRef(i), right: num(1), at, label }, at, label }, at, label }, + ], at, label }, + ]; + } + + /** `const board = ui.lines(…)`: its task's number, to stop it with. */ + private uiLinesValue(e: TS.CallExpression): NumExpr { + const at = this.at(e), label = this.label(e); + const t = this.newVar("(task)", "number", at, { temp: true }); + this.emit({ kind: "declare", decl: t, init: num(0), at, label }, e); + this.uiLines(e, t); + return varRef(t); + } + + /** + * `on.created(…)`, `on.died(…)`, `on.chat(…)`, `on.key(…)` (TrigScript 4, B2): a task that, from the next frame on, runs + * the handler for every time it happens — a unit the frame's events pass found, a line typed, a key pressed. A handler + * that is not async is inlined in the task's loop; an async one is a start of its own each time, its parameters copies. + */ + private onEvent(call: TS.CallExpression, handle?: VarDecl) { + const { ts } = this; + const what = (call.expression as TS.PropertyAccessExpression).name.text as (typeof ON_EVENTS)[number]; + const at = this.at(call), label = this.label(call); + const named = `on.${what}()`; + if (!this.inAsync(call)) { this.c.error(call, `${named} listens beside the program as a task: at the top of a script, or inside program(async () => { … }).`); return; } + const args = [...call.arguments]; + const isFn = (a: TS.Expression) => { const u = this.unwrap(a); return ts.isArrowFunction(u) || ts.isFunctionExpression(u); }; + const ofUnits = what !== "chat" && what !== "key"; + let filter: EventFilter = {}; + let fnArg: TS.Expression | undefined, optArg: TS.Expression | undefined, word = ""; + if (ofUnits) { + const f = args.findIndex(isFn); + const example = what === "order" ? "on.order(units.ProtossHighTemplar, orders.CastPsionicStorm, (u) => { … })" : `on.${what}(units.ZergZergling, (u) => { … })`; + if (f < 0 || f > (what === "order" ? 2 : 1)) { this.c.error(call, `${named} takes what to look for, if you like, and a function written where it is used: ${example}.`); return; } + if (f >= 1) { const got = this.eventFilter(args[0], named, what); if (!got) return; filter = got; } + // on.order(filter, order, handler): the order as the second argument. + if (f === 2) { + const o = this.evaluate(args[1]); + if (!o || typeof o.value !== "number" || !Number.isInteger(o.value) || o.value < 0 || o.value >= ORDER_NAMES.length) { this.c.error(args[1], `on.order() takes the order after the filter: orders.CastPsionicStorm.`); return; } + if (filter.order !== undefined) { this.c.error(args[1], "The filter already names the order."); return; } + filter.order = o.value; + } + fnArg = args[f]; optArg = args[f + 1]; + if (args.length > f + 2) { this.c.error(args[f + 2], `${named} takes the filter, the function and the options.`); return; } + } else { + const h = args[0] && this.evaluate(args[0]); + if (!h || typeof h.value !== "string") { this.c.error(args[0] ?? call, what === "chat" ? `on.chat() takes the pattern first, as chatted() does: on.chat("-give {n}", (p, { n }) => { … }).` : `on.key() takes the key first, as keyPressed() does: on.key("F7", (p) => { … }).`); return; } + word = h.value; + fnArg = args[1]; optArg = args[2]; + if (!fnArg || !isFn(fnArg)) { this.c.error(args[1] ?? call, `${named} takes a function written where it is used, after the ${what === "chat" ? "pattern" : "key"}.`); return; } + if (args.length > 3) { this.c.error(args[3], `${named} takes the ${what === "chat" ? "pattern" : "key"}, the function and the options.`); return; } + } + // The options: how many async handlers at once, and whose lines or keys. + let max = TASK_MAX; + let from: number | undefined; + if (optArg) { + const h = this.evaluate(optArg); + const o = h?.value as Record | undefined; + if (!o || typeof o !== "object" || Array.isArray(o)) { this.c.error(optArg, `${named}'s options are written out, as { max: 4 }.`); return; } + for (const [k, v] of Object.entries(o)) { + if (v === undefined) continue; + if (k === "max") { + if (typeof v !== "number" || !Number.isInteger(v) || v < 1 || v > 255) { this.c.error(optArg, `${named}'s max is how many of its handlers can be waiting at once: a whole number from 1 to 255.`); return; } + max = v; + } else if (k === "from" && !ofUnits) { + if (typeof v !== "number" || !Number.isInteger(v) || ((v < 0 || v > 7) && v !== CURRENT_PLAYER)) { this.c.error(optArg, `${named}'s from is one player who can type and press keys: P1 … P8, or CurrentPlayer.`); return; } + from = v; + } else { this.c.error(optArg, `${named} has no option "${k}"; it has max${ofUnits ? "" : " and from"}.`); return; } + } + } + const fn = this.unwrap(fnArg) as TS.ArrowFunction | TS.FunctionExpression; + const waits = !!fn.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword); + if (!waits && optArg && max !== TASK_MAX) { this.c.error(optArg, `max is for an async handler: how many can be waiting at once. This one runs within the frame.`); return; } + const caught = this.loopCapture(fn, call); + if (caught) { this.c.error(caught, `This handler reads ${caught.text}, which the loop around ${named} changes; the task would find it as it is when it reads it. Keep it in a variable the program changes instead.`); return; } + const task = ++this.taskCount; + const heard: InputSource[] = []; + const name = (i: number, or: string) => { const p = fn.parameters[i]; return p && ts.isIdentifier(p.name) ? p.name.text : or; }; + // The handler, handed `bound`: inlined, or started — its parameters copies of what it is handed, in the started task's cells. + const run = (bound: Binding[]) => { + if (!waits) { + const c = this.callback(fnArg, named, bound, "void", call); + if (!c) return; + // An error the handler does not catch ends only this turn of it (B4): the listener goes on. + const g: Stmt = { kind: "try", body: [{ kind: "call", call: c, at, label }], catch: [{ kind: "uncaught", at, label }], at, label }; + this.guards.push(g); + this.emit(g, call); + return; + } + const params: Call["params"] = []; + const copy = (b: Binding): Binding => { + if (b.kind === "var") { const v = this.newVar(b.v.name, b.v.kind, b.v.at, { ...(b.v.bits ? { bits: b.v.bits } : {}), ...(b.v.unsigned ? { unsigned: true } : {}) }); params.push({ decl: v, init: refOf(b.v), label }); return { kind: "var", v }; } + if (b.kind === "record") return { ...b, fields: new Map([...b.fields].map(([k, f]) => [k, copy(f)])) }; + return b; + }; + const mine = bound.map(copy); + const inner = ++this.taskCount; + const c = this.callback(fnArg, named, mine, "void", call, true); + if (!c) return; + c.params.unshift(...params); + this.claimTask(c, inner); + this.emit({ kind: "start", task: inner, max, call: c, at, label }, call); + }; + const source: EventSource | undefined = what === "order" ? "ordered" : ofUnits ? what as EventSource : undefined; + const body = this.collect(() => { + const loop = this.collect(() => { + // What happened since the frame before: the first frame of the task, the frame it was started in, looks at nothing. + this.emit({ kind: "sleep", cycles: 1, at, label }, call); + if (source) { + const { order, by, ...units } = filter; + const event = { ...(order !== undefined ? { order } : {}), ...(by !== undefined ? { by } : {}) }; + const dead = source === "died" || source === "killed"; + const v = this.newVar(dead ? "(dead unit)" : name(0, "(unit)"), "unit", at, { temp: true }); + // The second value the handler is handed, when the filter does not say it for certain: the killer, or what the + // unit was doing or was. Only kept when the handler takes it. + const wasAt = source === "ordered" ? 2 : 1; + const known = source === "killed" ? by : undefined; + const was = source !== "created" && source !== "died" && fn.parameters.length > wasAt && known === undefined ? this.newVar(name(wasAt, "(was)"), "number", at, { temp: true }) : undefined; + const turn = this.collect(() => { + const bound: Binding[] = []; + if (dead) { + // What it was and where, taken as it died; what the filter says for certain is known when the script is built. + const fields = new Map(); + const sure: Record = { type: units.type !== undefined && units.type < 228 ? units.type : undefined, owner: units.owner !== undefined && units.owner !== CURRENT_PLAYER ? units.owner : undefined }; + // Only what the handler reads is read from the dead unit: each field is a read of the game. + const wanted = this.fieldsRead(fn, 0); + for (const f of ["type", "owner", "x", "y"] as const) { + const k = sure[f]; + if (k !== undefined) { fields.set(f, { kind: "value", value: k }); continue; } + if (wanted && !wanted.has(f)) { fields.set(f, { kind: "value", value: 0 }); continue; } + const fv = this.newVar(`${name(0, "(dead unit)")}.${f}`, "number", at, { temp: true }); + this.emit({ kind: "declare", decl: fv, init: { kind: "unitField", unit: unitRef(v), field: f, at, label }, at, label }, call); + fields.set(f, { kind: "var", v: fv }); + } + bound.push({ kind: "record", fields }); + } else bound.push({ kind: "var", v }); + if (source === "ordered" && fn.parameters.length > 1) { + // The order it took up: known when the filter names it, else read from the unit as it is now. + if (order !== undefined) bound.push({ kind: "value", value: order }); + else { + const ov = this.newVar(name(1, "(order)"), "number", at, { temp: true }); + this.emit({ kind: "declare", decl: ov, init: { kind: "unitField", unit: unitRef(v), field: "orderId", at, label }, at, label }, call); + bound.push({ kind: "var", v: ov }); + } + } + if (source === "killed" && known !== undefined) bound.push({ kind: "value", value: known }); + else if (source === "killed" && was) { + // The killer is one of eight players: a copy of the handler for each, so that it is known when the script + // is built there and an action can be for them (on.chat's player is the same). + for (let p = 0; p < 8; p++) { + const then = this.collect(() => run([...bound, { kind: "value", value: p }])); + this.emit({ kind: "if", cond: { kind: "compare", op: "==", left: varRef(was), right: num(p), at, label }, then, at, label }, call); + } + return; + } else if (was) bound.push({ kind: "var", v: was }); + run(bound); + }); + this.emit({ kind: "unitLoop", decl: v, filter: units, source, ...(was ? { was } : {}), ...(Object.keys(event).length ? { event } : {}), body: turn, at, label }, call); + } else { + const players = from !== undefined ? [from] : this.c.perPlayer ? [CURRENT_PLAYER] : [0, 1, 2, 3, 4, 5, 6, 7]; + let parsed: ChatPattern | undefined; + let key = ""; + if (what === "chat") { + try { parsed = parseChatPattern(word); } catch (err) { this.c.error(args[0], `on.chat(): ${err instanceof Error ? err.message : String(err)}`); return; } + } else { + const deaf = DEAF_KEYS[word.trim().toUpperCase()]; + const k = keyName(word); + if (deaf || !k) { this.c.error(args[0], deaf ? `on.key(): ${deaf}.` : `on.key(): the key is one of ${KEY_NAMES.slice(0, 3).map((x) => JSON.stringify(x)).join(", ")} … "F1" … "Space", "Enter", "Escape", "Left" …; got ${JSON.stringify(word)}.`); return; } + key = k; + } + for (const p of players) { + const input = (source: InputSource): NumExpr => ({ kind: "input", input: source, at, label }); + const source: InputSource = what === "chat" ? { source: "chat", pattern: word, capture: null, player: p } : { source: "key", key, player: p }; + heard.push(source); + const happened = input(source); + const turn = this.collect(() => { + const bound: Binding[] = [{ kind: "value", value: p }]; + if (parsed) { + const fields = new Map(); + parsed.captures.forEach((c, i) => { + const fv = this.newVar(`${name(1, "(typed)")}.${c.name}`, "number", at, { temp: true }); + this.emit({ kind: "declare", decl: fv, init: input({ source: "chat", pattern: word, capture: i, player: p }), at, label }, call); + fields.set(c.name, { kind: "var", v: fv }); + }); + bound.push({ kind: "record", fields }); + } + run(bound); + }); + this.emit({ kind: "if", cond: { kind: "test", expr: happened, at, label }, then: turn, at, label }, call); + } + } + }); + this.emit({ kind: "while", body: loop, at, label }, call); + }); + const listener: Call = this.mark({ name: named, at, label, params: [], body }, call); + this.claimTask(listener, task); + this.emit({ kind: "start", task, max: 1, call: listener, ...(handle ? { handle: handle.id } : {}), listen: source ? { source } : { inputs: heard }, at, label }, call); + } + + /** + * The fields of a function's parameter its body reads — `u.x`, `const { type } = u` — or null when the parameter is used + * some other way (handed on whole), and then every field is wanted. + */ + private fieldsRead(fn: TS.ArrowFunction | TS.FunctionExpression, index: number): Set | null { + const { ts } = this; + const p = fn.parameters[index]; + if (!p) return new Set(); + const out = new Set(); + if (ts.isObjectBindingPattern(p.name)) { + for (const el of p.name.elements) { + if (el.dotDotDotToken) return null; + const key = el.propertyName ?? el.name; + if (!ts.isIdentifier(key)) return null; + out.add(key.text); + } + return out; + } + if (!ts.isIdentifier(p.name)) return null; + const symbol = this.c.checker.getSymbolAtLocation(p.name); + let whole = false; + const walk = (n: TS.Node): void => { + if (whole) return; + if (ts.isIdentifier(n) && n !== p.name && this.c.checker.getSymbolAtLocation(n) === symbol) { + const parent = n.parent; + if (ts.isPropertyAccessExpression(parent) && parent.expression === n) out.add(parent.name.text); + else whole = true; + return; + } + ts.forEachChild(n, walk); + }; + walk(fn.body); + return whole ? null : out; + } + + /** `const deaths = on.died(…)`: its task's number, to stop it with. */ + private onEventValue(e: TS.CallExpression): NumExpr { + const at = this.at(e), label = this.label(e); + const t = this.newVar("(task)", "number", at, { temp: true }); + this.emit({ kind: "declare", decl: t, init: num(0), at, label }, e); + this.onEvent(e, t); + return varRef(t); + } + + /** + * What an on.…() of units looks for: a unit type, or a filter as unitsOf() takes one — with the order for on.order() and + * the killer for on.killed(); known when the script is built. + */ + private eventFilter(arg: TS.Expression, named: string, what: string): EventFilter | null { + const h = this.evaluate(arg); + if (!h) { this.notConstant(arg, `What ${named} looks for`); return null; } + const v = h.value; + const out: EventFilter = {}; + const extra = what === "order" ? "order" : what === "killed" ? "by" : null; + const fail = () => { this.c.error(arg, `${named} looks for a unit type — units.ZergZergling — or a filter such as { type: units.TerranMarine, owner: P1, at: locations.Base${extra === "order" ? ", order: orders.Move" : extra === "by" ? ", by: P2" : ""} }.`); return null; }; + if (typeof v === "number") out.type = v; + else if (v && typeof v === "object" && !Array.isArray(v) && !isGameValue(v)) { + for (const [k, x] of Object.entries(v as Record)) { + if (x === undefined || x === null) continue; + if ((k !== "type" && k !== "owner" && k !== "at" && k !== extra) || typeof x !== "number" || !Number.isInteger(x)) return fail(); + out[k as keyof EventFilter] = x; + } + } else return fail(); + if (out.type === 229) delete out.type; + if (out.type !== undefined && (out.type < 0 || out.type > 232)) return fail(); + if (out.owner !== undefined && (out.owner < 0 || (out.owner > 11 && out.owner !== CURRENT_PLAYER))) { this.c.error(arg, `${named}'s owner is one player: P1 … P12, or CurrentPlayer.`); return null; } + if (out.at !== undefined && (out.at === 0 || out.at === 64)) delete out.at; + if (out.at !== undefined && (out.at < 0 || out.at > 255)) return fail(); + if (out.order !== undefined && (out.order < 0 || out.order >= ORDER_NAMES.length)) { this.c.error(arg, `${named}'s order is one of orders.*: orders.Move, orders.CastPsionicStorm …`); return null; } + // A killer is one of the eight players who can fight (a neutral player's units do not attack), or CurrentPlayer. + if (out.by !== undefined && (out.by < 0 || (out.by > 7 && out.by !== CURRENT_PLAYER))) { this.c.error(arg, `${named}'s by is the player who killed the unit: P1 … P8, or CurrentPlayer.`); return null; } + return out; + } + + /** What a task's body declares is the task's own: a cell of each a slot. A task started inside it marked its own first. */ + private claimTask(c: Call, task: number) { + for (const d of [...c.params.map((p) => p.decl), ...(c.result ? [c.result.decl] : []), ...declarations(c.body)]) d.task ??= task; + const arrays = new Set(); + const visit = (root: unknown): void => { + if (Array.isArray(root)) { root.forEach(visit); return; } + if (!root || typeof root !== "object") return; + const o = root as Record; + if (o.kind === "declareArray" && typeof o.array === "string") arrays.add(o.array); + Object.values(o).forEach((x) => { if (x && typeof x === "object") visit(x); }); + }; + visit(c.body); + for (const a of this.arrays) if (arrays.has(a.id)) a.task ??= task; + } + + /** `names.join(", ")`: the texts one after another with the separator between, made where the call stands. */ + private textsJoin(e: TS.CallExpression, of: Texts): TextExpr | null { + const at = this.at(e); + const label = this.label(e); + const between = e.arguments[0] ? this.text(e.arguments[0]) : { kind: "text" as const, text: "," }; + if (!between) return null; + const sep = this.newVar("(separator)", "text", at, { temp: true, text: "made" }); + const out = this.newVar(`(${of.name}.join)`, "text", at, { temp: true, text: "made" }); + const i = this.newVar(`(index of ${of.name})`, "number", at, { temp: true }); + const add = (t: TextExpr): Stmt => ({ kind: "assignText", target: out.id, value: { kind: "template", parts: [{ kind: "value", text: { kind: "textVar", id: out.id } }, { kind: "value", text: t }], at, label }, at, label }); + // A call where the text is used: what the array holds then. + const result = this.newVar(`(${of.name}.join result)`, "text", at, { temp: true, text: "made" }); + return this.mark({ kind: "textCall", call: { name: `${of.name}.join`, at, label, params: [{ decl: sep, init: between, label }], result: { decl: result, kind: "text" }, body: [ + { kind: "declare", decl: out, init: { kind: "text", text: "" }, at, label }, + { kind: "declare", decl: i, init: num(0), at, label }, + { kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: { kind: "length", array: [...of.rows.fields.values()][0].id, at }, at, label }, update: [{ kind: "assign", target: i.id, value: { kind: "binary", op: "+", left: varRef(i), right: num(1), at, label }, at, label }], + body: [{ kind: "if", cond: { kind: "compare", op: ">", left: varRef(i), right: num(0), at, label }, then: [add({ kind: "textVar", id: sep.id })], at, label }, add(this.textAtCells(this.textAtIndex(of, varRef(i)), e))], at, label }, + { kind: "return", value: { kind: "textVar", id: out.id }, at, label }, + ] } }, e); + } + + /** `xs.join(sep)` of an array of numbers or booleans: their texts with `sep` between (a comma when it is left out). */ + private arrayJoin(e: TS.CallExpression, a: ArrayDecl): TextExpr | null { + const b = this.irAt(e); + const between = e.arguments[0] ? this.text(e.arguments[0]) : ({ kind: "text", text: "," } as TextExpr); + if (!between) return null; + const sep = b.txt("(separator)"), out = b.txt(`(${a.name}.join)`), i = b.num(`(index of ${a.name})`); + const cell: NumExpr = { kind: "element", array: a.id, index: b.ref(i), at: b.at }; + const item: TextPart[] = a.kind === "number" ? [{ kind: "number", expr: cell, ...(a.unsigned ? { unsigned: true } : {}), ...(a.fixed ? { fixed: true } : {}) }] + : [{ kind: "value", text: { kind: "textTernary", cond: { kind: "element", array: a.id, index: b.ref(i), at: b.at }, whenTrue: { kind: "text", text: "true" }, whenFalse: { kind: "text", text: "false" }, at: b.at, label: b.label } }]; + // A call where the text is used: what the array holds then, after anything the same statement did to it before. + return this.mark({ kind: "textCall", call: b.call(`${a.name}.join`, "text", [[sep, between]], [ + b.declare(out, { kind: "text", text: "" }), + b.declare(i, b.n(0)), + b.loop(b.cmp("<", b.ref(i), { kind: "length", array: a.id, at: b.at }), [ + b.when(b.cmp(">", b.ref(i), b.n(0)), [b.setText(out, b.join(b.tref(out), b.tref(sep)))]), + b.setText(out, { kind: "template", parts: [{ kind: "value", text: b.tref(out) }, ...item], at: b.at, label: b.label }), + b.set(i, b.add(b.ref(i), b.n(1))), + ]), + b.ret(b.tref(out)), + ]) }, e); + } + + /** A loop over an array of texts: `turn` is called, where the body goes, with the text at each place and the place. As many as there are when the loop starts. */ + private textsLoop(of: Texts, node: TS.Node, turn: (item: Binding, index: VarDecl) => void) { + const at = this.at(node); + const label = this.label(node); + const i = this.newVar(`(index of ${of.name})`, "number", at, { temp: true }); + const n = this.newVar(`(length of ${of.name})`, "number", at, { temp: true }); + const body = this.collect(() => turn(this.textAtIndex(of, varRef(i)), i)); + this.emit({ kind: "declare", decl: n, init: { kind: "length", array: [...of.rows.fields.values()][0].id, at }, at, label }, node); + this.emit({ kind: "declare", decl: i, init: num(0), at, label }, node); + this.emit({ kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: varRef(n), at, label }, update: [{ kind: "assign", target: i.id, value: { kind: "binary", op: "+", left: varRef(i), right: num(1), at, label }, at, label }], body, at, label }, node); + } + + /* ── A Map and a Set over any number ── */ + + /** Where each such table was declared: where its functions say they are from. */ + private readonly hashNodes = new WeakMap(); + + /** + * `new Map()`, `new Set()`: keys that are any number, where a table keyed by ids of the game has + * a cell for every id there is. The entries are kept in the order they went in — three arrays that grow, a key, a + * value and whether it is still there — which is the order JavaScript goes through a Map in: a key set again stays + * where it was, one deleted and set again goes to the end, one added while a loop goes through is reached by it. A + * key is found through `slots`, open addressing over a power of two of cells, made again at twice the size when three + * quarters of it is taken. The work is in functions of the table's own (`hashFunction`), called where it is used. + */ + private declareHash(name: string, as: "map" | "set", init: TS.Expression, type: TS.Type, at: TS.Node): Hash | null { + const { ts } = this; const args = this.c.checker.getTypeArguments(type as TS.TypeReference); const valueType = as === "map" ? args[1] : undefined; const kind = valueType ? this.kindOf(valueType) : null; + if (as === "map" && kind !== "number" && kind !== "boolean" && valueType && this.instanceClasses(valueType) && this.needRef(this.instanceClasses(valueType)!)) { this.c.error(at, `${name} holds instances: they are kept by reference.`); return null; } if (as === "map" && kind !== "number" && kind !== "boolean") { this.c.error(at, `${name} holds numbers or booleans; for anything more, keep the place of a row of an array of records in it.`); return null; } const where = this.sourceOf(at); const atIr = this.at(at); @@ -4921,7 +8562,7 @@ export class Structured { for (const [v, first] of [[h.size, 0], [h.mask, HASH_START - 1], [h.used, 0], [h.dead, 0], [h.walking, 0]] as const) this.emit({ kind: "declare", decl: v, init: num(first), at: atIr, label }, at); // What it starts with: worked out whole when nothing of the program is in it, else the pairs as they are written. const whole = this.evaluate(init)?.value; - const constant = (v: unknown): NumExpr | BoolExpr | null => (h.values?.kind === "boolean" ? (typeof v === "boolean" ? { kind: "const", value: v } : null) : (() => { const n = this.asInteger({ value: v }, init); return n === null ? null : num(n); })()); + const constant = (v: unknown): NumExpr | BoolExpr | null => (h.values?.kind === "boolean" ? (typeof v === "boolean" ? { kind: "const", value: v } : null) : this.constOf({ value: v }, init)); if (unitKeys && (whole instanceof Map || whole instanceof Set) && whole.size > 0) { this.c.error(init, `${name}'s keys are units of the game, which the script has none of: it starts empty.`); return null; } if (whole instanceof Map || whole instanceof Set) { for (const [k, v] of whole instanceof Map ? whole.entries() : [...whole.values()].map((x) => [x, true] as const)) { @@ -5224,7 +8865,11 @@ export class Structured { }); this.emit(step(h.walking, "+"), node); this.emit({ kind: "declare", decl: i, init: num(0), at, label }, node); - this.emit({ kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: { kind: "length", array: h.keys.id, at }, at, label }, update: [step(i, "+")], body: [{ kind: "if", cond: { kind: "element", array: h.live.id, index: varRef(i), at }, then: leaving(body), at, label }], at, label }, node); + const loop: Stmt = { kind: "for", cond: { kind: "compare", op: "<", left: varRef(i), right: { kind: "length", array: h.keys.id, at }, at, label }, update: [step(i, "+")], body: [{ kind: "if", cond: { kind: "element", array: h.live.id, index: varRef(i), at }, then: leaving(body), at, label }], at, label }; + // An error out of the body leaves the loop too (B4): counted out on its way, once the program is known to throw. + const held: Stmt & { kind: "block" } = { kind: "block", body: [loop], at }; + this.unwinds.push({ held, cleanup: [step(h.walking, "-")] }); + this.emit(held, node); this.emit(step(h.walking, "-"), node); } @@ -5242,20 +8887,62 @@ export class Structured { /** The constructors being walked, innermost last: whose `super(…)` a call of it is, the instance, and the name its fields are declared under. */ private readonly constructing: { cls: TS.ClassDeclaration; self: Instance; name: string }[] = []; - /** The class an expression names, when it is one declared in the body. */ + /** The class an expression names, when it is one declared in the body or one of the script's own (`scriptClass`). */ private classOf(expr: TS.Expression): TS.ClassDeclaration | undefined { const { ts } = this; const e = this.unwrap(expr); - if (!ts.isIdentifier(e)) return undefined; - const decl = declarationOf(ts, this.c.checker, e); - return decl && ts.isClassDeclaration(decl) && this.body.plan.game.has(decl) ? decl : undefined; + // `new Geo.Point(…)`: a class of a namespace. + const id = ts.isIdentifier(e) ? e : ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.name) && this.isNamespace(this.unwrap(e.expression)) ? e.name : undefined; + if (!id) return undefined; + const decl = declarationOf(ts, this.c.checker, id); + return decl && ts.isClassDeclaration(decl) && (this.body.plan.game.has(decl) || this.scriptClass(decl)) ? decl : undefined; } - /** The class a type is an instance of, when it is one declared in the body. */ + /** The class a type is an instance of, when it is one declared in the body or one of the script's own. */ private classOfType(type: TS.Type): TS.ClassDeclaration | undefined { const { ts } = this; const decl = type.getSymbol()?.valueDeclaration; - return decl && ts.isClassDeclaration(decl) && this.body.plan.game.has(decl) ? decl : undefined; + return decl && ts.isClassDeclaration(decl) && (this.body.plan.game.has(decl) || this.scriptClass(decl)) ? decl : undefined; + } + + /** + * A class declared at the top of one of the script's files: the program's wherever a program makes an instance of it, + * its bodies walked in their own file with what they compute when the script is built (`hoist.ts#planClass`). Known + * once `run` has declared it — its static fields — before the program's first statement. + */ + private scriptClass(decl: TS.ClassDeclaration): boolean { + return this.classBodies.has(decl) && isScriptClass(this.ts, decl); + } + + /** The script's classes a program uses — the classes it names, and theirs in turn, what they extend first — declared before its first statement. */ + private declareScriptClasses(root: TS.Node) { + const { ts } = this; + const seen = new Set(); + const visit = (n: TS.Node) => { + if (ts.isIdentifier(n)) { + const decl = declarationOf(ts, this.c.checker, n); + if (decl && isScriptClass(ts, decl) && !seen.has(decl)) use(decl); + // A game() function the program calls, wherever it is written: the classes it names too. + const init = decl && ts.isVariableDeclaration(decl) ? decl.initializer : undefined; + if (init && ts.isCallExpression(init) && !followed.has(init) && libraryCallName(ts, this.c.checker, init) === "game") { followed.add(init); init.arguments.forEach(visit); } + // And a function at the top of a file it calls (a library's `async function scene()`): the classes its body names. + const top = decl && (isScriptFunction(ts, decl) ? decl : scriptArrow(ts, decl)); + if (top && !followed.has(top)) { followed.add(top); visit(top); } + } + ts.forEachChild(n, visit); + }; + const followed = new Set(); + const use = (cls: TS.ClassDeclaration) => { + seen.add(cls); + const body = this.c.classBody?.(cls); + if (!body) return; + // What it extends, and the classes its bodies name, first: a static field's first value may make one. + visit(cls); + if (this.classBodies.has(cls)) return; + const saved = this.enterBody(body); + try { this.declareClass(cls); } finally { this.leaveBody(saved); } + }; + visit(root); } /** What a class extends: a class of the body, null when it extends nothing, undefined — with a diagnostic — when it extends anything else. */ @@ -5263,6 +8950,8 @@ export class Structured { const { ts } = this; const clause = cls.heritageClauses?.find((h) => h.token === ts.SyntaxKind.ExtendsKeyword)?.types[0]; if (!clause) return null; + // `extends Error`: an error class (B4), whose base is the language's; what super(…) makes of it is `errorFields`. + if (this.builtinError(clause.expression)) return null; const base = this.classOf(clause.expression); if (!base) this.c.error(clause, `${this.className(cls)} extends ${clause.expression.getText(this.body.sf)}, which is not a class declared in this program: a class of a program extends another one of it.`); return base; @@ -5307,10 +8996,9 @@ export class Structured { private declareClass(cls: TS.ClassDeclaration) { const { ts } = this; this.classBodies.set(cls, this.body); - if (cls.typeParameters?.length) this.c.error(cls.typeParameters[0], "A class of a program takes no type parameters: what each field holds has to be known when the script is built."); + for (const m of cls.members) { if (ts.isClassStaticBlockDeclaration(m)) { this.c.error(m, "A static block is not supported in a program; give the static fields their values where they are declared."); continue; } - if (ts.isMethodDeclaration(m) && (m.asteriskToken || ts.getModifiers(m)?.some((x) => x.kind === ts.SyntaxKind.AsyncKeyword))) this.c.error(m, "Generators and async functions are not supported in a program."); if (!ts.isPropertyDeclaration(m) || !this.isStatic(m)) continue; const key = this.memberKey(m.name); if (!key) { this.c.error(m.name, "A field's name is written out."); continue; } @@ -5324,8 +9012,10 @@ export class Structured { const cls = this.classOf(e.expression); if (!cls) { this.c.error(e.expression, "new makes an instance of a class declared in the program."); return null; } if (this.constructing.length >= MAX_INLINE_DEPTH) { this.c.error(e, `${this.className(cls)} makes another instance while it is being made, sixteen deep: an instance is a set of variables of its own, made when the script is built, so this would have no end.`); return null; } - const self: Instance = { kind: "record", fields: new Map(), cls }; - return this.construct(cls, e.arguments ?? [], self, e, name) ? self : null; + // `new Signal()`: the instance's type parameters, in force for its fields, its constructor and every method called on it. + const typeArgs = this.classTypeArgs(cls, e); + const self: Instance = { kind: "record", fields: new Map(), cls, ...(typeArgs ? { typeArgs } : {}) }; + return this.withTypeArgs(typeArgs, () => this.construct(cls, e.arguments ?? [], self, e, name)) ? self : null; } private construct(cls: TS.ClassDeclaration, args: readonly TS.Expression[], self: Instance, at: TS.Expression, name: string): boolean { @@ -5338,6 +9028,7 @@ export class Structured { if (!ctor) { // No constructor of its own: what it extends is made from the same arguments, then its own fields get their values. if (base) { if (!this.construct(base, args, self, at, name)) return false; } + else if (this.extendsBuiltin(cls)) { if (!this.errorFields(self, args[0], name, at)) return false; } else if (args.length) { this.c.error(at, `${this.className(cls)} has no constructor, so new ${this.className(cls)}() takes nothing.`); return false; } const scope = new Scope(target === this.c.body ? this.topScope : null); scope.bind(THIS, self); @@ -5361,177 +9052,1369 @@ export class Structured { if (field) self.fields.set(p.name.text, field); } // A class that extends another gets its fields' values when `super(…)` comes back; one that does not, before its constructor's first line. - if (!base) this.declareFields(cls, self, name); + if (!base && !this.extendsBuiltin(cls)) this.declareFields(cls, self, name); }, }); if (!call) return false; this.emit({ kind: "call", call, at: call.at, label: call.label }, at); - if (base && !this.superCalled.delete(ctor)) this.c.error(ctor, `${this.className(cls)} extends ${this.className(base)}, so its constructor calls super(…) before anything else.`); + const builtin = this.extendsBuiltin(cls); + if ((base || builtin) && !this.superCalled.delete(ctor)) this.c.error(ctor, `${this.className(cls)} extends ${base ? this.className(base) : builtin}, so its constructor calls super(…) before anything else.`); return true; } finally { this.constructing.pop(); } } - private readonly superCalled = new Set(); + private readonly superCalled = new Set(); + + /** `super(…)` inside a constructor: what the class extends is made on the same instance, and then the class's own fields get their values. */ + private superCall(e: TS.CallExpression) { + const top = this.constructing[this.constructing.length - 1]; + const cls = this.enclosingClass(e); + const base = cls && this.baseOf(cls); + const builtin = cls && this.extendsBuiltin(cls); + if (!top || !cls || top.cls !== cls || (!base && !builtin)) { this.c.error(e, "super(…) is the first thing the constructor of a class that extends another does."); return; } + const ctor = cls.members.find((m) => this.ts.isConstructorDeclaration(m) && !!m.body); + if (ctor) this.superCalled.add(ctor); + // `super(message)` of a class that extends Error: the message and the name, then the class's own fields. + if (!base) { if (this.errorFields(top.self, e.arguments[0], top.name, e)) this.declareFields(cls, top.self, top.name); return; } + if (this.construct(base, e.arguments, top.self, e, top.name)) this.declareFields(cls, top.self, top.name); + } + + /** A class's own fields, each with the value it is declared with — or 0, false, no unit, no text. `this` is bound where this runs. */ + private declareFields(cls: TS.ClassDeclaration, self: Instance, name: string) { + const { ts } = this; + for (const m of cls.members) { + if (!ts.isPropertyDeclaration(m) || this.isStatic(m)) continue; + const key = this.memberKey(m.name); + if (!key) { this.c.error(m.name, "A field's name is written out."); continue; } + // `declare hp: number`, or a field what the class extends already made and this one only types again. + if (!m.initializer && self.fields.has(key)) continue; + // On a row the cells are there already, and nothing: the field's first value is stored into them. + if (this.onRow.has(self)) { if (m.initializer) this.rowFieldGiven(self, key, { expr: m.initializer }, m); continue; } + const held = this.declareField(`${name}.${key}`, m.initializer, this.c.checker.getTypeAtLocation(m.name), this.c.checker.getSymbolAtLocation(m.name), m, m.name, m); + if (held) self.fields.set(key, held); + } + } + + /** A field that a constructor's parameter declares (`public owner: Player`): a variable of its own that starts as the argument — an array, a record or an instance handed over is itself. */ + private parameterField(from: Binding, full: string, p: TS.ParameterDeclaration): Binding | null { + if (from.kind !== "value") return this.takenCopy(from, full, p); + const type = this.c.checker.getTypeAtLocation(p.name); + if (typeof from.value === "string" && this.isTextType(type)) { + const [, prop] = this.c.checker.getSymbolsOfParameterPropertyDeclaration(p, (p.name as TS.Identifier).text); + const keptAs = hasTextMark(from.value) || !prop ? "made" : this.textKept(undefined, (left) => this.ts.isPropertyAccessExpression(left) && this.c.checker.getSymbolAtLocation(left.name) === prop); + const v = this.newVar(full, "text", this.sourceOf(p.name), { text: keptAs }); + this.emit({ kind: "declare", decl: v, init: this.literalText(from.value, p), at: this.at(p), label: this.label(p) }, p); + return { kind: "var", v }; + } + const kind = this.kindOf(type); + if (kind === "unit" && (from.value === null || from.value === undefined)) { + const v = this.newVar(full, "unit", this.sourceOf(p.name)); + this.emit({ kind: "declare", decl: v, init: NO_UNIT, at: this.at(p), label: this.label(p) }, p); + return { kind: "var", v }; + } + if ((kind === "number" && typeof from.value === "number") || (kind === "boolean" && typeof from.value === "boolean")) return this.fieldCopy(from, full, p); + this.c.error(p, `${full} would hold ${describe(from.value)}, which only the script has: a field holds a number, a boolean, a text, a unit, an array or a record.`); + return null; + } + + /** + * A call of a method — `s.add(u)`, `super.hit(n)`, `Squad.make()` — as the call of a function it is, the instance + * bound to `this`. Undefined when the call is not of a method of a class of the program; null, with a diagnostic, when + * it is and cannot be made. + */ + private methodCall(e: TS.CallExpression): Call | null | undefined { + const { ts } = this; + const callee = this.unwrap(e.expression); + if (!ts.isPropertyAccessExpression(callee)) return undefined; + const ofCaught = this.caughtMember(e, callee, false); + if (ofCaught !== undefined) return ofCaught; + const found = this.memberAt(callee, (m, name) => ts.isMethodDeclaration(m) && this.memberKey(m.name) === name); + if (!found) return found; + const run = (member: TS.ClassElement, self: Binding | undefined, name: string): Call | null => { + const method = member as TS.MethodDeclaration; + if (!method.body) { this.c.error(e, `${name} has no body here: it is abstract, and what this instance is does not give it one.`); return null; } + return this.inline(e, method.parameters, method.body, this.classBodies.get(method.parent as TS.ClassDeclaration) ?? this.body, name, method, { ...(self ? { self } : {}), ...(found.before ? { before: found.before } : {}) }) ?? null; + }; + const call = found.virtual ? this.virtualCall(e, found.name, found.self as Instance, found.virtual, (self, member) => run(member, self, `${this.className(member.parent as TS.ClassDeclaration)}.${callee.name.text}`)) : run(found.member, found.self, found.name); + // `target?.hit(3)` (B9): nothing called when there is no instance. + const ref = found.self?.kind === "record" ? found.self.ref : undefined; + if (call && ref && callee.questionDotToken) return this.whenThere(call, ref.place, e); + return call; + } + + /** A call made only when a reference is not none (`target?.hit(3)`): a call of its own around it, which gives 0 when it is. */ + private whenThere(call: Call, place: NumExpr, e: TS.Expression): Call { + const at = this.at(e), label = this.label(e); + const out: Call = { name: call.name, at, label, params: [], body: [] }; + if (call.result) out.result = { decl: this.newVar(`(${call.name ?? "call"} result)`, call.result.kind, at, { temp: true }), kind: call.result.kind }; + const run: Stmt = call.result ? { kind: "return", value: call.result.kind === "text" ? { kind: "textCall", call } : { kind: "call", call } as NumExpr, at, label } : { kind: "call", call, at, label }; + out.body = [{ kind: "if", cond: { kind: "compare", op: "!=", left: place, right: num(0), at, label }, then: [run], at, label }]; + return this.mark(out, e); + } + + /** `s.size` where `size` is a getter: the call it is. Undefined when it is not one. */ + private getterCall(expr: TS.Expression): Call | null | undefined { + const { ts } = this; + const e = this.unwrap(expr); + if (!ts.isPropertyAccessExpression(e)) return undefined; + const ofCaught = this.caughtMember(e, e, true); + if (ofCaught !== undefined) return ofCaught; + const found = this.memberAt(e, (m, name) => ts.isGetAccessorDeclaration(m) && this.memberKey(m.name) === name, true); + if (!found) return found; + const run = (member: TS.ClassElement, self: Binding | undefined, name: string): Call | null => { + const getter = member as TS.GetAccessorDeclaration; + return this.inline(e, [], getter.body, this.classBodies.get(getter.parent as TS.ClassDeclaration) ?? this.body, name, getter, { self, args: [], ...(found.before ? { before: found.before } : {}) }) ?? null; + }; + if (found.virtual) return this.virtualCall(e, found.name, found.self as Instance, found.virtual, (self, member) => run(member, self, `${this.className(member.parent as TS.ClassDeclaration)}.${e.name.text}`)); + return run(found.member, found.self, found.name); + } + + /** `s.hp = 5` where `hp` is a setter: the call it is, emitted. False when it is not one. */ + private setterCall(e: TS.BinaryExpression, op: TS.SyntaxKind): boolean { + const { ts } = this; + const left = this.unwrap(e.left); + if (!ts.isPropertyAccessExpression(left)) return false; + const found = this.memberAt(left, (m, name) => ts.isSetAccessorDeclaration(m) && this.memberKey(m.name) === name, true); + if (found === undefined) return false; + if (!found) return true; + if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, `${found.name} is a setter, which takes = only: read it, work the value out, and assign that.`); return true; } + const run = (member: TS.ClassElement, self: Binding | undefined, name: string): Call | null => { + const setter = member as TS.SetAccessorDeclaration; + return this.inline(e, setter.parameters, setter.body, this.classBodies.get(setter.parent as TS.ClassDeclaration) ?? this.body, name, setter, { self, args: [e.right], nothing: true, ...(found.before ? { before: found.before } : {}) }) ?? null; + }; + const call = found.virtual ? this.virtualCall(e, found.name, found.self as Instance, found.virtual, (self, member) => run(member, self, `${this.className(member.parent as TS.ClassDeclaration)}.${left.name.text}`), true) : run(found.member, found.self, found.name); + if (call) this.emit({ kind: "call", call, at: call.at, label: call.label }, e); + return true; + } + + /** + * A call that gives an instance — `make(3)`, `v.add(w)` where `add` returns `this`: the call made, as a statement, and + * the instance it settled on. Undefined when the call gives no instance of a class of the program; null with a diagnostic. + */ + private instanceCall(e: TS.CallExpression, name: string, into?: Stmt[]): Binding | null | undefined { + const { ts } = this; + const type = this.c.checker.getTypeAtLocation(e); + // A reference (B9) is a number a call gives, as any number is. + if (this.refClasses(type)) return undefined; + const record = !this.classOfType(type) && this.plainRecordType(type); + // `const xs = evens(n)`: an array a function of the script's makes and gives back, the call's own. + const list = !this.classOfType(type) && !record && (this.c.checker.isArrayType(type) || this.c.checker.isTupleType(type)); + if (!this.classOfType(type) && !record && !list) return undefined; + const instance: { name: string; made?: Binding; list?: boolean } = { name, ...(list ? { list } : {}) }; + let call: Call | undefined; + const callee = this.unwrap(e.expression); + if (ts.isIdentifier(callee)) { + const decl = this.gameDeclaration(callee); + // `function counter() { let c = 0; return { inc: () => c++, get: () => c }; }`: a record the call makes, a call's own. + const fn = decl && ts.isFunctionDeclaration(decl) ? undefined : this.fnValueOf(callee); + if (fn) call = this.inline(e, fn.parameters, fn.body, fn.target as Body, this.functionName(fn), fn.decl, { instance, closure: fn.closure }); + else if (!decl || !ts.isFunctionDeclaration(decl)) { if (record || list) return undefined; this.c.error(e, "A function that gives an instance is one declared in the program."); return null; } + else call = this.inline(e, decl.parameters, decl.body, this.body, decl.name?.text, decl, { instance }); + } else if (record || (list && !(ts.isPropertyAccessExpression(callee) && this.classOfType(this.c.checker.getTypeAtLocation(callee.expression))))) { + return undefined; + } else if (ts.isPropertyAccessExpression(callee)) { + const found = this.memberAt(callee, (m, n) => ts.isMethodDeclaration(m) && this.memberKey(m.name) === n); + if (!found) return null; + const method = found.member as TS.MethodDeclaration; + call = this.inline(e, method.parameters, method.body, this.classBodies.get(method.parent as TS.ClassDeclaration) ?? this.body, found.name, method, { ...(found.self ? { self: found.self } : {}), ...(found.before ? { before: found.before } : {}), instance }); + } + if (!call) return null; + // `into`: the call is part of a larger one's chain, and runs inside it; else it runs here. + if (into) into.push(this.mark({ kind: "call", call, at: call.at, label: call.label }, e)); else this.emit({ kind: "call", call, at: call.at, label: call.label }, e); + if (!instance.made) { this.c.error(e, `This call gave no ${list ? "array" : record ? "record" : "instance"}: every way through the function has to return one.`); return null; } + return instance.made; + } + + /** `xs.find(…)` of an array of records or instances: the row at the place found (row 0 when none is, never read then), with whether one was. */ + private foundRow(e: TS.CallExpression, over: Over & { kind: "records" }, name: string, at: TS.Node): Binding | null { + const method = (e.expression as TS.PropertyAccessExpression).name.text === "find" ? "findIndex" : "findLastIndex"; + const index = this.searchCall(e, over, method, "number") as NumExpr | null; + if (!index) return null; + const where = this.at(at), label = this.label(at); + const found = this.newVar(`${name} (place found)`, "number", where, { temp: true }); + this.emit({ kind: "declare", decl: found, init: index, at: where, label }, at); + const truth = this.newVar(`${name} (found)`, "boolean", this.sourceOf(at)); + this.emit({ kind: "declare", decl: truth, init: { kind: "compare", op: ">=", left: varRef(found), right: num(0), at: where, label }, at: where, label }, at); + const place = this.newVar(`${name} (row of ${over.name})`, "number", where); + this.emit({ kind: "declare", decl: place, init: { kind: "intrinsic", name: "max", args: [varRef(found), num(0)], at: where, label }, at: where, label }, at); + const row = this.rowOf(over, varRef(place)); + return row.kind === "record" ? { ...row, truth } : row; + } + + /** The kinds of binding an array is. */ + private static readonly LIST_KINDS: ReadonlySet = new Set(["array", "records", "units", "texts", "grid", "lists"]); + + /** A type of records a function may give: an object with fields, not an array, a function, a class's instance or one of the library's. */ + private plainRecordType(type: TS.Type): boolean { + const { ts } = this; + const { checker } = this.c; + if (!(type.flags & ts.TypeFlags.Object) || type.getCallSignatures().length || checker.isArrayType(type) || checker.isTupleType(type)) return false; + if (!type.getProperties().length || this.kindOf(type) || this.isTextType(type)) return false; + const decl = type.getSymbol()?.declarations?.[0]; + return !!decl && (ts.isObjectLiteralExpression(decl) || ts.isTypeLiteralNode(decl) || (ts.isInterfaceDeclaration(decl) && !decl.getSourceFile().isDeclarationFile)); + } + + /** `return [a, b]`: an array of numbers or booleans made where the return is. */ + private returnedArray(given: TS.ArrayLiteralExpression, name: string): Binding | null { + const a = this.declareArray(name, given, this.c.checker.getTypeAtLocation(given), given); + return a ? { kind: "array", a } : null; + } + + /** What `return …` gives of a function that gives a record or an instance: one it makes, itself, or one it was handed. */ + private madeInstance(given: TS.Expression, name: string): Binding | null | undefined { + const { ts } = this; + if (ts.isObjectLiteralExpression(given)) return this.declareRecord(name, given, this.c.checker.getTypeAtLocation(given), given); + return ts.isNewExpression(given) && this.classOf(given.expression) ? this.instantiate(given, name) : ts.isCallExpression(given) ? this.instanceCall(given, name) : this.bindingOf(given); + } + + /** + * The member of a class that `obj.name` reaches: through an instance (by the class it is, so an overridden method is + * the one found), through `super` (from what the enclosing class extends on), or — a static one — through the class's + * name. `quiet`: a name that is no such member is simply not one (a field, read as any field is). + */ + private memberAt(e: TS.PropertyAccessExpression, pick: (m: TS.ClassElement, name: string) => boolean, quiet = false): { member: TS.ClassElement; self?: Binding; name: string; before?: Stmt[]; /** Through a reference that may be one of several classes giving it bodies of their own (B9): each body, and the classes that run it. */ virtual?: Map } | null | undefined { + const { ts } = this; + const name = e.name.text; + const obj = this.unwrap(e.expression); + if (obj.kind === ts.SyntaxKind.SuperKeyword) { + const cls = this.enclosingClass(e); + const self = this.scope.lookup(THIS); + if (!cls || !self) { this.c.error(e, "super is for a method of a class that extends another."); return null; } + const member = this.memberOf(cls, (m) => !this.isStatic(m) && pick(m, name), 1); + if (!member) { if (quiet) return undefined; this.c.error(e, `What ${this.className(cls)} extends has no ${name}().`); return null; } + return { member, self, name: `${this.className(member.parent as TS.ClassDeclaration)}.${name}` }; + } + const named = this.classOf(obj); + if (named) { + const member = this.memberOf(named, (m) => this.isStatic(m) && pick(m, name)); + if (!member) { if (quiet) return undefined; this.c.error(e, `${this.className(named)} has no static ${name}().`); return null; } + return { member, name: `${this.className(named)}.${name}` }; + } + // `v.add(w).scale(2)`: what the call before gives is what this one is called on. + const before: Stmt[] = []; + // Through a reference (B9): the row, worked out once. + const byRef = !!this.refClasses(this.c.checker.getTypeAtLocation(e.expression)); + const self = byRef ? this.objectOf(e.expression) : ts.isCallExpression(obj) ? this.instanceCall(obj, "(made)", before) ?? undefined : this.bindingOf(obj); + if (self?.kind === "record" && self.ref && self.ref.classes.length > 1) { + // Which class the row is is the game's: each class's own member, or one for all when they share it. + const groups = this.memberGroups(self.ref.classes, (m) => pick(m, name)); + if (!groups.size) { if (quiet || self.fields.has(name)) return undefined; this.c.error(e, `No class this may be has a method ${name}().`); return null; } + const [[member, group]] = groups; + const owner = (m: TS.ClassElement) => this.className(m.parent as TS.ClassDeclaration); + if (groups.size === 1) return { member, self: this.narrowed(self, group), name: `${owner(member)}.${name}` }; + return { member, self, name: `${[...groups.keys()].map(owner).join("|")}.${name}`, virtual: groups }; + } + if (self?.kind !== "record" || !self.cls) return undefined; + const member = this.memberOf(self.cls, (m) => !this.isStatic(m) && pick(m, name)); + if (!member) { if (quiet || self.fields.has(name)) return undefined; this.c.error(e, `${this.className(self.cls)} has no method ${name}().`); return null; } + return { member, self, name: `${this.className(self.cls)}.${name}`, ...(before.length ? { before } : {}) }; + } + + /* ── References (TrigScript 4, B9) ── */ + + /** Whether a class is kept by reference in this walk: its instances rows of the object table, a reference a row. */ + private isRefClass(cls: TS.ClassDeclaration | undefined): boolean { + return !!cls && this.refs.has(cls); + } + + /** The classes a program can make: declared in its body, or at the top of one of the script's files. Not an error's class, nor an abstract one. */ + private knownClasses(): TS.ClassDeclaration[] { + const { ts } = this; + return (this.c.classes ?? []).filter((c) => (isScriptClass(ts, c) || this.c.body.plan.game.has(c) || this.body.plan.game.has(c)) + && !ts.getModifiers(c)?.some((m) => m.kind === ts.SyntaxKind.AbstractKeyword) && !this.extendsBuiltin(c)); + } + + private readonly instanceCache = new Map(); + + /** + * The classes whose instances a type may be: a class and the classes that fit it, an interface's or an abstract + * class's, every member of a union — null and undefined left out. Null when the type is anything but instances of + * classes (a record written out, an array, a function, a unit, one of the library's). + */ + private instanceClasses(type: TS.Type): TS.ClassDeclaration[] | null { + if (this.instanceCache.has(type)) return this.instanceCache.get(type)!; + const { ts } = this; + const { checker } = this.c; + let out: Set | null = new Set(); + const bare = this.membersOf(type).filter((t) => (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined | ts.TypeFlags.Void)) === 0); + if (!bare.length) out = null; + for (const t of bare) { + if (!out) break; + if (!(t.flags & ts.TypeFlags.Object) || t.getCallSignatures().length || checker.isArrayType(t) || checker.isTupleType(t) || t.getProperty("__unit") || t.getProperty("__task") || t.getProperty("__game") || t.getProperty("__kind")) { out = null; break; } + const decl = t.getSymbol()?.valueDeclaration ?? t.getSymbol()?.declarations?.[0]; + const ofClass = !!decl && ts.isClassDeclaration(decl); + const ofInterface = !!decl && ts.isInterfaceDeclaration(decl) && !decl.getSourceFile().isDeclarationFile; + if (!ofClass && !ofInterface) { out = null; break; } + const fits = this.knownClasses().filter((c) => c === decl || checker.isTypeAssignableTo(this.instanceType(c), t)); + if (!fits.length) { out = null; break; } + for (const c of fits) out.add(c); + } + const result = out ? [...out] : null; + this.instanceCache.set(type, result); + return result; + } + + /** The classes a reference of this type may be a row of, when every class it may be is kept by reference; null otherwise. */ + private refClasses(type: TS.Type): TS.ClassDeclaration[] | null { + if (!this.refs.size) return null; + const classes = this.instanceClasses(type); + return classes && classes.every((c) => this.refs.has(c)) ? classes : null; + } + + /** + * What a type of instances needs to be kept by reference, when it does: one that may be none (`Node | null`), or one + * more than one class fits (a base class, an interface, a union). Null when it is one class, always there. + */ + private refWanted(type: TS.Type): TS.ClassDeclaration[] | null { + const { ts } = this; + const classes = this.instanceClasses(type); + if (!classes) return null; + const nullable = this.membersOf(type).some((t) => (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0); + return nullable || classes.length > 1 || classes[0] !== this.classOfType(this.c.checker.getNonNullableType(type)) ? classes : null; + } + + /** + * The program needs these classes kept by reference — and every class that fits one of them, since a variable of + * the one may hold the other. True when any was not yet: the program is walked again with them (what this walk + * says about them is not kept). + */ + private needRef(classes: Iterable): boolean { + let more = false; + const add = (c: TS.ClassDeclaration) => { + if (this.refs.has(c) || this.refsFound.has(c)) return; + this.refsFound.add(c); + more = true; + for (const other of this.instanceClasses(this.instanceType(c)) ?? []) add(other); + }; + for (const c of classes) add(c); + return more; + } + + /** The object table, made with the first class kept by reference: rows of the closures' pool, a column for each field. */ + private objectTable(at: At): NonNullable { + if (!this.objectsTable) { + this.poolNow(at); + this.objectsTable = { table: { kind: "records", name: "(objects)", fields: new Map(), shape: new Map() }, tags: new Map(), merged: new Set(), columns: new Set() }; + } + return this.objectsTable; + } + + /** + * A class kept by reference, made ready: its number, and its fields' columns in the object table. A field shares the + * column of another class's field of the same name and kind, so a field of an interface is one column whatever class + * the row is. False, with a diagnostic, when two classes give one name different kinds. + */ + private refClass(cls: TS.ClassDeclaration, node: TS.Node): boolean { + const o = this.objectTable(this.at(node)); + if (o.merged.has(cls)) return true; + o.merged.add(cls); + if (!o.tags.has(cls)) o.tags.set(cls, OBJECT_TAG + o.tags.size + 1); + const why: string[] = []; + const shape = this.shapeOf(this.instanceType(cls), why) ?? (why[0] === "it has no fields" ? new Map() as RowShape : null); + if (!shape) { this.c.error(node, `${this.className(cls)} is kept by reference, and cannot be: ${why[0] ?? "its fields could not be read"}. An instance kept by reference holds numbers, booleans, texts, units, references, arrays of those, and records or instances of the same.`); return false; } + const table = o.table; + const into = table.shape!; + const pool = this.pool!; + const describeField = (f: RowField): string => JSON.stringify(f, (k, v) => (k === "cls" ? (v as TS.ClassDeclaration).name?.text : v)); + for (const [name, f] of shape) { + const had = into.get(name); + if (had) { + if (describeField(had) === describeField(f)) continue; + const other = [...o.merged].find((c) => c !== cls && this.shapeOf(this.instanceType(c))?.has(name)); + this.c.error(node, `${this.className(cls)} and ${other ? this.className(other) : "another class"} are both kept by reference and both have a field ${name}, of two kinds: a field of that name is one column of the object table, so give the two one kind, or give one of them another name.`); + return false; + } + into.set(name, f); + for (const { key, kind, width } of this.columnsOf(new Map([[name, f]]))) { + const a = this.newArray(`(objects) ${key.replace(/ /g, ".")}`, kind, CLOSURE_CAP, pool.make.at, width); + table.fields.set(key, a); + o.columns.add(a.id); + } + } + // What a row of this class given back lets go of: its texts, its arrays' blocks, and every other cell emptied. + const texts = this.ownedTexts(table, shape); + const lists = this.ownedLists(table, shape); + const skip = new Set([...texts.map((t) => t.block.id), ...lists.flatMap((l) => [l.ptr.id, l.len.id, l.room.id, l.k.id])]); + // A field every constructor gives a first value (declared with one, or a constructor's parameter) is written over + // when the row is made again: it needs letting go of, when it holds a reference, but no emptying. + const given = new Set(); + for (const c of this.chainOf(cls)) for (const m of c.members) { + if (this.ts.isPropertyDeclaration(m) && !this.isStatic(m) && m.initializer) { const k = this.memberKey(m.name); if (k) given.add(k); } + if (this.ts.isConstructorDeclaration(m)) for (const p of m.parameters) if (this.ts.getModifiers(p)?.length && this.ts.isIdentifier(p.name)) given.add(p.name.text); + } + const written = new Set(this.columnsOf(new Map([...shape].filter(([k, f]) => given.has(k) && (f.kind === "number" || f.kind === "boolean")))).map(({ key }) => table.fields.get(key)!.id)); + pool.objects.set(o.tags.get(cls)!, { + columns: this.columnsOf(shape).map(({ key }) => table.fields.get(key)!).filter((a) => !skip.has(a.id)), + overwritten: written, + texts: texts.map((t) => ({ addr: t.addr, block: t.block, chars: t.chars })), + lists: lists.map((l) => ({ ptr: l.ptr, len: l.len, room: l.room, k: l.k, of: l.of, refs: l.of === "number" && this.listHoldsRefs(cls, l.name.split(".").pop() ?? "") })), + }); + return true; + } + + /** Whether a field of a class kept by reference is an array of references (what a row given back lets go of, cell by cell). */ + private listHoldsRefs(cls: TS.ClassDeclaration, name: string): boolean { + const prop = this.instanceType(cls).getProperty(name); + if (!prop) return false; + const t = this.c.checker.getTypeOfSymbol(prop); + const el = this.c.checker.isArrayType(t) ? this.c.checker.getIndexTypeOfType(t, this.ts.IndexKind.Number) : undefined; + return !!el && !!this.refClasses(el); + } + + /** A reference as the row it is: the object table's cells at `place`, and the classes it may be an instance of. */ + private refRow(place: NumExpr, classes: readonly TS.ClassDeclaration[], node: TS.Node): Instance | undefined { + for (const c of classes) if (!this.refClass(c, node)) return undefined; + const row = this.rowOf(this.objectsTable!.table, place) as Instance; + if (classes.length === 1) row.cls = classes[0]; + row.ref = { place, classes }; + return row; + } + + /** The same row, as one of fewer classes: what a call made in one case of the question of its class has for `this`. */ + private narrowed(self: Instance, classes: readonly TS.ClassDeclaration[]): Instance { + return { ...self, cls: classes[0], ref: { place: self.ref!.place, classes } }; + } + + /** + * What an expression is as an object: the record or the instance its name is bound to, or — of a type kept by + * reference — the row the reference is, worked out once (into a temporary, so that what the row is does not move under + * a call made on it). + */ + private objectOf(expr: TS.Expression): Binding | undefined { + const { ts } = this; + const e = this.unwrap(expr); + if (e.kind === ts.SyntaxKind.ThisKeyword) return this.scope.lookup(THIS); + // The type as written (`x as Node | null`), not what the checker narrowed the bare name to. + const classes = this.refClasses(this.c.checker.getTypeAtLocation(expr)); + if (!classes) return ts.isCallExpression(e) ? undefined : this.bindingOf(e); + if (ts.isCallExpression(e) && this.madeHere.has(e)) return this.madeHere.get(e) ?? undefined; + const place = this.num(e); + const row = place ? this.refRow(this.temp(place, e, place.kind !== "const" && !(place.kind === "var" && this.isTemp(place.id))), classes, e) : undefined; + if (ts.isCallExpression(e)) this.madeHere.set(e, row ?? null); + return row; + } + + private readonly temps = new Set(); + private isTemp(id: string): boolean { return this.temps.has(id); } + + /** `new Bullet(x, y)` of a class kept by reference: a row of the pool, the class's number in it, the constructor run on the row. The value is the row. */ + private newObject(e: TS.NewExpression, cls: TS.ClassDeclaration): NumExpr | null { + if (!this.refClass(cls, e)) return null; + const o = this.objectsTable!; + const at = this.at(e); + const row = this.newVar(`(new ${this.className(cls)})`, "number", at, { temp: true }); + this.emit({ kind: "declare", decl: row, init: { kind: "call", call: this.makeCall(num(o.tags.get(cls)!), num(0), at) }, at, label: this.label(e) }, e); + const self = this.refRow(varRef(row), [cls], e)!; + this.onRow.set(self, { of: o.table, index: varRef(row), shape: o.table.shape! }); + const typeArgs = this.classTypeArgs(cls, e); + if (typeArgs) self.typeArgs = typeArgs; + // Made only when the pool had a row to give: one test for the whole constructor, not one a field. + this.freshRows.add(row.id); + let made = false; + const body = this.collect(() => { made = this.withTypeArgs(typeArgs, () => this.construct(cls, e.arguments ?? [], self, e, `(${this.className(cls)})`)); }); + this.emit({ kind: "if", cond: { kind: "compare", op: "!=", left: varRef(row), right: num(0), at, label: this.label(e) }, then: body, at, label: this.label(e) }, e); + return made ? varRef(row) : null; + } + + /** The rows of objects being made: a field written at one needs no test that it is there (the constructor runs under one). */ + private readonly freshRows = new Set(); + /** The stores of a field's first value into a row only just made: nothing was there to let go of. */ + private readonly freshStores = new WeakSet(); + + /** The number of each class among `classes` that `test` holds of, for a question of a row's class. */ + private tagsOf(classes: readonly TS.ClassDeclaration[]): number[] { + const o = this.objectsTable!; + return classes.map((c) => { if (!o.tags.has(c)) o.tags.set(c, OBJECT_TAG + o.tags.size + 1); return o.tags.get(c)!; }); + } + + /** Whether the row at `place` is an instance of one of `classes`: its number, in the pool's function column, one of theirs (none is none of them). */ + private rowIs(place: NumExpr, classes: readonly TS.ClassDeclaration[], node: TS.Node): BoolExpr { + if (!classes.length) return FALSE; + this.objectTable(this.at(node)); + const at = this.at(node), label = this.label(node); + const tag: NumExpr = { kind: "element", array: this.pool!.fnOf.id, index: place, at }; + const items = this.tagsOf(classes).map((t): BoolExpr => ({ kind: "compare", op: "==", left: tag, right: num(t), at, label })); + return items.length === 1 ? items[0] : { kind: "or", items }; + } + + /** + * A method, a getter or a setter called on a reference whose class could be any of several that give it bodies of their + * own: one call, which asks the row's class and runs, for each, that class's member with `this` that class. A row of + * no class among them (none) runs nothing, and gives 0. + */ + private virtualCall(e: TS.Expression, name: string, self: Instance, groups: Map, call: (self: Instance, member: TS.ClassElement) => Call | null | undefined, nothing = false): Call | null { + const at = this.at(e), label = this.label(e); + const type = this.optionalBase(e) ? this.c.checker.getNonNullableType(this.c.checker.getTypeAtLocation(e)) : this.c.checker.getTypeAtLocation(e); + const kind: Kind | "text" | "void" = nothing ? "void" : this.kindOf(type) ?? (this.isTextType(type) ? "text" : "void"); + const out: Call = { name, at, label, params: [], body: [] }; + if (kind !== "void") out.result = { decl: this.newVar(`(${name} result)`, kind, at, { temp: true, ...(kind === "number" ? this.widthOf(type) : {}) }), kind }; + const cases: { value: number | null; body: Stmt[] }[] = []; + for (const [member, group] of groups) { + let made: Call | null | undefined; + const body = this.collect(() => { made = call(this.narrowed(self, group), member); }); + if (!made) return null; + const result = made; + if (kind === "void") body.push({ kind: "call", call: result, at, label }); + else body.push({ kind: "return", value: kind === "text" ? { kind: "textCall", call: result } : { kind: "call", call: result }, at, label }); + body.push({ kind: "break", at, label }); + const tags = this.tagsOf(group); + tags.forEach((t, i) => cases.push({ value: t, body: i === tags.length - 1 ? body : [] })); + } + out.body = [{ kind: "switch", value: { kind: "element", array: this.pool!.fnOf.id, index: self.ref!.place, at }, cases, at, label }]; + return this.mark(out, e); + } + + /** The classes among a reference's that give a member, grouped by the member each finds (its own, or what it extends). */ + private memberGroups(classes: readonly TS.ClassDeclaration[], pick: (m: TS.ClassElement) => boolean): Map { + const groups = new Map(); + for (const c of classes) { + const m = this.memberOf(c, (x) => !this.isStatic(x) && pick(x)); + if (m) groups.set(m, [...(groups.get(m) ?? []), c]); + } + return groups; + } + + /* ── Records and instances as values (TrigScript 4, B9) ── */ + + private readonly valueClasses = new Map(); + + /** + * Whether a class's fields are only ever written by its constructors — `Vec`, whose methods give new ones. Given whole, + * a copy of such an instance shows what a JavaScript reference to it would: nothing ever changes it in place. + */ + private isValueClass(cls: TS.ClassDeclaration): boolean { + const known = this.valueClasses.get(cls); + if (known !== undefined) return known; + const { ts } = this; + const { checker } = this.c; + const props = new Set(checker.getPropertiesOfType(this.instanceType(cls)).map((p) => p.valueDeclaration).filter((d): d is TS.Declaration => !!d)); + let written = false; + const target = (e: TS.Expression) => { + const u = this.unwrap(e); + if (!ts.isPropertyAccessExpression(u) && !ts.isElementAccessExpression(u)) return; + const sym = ts.isPropertyAccessExpression(u) ? checker.getSymbolAtLocation(u.name) : undefined; + if (!sym?.valueDeclaration || !props.has(sym.valueDeclaration)) return; + for (let n: TS.Node | undefined = u.parent; n; n = n.parent) if (ts.isConstructorDeclaration(n)) return; + written = true; + }; + const walk = (n: TS.Node) => { + if (written) return; + if (ts.isBinaryExpression(n) && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && n.operatorToken.kind <= ts.SyntaxKind.LastAssignment) target(n.left); + if ((ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)) target(n.operand); + ts.forEachChild(n, walk); + }; + const files = new Set([this.c.body.sf, ...(this.c.classes ?? []).map((c) => c.getSourceFile())]); + for (const sf of files) walk(sf); + this.valueClasses.set(cls, !written); + return !written; + } + + /** Whether a field of what a declaration holds is written anywhere: `p.x = 1`, `p.hp--`. */ + private fieldsWritten(decl: TS.Node): boolean { + const { ts } = this; + let found = false; + const target = (e: TS.Expression) => { + let u = this.unwrap(e); + while (ts.isPropertyAccessExpression(u) || ts.isElementAccessExpression(u)) { + const inner = this.unwrap(u.expression); + if (ts.isIdentifier(inner) && declarationOf(ts, this.c.checker, inner) === decl) { found = true; return; } + u = inner; + } + }; + const walk = (n: TS.Node) => { + if (found) return; + if (ts.isBinaryExpression(n) && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && n.operatorToken.kind <= ts.SyntaxKind.LastAssignment) target(n.left); + if ((ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) && (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)) target(n.operand); + ts.forEachChild(n, walk); + }; + walk(this.body.plan.root); + return found; + } + + /** A copy of a record or an instance, every field a variable of its own under `name`, its class kept. */ + private recordCopy(src: Instance, name: string, node: TS.Node): Instance { + const copy = this.fieldCopy(src, name, node) as Instance; + return { ...copy, ...(src.cls ? { cls: src.cls } : {}), ...(src.typeArgs ? { typeArgs: src.typeArgs } : {}) }; + } + + /** + * A record or an instance of the type, every field 0, false, no unit or the empty text: what `const r = c ? a : b` + * declares before one or the other is copied in. Null when a field is anything else (an array). + */ + private zeroRecord(name: string, type: TS.Type, node: TS.Node, cls?: TS.ClassDeclaration): Instance | null { + const why: string[] = []; + const shape = this.shapeOf(type, why); + if (!shape) { this.c.error(node, `${name} is given one record or another, and so is made first with every field empty: ${why[0] ?? "its fields could not be read"}.`); return null; } + const build = (shape: RowShape, prefix: string): Map | null => { + const fields = new Map(); + for (const [key, f] of shape) { + const full = `${prefix}.${key}`; + if (f.kind === "record") { const inner = build(f.shape, full); if (!inner) return null; fields.set(key, { kind: "record", fields: inner, ...(f.cls ? { cls: f.cls } : {}) }); continue; } + if (f.kind === "maybe") { + const value = this.newVar(full, f.of, this.sourceOf(node), f.width), there = this.newVar(`${full} (there)`, "boolean", this.sourceOf(node)); + this.emit({ kind: "declare", decl: value, init: f.of === "number" ? num(0) : FALSE, at: this.at(node), label: this.label(node) }, node); + this.emit({ kind: "declare", decl: there, init: FALSE, at: this.at(node), label: this.label(node) }, node); + fields.set(key, { kind: "maybe", value: { kind: "var", v: value }, there: { kind: "var", v: there } }); + continue; + } + if (f.kind !== "number" && f.kind !== "boolean" && f.kind !== "unit" && f.kind !== "text") { this.c.error(node, `${name} is given one record or another, and ${full.slice(name.length + 1)} is an array: give the record its fields one by one.`); return null; } + const v = this.newVar(full, f.kind, this.sourceOf(node), f.kind === "number" ? f.width : f.kind === "text" ? { text: "made" } : {}); + this.emit({ kind: "declare", decl: v, init: f.kind === "number" ? num(0) : f.kind === "boolean" ? FALSE : f.kind === "unit" ? NO_UNIT : { kind: "text", text: "" }, at: this.at(node), label: this.label(node) }, node); + fields.set(key, { kind: "var", v }); + } + return fields; + }; + const fields = build(shape, name); + return fields ? { kind: "record", fields, ...(cls ? { cls } : {}) } : null; + } + + /** + * `p = …` of a record, or of an instance of a class whose instances are values: a copy of what it is given — a record + * written there, an instance made there, what a call gives, another record's fields — stored into its fields; for + * `c ? a : b`, one or the other. + */ + private assignWhole(target: Instance, given: TS.Expression, node: TS.Node) { + const { ts } = this; + const e = this.unwrap(given); + if (ts.isConditionalExpression(e)) { + const known = this.knownCondition(e.condition); + if (known !== undefined) { this.assignWhole(target, known ? e.whenTrue : e.whenFalse, node); return; } + const cond = this.bool(e.condition); + const then = this.collect(() => this.assignWhole(target, e.whenTrue, node)); + const otherwise = this.collect(() => this.assignWhole(target, e.whenFalse, node)); + this.emit({ kind: "if", cond, then, else: otherwise, at: this.at(e), label: this.label(e) }, e); + return; + } + const src = this.wholeValue(e); + if (src) this.storeInto(target, src, node); + } + + /** The shapes that are a union of records' (B9): a row of one kind leaves the others' fields empty. */ + private readonly unionShapes = new WeakSet(); + + /** What a field of a union of records that a row's kind has not got is given: nothing of its kind. */ + private emptyField(f: RowField): FieldSource { + switch (f.kind) { + case "number": return { value: 0 }; + case "boolean": return { value: false }; + case "text": return { value: "" }; + case "unit": return { value: null }; + case "list": case "squad": return { value: [] }; + case "maybe": return { value: undefined }; + case "record": return { value: Object.fromEntries([...f.shape].map(([k, inner]) => [k, (this.emptyField(inner) as { value: unknown }).value])) }; + } + } + + /** A record or an instance as a value of its own, fields that nothing else will change: what is copied into another. */ + private wholeValue(e: TS.Expression): Instance | null { + const { ts } = this; + if (ts.isObjectLiteralExpression(e)) return this.declareRecord("(value)", e, this.c.checker.getTypeAtLocation(e), e) as Instance | null; + if (ts.isNewExpression(e) && this.classOf(e.expression) && !this.isRefClass(this.classOf(e.expression))) return this.instantiate(e, "(value)"); + const made = ts.isCallExpression(e) ? this.instanceCall(e, "(value)") : this.bindingOf(e); + // What a call gives may be its own `this`, or what it was handed — the very fields being given a value: copied first. + if (made?.kind === "record") return this.recordCopy(made, "(value)", e); + if (made !== null) this.c.error(e, "A record is given a record: one written out ({ x: 1, y: 2 }), one made (new Vec(…)), what a call gives, or another record."); + return null; + } + + /** Every field of `target` given the value of the same field of `src`. False, with a diagnostic, for a field that cannot be. */ + private storeInto(target: Binding, src: Binding, node: TS.Node): boolean { + const at = this.at(node), label = this.label(node); + switch (target.kind) { + case "record": { + if (src.kind !== "record") { this.c.error(node, "A record is given a record."); return false; } + for (const [key, t] of target.fields) { + const s = src.fields.get(key); + if (!s) { this.c.error(node, `What is given has no ${key}.`); return false; } + if (!this.storeInto(t, s, node)) return false; + } + return true; + } + case "var": { + if (target.v.kind === "text") { + const value = src.kind === "var" && src.v.kind === "text" ? { kind: "textVar", id: src.v.id } as TextExpr : src.kind === "textAt" ? this.textAtCells(src, node) : src.kind === "value" && typeof src.value === "string" ? this.literalText(src.value, node) : null; + if (!value) { this.c.error(node, "A text field is given a text."); return false; } + this.emit({ kind: "assignText", target: target.v.id, value, at, label }, node); + return true; + } + const value = this.valueOf(src, target.v.kind as Kind, node); + if (!value) return false; + this.emit(target.v.kind === "unit" ? { kind: "assignUnit", target: target.v.id, value: value as UnitExpr, at, label } : target.v.kind === "boolean" ? { kind: "assignBool", target: target.v.id, value: value as BoolExpr, at, label } : { kind: "assign", target: target.v.id, value: value as NumExpr, at, label }, node); + return true; + } + case "cell": { + const value = this.valueOf(src, target.a.kind, node); + if (!value) return false; + this.emit({ kind: "store", array: target.a.id, index: target.index, value: value as NumExpr | BoolExpr, at, label }, node); + return true; + } + case "unitAt": { + const value = this.valueOf(src, "unit", node); + if (value) this.storeUnitAt(target, value as UnitExpr, node); + return !!value; + } + case "textAt": { + const value = src.kind === "var" && src.v.kind === "text" ? { kind: "textVar", id: src.v.id } as TextExpr : src.kind === "textAt" ? this.textAtCells(src, node) : src.kind === "value" && typeof src.value === "string" ? this.literalText(src.value, node) : null; + if (!value) { this.c.error(node, "A text field is given a text."); return false; } + this.storeTextAt(target, value, node); + return true; + } + case "maybe": { + if (src.kind === "maybe") return this.storeInto(target.value, src.value, node) && this.storeInto(target.there, src.there, node); + return this.storeInto(target.value, src, node) && this.storeInto(target.there, { kind: "value", value: true }, node); + } + default: + this.c.error(node, "A record that holds an array is not given whole: give it its fields one by one."); + return false; + } + } + + /* ── A number or nothing (TrigScript 4, B9) ── */ + + /** `number | undefined`, `boolean | null`: what it holds when it holds something; null for any other type. */ + private maybeKind(type: TS.Type): "number" | "boolean" | null { + const { ts } = this; + const members = this.membersOf(type); + // `number | void` is what a race of a sleep and a value gives, which is said otherwise: not this. + if (members.some((t) => (t.flags & ts.TypeFlags.Void) !== 0)) return null; + const nothing = members.filter((t) => (t.flags & (ts.TypeFlags.Null | ts.TypeFlags.Undefined)) !== 0); + const bare = members.filter((t) => !nothing.includes(t)); + if (!nothing.length || !bare.length) return null; + if (bare.every((t) => (t.flags & ts.TypeFlags.BooleanLike) !== 0)) return "boolean"; + const isNumber = (t: TS.Type): boolean => (this.resolveType(t).flags & ts.TypeFlags.NumberLike) !== 0 || (t.isIntersection() && t.types.some(isNumber)); + return bare.every(isNumber) ? "number" : null; + } + + /** A variable of `number | undefined`: the value, and beside it whether there is one. */ + private declareMaybe(name: string, kind: "number" | "boolean", type: TS.Type, init: TS.Expression | undefined, at: TS.Node): Extract | null { + const where = this.sourceOf(at); + const value = this.newVar(name, kind, where, kind === "number" ? this.widthOf(this.c.checker.getNonNullableType(type)) : {}); + const there = this.newVar(`${name} (there)`, "boolean", where); + const given = init ? this.maybeValue(init, kind) : { value: kind === "number" ? num(0) : FALSE, there: FALSE }; + if (!given) return null; + this.emit({ kind: "declare", decl: value, init: given.value, at: this.at(at), label: this.label(at) }, at); + this.emit({ kind: "declare", decl: there, init: given.there, at: this.at(at), label: this.label(at) }, at); + return { kind: "maybe", value: { kind: "var", v: value }, there: { kind: "var", v: there } }; + } + + /** Whether a binding holds something, as a condition. */ + private thereOf(b: Extract, at: TS.Node): BoolExpr { + return this.valueOf(b.there, "boolean", at) as BoolExpr; + } + + /** + * What an expression of `number | undefined` is, as a value and whether there is one: nothing (`undefined`), a number, + * another such variable, `xs.find(…)`, `m.get(k)`, `c ? a : b`. Null, with a diagnostic, for anything else. + */ + private maybeValue(expr: TS.Expression, kind: "number" | "boolean"): { value: NumExpr | BoolExpr; there: BoolExpr } | null { + const { ts } = this; + const e = this.unwrap(expr); + const at = this.at(e), label = this.label(e); + const none = { value: kind === "number" ? num(0) : FALSE, there: FALSE }; + const h = this.evaluate(e); + if (h && (h.value === undefined || h.value === null)) return none; + const b = ts.isIdentifier(e) || ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e) ? this.bindingOf(e) : undefined; + if (b?.kind === "maybe") return { value: this.valueOf(b.value, kind, e) as NumExpr | BoolExpr, there: this.thereOf(b, e) }; + if (ts.isConditionalExpression(e)) { + const known = this.knownCondition(e.condition); + if (known !== undefined) return this.maybeValue(known ? e.whenTrue : e.whenFalse, kind); + const cond = this.bool(e.condition); + const a = this.maybeValue(e.whenTrue, kind), c = this.maybeValue(e.whenFalse, kind); + if (!a || !c) return null; + const t = this.newVar("(condition)", "boolean", at, { temp: true }); + this.emit({ kind: "declare", decl: t, init: cond, at, label }, e); + return { value: { kind: "ternary", cond: boolRef(t), whenTrue: a.value, whenFalse: c.value, at, label } as NumExpr, there: { kind: "ternary", cond: boolRef(t), whenTrue: a.there, whenFalse: c.there, at, label } }; + } + if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression)) { + const method = e.expression.name.text; + // `xs.find(…)`: where it is found, then the cell there. + if (method === "find" || method === "findLast") { + const over = this.overOf(e.expression.expression); + if (over) { + const found = this.newVar("(found)", "boolean", at, { temp: true }); + this.emit({ kind: "declare", decl: found, init: FALSE, at, label }, e); + const value = this.searchCall(e, over, method, kind, undefined, found) as NumExpr | BoolExpr | null; + if (!value) return null; + const t = this.newVar("(found value)", kind, at, { temp: true }); + this.emit({ kind: "declare", decl: t, init: value, at, label }, e); + return { value: kind === "number" ? varRef(t) : boolRef(t), there: boolRef(found) }; + } + } + // `m.get(k)` of a Map: whether it has the key, and what it holds there. + if (method === "get" && e.arguments.length === 1) { + const table = this.bindingOf(e.expression.expression); + if (table?.kind === "hash" && table.values?.kind === kind) { + const there = this.hashMethod(e, table, "has", "boolean"), value = this.hashMethod(e, table, "get", kind); + return there && value && there !== true && value !== true ? { value: value as NumExpr | BoolExpr, there: there as BoolExpr } : null; + } + if (table?.kind === "keyed" && table.values?.kind === kind) { + const there = this.keyedCall(e, table, "has", "boolean"), value = this.keyedCall(e, table, "get", kind); + return there && value && there !== true && value !== true ? { value: value as NumExpr | BoolExpr, there: there as BoolExpr } : null; + } + } + } + const value = kind === "number" ? this.num(e) : this.boolValue(e); + return value ? { value, there: TRUE } : null; + } + + /** `best = x`, `best = undefined`, `best += 1` of a number or nothing (B9): both cells, worked out before either is written. */ + private assignMaybe(e: TS.BinaryExpression, b: Extract, op: TS.SyntaxKind) { + const { ts } = this; + const at = this.at(e), label = this.label(e); + const kind: "number" | "boolean" = b.value.kind === "var" ? (b.value.v.kind as "number" | "boolean") : b.value.kind === "cell" ? b.value.a.kind : "number"; + const put = (into: Binding, value: NumExpr | BoolExpr) => { + if (into.kind === "var") this.emit(into.v.kind === "boolean" ? { kind: "assignBool", target: into.v.id, value: value as BoolExpr, at, label } : { kind: "assign", target: into.v.id, value: value as NumExpr, at, label }, e); + else if (into.kind === "cell") this.emit({ kind: "store", array: into.a.id, index: into.index, value, at, label }, e); + }; + if (op !== ts.SyntaxKind.EqualsToken) { + const arith = compoundOp(ts, op); + const now = this.valueOf(b.value, "number", e) as NumExpr | null; + const rhs = arith && now ? this.num(e.right) : null; + if (!arith || !now || !rhs) { if (!arith) this.c.error(e, "Only = += -= *= /= %= &= |= ^= <<= >>= >>>= assign a number."); return; } + put(b.value, this.mark({ kind: "binary", op: arith, left: now, right: rhs, at, label }, e)); + return; + } + const given = this.maybeValue(e.right, kind); + if (!given) return; + const there = this.newVar("(there)", "boolean", at, { temp: true }); + this.emit({ kind: "declare", decl: there, init: given.there, at, label }, e); + put(b.value, given.value); + put(b.there, boolRef(there)); + } + + /* ── Functions ── */ + + /** A call of a `game()` function: inlined from its own body, wherever that file is. */ + /* ── Functions as values (TrigScript 4, B3) ── */ + + /** + * The function an expression is, when that is known when the script is built: an arrow or a function written there, a + * function declared in the body, a const holding one, a parameter handed one. Undefined for anything else — a function + * kept in a variable is a number of the program (`fnHeld`). + */ + /** Whether an expression names a namespace of the script's: `Geo`, `Geo.Deep`. */ + private isNamespace(e: TS.Expression): boolean { + const { ts } = this; + const id = ts.isIdentifier(e) ? e : ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.name) ? e.name : undefined; + if (!id) return false; + const decl = declarationOf(ts, this.c.checker, id); + return !!decl && this.ts.isModuleDeclaration(decl); + } + + private fnValueOf(expr: TS.Expression): FnValue | undefined { + const { ts } = this; + const e = this.unwrap(expr); + if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) { + return { decl: e, parameters: e.parameters, body: e.body, closure: this.scope, target: this.body, ...(e.name ? { name: e.name.text } : {}) }; + } + if (!ts.isIdentifier(e) && !ts.isPropertyAccessExpression(e)) return undefined; + const b = this.bindingOf(e); + if (b) return b.kind === "fn" ? b.fn : undefined; + // `Shapes.area(…)`: a function of a namespace at the top of a file. + const named = ts.isIdentifier(e) ? e : this.isNamespace(this.unwrap(e.expression)) ? e.name : undefined; + if (!named || !ts.isIdentifier(named)) return undefined; + const decl = declarationOf(ts, this.c.checker, named); + if (!decl) return undefined; + const known = this.fnValues.get(decl); + if (known) return known; + const closure = this.body === this.c.body ? this.topScope : null; + let fn: FnValue | undefined; + if (ts.isFunctionDeclaration(decl) && decl.body && this.body.plan.game.has(decl) && !decl.asteriskToken) { + fn = { decl, parameters: decl.parameters, body: decl.body, closure: this.declaredIn.get(decl) ?? closure, target: this.body, name: decl.name?.text ?? "function" }; + } else if (isScriptFunction(ts, decl) && !this.body.plan.game.has(decl)) { + // A function at the top of a file (TrigScript 4): walked in its own file, seeing nothing of the program's. + const target = this.c.functionBody?.(decl); + if (target) fn = { decl, parameters: decl.parameters, body: decl.body!, closure: null, target, name: decl.name!.text }; + } else if (scriptArrow(ts, decl) && !this.body.plan.consts.has(decl as TS.VariableDeclaration)) { + const arrow = scriptArrow(ts, decl)!; + const target = this.c.functionBody?.(arrow); + if (target) fn = { decl: arrow, parameters: arrow.parameters, body: arrow.body, closure: null, target, name: ((decl as TS.VariableDeclaration).name as TS.Identifier).text }; + } else if (ts.isVariableDeclaration(decl) && this.body.plan.consts.has(decl) && decl.initializer && ts.isIdentifier(decl.name)) { + // `const sq = (x: number) => x * x`, which uses nothing of the program: a value of the script, and a function the program may call. + const written = this.unwrap(decl.initializer); + if (ts.isArrowFunction(written) || ts.isFunctionExpression(written)) fn = { decl: written, parameters: written.parameters, body: written.body, closure, target: this.body, name: decl.name.text }; + } + if (fn) this.fnValues.set(decl, fn); + return fn; + } + + /** A name for a function kept as a value: its own, or what holds it where it is written. */ + private functionName(fn: FnValue): string { + const { ts } = this; + if (fn.name) return fn.name; + const p = fn.decl.parent; + if (p && (ts.isVariableDeclaration(p) || ts.isPropertyAssignment(p) || ts.isPropertyDeclaration(p)) && ts.isIdentifier(p.name)) return p.name.text; + if (p && ts.isBinaryExpression(p) && p.right === fn.decl) return p.left.getText(this.body.sf); + // `return (x) => x + k`: the function its maker returns. + for (let up: TS.Node | undefined = p; up; up = up.parent) { + if (ts.isReturnStatement(up) || (ts.isArrowFunction(up) && up.body === fn.decl)) continue; + if ((ts.isFunctionDeclaration(up) || ts.isMethodDeclaration(up)) && up.name) return `the function ${up.name.getText(this.body.sf)} returns`; + if (ts.isArrowFunction(up) && ts.isVariableDeclaration(up.parent) && ts.isIdentifier(up.parent.name)) return `the function ${up.parent.name.text} returns`; + if (!ts.isParenthesizedExpression(up) && !ts.isBlock(up) && !ts.isReturnStatement(up)) break; + } + if (p && ts.isCallExpression(p) && p.arguments.includes(fn.decl as TS.Expression)) return `the function handed to ${p.expression.getText(this.body.sf)}`; + return "the function"; + } + + /** What a value of this type is to a function kept as a value: a number, a boolean, a unit, a text. */ + private valueKind(type: TS.Type): ValueKind | null { + return this.kindOf(type) ?? (this.isTextType(type) ? "text" : null); + } + + /** + * A function as a value of the program: a number, one for each function, and the function made one that is called — + * walked once, its parameters variables, what it sees where it was written. A variable it may keep is the program's + * own (one cell, for good) or a value of the script; a variable of the function or the loop it was written in would be + * gone, or another, by the time it is called, and is refused with a word on what to write instead. + */ + private functionId(fn: FnValue, node: TS.Node): NumExpr | null { + const { ts } = this; + let t = this.targets.get(fn); + if (!t) { + const name = this.functionName(fn); + const signature = this.c.checker.getSignatureFromDeclaration(fn.decl); + if (!signature) { this.c.error(node, `${name}: its parameters could not be read.`); return null; } + if (fn.decl.asteriskToken) { this.c.error(node, `${name} is a generator: a program walks one with for…of, and cannot keep it as a value.`); return null; } + if (ts.getModifiers(fn.decl)?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) { this.c.error(node, `${name} is async, and an async function cannot be kept as a value yet: call it where it is written, or start() it.`); return null; } + const params: ValueKind[] = []; + for (const p of fn.parameters) { + const k = this.valueKind(this.c.checker.getTypeAtLocation(p.name)); + if (p.dotDotDotToken || !ts.isIdentifier(p.name) || !k) { + this.c.error(p, `${name} is kept as a value, so it is called through its number, and its parameters are numbers, booleans, texts, units or other functions — ${p.name.getText(this.body.sf)} is not one of those.`); + return null; + } + params.push(k); + } + const returned = this.c.checker.getReturnTypeOfSignature(signature); + const result: ValueKind | "void" = (returned.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) !== 0 ? "void" : this.valueKind(returned) ?? "void"; + if (result === "void" && (returned.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined | ts.TypeFlags.Never)) === 0) { + this.c.error(node, `${name} is kept as a value, so it is called through its number, and it returns a number, a boolean, a text, a unit, another function or nothing — not ${this.c.checker.typeToString(returned)}.`); + return null; + } + const target = fn.target as Body; + const at = this.sourceOfIn(target, (fn.decl as { name?: TS.Node }).name ?? fn.decl); + const fd: FuncDecl = { id: `${name}#${this.nextId++}`, name, params: [], body: [], at }; + t = { id: this.targets.size + 1, fd, params, result, name }; + // Made inside a run of a block that keeps variables in an environment: handed that environment (closures over locals). + const envScope = this.boxed.size ? this.nearestEnv(fn.closure) : null; + if (envScope) { t.envScope = envScope; t.env = this.newVar("(environment)", "number", at); } + // Set before the body is walked: a function that keeps itself (`state = tick`) is the same number. + this.targets.set(fn, t); + // Inside an attempt to make a function one that is called, now: what the body cannot keep is that attempt's failure. + if (this.attempts > 0) this.walkTarget({ fn, t, node, returned }); + else this.pendingTargets.push({ fn, t, node, returned, ...(this.typeArgs.length ? { typeArgs: [...this.typeArgs] } : {}) }); + } + const value = this.mark({ kind: "const", value: this.boxed.size ? FN_BASE + t.id : t.id, fn: t.fd.id }, node); + if (!t.envScope) return value; + // Reached from where the function was written — the same run of its block, wherever it is handed on to (a function + // known when built never outlives it) — not from here, which may be a game function that sees nothing of the program. + const env = this.envRefAt(t.envScope, fn.closure ?? this.scope) ?? this.envRefAt(t.envScope, this.scope); + if (!env) { this.envUnreachable = true; return value; } + return this.mark({ kind: "call", call: this.makeCall(value, env, this.at(node)) }, node); + } + + /** + * The bodies of the functions given numbers, each walked once the program's body is — `let state = idle` before + * `function idle() { state = busy; }` has to see `state` — with what it names from outside it watched. + */ + private walkTargets() { + for (let next = this.pendingTargets.shift(); next; next = this.pendingTargets.shift()) { + // Walked later, with what the type parameters stood for where it was made (a handler written in a generic method). + const saved = this.typeArgs; + this.typeArgs = next.typeArgs ?? []; + try { this.walkTarget(next); } finally { this.typeArgs = saved; } + } + } + + private walkTarget(next: { fn: FnValue; t: FnTarget; node: TS.Node; returned: TS.Type }) { + { + const { fn, t, node, returned } = next; + const { fd, params, result, name } = t; + const target = fn.target as Body; + const at = fd.at; + const scope = new Scope(fn.closure); + fn.parameters.forEach((p, i) => { + const type = this.c.checker.getTypeAtLocation(p.name); + const v = this.newVar((p.name as TS.Identifier).text, params[i], this.sourceOfIn(target, p.name), params[i] === "number" ? this.widthOf(type) : params[i] === "text" ? { text: "made" } : {}); + fd.params.push(v); + scope.bind(p, { kind: "var", v }); + }); + if (result !== "void") fd.result = { decl: this.newVar(`(${name} result)`, result, at, { temp: true, ...(result === "number" ? this.widthOf(returned) : {}) }), kind: result }; + // The environment it was made in, handed last: what it reaches the variables it keeps through. + if (t.env) fd.params.push(t.env); + if (this.boxed.size) this.envBoundaries.set(scope, t.env && t.envScope ? { expr: varRef(t.env), env: t.envScope } : { expr: num(0), env: null }); + const top = this.targetTop; + this.targetTop = scope; + // What the body names from outside it, watched while it is walked. + const inside = scope.serial; + const refused = new Set(); + const watching = Scope.watch; + Scope.watch = (b, found, decl) => { + if (found.serial >= inside || refused.has(decl) || this.keepable(b, found)) return; + refused.add(decl); + // A variable of a number, a boolean, a unit or a text: kept in an environment, the next walk. + if (b.kind === "var" && !b.v.shared && !b.v.temp && (this.ts.isVariableDeclaration(decl) || this.ts.isParameter(decl) || this.ts.isBindingElement(decl))) { this.boxesFound.add(decl); return; } + const what = (decl as { name?: TS.Node }).name?.getText((decl.getSourceFile?.() ?? this.body.sf)) ?? "a variable"; + this.c.error(node, `${name} is kept as a value, and uses ${what}, a variable of the function or the block it was written in: by the time ${name} is called, ${what} may be gone or hold another call's value. A function kept as a value may use the program's own variables and what the script knows when it is built — hand ${what} over as an argument, or keep it in a variable of the program.`); + }; + const depth = this.inlineDepth; + const copyArgsAt = this.copyArgsAt; + this.inlineDepth = 0; + this.copyArgsAt = -1; + try { + fd.body = this.walkFunction(fn.body, result, target, scope); + } finally { + Scope.watch = watching; + this.inlineDepth = depth; + this.copyArgsAt = copyArgsAt; + this.targetTop = top; + } + if (mentions(fd.body, "sleep") || mentions(fd.body, "parallel") || mentions(fd.body, "start")) this.c.error(node, `${name} is kept as a value, and it waits or starts a task: a function called through its number runs to its end in the frame it is called. Call it where it is written instead.`); + if (mentions(fd.body, "edge")) this.c.error(node, `${name} is kept as a value, and rose() / once() remember what they saw at the place they are written: test them where the function is written, and hand it what they found.`); + this.mark(fd, fn.decl); + this.functions.push(fd); + } + } + + /** Whether a function handed to a task can be kept as a value there: not async, nor a generator, taking and giving what a number can stand for. */ + private keepableInTask(fn: FnValue): boolean { + const { ts } = this; + if (fn.decl.asteriskToken || ts.getModifiers(fn.decl)?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return false; + const signature = this.c.checker.getSignatureFromDeclaration(fn.decl); + if (!signature) return false; + if (fn.parameters.some((p) => p.dotDotDotToken || !ts.isIdentifier(p.name) || !this.valueKind(this.c.checker.getTypeAtLocation(p.name)))) return false; + const returned = this.c.checker.getReturnTypeOfSignature(signature); + if (returned.getProperty("then")) return false; + return (returned.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) !== 0 || !!this.valueKind(returned); + } + + /** Whether a function kept as a value may name a binding found outside its body: the program's own, or a value of the script. */ + private keepable(b: Binding, found: Scope): boolean { + if (b.kind === "value" || b.kind === "fn") return true; + if (this.boxedCells.has(b)) return true; + if (found === this.topScope) return true; + // `this` of an instance the program made at its top, or an array of the program's handed over as itself: the same cells. + for (const own of this.topScope.own()) if (own === b) return true; + return false; + } + + /* ── Closures over locals (TrigScript 4) ── */ - /** `super(…)` inside a constructor: what the class extends is made on the same instance, and then the class's own fields get their values. */ - private superCall(e: TS.CallExpression) { - const top = this.constructing[this.constructing.length - 1]; - const cls = this.enclosingClass(e); - const base = cls && this.baseOf(cls); - if (!top || !cls || top.cls !== cls || !base) { this.c.error(e, "super(…) is the first thing the constructor of a class that extends another does."); return; } - const ctor = cls.members.find((m) => this.ts.isConstructorDeclaration(m) && !!m.body); - if (ctor) this.superCalled.add(ctor); - if (this.construct(base, e.arguments, top.self, e, top.name)) this.declareFields(cls, top.self, top.name); + private poolMaker(): PoolMaker { + return { + newVar: (name, kind, at, extra) => this.newVar(name, kind, at, extra), + newArray: (name, kind, length, at, extra) => this.newArray(name, kind, length, at, extra), + fnId: (name) => `${name}#${this.nextId++}`, + }; } - /** A class's own fields, each with the value it is declared with — or 0, false, no unit, no text. `this` is bound where this runs. */ - private declareFields(cls: TS.ClassDeclaration, self: Instance, name: string) { - const { ts } = this; - for (const m of cls.members) { - if (!ts.isPropertyDeclaration(m) || this.isStatic(m)) continue; - const key = this.memberKey(m.name); - if (!key) { this.c.error(m.name, "A field's name is written out."); continue; } - // `declare hp: number`, or a field what the class extends already made and this one only types again. - if (!m.initializer && self.fields.has(key)) continue; - // On a row the cells are there already, and nothing: the field's first value is stored into them. - if (this.onRow.has(self)) { if (m.initializer) this.rowFieldGiven(self, key, { expr: m.initializer }, m); continue; } - const held = this.declareField(`${name}.${key}`, m.initializer, this.c.checker.getTypeAtLocation(m.name), this.c.checker.getSymbolAtLocation(m.name), m, m.name, m); - if (held) self.fields.set(key, held); + private poolNow(at: At): ClosurePool { + if (!this.pool) { + this.pool = makePool(this.poolMaker(), at); + this.functions.push(this.pool.make, this.pool.drop, this.pool.sweep); } + return this.pool; } - /** A field that a constructor's parameter declares (`public owner: Player`): a variable of its own that starts as the argument — an array, a record or an instance handed over is itself. */ - private parameterField(from: Binding, full: string, p: TS.ParameterDeclaration): Binding | null { - if (from.kind !== "value") return this.takenCopy(from, full, p); - const type = this.c.checker.getTypeAtLocation(p.name); - if (typeof from.value === "string" && this.isTextType(type)) { - const [, prop] = this.c.checker.getSymbolsOfParameterPropertyDeclaration(p, (p.name as TS.Identifier).text); - const keptAs = hasTextMark(from.value) || !prop ? "made" : this.textKept(undefined, (left) => this.ts.isPropertyAccessExpression(left) && this.c.checker.getSymbolAtLocation(left.name) === prop); - const v = this.newVar(full, "text", this.sourceOf(p.name), { text: keptAs }); - this.emit({ kind: "declare", decl: v, init: this.literalText(from.value, p), at: this.at(p), label: this.label(p) }, p); - return { kind: "var", v }; - } - const kind = this.kindOf(type); - if (kind === "unit" && (from.value === null || from.value === undefined)) { - const v = this.newVar(full, "unit", this.sourceOf(p.name)); - this.emit({ kind: "declare", decl: v, init: NO_UNIT, at: this.at(p), label: this.label(p) }, p); - return { kind: "var", v }; - } - if ((kind === "number" && typeof from.value === "number") || (kind === "boolean" && typeof from.value === "boolean")) return this.fieldCopy(from, full, p); - this.c.error(p, `${full} would hold ${describe(from.value)}, which only the script has: a field holds a number, a boolean, a text, a unit, an array or a record.`); + /** `make(fn, parent)`: a new row of the pool — a closure of `fn` made in `parent`, or (fn 0) an environment around `parent`. */ + private makeCall(fn: NumExpr, parent: NumExpr, at: At): Call { + const pool = this.poolNow(at); + const label = "(closures)"; + return { name: pool.make.name, fn: pool.make.id, at, label, params: [{ decl: pool.make.params[0], init: fn, label }, { decl: pool.make.params[1], init: parent, label }], result: { decl: this.newVar("(made)", "number", at, { temp: true }), kind: "number" }, body: [] }; + } + + /** + * A variable bound where a kept function uses it: its cell in the environment of this run of the scope instead, the + * value it was declared with copied there — now, or where its function's body starts (a parameter). + */ + private boxBinding(scope: Scope, decl: TS.Node, b: Binding): Binding | undefined { + if (b.kind !== "var" || !this.boxed.has(decl) || this.boxedCells.has(b)) return undefined; + const v = b.v; + const at = v.at; + const label = `${v.name} (kept by a closure)`; + const pool = this.poolNow(at); + let env = this.scopeEnv.get(scope); + if (!env) { + env = this.newVar("(environment)", "number", at); + this.scopeEnv.set(scope, env); + pool.envVars.add(env.id); + } + const index = varRef(env); + const named = `${v.name} (kept)`; + let cell: BoxedCell; + let into: Stmt[]; + if (v.kind === "number" || v.kind === "boolean") { + const a = this.newArray(named, v.kind, CLOSURE_CAP, at, { ...(v.bits ? { bits: v.bits } : {}), ...(v.unsigned ? { unsigned: true } : {}), ...(v.fixed ? { fixed: true } : {}) }); + if (v.kind === "number") pool.cells.push(a); + const read = (i: NumExpr) => ({ kind: "element", array: a.id, index: i, at }) as NumExpr & BoolExpr; + cell = { home: scope, at: (i) => ({ kind: "cell", a, index: i }), copy: (to, from) => [{ kind: "store", array: a.id, index: to, value: read(from), at, label }] }; + into = [{ kind: "store", array: a.id, index, value: refOf(v) as NumExpr | BoolExpr, at, label }]; + } else if (v.kind === "unit") { + const [ptr, epd, uid] = UNIT_PARTS.map((part) => this.newArray(`${named} ${part}`, "number", CLOSURE_CAP, at)); + const parts = [[ptr, "ptr"], [epd, "epd"], [uid, "uid"]] as const; + cell = { home: scope, at: (i) => ({ kind: "unitAt", ptr: { a: ptr, index: i }, epd: { a: epd, index: i }, uid: { a: uid, index: i } }), + copy: (to, from) => parts.map(([a]): Stmt => ({ kind: "store", array: a.id, index: to, value: { kind: "element", array: a.id, index: from, at }, at, label })) }; + into = parts.map(([a, part]): Stmt => ({ kind: "store", array: a.id, index, value: { kind: "unitPart", unit: unitRef(v), part, at }, at, label })); + } else { + const [addr, block, chars] = TEXT_PARTS.map((part) => this.newArray(`${named} ${part}`, "number", CLOSURE_CAP, at)); + pool.texts.push({ addr, block, chars }); + const textAt = (i: NumExpr): TextExpr => ({ kind: "textAt", addr: addr.id, block: block.id, chars: chars.id, index: i, at }); + cell = { home: scope, at: (i) => ({ kind: "textAt", addr, block, chars, index: i }), copy: (to, from) => [{ kind: "storeText", addr: addr.id, block: block.id, chars: chars.id, index: to, value: textAt(from), at, label }] }; + into = [{ kind: "storeText", addr: addr.id, block: block.id, chars: chars.id, index, value: { kind: "textVar", id: v.id }, at, label }]; + } + const placeholder = cell.at(index); + this.boxedCells.set(placeholder, cell); + const cells = this.scopeCells.get(scope) ?? []; + cells.push(cell); + this.scopeCells.set(scope, cells); + const box = () => { this.makeEnv(scope, decl); for (const st of into) this.emit(st, decl); }; + if (scope === this.scope) box(); + else this.pendingBoxes.set(scope, [...(this.pendingBoxes.get(scope) ?? []), box]); + return placeholder; + } + + /** The copies into environments waiting for the scope being walked, or one around it, to start: outermost first. */ + private flushBoxes() { + if (!this.pendingBoxes.size) return; + const chain: Scope[] = []; + for (let s: Scope | null = this.scope; s; s = s.parent) if (this.pendingBoxes.has(s)) chain.push(s); + for (const s of chain.reverse()) { + const boxes = this.pendingBoxes.get(s)!; + this.pendingBoxes.delete(s); + for (const box of boxes) box(); + } + } + + /** The environment of this run of a scope, made where its first kept variable is: its parent the environment around it. */ + private makeEnv(scope: Scope, at: TS.Node) { + if (this.envMade.has(scope)) return; + this.envMade.add(scope); + const env = this.scopeEnv.get(scope)!; + const where = this.at(at); + this.emit({ kind: "declare", decl: env, init: { kind: "call", call: this.makeCall(num(0), this.envParentOf(scope), where) }, at: where, label: `${this.label(at)} (environment)` }, at); + } + + /** What an environment made at the start of a scope has around it. */ + private envParentOf(scope: Scope): NumExpr { + const b = this.envBoundaries.get(scope); + if (b) return b.env ? b.expr : num(0); + const env = this.nearestEnv(scope.parent); + return (env && this.envRefAt(env, scope.parent!)) || num(0); + } + + /** At the end of a turn of a `for`: a new environment for the loop's own variables, a copy of this turn's, as JavaScript makes. */ + private renewEnv(scope: Scope, at: TS.Node): Stmt[] { + const env = this.scopeEnv.get(scope)!; + const where = this.at(at); + const label = `${this.label(at)} (a new environment for the next turn)`; + const next = this.newVar("(next environment)", "number", where, { temp: true }); + const out: Stmt[] = [{ kind: "declare", decl: next, init: { kind: "call", call: this.makeCall(num(0), this.envParentOf(scope), where) }, at: where, label }]; + for (const cell of this.scopeCells.get(scope) ?? []) out.push(...cell.copy(varRef(next), varRef(env))); + out.push({ kind: "assign", target: env.id, value: varRef(next), at: where, label }); + return out; + } + + /** The nearest scope at or above `from` that keeps variables in an environment. */ + private nearestEnv(from: Scope | null): Scope | null { + for (let s = from; s; s = s.parent) if (this.scopeEnv.has(s)) return s; return null; } /** - * A call of a method — `s.add(u)`, `super.hit(n)`, `Squad.make()` — as the call of a function it is, the instance - * bound to `this`. Undefined when the call is not of a method of a class of the program; null, with a diagnostic, when - * it is and cannot be made. + * The environment of `env` (a scope that has one) as reached from `from`: its variable, within the same run of the + * function; past where the code runs elsewhere (a kept function's top), what it was handed, and from there a parent a + * scope that has an environment. Null when it cannot be reached. */ - private methodCall(e: TS.CallExpression): Call | null | undefined { - const { ts } = this; - const callee = this.unwrap(e.expression); - if (!ts.isPropertyAccessExpression(callee)) return undefined; - const found = this.memberAt(callee, (m, name) => ts.isMethodDeclaration(m) && this.memberKey(m.name) === name); - if (!found) return found; - const method = found.member as TS.MethodDeclaration; - if (!method.body) { this.c.error(e, `${found.name} has no body here: it is abstract, and what this instance is does not give it one.`); return null; } - return this.inline(e, method.parameters, method.body, this.classBodies.get(method.parent as TS.ClassDeclaration) ?? this.body, found.name, method, { ...(found.self ? { self: found.self } : {}), ...(found.before ? { before: found.before } : {}) }) ?? null; + private envRefAt(env: Scope, from: Scope): NumExpr | null { + for (let s: Scope | null = from; s; s = s.parent) { + if (s === env) return varRef(this.scopeEnv.get(env)!); + const b = this.envBoundaries.get(s); + if (!b) continue; + let expr = b.expr; + for (let u = b.env; u; u = this.nearestEnv(u.parent)) { + if (u === env) return expr; + expr = { kind: "element", array: this.poolNow(this.scopeEnv.get(u)!.at).parent.id, index: expr, at: this.scopeEnv.get(u)!.at }; + } + return null; + } + return null; } - /** `s.size` where `size` is a getter: the call it is. Undefined when it is not one. */ - private getterCall(expr: TS.Expression): Call | null | undefined { - const { ts } = this; - const e = this.unwrap(expr); - if (!ts.isPropertyAccessExpression(e)) return undefined; - const found = this.memberAt(e, (m, name) => ts.isGetAccessorDeclaration(m) && this.memberKey(m.name) === name, true); - if (!found) return found; - const getter = found.member as TS.GetAccessorDeclaration; - return this.inline(e, [], getter.body, this.classBodies.get(getter.parent as TS.ClassDeclaration) ?? this.body, found.name, getter, { self: found.self, args: [], ...(found.before ? { before: found.before } : {}) }) ?? null; + /** A kept variable found from somewhere else: its cell at the environment as reached from there. */ + private resolveBoxed(b: Binding, from: Scope): Binding { + const cell = this.boxedCells.get(b); + if (!cell) return b; + const index = this.envRefAt(cell.home, from); + if (!index) { this.envUnreachable = true; return b; } + if (index.kind === "var" && index.id === this.scopeEnv.get(cell.home)!.id) return b; + return cell.at(index); } - /** `s.hp = 5` where `hp` is a setter: the call it is, emitted. False when it is not one. */ - private setterCall(e: TS.BinaryExpression, op: TS.SyntaxKind): boolean { - const { ts } = this; - const left = this.unwrap(e.left); - if (!ts.isPropertyAccessExpression(left)) return false; - const found = this.memberAt(left, (m, name) => ts.isSetAccessorDeclaration(m) && this.memberKey(m.name) === name, true); - if (found === undefined) return false; - if (!found) return true; - if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, `${found.name} is a setter, which takes = only: read it, work the value out, and assign that.`); return true; } - const setter = found.member as TS.SetAccessorDeclaration; - const call = this.inline(e, setter.parameters, setter.body, this.classBodies.get(setter.parent as TS.ClassDeclaration) ?? this.body, found.name, setter, { self: found.self, args: [e.right], nothing: true, ...(found.before ? { before: found.before } : {}) }); - if (call) this.emit({ kind: "call", call, at: call.at, label: call.label }, e); - return true; + /** A scope made inside a kept function's body whose code was written outside it (a function inlined there): it reaches environments through the kept function's. */ + private scopeCreated(scope: Scope) { + const top = this.targetTop; + if (!top || !scope.parent) return; + for (let u: Scope | null = scope.parent; u; u = u.parent) if (u === top) return; + const env = this.nearestEnv(scope.parent); + if (!env) return; + const expr = this.envRefAt(env, this.scope); + if (expr) this.envBoundaries.set(scope, { expr, env }); } /** - * A call that gives an instance — `make(3)`, `v.add(w)` where `add` returns `this`: the call made, as a statement, and - * the instance it settled on. Undefined when the call gives no instance of a class of the program; null with a diagnostic. + * A call of a function of the program's: one declared in the body, one known as a value when the script is built + * (inlined or called as any function), or one kept in a variable, an array or a field — called through its number. + * Undefined when the callee is none of those, for the caller to try what else it may be; null when it was one and + * could not be compiled (reported). */ - private instanceCall(e: TS.CallExpression, name: string, into?: Stmt[]): Binding | null | undefined { + private userCall(e: TS.CallExpression): Call | null | undefined { const { ts } = this; - if (!this.classOfType(this.c.checker.getTypeAtLocation(e))) return undefined; - const instance: { name: string; made?: Binding } = { name }; - let call: Call | undefined; - const callee = this.unwrap(e.expression); - if (ts.isIdentifier(callee)) { - const decl = this.gameDeclaration(callee); - if (!decl || !ts.isFunctionDeclaration(decl)) { this.c.error(e, "A function that gives an instance is one declared in the program."); return null; } - call = this.inline(e, decl.parameters, decl.body, this.body, decl.name?.text, decl, { instance }); - } else if (ts.isPropertyAccessExpression(callee)) { - const found = this.memberAt(callee, (m, n) => ts.isMethodDeclaration(m) && this.memberKey(m.name) === n); - if (!found) return null; - const method = found.member as TS.MethodDeclaration; - call = this.inline(e, method.parameters, method.body, this.classBodies.get(method.parent as TS.ClassDeclaration) ?? this.body, found.name, method, { ...(found.self ? { self: found.self } : {}), ...(found.before ? { before: found.before } : {}), instance }); + // `g?.()` of a function known to be left out: nothing happens. + if (e.questionDotToken) { + const known = this.evaluate(e.expression); + if (known && (known.value === undefined || known.value === null)) return { at: this.at(e), label: this.label(e), params: [], body: [] }; } - if (!call) return null; - // `into`: the call is part of a larger one's chain, and runs inside it; else it runs here. - if (into) into.push(this.mark({ kind: "call", call, at: call.at, label: call.label }, e)); else this.emit({ kind: "call", call, at: call.at, label: call.label }, e); - if (!instance.made) { this.c.error(e, "This call gave no instance: every way through the function has to return one."); return null; } - return instance.made; + if (ts.isIdentifier(e.expression)) { + const decl = this.gameDeclaration(e.expression); + if (decl && ts.isFunctionDeclaration(decl)) { + const where = this.declaredIn.get(decl); + return this.inline(e, decl.parameters, decl.body, this.body, decl.name?.text, decl, where && where !== this.topScope ? { closure: where } : {}) ?? null; + } + } + const fn = this.fnValueOf(e.expression); + if (fn) { + if (fn.decl.asteriskToken) return undefined; + return this.inline(e, fn.parameters, fn.body, fn.target as Body, this.functionName(fn), fn.decl, { closure: fn.closure }) ?? null; + } + if (this.fnHeld(e.expression)) return this.dispatch(e) ?? null; + return undefined; + } + + /** Whether an expression is a function kept by the program — a variable, a cell of an array, a field, what a call gives — rather than one of the library's. */ + private fnHeld(expr: TS.Expression): boolean { + const { ts } = this; + const e = this.unwrap(expr); + const type = this.c.checker.getTypeAtLocation(e); + if (this.kindOf(type) !== "number" || !this.isFunctionType(this.c.checker.getNonNullableType(type))) return false; + if (this.evaluate(e)) return false; + if (ts.isIdentifier(e) || ts.isPropertyAccessExpression(e)) { const b = this.bindingOf(e); return b?.kind === "var" || b?.kind === "cell"; } + if (ts.isElementAccessExpression(e)) return !!this.bindingOf(e.expression) || !!this.arrayOf(e.expression); + return ts.isCallExpression(e) || ts.isConditionalExpression(e) || ts.isBinaryExpression(e); } /** - * The member of a class that `obj.name` reaches: through an instance (by the class it is, so an overridden method is - * the one found), through `super` (from what the enclosing class extends on), or — a static one — through the class's - * name. `quiet`: a name that is no such member is simply not one (a field, read as any field is). + * A call through a function's number: the arguments worked out, then the dispatcher for calls of this shape, which runs + * the function whose number it is handed. A number known when the script is built (`makeAdder(3)(4)` inlined to a + * constant) calls its function straight away. */ - private memberAt(e: TS.PropertyAccessExpression, pick: (m: TS.ClassElement, name: string) => boolean, quiet = false): { member: TS.ClassElement; self?: Binding; name: string; before?: Stmt[] } | null | undefined { + private dispatch(e: TS.CallExpression): Call | undefined { const { ts } = this; - const name = e.name.text; - const obj = this.unwrap(e.expression); - if (obj.kind === ts.SyntaxKind.SuperKeyword) { - const cls = this.enclosingClass(e); - const self = this.scope.lookup(THIS); - if (!cls || !self) { this.c.error(e, "super is for a method of a class that extends another."); return null; } - const member = this.memberOf(cls, (m) => !this.isStatic(m) && pick(m, name), 1); - if (!member) { if (quiet) return undefined; this.c.error(e, `What ${this.className(cls)} extends has no ${name}().`); return null; } - return { member, self, name: `${this.className(member.parent as TS.ClassDeclaration)}.${name}` }; - } - const named = this.classOf(obj); - if (named) { - const member = this.memberOf(named, (m) => this.isStatic(m) && pick(m, name)); - if (!member) { if (quiet) return undefined; this.c.error(e, `${this.className(named)} has no static ${name}().`); return null; } - return { member, name: `${this.className(named)}.${name}` }; - } - // `v.add(w).scale(2)`: what the call before gives is what this one is called on. - const before: Stmt[] = []; - const self = ts.isCallExpression(obj) ? this.instanceCall(obj, "(made)", before) ?? undefined : this.bindingOf(obj); - if (self?.kind !== "record" || !self.cls) return undefined; - const member = this.memberOf(self.cls, (m) => !this.isStatic(m) && pick(m, name)); - if (!member) { if (quiet || self.fields.has(name)) return undefined; this.c.error(e, `${this.className(self.cls)} has no method ${name}().`); return null; } - return { member, self, name: `${this.className(self.cls)}.${name}`, ...(before.length ? { before } : {}) }; + const { checker } = this.c; + const callee = checker.getNonNullableType(checker.getTypeAtLocation(e.expression)); + const signature = callee.getCallSignatures()[0]; + if (!signature) { this.c.error(e, "This is not a function."); return undefined; } + const params: ValueKind[] = []; + const widths: string[] = []; + for (const p of signature.parameters) { + const type = checker.getTypeOfSymbolAtLocation(p, e); + const k = this.valueKind(type); + if (!k) { this.c.error(e, `A function called through its number takes numbers, booleans, texts, units or other functions; ${p.name} is ${checker.typeToString(type)}.`); return undefined; } + params.push(k); + widths.push(k === "number" && this.widthOf(type).fixed ? "fixed" : k); + } + const returned = checker.getReturnTypeOfSignature(signature); + if (returned.getProperty("then")) { this.c.error(e, "A function kept as a value is not async yet: await it where it is written."); return undefined; } + const result: ValueKind | "void" = (returned.flags & (ts.TypeFlags.Void | ts.TypeFlags.Undefined)) !== 0 ? "void" : this.valueKind(returned) ?? "void"; + const key = `${widths.join(",")}->${result}`; + let d = this.dispatchers.get(key); + if (!d) { + const at = this.at(e); + const fd: FuncDecl = { id: `(call through a value ${this.dispatchers.size + 1})#${this.nextId++}`, name: "(call through a value)", params: [], body: [], at }; + fd.params.push(this.newVar("(which function)", "number", at)); + params.forEach((k, i) => fd.params.push(this.newVar(signature.parameters[i].name, k, at, widths[i] === "fixed" ? { fixed: true } : k === "text" ? { text: "made" } : {}))); + if (result !== "void") fd.result = { decl: this.newVar("(call through a value result)", result, at, { temp: true }), kind: result }; + d = { fd, params, result, at, label: this.label(e) }; + this.dispatchers.set(key, d); + this.functions.push(d.fd); + } + const which = this.num(e.expression); + if (!which) return undefined; + const label = this.label(e); + const out: Call = { name: e.expression.getText(this.body.sf), fn: d.fd.id, at: this.at(e), label, params: [{ decl: d.fd.params[0], init: which, label }], body: [] }; + for (let i = 0; i < params.length; i++) { + const arg = e.arguments[i]; + const k = params[i]; + let init: NumExpr | BoolExpr | UnitExpr | TextExpr | null; + if (!arg) init = k === "number" ? num(0) : k === "boolean" ? FALSE : k === "unit" ? NO_UNIT : this.literalText("", e); + else if (ts.isSpreadElement(arg)) { this.c.error(arg, "A call through a function's number takes its arguments one by one."); return undefined; } + else init = k === "number" ? this.num(arg) : k === "boolean" ? this.boolValue(arg) : k === "unit" ? this.unitExpr(arg) : this.text(arg); + if (!init) return undefined; + out.params.push({ decl: d.fd.params[i + 1], init, label }); + } + if (result !== "void") out.result = { decl: this.newVar(`(${out.name} result)`, result, out.at, { temp: true }), kind: result }; + this.mark(out, e); + return out; } - /* ── Functions ── */ + /** + * Once the program is walked: what each dispatcher runs. A function kept as a value is among its cases when its number + * is somewhere the program can reach (a function only an abandoned attempt made a number of is not) and its shape fits + * the calls — the parameters the dispatcher is handed, in order, and the result the calls want. + */ + private fillDispatchers(program: Program) { + if (!this.dispatchers.size) return; + const byId = new Map(this.functions.map((f) => [f.id, f])); + const byTarget = new Map([...this.targets.values()].map((t) => [t.fd.id, t])); + const live = new Set(); + const seen = new Set(); + const walk = (root: unknown): void => { + if (Array.isArray(root)) { root.forEach(walk); return; } + if (!root || typeof root !== "object") return; + const o = root as Record; + if (o.kind === "const" && typeof o.fn === "string") { + const t = byTarget.get(o.fn); + if (t && !live.has(t)) { live.add(t); walk(t.fd.body); } + } + if ((o.kind === "call" || o.kind === "textCall") && o.call && typeof o.call === "object") { + const fn = (o.call as Call).fn; + if (fn && !seen.has(fn)) { seen.add(fn); const f = byId.get(fn); if (f) walk(f.body); } + } + for (const v of Object.values(o)) if (v && typeof v === "object") walk(v); + }; + walk(program.body); + const base = this.boxed.size ? FN_BASE : 0; + for (const d of this.dispatchers.values()) { + const cases: { value: number | null; body: Stmt[] }[] = []; + const { at, label } = d; + const which = d.fd.params[0]; + // A closure (a row of the pool): the function it runs, and the environment it was made in. + const env = this.pool ? this.newVar("(environment)", "number", at) : null; + for (const t of [...live].sort((a, b) => a.id - b.id)) { + if (t.params.length > d.params.length || t.params.some((k, i) => k !== d.params[i]) || (d.result !== "void" && t.result !== d.result)) continue; + const call: Call = { name: t.name, fn: t.fd.id, at, label, params: t.fd.params.map((p, i) => ({ decl: p, init: p === t.env && env ? varRef(env) : refOf(d.fd.params[i + 1]), label })), body: [] }; + if (d.result === "void" || t.result === "void") { cases.push({ value: base + t.id, body: [{ kind: "call", call, at, label }, { kind: "break", at, label }] }); continue; } + call.result = { decl: this.newVar(`(${t.name} result)`, d.result, at, { temp: true }), kind: d.result }; + const value = d.result === "text" ? ({ kind: "textCall", call } as TextExpr) : ({ kind: "call", call } as NumExpr); + cases.push({ value: base + t.id, body: [{ kind: "return", value, at, label }] }); + } + const open: Stmt[] = this.pool && env ? [ + { kind: "declare", decl: env, init: num(0), at, label }, + { kind: "if", cond: { kind: "compare", op: "<", left: varRef(which), right: num(FN_BASE), at, label }, then: [ + { kind: "assign", target: env.id, value: { kind: "element", array: this.pool.parent.id, index: varRef(which), at }, at, label }, + { kind: "assign", target: which.id, value: { kind: "element", array: this.pool.fnOf.id, index: varRef(which), at }, at, label }, + ], at, label }, + ] : []; + d.fd.body = cases.length ? [...open, { kind: "switch", value: varRef(which), cases, at, label }] : []; + } + } - /** A call of a `game()` function: inlined from its own body, wherever that file is. */ private gameCall(call: TS.CallExpression, fn: GameFunctionValue): Call | undefined { const target = this.c.resolve(fn); if (!target) { this.c.error(call, "This game function's body could not be found again."); return undefined; } const arrow = target.plan.arrow; + if (!arrow) { this.c.error(call, "This game function's body could not be found again."); return undefined; } return this.inline(call, arrow.parameters, target.plan.expression ?? (arrow.body as TS.Block), target, target.name, arrow); } @@ -5542,6 +10425,15 @@ export class Structured { * in a variable of the call's own that dies with the statement. */ private inline(call: TS.Expression, parameters: readonly TS.ParameterDeclaration[], body: TS.Block | TS.Expression | undefined, target: Body, name: string | undefined, decl: TS.Node, as: MethodCall = {}): Call | undefined { + // Generics: a method of an instance of a generic class, a generic function at this call — their type parameters stand + // for what they are here, while the call is worked out (its parameters' kinds included). + const own = this.callTypeArgs(decl, call); + const fromSelf = as.self?.kind === "record" ? as.self.typeArgs : undefined; + const args = own?.size && fromSelf?.size ? new Map([...fromSelf, ...own]) : own?.size ? own : fromSelf; + return this.withTypeArgs(args, () => this.inlineWith(call, parameters, body, target, name, decl, as, args)); + } + + private inlineWith(call: TS.Expression, parameters: readonly TS.ParameterDeclaration[], body: TS.Block | TS.Expression | undefined, target: Body, name: string | undefined, decl: TS.Node, as: MethodCall, typeArgs: TypeArgs | undefined): Call | undefined { const { ts } = this; // What is handed over: a call's arguments — or, where `call` is not one (`new Squad(P1)`, a getter read, a setter's `=`), what `as` says. const written: readonly TS.Expression[] = as.args ?? (ts.isCallExpression(call) ? call.arguments : []); @@ -5559,18 +10451,24 @@ export class Structured { for (const item of items) { spread.set(given.length, item); given.push(arg); } } const what = name ?? "The function"; + // The call start() runs as a task: its arguments are worked out at the start, and the caller's variables may change after. + const copies = this.copyArgsAt === this.inlineDepth; + if (copies) this.copyArgsAt = -1; if (!body) { this.c.error(call, "The function has no body."); return undefined; } // A copy of a body inside a copy of a body, sixteen times over: what happens from here is decided once the arguments are known. const deep = this.inlineDepth >= MAX_INLINE_DEPTH; - if ((ts.isFunctionDeclaration(decl) || ts.isArrowFunction(decl) || ts.isFunctionExpression(decl) || ts.isMethodDeclaration(decl)) && (decl.asteriskToken || decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword))) { this.c.error(decl, "Generators and async functions are not supported in a program."); return undefined; } - const kind: Kind | "text" | "void" = as.nothing ? "void" : this.kindOf(this.c.checker.getTypeAtLocation(call)) ?? (this.isTextType(this.c.checker.getTypeAtLocation(call)) ? "text" : "void"); + if ((ts.isFunctionDeclaration(decl) || ts.isArrowFunction(decl) || ts.isFunctionExpression(decl) || ts.isMethodDeclaration(decl)) && decl.asteriskToken && !as.generator) { this.c.error(call, `${what} is a generator: a program walks one with for…of — for (const w of ${name ?? "gen"}(…)) { … }.`); return undefined; } + // `target?.hit(3)` (B9) is the call's own type or undefined: the call itself gives its own. + const callType = ts.isCallExpression(call) && this.optionalBase(call) ? this.c.checker.getNonNullableType(this.c.checker.getTypeAtLocation(call)) : this.c.checker.getTypeAtLocation(call); + const kind: Kind | "text" | "void" = as.nothing ? "void" : this.kindOf(callType) ?? (this.isTextType(callType) ? "text" : "void"); const line = this.line(call); const out: Call = { ...(name ? { name } : {}), at: this.at(call), label: this.label(call), params: [], body: [] }; - if (kind !== "void") out.result = { decl: this.newVar(`(${name ?? "function"} result)`, kind, this.at(call), { temp: true, ...(kind === "number" ? this.widthOf(this.c.checker.getTypeAtLocation(call)) : {}) }), kind }; + if (kind !== "void") out.result = { decl: this.newVar(`(${name ?? "function"} result)`, kind, this.at(call), { temp: true, ...(kind === "number" ? this.widthOf(callType) : {}) }), kind }; this.mark(out, call); this.callees.set(out, decl); - // A function of the body closes over the program's variables; a game function sees only its own. - const closure = target === this.body && target === this.c.body ? this.topScope : null; + // A function of the body closes over the program's variables; a game function sees only its own; a function written as + // a value (B3) sees the scope it was written in. + const closure = as.closure !== undefined ? as.closure : target === this.body && target === this.c.body ? this.topScope : null; const scope = new Scope(closure); if (as.self) scope.bind(THIS, as.self); let ok = true; @@ -5584,6 +10482,8 @@ export class Structured { }; // Which instance comes back is settled where the call is: a copy of the body a call. if (as.instance) asCalled("it returns an instance, and which one is settled at each call"); + if (copies) asCalled("it runs as a task, a copy of its body for each start()"); + if (as.generator) asCalled("it is a generator, a copy of its body at each for…of"); if (as.before?.length) asCalled("it is called on what another call gives, which runs first and inside it"); // `function len({ x, y }: Point)`: taken apart when the function starts, inside its body — a called function's too. const patterns: (() => void)[] = []; @@ -5634,6 +10534,12 @@ export class Structured { return; } if (!arg) { + // An optional parameter left out (\`shaded?: number[]\`) is undefined, which the body can ask about when it is built. + if (!p.initializer && p.questionToken) { + scope.bind(p, { kind: "value", value: undefined }); + asCalled(`${p.name.text} is left out of this call`); + return; + } if (!p.initializer) { this.c.error(call, `Missing argument ${p.name.text}.`); ok = false; return; } // The default is the function's own expression: evaluated in its body. const saved = this.enterBody(target); @@ -5644,12 +10550,33 @@ export class Structured { asCalled(this.constantArgument(h.value, label, p) ?? `${p.name.text} is ${describe(h.value)}, which only the script has`); return; } + // `twice(() => n++)`, `each(xs, add)` (B3): the function itself, known when the script is built — a call of the + // parameter is a call of it, and a function that is called is one copy for each function it is handed. + const fnArg = this.isFunctionType(this.c.checker.getTypeAtLocation(p.name)) ? this.fnValueOf(arg) : undefined; + // Handed to a task, which runs on after this frame (`every(30, () => …)`): a function kept as a value, so that what + // it uses of the block it was written in is kept for it, a turn of a loop each its own — as JavaScript has it. + if (fnArg && copies && this.keepableInTask(fnArg)) { + const id = this.functionId(fnArg, arg); + if (!id) { ok = false; return; } + const copy = this.newVar(p.name.text, "number", this.sourceOfIn(target, p.name)); + out.params.push({ decl: copy, init: id, label }); + scope.bind(p, { kind: "var", v: copy }); + asCalled({ init: id, label }); + return; + } + if (fnArg) { + if (this.assigns(body, p)) { this.c.error(p, `${p.name.text} is the function handed over; keep another in a let of the function's own to change it.`); ok = false; return; } + const b: Binding = { kind: "fn", fn: fnArg }; + scope.bind(p, b); + asCalled({ binding: b }); + return; + } const h = this.evaluate(arg); if (this.isTextType(this.c.checker.getTypeAtLocation(p.name)) && !(h && !isGameValue(h.value) && !this.assigns(body, p))) { // A text of the program: the caller's own variable when the function only reads it, else a variable of the parameter's own that starts as a copy. // A function that is called has the parameter for a variable either way, set to a copy at each call. const given = this.bindingOf(arg); - if (given?.kind === "var" && given.v.kind === "text" && !this.assigns(body, p)) { scope.bind(p, given); asCalled({ init: { kind: "textVar", id: given.v.id }, label }); return; } + if (given?.kind === "var" && given.v.kind === "text" && !this.assigns(body, p) && !copies) { scope.bind(p, given); asCalled({ init: { kind: "textVar", id: given.v.id }, label }); return; } const value = this.text(arg); if (!value) { ok = false; return; } asCalled({ init: value, label }); @@ -5676,7 +10603,7 @@ export class Structured { } // `hurt(new Boss(3))`: the instance made at the call, under the parameter's name. const fresh = this.unwrap(arg); - if (ts.isNewExpression(fresh) && this.classOf(fresh.expression)) { + if (ts.isNewExpression(fresh) && this.classOf(fresh.expression) && !this.isRefClass(this.classOf(fresh.expression))) { const instance = this.instantiate(fresh, p.name.text); if (!instance) { ok = false; return; } scope.bind(p, instance); @@ -5691,6 +10618,19 @@ export class Structured { const binding = this.listOf(arg); // An array reaches a function as itself, as it does in TypeScript: what the function stores, the caller sees. if (binding?.kind === "array" || binding?.kind === "records" || binding?.kind === "units" || binding?.kind === "keyed" || binding?.kind === "hash" || binding?.kind === "texts" || binding?.kind === "grid" || binding?.kind === "lists") { scope.bind(p, binding); asCalled({ binding }); return; } + // A number or nothing (B9): the caller's own, when the function only reads it; else a copy of both. + const maybeArg = ts.isIdentifier(fresh) || ts.isPropertyAccessExpression(fresh) || ts.isElementAccessExpression(fresh) ? this.bindingOf(fresh) : undefined; + if (maybeArg?.kind === "maybe") { + const binding = maybeArg; + if (this.assigns(body, p) || copies) { + const kindM = binding.value.kind === "var" ? binding.value.v.kind as "number" | "boolean" : binding.value.kind === "cell" ? binding.value.a.kind : "number"; + const copy = this.declareMaybe(p.name.text, kindM, this.c.checker.getTypeAtLocation(p.name), arg, p); + if (!copy) { ok = false; return; } + scope.bind(p, copy); + } else scope.bind(p, binding); + asCalled(`${p.name.text} may be undefined, a value and whether there is one`); + return; + } if (binding?.kind === "record") { if (this.assigns(body, p)) { this.c.error(p, `${p.name.text} is a record; a record parameter can have its fields assigned, not be reassigned itself.`); ok = false; return; } scope.bind(p, binding); @@ -5702,7 +10642,7 @@ export class Structured { const init = variable.kind === "number" ? varRef(variable) : variable.kind === "unit" ? unitRef(variable) : boolRef(variable); asCalled({ init, label }); // By value, as in TypeScript. A parameter the function never assigns can read the caller's variable directly; one it assigns gets a copy. - if (!this.assigns(body, p)) { scope.bind(p, { kind: "var", v: variable }); return; } + if (!this.assigns(body, p) && !copies) { scope.bind(p, { kind: "var", v: variable }); return; } const copy = this.newVar(p.name.text, variable.kind, this.sourceOfIn(target, p.name), { ...(variable.bits ? { bits: variable.bits } : {}), ...(variable.unsigned ? { unsigned: true } : {}) }); out.params.push({ decl: copy, init, label }); scope.bind(p, { kind: "var", v: copy }); @@ -5718,6 +10658,15 @@ export class Structured { asCalled({ init: unit, label }); return; } + if (!ts.isIdentifier(this.unwrap(arg)) && this.kindOf(this.c.checker.getTypeAtLocation(p.name)) === "boolean" && this.kindOf(this.c.checker.getTypeAtLocation(arg)) === "boolean") { + // `step(n, turns < 2)`: a comparison, or any condition of the program's, into a boolean of the parameter's own. + const test = this.bool(arg); + const copy = this.newVar(p.name.text, "boolean", this.sourceOfIn(target, p.name)); + out.params.push({ decl: copy, init: test, label }); + scope.bind(p, { kind: "var", v: copy }); + asCalled({ init: test, label }); + return; + } const value = ts.isIdentifier(this.unwrap(arg)) ? null : this.numQuietly(arg); if (value) { // An expression over variables: computed into a variable of the parameter's own. @@ -5731,20 +10680,28 @@ export class Structured { ok = false; }); // The instance a method is called on goes last: a function that is called is one copy an instance, as it is one an array. - if (as.self) asCalled({ binding: as.self }); + // By reference (B9) it is a number, its row, and the function one copy for every instance of the classes it may be. + // A constructor stays a copy at each `new`: what it gives the fields first is the row's own. + if (as.self?.kind === "record" && as.self.ref && ts.isConstructorDeclaration(decl)) asCalled("it is a constructor, a copy at each new"); + else if (as.self?.kind === "record" && as.self.ref) asCalled({ init: as.self.ref.place, label: `L${line}: this`, self: as.self.ref.classes }); + else if (as.self) asCalled({ binding: as.self }); if (!ok) return out; if (given.length > parameters.length && !parameters.some((p) => p.dotDotDotToken)) { this.c.error(call, `${what} takes ${parameters.length} argument${parameters.length === 1 ? "" : "s"}.`); return out; } const at = this.sourceOfIn(target, (decl as { name?: TS.Node }).name ?? decl); - const site = args ? this.siteOf(decl, args) : undefined; + // A generic function is one copy for each set of what its type parameters are, as it is one for each array. + const site = args ? this.siteOf(decl, args, as.closure ?? undefined, typeArgs && [...typeArgs.values()].map((t) => this.c.checker.typeToString(t)).join(",")) : undefined; if (site && args) { if (site.fn) return this.calls(out, site.fn, args); // Inside the attempt to make it a function that is called: the function calls itself, and this is that call. if (site.making) return this.calls(out, site.making, args); // Met a second time, or met inside itself: one copy that every call runs, when the function can be one. if ((site.first || (site.walking ?? 0) > 0) && !site.never && !site.busy) { - if (ts.isFunctionDeclaration(decl) && (closure === null || decl.parent !== this.c.body.plan.body)) site.never = "it is declared inside a block or another function, whose variables it may use"; - else if (ts.isClassDeclaration(decl.parent) && (closure === null || decl.parent.parent !== this.c.body.plan.body)) site.never = "its class is declared inside a block or a function, whose variables it may use"; + // At the top of one of the script's files it sees only that file, which is the same at every call. + const topFunction = isScriptFunction(ts, decl as TS.Node); + const topClass = isScriptClass(ts, decl.parent as TS.Node); + if (ts.isFunctionDeclaration(decl) && !topFunction && (closure === null || decl.parent !== this.c.body.plan.body)) site.never = "it is declared inside a block or another function, whose variables it may use"; + else if (ts.isClassDeclaration(decl.parent) && !topClass && (closure === null || decl.parent.parent !== this.c.body.plan.body)) site.never = "its class is declared inside a block or a function, whose variables it may use"; else { site.busy = true; let made: FuncDecl | string; @@ -5763,7 +10720,7 @@ export class Structured { if (because) this.inlinedBecause.set(decl, { why: because, name: name ?? "function", at }); if (deep) { this.c.error(call, (this.walkingBodies.get(decl) ?? 0) > 0 - ? `${what} calls itself, and here it cannot be a function that is called${because ? ` — ${because}` : ""}. A call that is not one is a copy of the function's body, and these copies would have no end.` + ? `${what} calls itself, and here it cannot be a function that is called${because ? ` — ${because}` : ""}. A call that is not one is a copy of the function's body, and these copies would have no end.${because.startsWith("it sleeps") || because.startsWith("it runs as a task") ? " A function that waits cannot call itself yet: write the recursion as a loop — while (n > 0) { …; await sleep(…); n--; }." : ""}` : "Functions nest too deeply: a call that is inlined is a copy of the function's body, and these are sixteen inside one another."); return undefined; } @@ -5771,6 +10728,10 @@ export class Structured { this.walkingBodies.set(decl, (this.walkingBodies.get(decl) ?? 0) + 1); try { out.body = this.walkFunction(body, kind, target, scope, () => { patterns.forEach((take) => take()); as.first?.(scope); }, as.instance); + const idle = this.neverAwaits(decl); + if (idle) { + out.body.unshift({ kind: "remark", short: "never awaits", text: `${what} is async but never awaits: it runs to its end in the frame it is called, as a function that is not async would. Take the async away, or await something in it.`, at: this.sourceOfIn(target, idle) }); + } if (as.before?.length) { // The chain so far, then this call's own arguments, then its body: the order JavaScript works them out in. out.body = [...as.before, ...out.params.map((p): Stmt => ({ kind: "declare", decl: p.decl, init: p.init, at: out.at, label: p.label })), ...out.body]; @@ -5796,6 +10757,8 @@ export class Structured { const fn: Ctx["fn"] = { kind, ...(instance ? { instance } : {}) }; try { return this.collect(() => { + // Its parameters a kept function uses, into the environment of this run. + this.flushBoxes(); // What the function does before its first statement: its parameters taken apart. first?.(); if (ts.isBlock(body)) this.block(body.statements, { fn }); @@ -5806,6 +10769,12 @@ export class Structured { else if (kind === "unit") { const value = this.unitExpr(body); if (value) this.emit({ kind: "return", value, at: this.at(body), label: this.label(body) }, body); } else if (kind === "boolean") this.emit({ kind: "return", value: this.boolValue(body), at: this.at(body), label: this.label(body) }, body); else if (kind === "text") { const value = this.text(body); if (value) this.emit({ kind: "return", value, at: this.at(body), label: this.label(body) }, body); } + else if (instance) { + // `() => ({ inc, get })`: the record it gives. + const made = this.madeInstance(this.unwrap(body), instance.name); + if (made?.kind === "record") instance.made = made; + else if (made !== null) this.c.error(body, "A function gives a record or an instance it made (=> ({ x, y })), or one it was handed."); + } else this.expressionStatement(body); } catch (err) { if (!(err instanceof LowerError)) throw err; @@ -5824,9 +10793,15 @@ export class Structured { /** A value the script has, as what a parameter that is a variable is set to: a whole number or a boolean, else nothing. */ private constantArgument(value: unknown, label: string, parameter: TS.ParameterDeclaration): CallArgument | null { // `hurt(null, 1)`: no unit, for a parameter that is one. - if (value === null || value === undefined) return this.kindOf(this.c.checker.getTypeAtLocation(parameter.name)) === "unit" ? { init: NO_UNIT, label } : null; + if (value === null || value === undefined) { + const type = this.c.checker.getTypeAtLocation(parameter.name); + // No instance (B9), for a parameter that is a reference: 0. + if (this.refClasses(type)) return { init: num(0), label }; + return this.kindOf(type) === "unit" ? { init: NO_UNIT, label } : null; + } if (typeof value === "boolean") return { init: value ? TRUE : FALSE, label }; if (typeof value === "number" && Number.isInteger(value) && value >= I32_MIN && value <= U32_MAX) return { init: num(value), label }; + if (typeof value === "number" && Number.isFinite(value) && !Number.isInteger(value) && value >= FIXED_MIN && value <= FIXED_MAX) return { init: { kind: "const", value: fixedRaw(value), fixed: true }, label }; if (typeof value === "string" && this.isTextType(this.c.checker.getTypeAtLocation(parameter.name))) return { init: this.literalText(value, parameter), label }; return null; } @@ -5838,7 +10813,7 @@ export class Structured { } /** What is known of a function at these arrays: a function that takes an array is one copy an array passed, as a template is. */ - private siteOf(decl: TS.Node, args: CallArgument[]): FunctionSite { + private siteOf(decl: TS.Node, args: CallArgument[], closure?: Scope, types?: string): FunctionSite { const part = (b: Binding): string => { switch (b.kind) { case "array": return `a${this.identity(b.a)}`; @@ -5847,11 +10822,14 @@ export class Structured { case "keyed": return `k${this.identity(b.values ?? b.present ?? b)}`; case "hash": return `h${this.identity(b.slots)}`; case "texts": return `t${this.identity(b.rows)}`; + // A function handed over (B3): one copy a function, as one an array — the same function wherever it is named. + case "fn": return `f${this.identity(b.fn)}`; // A record kept in a `let` is one object the scope hands back; a row of an array of records is made anew at every use, and so never met twice. default: return `o${this.identity(b)}`; } }; - const key = `${this.identity(decl)}:${args.map((a) => ("binding" in a ? part(a.binding) : "")).join(",")}`; + // A function written as a value is one in the scope it was written in: the same arrow in another copy of what holds it sees other variables. + const key = `${this.identity(decl)}${closure ? `@${this.identity(closure)}` : ""}${types ? `<${types}>` : ""}:${args.map((a) => ("binding" in a ? part(a.binding) : a.self ? `s${a.self.map((c) => this.identity(c)).join("|")}` : "")).join(",")}`; let site = this.sites.get(key); if (!site) { site = {}; this.sites.set(key, site); } return site; @@ -5894,9 +10872,16 @@ export class Structured { fn.params.push(v); scope.bind(p, { kind: "var", v }); } - // A method's instance came last among what the call hands over. + // A method's instance came last among what the call hands over: by reference (B9), a parameter of its own, its row. const self = args[parameters.length]; if (self && "binding" in self) scope.bind(THIS, self.binding); + else if (self?.self) { + const v = this.newVar("this", "number", this.sourceOfIn(target, decl)); + fn.params.push(v); + const row = this.refRow(varRef(v), self.self, decl); + if (!row) return "its instance cannot be kept by reference"; + scope.bind(THIS, row); + } if (kind !== "void") { const type = this.c.checker.getReturnTypeOfSignature(this.c.checker.getSignatureFromDeclaration(decl as TS.SignatureDeclaration)!); fn.result = { decl: this.newVar(`(${name} result)`, kind, at, { temp: true, ...(kind === "number" ? this.widthOf(type) : {}) }), kind }; @@ -5909,15 +10894,22 @@ export class Structured { // Its body is a place of its own: how deep the copies around this attempt go says nothing about it. const depth = this.inlineDepth; this.inlineDepth = 0; + this.attempts++; try { fn.body = this.walkFunction(body, kind, target, scope, () => patterns.forEach((take) => take())); } finally { + this.attempts--; this.inlineDepth = depth; (this.c as { error: StructuredContext["error"] }).error = error; } if (caught.length) return `with its parameters as variables of the game it does not compile — ${caught[0].replace(/\.$/, "")}`; - if (mentions(fn.body, "sleep")) return "it sleeps, and the program wakes up inside it"; + if (mentions(fn.body, "sleep") || mentions(fn.body, "parallel")) return "it sleeps, and the program wakes up inside it"; + if (mentions(fn.body, "start")) return "it starts a task"; if (mentions(fn.body, "edge")) return "rose() / once() remember what they saw, a call each"; + const idle = this.neverAwaits(decl); + if (idle) { + fn.body.unshift({ kind: "remark", short: "never awaits", text: `${name} is async but never awaits: it runs to its end in the frame it is called, as a function that is not async would. Take the async away, or await something in it.`, at: this.sourceOfIn(target, idle) }); + } this.mark(fn, decl); this.functions.push(fn); this.declared.set(fn, decl); @@ -5985,6 +10977,7 @@ export class Structured { if (!root || typeof root !== "object") return; const o = root as Record; if (typeof o.array === "string") used.add(o.array); + for (const k of ["addr", "block", "chars"]) if (typeof o[k] === "string") used.add(o[k] as string); for (const v of Object.values(o)) if (v && typeof v === "object") arrays(v); }; arrays(program.body); @@ -6035,11 +11028,33 @@ export class Structured { const v = h.value; if (typeof v === "boolean") return v ? 1 : 0; if (typeof v !== "number" || !Number.isFinite(v)) { this.c.error(at, `Expected a number, got ${describe(v)}.`); return null; } - if (!Number.isInteger(v)) { this.c.error(at, `Only whole numbers exist in the game (got ${v}).`); return null; } + if (!Number.isInteger(v)) { this.c.error(at, `Only a whole number goes here (got ${v}): Math.floor(x), Math.round(x) or Math.trunc(x) says how to round it.`); return null; } if (v < I32_MIN || v > U32_MAX) { this.c.error(at, `A number of the game has 32 bits: −2 147 483 648 to 2 147 483 647, or up to 4 294 967 295 as a u32 (got ${v}).`); return null; } return v; } + /** + * A number known when the script is built, as a constant of the program: a whole number as it is, one with a fraction + * a fixed number (16.16, `numbers.ts`), which is what makes `0.5`, `Math.PI` and `Math.cos(1)` mean what they say. + */ + private constOf(h: { value: unknown }, at: TS.Node): NumExpr | null { + const v = h.value; + if (typeof v === "number" && Number.isFinite(v) && !Number.isInteger(v)) return this.fixedConst(v, at); + const n = this.asInteger(h, at); + return n === null ? null : num(n); + } + + private fixedConst(v: number, at: TS.Node): NumExpr | null { + if (v < FIXED_MIN || v > FIXED_MAX) { this.c.error(at, `${v} has a fraction, which makes it a fixed number, and a fixed number holds −32 768 … 32 767.99998.`); return null; } + return { kind: "const", value: fixedRaw(v), fixed: true }; + } + + /** A list's numbers as cells: whole as they are, or every one a fixed number's cell when any has a fraction. */ + private listCells(list: readonly number[]): { values: number[]; fixed?: boolean } { + if (list.every((v) => Number.isInteger(v))) return { values: [...list] }; + return { values: list.map(fixedRaw), fixed: true }; + } + /** `num` without diagnostics, for a probe that may fail. */ private numQuietly(expr: TS.Expression): NumExpr | null { const { error } = this.c; @@ -6054,19 +11069,104 @@ export class Structured { } /** A number expression: a constant, a variable, arithmetic, an intrinsic, a call — or null, with a diagnostic. */ + /** + * `++n`, `n--`, `n = v`, `n += v` used for their value (`return ++count`, `ids[next++] = id`): the update runs where + * the expression is — a call inlined there, so a `&&`, a `?:` or a loop's condition runs it only when JavaScript would — + * and the value is the variable's after it (before it, for `n++`). Undefined for any other expression. + */ + private updateValue(e: TS.Expression): NumExpr | null | undefined { + const { ts } = this; + const step = (ts.isPrefixUnaryExpression(e) || ts.isPostfixUnaryExpression(e)) && (e.operator === ts.SyntaxKind.PlusPlusToken || e.operator === ts.SyntaxKind.MinusMinusToken); + const assigns = ts.isBinaryExpression(e) && e.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && e.operatorToken.kind <= ts.SyntaxKind.LastAssignment; + if (!step && !assigns) return undefined; + const target = step ? (e as TS.PrefixUnaryExpression | TS.PostfixUnaryExpression).operand : (e as TS.BinaryExpression).left; + if (this.kindOf(this.c.checker.getTypeAtLocation(target)) !== "number") return undefined; + const at = this.at(e); + const label = this.label(e); + const result = this.newVar("(value)", "number", at, { temp: true, ...this.widthOf(this.c.checker.getTypeAtLocation(target)) }); + let ok = true; + const body = this.collect(() => { + let before: VarDecl | null = null; + if (ts.isPostfixUnaryExpression(e)) { + const old = this.num(target); + if (!old) { ok = false; return; } + before = this.newVar("(before)", "number", at, { temp: true, ...this.widthOf(this.c.checker.getTypeAtLocation(target)) }); + this.emit({ kind: "declare", decl: before, init: old, at, label }, e); + } + this.expressionStatement(e); + const value = before ? varRef(before) : this.num(target); + if (!value) { ok = false; return; } + this.emit({ kind: "return", value, at, label }, e); + }); + if (!ok) return null; + return this.mark({ kind: "call", call: { name: "(update)", at, label, params: [], result: { decl: result, kind: "number" }, body } }, e); + } + private num(expr: TS.Expression): NumExpr | null { const { ts } = this; const e = this.unwrap(expr); + // A function as a value (B3): its number. + const fn = ts.isArrowFunction(e) || ts.isFunctionExpression(e) || ts.isIdentifier(e) || ts.isPropertyAccessExpression(e) ? this.fnValueOf(e) : undefined; + if (fn) return this.functionId(fn, e); + // `new Bullet(x, y)` of a class kept by reference (B9): the row it is made on. + if (ts.isNewExpression(e)) { const cls = this.classOf(e.expression); if (cls && this.isRefClass(cls)) return this.newObject(e, cls); } + // `this` of an instance kept by reference: its row. + if (e.kind === ts.SyntaxKind.ThisKeyword) { const self = this.scope.lookup(THIS); if (self?.kind === "record" && self.ref) return self.ref.place; } + // `await sleep(frames(1)).then(() => 7)` as a value: the wait, then what the function gives, in a variable. + if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression) && e.expression.name.text === "then" && this.isPromise(e.expression.expression)) { + const at = this.at(e), label = this.label(e); + const v = this.newVar("(then)", "number", at, { temp: true, ...this.widthOf(this.c.checker.getTypeAtLocation(expr)) }); + this.emit({ kind: "declare", decl: v, init: num(0), at, label }, e); + this.task(e, { decl: v, kind: "number" }); + return varRef(v); + } const h = this.evaluate(expr); + // No function: `let f: (() => void) | undefined = undefined`; no instance (B9): `let next: Node | null = null`. + if (h && (h.value === undefined || h.value === null) && this.noFunction(expr)) return num(0); + if (!h) { const updated = this.updateValue(e); if (updated !== undefined) return updated; } + // `best ?? 0` of a number or nothing (B9); `xs.find(…) ?? 0` stays the search's own. + if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) { + const left = this.unwrap(e.left); + const b = ts.isIdentifier(left) || ts.isPropertyAccessExpression(left) || ts.isElementAccessExpression(left) ? this.bindingOf(left) : undefined; + if (b?.kind === "maybe") { + const value = this.valueOf(b.value, "number", e), other = this.num(e.right); + if (!value || !other) return null; + return this.mark({ kind: "ternary", cond: this.thereOf(b, e), whenTrue: value as NumExpr, whenFalse: other, at: this.at(e), label: this.label(e) }, e); + } + } + // `t?.hp ?? 0` of a row find() gave, or of what chatted() found: the other value when there was none. + if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) { + const truth = this.foundBase(e.left); + if (truth) { + const value = this.num(e.left); + const other = value && this.num(e.right); + if (!value || !other) return null; + return this.mark({ kind: "ternary", cond: boolRef(truth), whenTrue: value, whenFalse: other, at: this.at(e), label: this.label(e) }, e); + } + } + // `target?.hp ?? 0` through a reference (B9): the other value when there is no instance. + if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) { + const base = this.optionalBase(e.left); + if (base) { + const there = this.num(base); + const value = there && this.num(e.left); + const other = value && this.num(e.right); + if (!there || !value || !other) return null; + return this.mark({ kind: "ternary", cond: { kind: "compare", op: "!=", left: there, right: num(0), at: this.at(e), label: this.label(e) }, whenTrue: value, whenFalse: other, at: this.at(e), label: this.label(e) }, e); + } + } if (h) { if (isRead(h.value)) return this.readValue(h.value, e); if (isTable(h.value)) return this.tableRead(h.value, e); + if (isSelected(h.value)) { this.c.error(e, "local.selected is a unit, not a number: keep it and read one of its fields — const u = local.selected; if (u) … u.hp."); return null; } + if (isSelectionCount(h.value)) return this.selectionCount(e); if (isUnitPick(h.value)) { this.c.error(e, `${h.value.ident}() is a unit, not a number; read one of its fields: ${h.value.ident}(…)?.hp — or keep it: const u = ${h.value.ident}(…).`); return null; } if (isInput(h.value)) return this.inputValue(h.value, e); if (isMouse(h.value)) { this.c.error(e, "mouse() is a place on the map: read its x or its y."); return null; } if (isChat(h.value)) { this.c.error(e, "chatted() is what a player typed: test it in an if, or read one of the values its pattern names."); return null; } - const n = this.asInteger(h, e); - return n === null ? null : num(n); + // A value whose type is \`fixed\` (fixed(2), a parameter declared so) is a fixed number even when it is whole. + if (typeof h.value === "number" && Number.isInteger(h.value) && this.brandOf(e) === "fixed") return this.fixedConst(h.value, e); + return this.constOf(h, e); } if (ts.isPropertyAccessExpression(e)) { const member = this.unitMember(e); @@ -6074,6 +11174,14 @@ export class Structured { const got = this.getterCall(e); if (got !== undefined) return this.numberOf(got, e); } + if (ts.isElementAccessExpression(e)) { + const picked = this.fieldAtText(e); + if (picked === null) return null; + if (picked) { + const at = this.at(e), label = this.label(e); + return this.pickField(picked, (b) => this.valueOf(b, "number", e) as NumExpr | null, (cond, whenTrue, whenFalse) => ({ kind: "ternary", cond, whenTrue, whenFalse, at, label })); + } + } if (ts.isElementAccessExpression(e)) { const el = this.elementOf(e); if (el === null) return null; @@ -6120,6 +11228,12 @@ export class Structured { if (b.v.kind !== "number") { this.c.error(e, `${b.v.name} is a boolean.`); return null; } return varRef(b.v); } + if (b?.kind === "record" && b.ref) return b.ref.place; + if (b?.kind === "maybe") { + // Read as a number where the checker knows there is one (`if (x !== undefined)`, `x!`); elsewhere it may be nothing. + if (this.maybeKind(this.c.checker.getTypeAtLocation(expr))) { this.c.error(e, `${e.getText(this.body.sf)} may be undefined here: test it first (if (x !== undefined) …), or say what it is then — ${e.getText(this.body.sf)} ?? 0.`); return null; } + return this.valueOf(b.value, "number", e) as NumExpr | null; + } if (b?.kind === "record") { this.c.error(e, "This is a record; use one of its fields."); return null; } if (ts.isIdentifier(e)) this.c.error(e, `${e.text} is not a variable of the program.`); else this.notConstant(e, "A value"); @@ -6130,13 +11244,38 @@ export class Structured { if (!inner) return null; if (e.operator === ts.SyntaxKind.PlusToken) return inner; if (e.operator === ts.SyntaxKind.MinusToken) return this.mark({ kind: "unary", op: "-", expr: inner, at: this.at(e) }, e); - this.c.error(e, "Only + and - apply to variables."); + // `~a`: every bit the other way, which is a ^ 0xFFFFFFFF (−a − 1 of a signed number, as in JavaScript). + if (e.operator === ts.SyntaxKind.TildeToken) return this.mark({ kind: "binary", op: "^", left: inner, right: num(-1), at: this.at(e), label: this.label(e) }, e); + this.c.error(e, "Only + - and ~ apply to variables."); return null; } // `queue.pop() ?? 0`: what TypeScript asks for, since a pop of an empty array is undefined there. The other value when it is empty. if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.QuestionQuestionToken) { const left = this.unwrap(e.left); - const list = ts.isCallExpression(left) && ts.isPropertyAccessExpression(left.expression) && left.expression.name.text === "pop" ? this.bindingOf(left.expression.expression) : undefined; + // `u?.hp ?? 0` of a unit or none: its field while it is there and on the map, the other value when not. + if (ts.isPropertyAccessExpression(left) && left.questionDotToken && this.isUnitTyped(left.expression)) { + const m = this.unitMember(left); + const other = this.num(e.right); + if (!m || !other) return null; + const value = this.unitField(left, m); + if (!value) return null; + const at = this.at(e), label = this.label(e); + return this.mark({ kind: "ternary", cond: { kind: "unitAlive", unit: m.unit, at, label }, whenTrue: value, whenFalse: other, at, label }, e); + } + const taken = ts.isCallExpression(left) && ts.isPropertyAccessExpression(left.expression) ? left.expression.name.text : ""; + // (`rows[i].pop() ?? -1`: the row an array of arrays holds at i is the list, as `listOf` finds it.) + const list = taken === "pop" || taken === "shift" ? this.listOf((left as TS.CallExpression & { expression: TS.PropertyAccessExpression }).expression.expression) : undefined; + // `xs.at(i) ?? 0`: the other value for a place past either end. + const atOf = taken === "at" ? this.bindingOf((left as TS.CallExpression & { expression: TS.PropertyAccessExpression }).expression.expression) : undefined; + if (atOf?.kind === "array" && atOf.a.kind === "number" && ts.isCallExpression(left) && left.arguments.length === 1) { + const i = this.num(left.arguments[0]); + const other = this.num(e.right); + if (!i || !other) return null; + const at = this.at(e), label = this.label(e); + const length: NumExpr = { kind: "length", array: atOf.a.id, at }; + const place = this.temp(this.fromEnd(i, length, e), e); + return this.mark({ kind: "ternary", cond: { kind: "and", items: [{ kind: "compare", op: ">=", left: place, right: num(0), at, label }, { kind: "compare", op: "<", left: place, right: length, at, label }] }, whenTrue: { kind: "element", array: atOf.a.id, index: place, at }, whenFalse: other, at, label }, e); + } const getter = ts.isCallExpression(left) && ts.isPropertyAccessExpression(left.expression) && left.expression.name.text === "get" ? left.expression.expression : undefined; const bound = getter ? this.bindingOf(getter) : undefined; // `seen.get(k) ?? 100` of a Map over any number: the other value for a key it has not got. @@ -6159,15 +11298,18 @@ export class Structured { return this.mark({ kind: "ternary", cond: { kind: "compare", op: ">", left: { kind: "length", array: list.a.id, at }, right: num(0), at, label: this.label(e) }, whenTrue: popped, whenFalse: other, at, label: this.label(e) }, e); } } + if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.AsteriskAsteriskToken) return this.power(e.left, e.right, e); if (ts.isBinaryExpression(e)) { const op = arithOp(ts, e.operatorToken.kind); - if (!op) { this.c.error(e, "Expected a number: variables take + - * / % and the bitwise & | ^ << >> >>>."); return null; } + if (!op) { this.c.error(e, "Expected a number: variables take + - * / % ** and the bitwise & | ^ << >> >>>."); return null; } const l = this.num(e.left); const r = this.num(e.right); if (!l || !r) return null; return this.mark({ kind: "binary", op, left: l, right: r, at: this.at(e), label: this.label(e) }, e); } if (ts.isConditionalExpression(e)) { + const known = this.knownCondition(e.condition); + if (known !== undefined) return this.num(known ? e.whenTrue : e.whenFalse); const cond = this.bool(e.condition); const whenTrue = this.num(e.whenTrue); const whenFalse = this.num(e.whenFalse); @@ -6227,6 +11369,13 @@ export class Structured { const { ts } = this; const ofText = this.textNumber(e); if (ofText !== undefined) return ofText; + const parsed = this.parseCall(e, false); + if (parsed !== undefined) return parsed ? this.mark({ kind: "call", call: parsed }, e) : null; + if (this.promiseCall(e)) return this.raceValue(e, "number") as NumExpr | null; + if (this.isLibraryCall(e, "start")) return this.startValue(e); + if (this.isLibraryCall(e, "lines")) return this.uiLinesValue(e); + if (this.isLibraryCall(e, "sync")) return this.syncValue(e); + if (isOnCall(this.ts, this.c.checker, e)) return this.onEventValue(e); const method = this.methodCall(e); if (method !== undefined) return this.numberOf(method, e); if (ts.isIdentifier(e.expression) && (e.expression.text === "parseInt" || e.expression.text === "Number" || e.expression.text === "parseFloat") && !this.gameDeclaration(e.expression)) { @@ -6238,7 +11387,7 @@ export class Structured { if (over) return this.searchCall(e, over, e.expression.name.text, "number") as NumExpr | null; } if (ts.isPropertyAccessExpression(e.expression)) { - const list = this.arrayOf(e.expression.expression); + const list = this.arrayOf(e.expression.expression) ?? this.listHere(e.expression.expression); if (list?.kind === "array") { const out = this.arrayCall(e, list.a, e.expression.name.text, "number"); return out && out !== true ? (out as NumExpr) : null; @@ -6249,7 +11398,8 @@ export class Structured { } if (list?.kind === "texts") { if (e.expression.name.text === "indexOf") return this.textsSearch(e, list, "indexOf") as NumExpr | null; - this.c.error(e, `${list.name}.${e.expression.name.text}(…) is not a number: an array of texts has indexOf(text) and length for those.`); + if (e.expression.name.text === "lastIndexOf") return this.textsSearch(e, list, "lastIndexOf") as NumExpr | null; + this.c.error(e, `${list.name}.${e.expression.name.text}(…) is not a number: an array of texts has indexOf(text), lastIndexOf(text) and length for those.`); return null; } const made = list?.kind === "keyed" ? list : !list && e.arguments.length && !this.evaluate(e.arguments[0]) ? this.collectionOf(e.expression.expression) : undefined; @@ -6258,14 +11408,13 @@ export class Structured { return out && out !== true ? (out as NumExpr) : null; } } - if (ts.isIdentifier(e.expression)) { - const decl = this.gameDeclaration(e.expression); - if (decl && ts.isFunctionDeclaration(decl)) { - const kind = this.kindOf(this.c.checker.getTypeAtLocation(e)); - if (kind !== "number") { this.c.error(e, `${e.expression.text} does not return a number.`); return null; } - const call = this.inline(e, decl.parameters, decl.body, this.body, decl.name?.text, decl); - return call?.result ? this.mark({ kind: "call", call }, e) : null; - } + { + const kind = this.kindOf(this.c.checker.getTypeAtLocation(e)); + const decl = ts.isIdentifier(e.expression) ? this.gameDeclaration(e.expression) : undefined; + if (decl && ts.isFunctionDeclaration(decl) && kind !== "number") { this.c.error(e, `${(e.expression as TS.Identifier).text} does not return a number.`); return null; } + const call = this.userCall(e); + if (call === null) return null; + if (call) return call.result ? this.mark({ kind: "call", call }, e) : (this.c.error(e, "This function does not return a number."), null); } const args = (n: number, what: string): NumExpr[] | null => { if (e.arguments.length !== n) { this.c.error(e, `${what} takes ${n} argument${n === 1 ? "" : "s"}.`); return null; } @@ -6300,24 +11449,105 @@ export class Structured { return call?.result ? this.mark({ kind: "call", call }, e) : null; } if (callee === Math.floor || callee === Math.trunc || callee === Math.round || callee === Math.ceil) { - const a = args(1, "Math rounding"); - return a ? a[0] : null; // Whole numbers already. + // Of a whole number nothing at all; of a fixed one, what numbers.ts makes of it. + const name = callee === Math.floor ? "floor" : callee === Math.trunc ? "trunc" : callee === Math.round ? "round" : "ceil"; + const a = args(1, `Math.${name}()`); + return a ? this.mark({ kind: "intrinsic", name, args: a, at: this.at(e), label: this.label(e) }, e) : null; + } + if (this.isLibraryCall(e, "fixed")) { + // A whole number as a fixed one: 3 is 3.0, so that 3 / fixed(2) is 1.5. + const a = args(1, "fixed()"); + return a ? this.mark({ kind: "cast", to: "fixed", expr: a[0], at: this.at(e) }, e) : null; + } + if (callee === Math.sqrt || callee === Math.sin || callee === Math.cos) { + const name = callee === Math.sqrt ? "sqrt" : callee === Math.sin ? "sin" : "cos"; + const a = args(1, `Math.${name}()`); + return a ? this.mark({ kind: "intrinsic", name, args: a, at: this.at(e), label: this.label(e) }, e) : null; + } + if (callee === Math.tan) { + // sin over cos, of the angle once. + const a = args(1, "Math.tan()"); + if (!a) return null; + const x = this.temp(a[0], e); + const at = this.at(e), label = this.label(e); + const sin: NumExpr = { kind: "intrinsic", name: "sin", args: [x], at, label }; + const cos: NumExpr = { kind: "intrinsic", name: "cos", args: [x], at, label }; + return this.mark({ kind: "binary", op: "/", left: sin, right: cos, at, label }, e); + } + if (callee === Math.atan2 || callee === Math.atan) { + const a = callee === Math.atan2 ? args(2, "Math.atan2()") : args(1, "Math.atan()"); + if (!a) return null; + return this.mark({ kind: "intrinsic", name: "atan2", args: callee === Math.atan2 ? a : [a[0], num(1)], at: this.at(e), label: this.label(e) }, e); + } + if (callee === Math.hypot) { + // The square root of the sum of the squares, each side once. + if (e.arguments.length < 1) { this.c.error(e, "Math.hypot() takes at least one argument."); return null; } + const at = this.at(e), label = this.label(e); + let sum: NumExpr | null = null; + for (const arg of e.arguments) { + const v = this.num(arg); + if (!v) return null; + const x = this.temp(v, arg); + const sq: NumExpr = { kind: "binary", op: "*", left: x, right: x, at, label }; + sum = sum ? { kind: "binary", op: "+", left: sum, right: sq, at, label } : sq; + } + return this.mark({ kind: "intrinsic", name: "sqrt", args: [sum!], at, label }, e); } if (callee === Math.abs) { const a = args(1, "Math.abs()"); return a ? intrinsic("abs", [a[0]]) : null; } + if (callee === Math.pow) { + if (e.arguments.length !== 2) { this.c.error(e, "Math.pow() takes 2 arguments."); return null; } + return this.power(e.arguments[0], e.arguments[1], e); + } + if (callee === Math.sign) { + // 1, −1 or 0, the number read as signed, as JavaScript's is. + const a = args(1, "Math.sign()"); + if (!a) return null; + const at = this.at(e), label = this.label(e); + const x = this.temp(a[0], e); + const below: NumExpr = { kind: "ternary", cond: { kind: "compare", op: "<", left: x, right: num(0), at, label }, whenTrue: num(-1), whenFalse: num(0), at, label }; + return this.mark({ kind: "ternary", cond: { kind: "compare", op: ">", left: x, right: num(0), at, label }, whenTrue: num(1), whenFalse: below, at, label }, e); + } + if (callee === Math.log2) { + // The place of the highest bit set — floor(log2 x) of a whole number — found by halving: five comparisons, each + // with a power of two. Of 0 it is 0 (JavaScript's −Infinity is no number of the game). + const a = args(1, "Math.log2()"); + if (!a) return null; + const at = this.at(e), label = this.label(e); + const x = this.temp(a[0], e); + const between = (lo: number, hi: number): NumExpr => { + if (lo === hi) return num(lo); + const mid = (lo + hi + 1) >> 1; + return { kind: "ternary", cond: { kind: "compare", op: ">=", left: x, right: num(2 ** mid), at, label, unsigned: true }, whenTrue: between(mid, hi), whenFalse: between(lo, mid - 1), at, label }; + }; + return this.mark(between(0, 31), e); + } if (callee === Math.min || callee === Math.max) { if (e.arguments.length === 0) { this.c.error(e, "Math.min / Math.max take at least one argument."); return null; } let acc: NumExpr | null = null; + const take = (l: NumExpr) => { acc = acc ? intrinsic(callee === Math.min ? "min" : "max", [acc, l]) : l; }; for (const a of e.arguments) { + // `Math.max(...xs)`: each cell of an array of the program, its length known when the script is built. + if (ts.isSpreadElement(a)) { + const list = this.listOf(a.expression); + if (list?.kind !== "array" || list.a.kind !== "number") { this.c.error(a, "Math.min / Math.max spread an array of numbers of the program: Math.max(...xs)."); return null; } + if (list.a.dynamic) { this.c.error(a, `${list.a.name} grows, so it may be empty, where Math.max(...) is -Infinity, which no number of the game is: xs.reduce((m, x) => Math.max(m, x), 0) says what an empty one gives.`.replace("xs.", `${list.a.name}.`)); return null; } + for (let i = 0; i < list.a.length; i++) take({ kind: "element", array: list.a.id, index: num(i), at: this.at(a) }); + continue; + } const l = this.num(a); if (!l) return null; - acc = acc ? intrinsic(callee === Math.min ? "min" : "max", [acc, l]) : l; + take(l); } return acc; } if (isBuilder(callee)) { this.c.error(e, callee.kind === "condition" ? "This is a condition; test it in an if or a while." : "This is an action; it stands as a statement."); return null; } + if (typeof callee === "function" && this.keepsState(e)) { + this.c.error(e, `${e.expression.getText()}() changes what it keeps between calls, but that was made when the script was built, where the game cannot change it. Make it inside the program, or keep what changes in a let of the program.`); + return null; + } if (typeof callee === "function") { this.c.error(e, "This helper is computed when the script is built and cannot take a variable of the program. Write it as a game() function to run it in the game."); return null; } this.notConstant(e, "A call's arguments"); return null; @@ -6395,6 +11625,7 @@ export class Structured { if (h) { if (h.value === null || h.value === undefined) return NO_UNIT; if (isUnitPick(h.value)) return this.pick(h.value, e); + if (isSelected(h.value)) return this.mark({ kind: "selected", index: h.value.index, at: this.at(e) }, e); this.c.error(e, `Expected a unit of the game, got ${describe(h.value)}.`); return null; } @@ -6435,10 +11666,10 @@ export class Structured { if (over) return this.searchCall(e, over, e.expression.name.text, "unit") as UnitExpr | null; } if (ts.isCallExpression(e)) { - let call: Call | undefined; - const decl = ts.isIdentifier(e.expression) ? this.gameDeclaration(e.expression) : undefined; - if (decl && ts.isFunctionDeclaration(decl)) call = this.inline(e, decl.parameters, decl.body, this.body, decl.name?.text, decl); - else { + const user = this.userCall(e); + if (user === null) return null; + let call: Call | undefined = user; + if (!call) { const callee = this.evaluate(e.expression)?.value; if (isGameFunction(callee)) call = this.gameCall(e, callee); else { this.notConstant(e, "What picks the unit (the type, the owner, the location)"); return null; } @@ -6639,18 +11870,28 @@ export class Structured { if (typeof v === "boolean") return v ? TRUE : FALSE; if (typeof v === "number") return v !== 0 ? TRUE : FALSE; if (typeof v === "string") return v !== "" ? TRUE : FALSE; - if (isCondition(v)) return this.mark({ kind: "cond", record: { ...v.record } }, at); + if (isCondition(v)) return this.conditionOf(v, at); if (isRead(v)) return this.readBool(v, at); if (isTable(v)) { const read = this.tableRead(v, at); return read ? this.mark({ kind: "test", expr: read, at: this.at(at), label: this.label(at) }, at) : FALSE; } if (isInput(v)) return this.inputBool(v, at); if (isChat(v)) return this.inputBool(v.matched, at); if (isMouse(v)) { this.c.error(at, "mouse() is a place on the map, not a condition: compare its x or its y."); return FALSE; } - if (Array.isArray(v) && v.length > 0 && v.every(isCondition)) return { kind: "and", items: v.map((c) => this.mark({ kind: "cond", record: { ...c.record } }, at)) }; + if (Array.isArray(v) && v.length > 0 && v.every(isCondition)) return { kind: "and", items: v.map((c) => this.conditionOf(c, at)) }; if (isAction(v)) { this.c.error(at, "This is an action, not a condition."); return FALSE; } + // Anything else known when the script is built is true or false as in JavaScript: a note or a rest (null) of a + // pattern, a clip, a picture — \`if (note) playWav(note)\` in a loop unrolled over them. + if (v === null || v === undefined) return FALSE; + if (typeof v === "object" || typeof v === "function") return TRUE; this.c.error(at, `Expected a condition, got ${describe(v)}.`); return FALSE; } + /** A condition of the game as the program tests it. `disabled(…)` is a trigger's: there, the game skips the condition; a program would hold it, so it is refused instead. */ + private conditionOf(v: ConditionValue, at: TS.Node): BoolExpr { + if (v.record.flags & ConditionFlag.Disabled) { this.c.error(at, "disabled() switches a condition of a trigger off, which the game then skips. A program tests its conditions: leave this one out, or take disabled() off it."); return FALSE; } + return this.mark({ kind: "cond", record: { ...v.record } }, at); + } + /** A condition as a `BoolExpr` tree. */ private bool(expr: TS.Expression): BoolExpr { return this.boolInner(expr, 0); @@ -6667,7 +11908,35 @@ export class Structured { } const h = this.evaluate(expr); if (h) return this.hoistedBool(h, e); + // `open[door]`: the field the door is. + const picked = this.fieldAtText(e); + if (picked === null) return FALSE; + if (picked) return this.pickField(picked, (b) => this.valueOf(b, "boolean", e) as BoolExpr | null, (cond, a, b) => ({ kind: "or", items: [{ kind: "and", items: [cond, a] }, { kind: "and", items: [{ kind: "not", expr: cond }, b] }] })) ?? FALSE; + if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.InstanceOfKeyword) { + const b = this.bindingOf(e.left); + const caught = b && this.caughtRecords.get(b); + if (caught) return this.caughtIs(caught, e.right, e); + // `e instanceof Boss` of a reference (B9): the row's class, one of those that are a Boss (none is none of them). + const classes = this.refClasses(this.c.checker.getTypeAtLocation(e.left)); + const cls = this.classOf(e.right); + if (classes && cls) { + const place = this.num(e.left); + return place ? this.mark(this.rowIs(place, classes.filter((c) => this.chainOf(c).includes(cls)), e), e) : FALSE; + } + } if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken) return { kind: "not", expr: this.boolInner(e.operand, depth + 1) }; + // `isNaN(parseInt(s))`: whether the text has no number at its start, where JavaScript would give NaN. + if (ts.isCallExpression(e) && e.arguments.length === 1 && this.isGlobal(e.expression) && /^(Number\.)?isNaN$/.test(e.expression.getText(this.body.sf))) { + const inner = this.unwrap(e.arguments[0]); + const tested = ts.isCallExpression(inner) ? this.parseCall(inner, true) : undefined; + if (tested) return this.mark({ kind: "call", call: tested }, e); + if (tested === undefined) { this.c.error(e, "A number of a program is never NaN: isNaN() of one asks whether a text has a number in it — isNaN(parseInt(s)) or isNaN(Number(s))."); return FALSE; } + return FALSE; + } + if (ts.isPropertyAccessExpression(e) && e.name.text === "running" && this.isTaskType(this.c.checker.getTypeAtLocation(e.expression))) { + const task = this.num(e.expression); + return task ? { kind: "running", task, at: this.at(e) } : FALSE; + } if ((ts.isCallExpression(e) || ts.isPropertyAccessExpression(e)) && !this.isTextTyped(e)) { // `if (s.alive())`, `if (s.ready)`: a method or a getter, tested as any call is. const kind = this.kindOf(this.c.checker.getTypeAtLocation(e)); @@ -6701,6 +11970,8 @@ export class Structured { } if (ts.isConditionalExpression(e)) { // `c ? p : q` as a truth value. + const known = this.knownCondition(e.condition); + if (known !== undefined) return this.boolValue(known ? e.whenTrue : e.whenFalse); const cond = this.bool(e.condition); const whenTrue = this.boolValue(e.whenTrue); const whenFalse = this.boolValue(e.whenFalse); @@ -6724,12 +11995,22 @@ export class Structured { } if (ts.isIdentifier(e) || ts.isPropertyAccessExpression(e) || ts.isElementAccessExpression(e)) { const b = this.bindingOf(e); + // A unit variable the checker narrowed to null where it is tested is still a unit: there, and on the map. + if (b?.kind === "var" && b.v.kind === "unit") return this.mark({ kind: "unitAlive", unit: unitRef(b.v), at: this.at(e), label: this.label(e) }, e); if (b?.kind === "var") return b.v.kind !== "number" ? boolRef(b.v) : this.mark({ kind: "test", expr: varRef(b.v), at: this.at(e), label: this.label(e) }, e); if (b?.kind === "cell") { const cell = { kind: "element" as const, array: b.a.id, index: b.index, at: this.at(e) }; return b.a.kind === "boolean" ? this.mark(cell, e) : this.mark({ kind: "test", expr: cell, at: this.at(e), label: this.label(e) }, e); } if (b?.kind === "record" && b.truth) return boolRef(b.truth); + // `if (best)` of a number or nothing (B9): there is one, and it is not 0 (or false). + if (b?.kind === "maybe") { + const there = this.thereOf(b, e); + const v = this.valueOf(b.value, b.value.kind === "var" ? (b.value.v.kind as Kind) : b.value.kind === "cell" ? b.value.a.kind : "number", e); + if (!v) return FALSE; + return { kind: "and", items: [there, (b.value.kind === "var" && b.value.v.kind === "boolean") || (b.value.kind === "cell" && b.value.a.kind === "boolean") ? v as BoolExpr : this.mark({ kind: "test", expr: v as NumExpr, at: this.at(e), label: this.label(e) }, e)] }; + } + if (b?.kind === "record" && b.ref) return this.mark({ kind: "test", expr: b.ref.place, at: this.at(e), label: this.label(e) }, e); if (b?.kind === "record") { this.c.error(e, "This is a record; test one of its fields."); return FALSE; } if (ts.isIdentifier(e)) this.c.error(e, `${e.text} is not a variable of the program or a condition.`); else this.notConstant(e, "A condition"); @@ -6746,7 +12027,7 @@ export class Structured { } } if (ts.isCallExpression(e) && ts.isPropertyAccessExpression(e.expression)) { - const list = this.arrayOf(e.expression.expression); + const list = this.arrayOf(e.expression.expression) ?? this.listHere(e.expression.expression); if (list?.kind === "array") { // A number of the array (indexOf, a pop of numbers) tested as one is `!= 0`, as any number is. const numeric = e.expression.name.text === "indexOf" || (e.expression.name.text === "pop" && list.a.kind === "number"); @@ -6771,9 +12052,9 @@ export class Structured { } } if (ts.isCallExpression(e)) { - if (ts.isIdentifier(e.expression)) { - const decl = this.gameDeclaration(e.expression); - if (decl && ts.isFunctionDeclaration(decl)) return this.callBool(e, () => this.inline(e, decl.parameters, decl.body, this.body, decl.name?.text, decl)); + { + const decl = ts.isIdentifier(e.expression) ? this.gameDeclaration(e.expression) : undefined; + if ((decl && ts.isFunctionDeclaration(decl)) || this.fnValueOf(e.expression) || this.fnHeld(e.expression)) return this.callBool(e, () => this.userCall(e) ?? undefined); } if (this.isLibraryCall(e, "random")) { if (e.arguments.length === 0) return this.mark({ kind: "random", at: this.at(e) }, e); @@ -6782,7 +12063,8 @@ export class Structured { } if (this.isLibraryCall(e, "rose")) return this.edge(e, "rose"); if (this.isLibraryCall(e, "once")) return this.edge(e, "once"); - if (this.isLibraryCall(e, "sleep")) { this.c.error(e, "sleep() is a statement, not a condition."); return FALSE; } + if (this.isSleep(e) || this.isLibraryCall(e, "until")) { this.c.error(e, `${this.isSleep(e) ? "sleep" : "until"}() is a statement, not a condition: await it on a line of its own.`); return FALSE; } + if (this.promiseCall(e)) return (this.raceValue(e, "boolean") as BoolExpr | null) ?? FALSE; const read = this.readCall(e); if (read !== undefined) return read ? this.readBool(read, e) : FALSE; const callee = this.evaluate(e.expression)?.value; @@ -6830,6 +12112,33 @@ export class Structured { const right = this.text(e.right); return left && right ? this.mark({ kind: "textCompare", op, left, right, at: this.at(e), label: this.label(e) }, e) : FALSE; } + // A number or nothing (B9): against undefined, whether there is one; against a value, there is one and it is that. + { + const { ts } = this; + const maybeOf = (x: TS.Expression) => { const u = this.unwrap(x); const b = ts.isIdentifier(u) || ts.isPropertyAccessExpression(u) || ts.isElementAccessExpression(u) ? this.bindingOf(u) : undefined; return b?.kind === "maybe" ? b : undefined; }; + const nullish = (x: TS.Expression) => { const h = this.evaluate(x); return !!h && (h.value === null || h.value === undefined); }; + const lm = maybeOf(e.left), rm = maybeOf(e.right); + const m = lm ?? rm; + if (m && (op === "==" || op === "!=")) { + const other = lm ? e.right : e.left; + if (nullish(other)) { const there = this.thereOf(m, e); return op === "!=" ? there : { kind: "not", expr: there }; } + const kindM: Kind = m.value.kind === "var" ? (m.value.v.kind as Kind) : m.value.kind === "cell" ? m.value.a.kind : "number"; + const mv = this.valueOf(m.value, kindM, e); + const om = maybeOf(other); + if (!mv) return FALSE; + if (om) { + // Both may be nothing: equal when neither is there, or both are and hold the same. + const ov = this.valueOf(om.value, kindM, e); + if (!ov) return FALSE; + const a = this.thereOf(m, e), b = this.thereOf(om, e); + const same: BoolExpr = { kind: "or", items: [{ kind: "and", items: [{ kind: "not", expr: a }, { kind: "not", expr: b }] }, { kind: "and", items: [a, b, kindM === "boolean" ? { kind: "or", items: [{ kind: "and", items: [mv as BoolExpr, ov as BoolExpr] }, { kind: "and", items: [{ kind: "not", expr: mv as BoolExpr }, { kind: "not", expr: ov as BoolExpr }] }] } : { kind: "compare", op: "==", left: mv as NumExpr, right: ov as NumExpr, at: this.at(e), label: this.label(e) }] }] }; + return op === "==" ? same : { kind: "not", expr: same }; + } + const equal: BoolExpr = kindM === "boolean" ? (() => { const o = this.boolInner(other, depth + 1); return { kind: "or", items: [{ kind: "and", items: [mv as BoolExpr, o] }, { kind: "and", items: [{ kind: "not", expr: mv as BoolExpr }, { kind: "not", expr: o }] }] } as BoolExpr; })() : (() => { const o = this.num(other); return o ? { kind: "compare", op: "==", left: mv as NumExpr, right: o, at: this.at(e), label: this.label(e) } as BoolExpr : FALSE; })(); + const same: BoolExpr = { kind: "and", items: [this.thereOf(m, e), equal] }; + return op === "==" ? same : { kind: "not", expr: same }; + } + } // What chatted() found, against null: `m != null` asks what `if (m)` asks. const found = (x: TS.Expression) => { const b = this.bindingOf(x); return b?.kind === "record" && b.truth ? b.truth : undefined; }; const isNull = (x: TS.Expression) => { const h = this.evaluate(x); return !!h && (h.value === null || h.value === undefined); }; @@ -6853,13 +12162,31 @@ export class Structured { const same: BoolExpr = { kind: "or", items: [{ kind: "and", items: [l, r] }, { kind: "and", items: [{ kind: "not", expr: l }, { kind: "not", expr: r }] }] }; return op === "==" ? same : { kind: "not", expr: same }; } - const l = this.num(e.left); - const r = this.num(e.right); + // A function against undefined (B3): a function's number is never 0, and no function is. + const fnSide = (x: TS.Expression) => this.isFunctionType(this.c.checker.getNonNullableType(this.c.checker.getTypeAtLocation(x))) || !!this.refClasses(this.c.checker.getTypeAtLocation(x)); + const l = isNull(e.left) && fnSide(e.right) ? num(0) : this.num(e.left); + const r = isNull(e.right) && fnSide(e.left) ? num(0) : this.num(e.right); if (!l || !r) return FALSE; return this.mark({ kind: "compare", op, left: l, right: r, at: this.at(e), label: `L${this.line(e)}: ${e.getText(this.body.sf).replace(/\s+/g, " ")}` }, e); } } +/** A comparison of two constants, worked out as the lowering would (`unsigned` read as u32), or undefined. */ +function foldedCompare(cond: BoolExpr): boolean | undefined { + if (cond.kind !== "compare" || cond.left.kind !== "const" || cond.right.kind !== "const") return undefined; + const u = cond.unsigned; + const a = u === true || u === "left" ? cond.left.value >>> 0 : cond.left.value | 0; + const b = u === true || u === "right" ? cond.right.value >>> 0 : cond.right.value | 0; + switch (cond.op) { + case "<": return a < b; + case "<=": return a <= b; + case ">": return a > b; + case ">=": return a >= b; + case "==": return a === b; + case "!=": return a !== b; + } +} + function compareOp(ts: typeof TS, kind: TS.SyntaxKind): CompareOp | null { switch (kind) { case ts.SyntaxKind.LessThanToken: return "<"; diff --git a/compiler/testing.ts b/compiler/testing.ts index 014ee5a..b7e56fc 100644 --- a/compiler/testing.ts +++ b/compiler/testing.ts @@ -16,6 +16,7 @@ */ import { ActionType, type ActionRecord, type TriggerRecord } from "../vendor/triggers"; import { actionDef } from "../vendor/triggerDefs"; +import type { TestCost } from "./gameCost"; import type { At, Program } from "./ir"; import type { ScriptString } from "./runtime"; import { Simulation } from "./simulate"; @@ -25,8 +26,8 @@ import type { SimBounds, SimUnit, SimUnitInit, SimUnitProperties } from "./world /* ── What is registered ── */ type Mode = "run" | "skip" | "only"; -type TestFn = (sim: TestSim, ...args: unknown[]) => unknown; -type Hook = (sim: TestSim) => unknown; +type TestFn = (sim: TestWorldLike, ...args: unknown[]) => unknown; +type Hook = (sim: TestWorldLike) => unknown; interface Where { file: string; line: number; column?: number } interface Suite { @@ -49,6 +50,8 @@ interface Case { args: unknown[]; /** `{ as: P2 }`: the player the world is simulated as, when the map's player settings are not given. */ as?: number; + /** `{ players: [P1, P2] }`: in the game, a copy of it for each, kept in step. */ + players?: number[]; } /** A test or a `describe`, as the editor lists it. `id` is the file and the names down to it: the same from one compile to the next. */ @@ -61,6 +64,8 @@ export interface TestInfo { file: string; line: number; mode: Mode; + /** A test's `{ players }`: run in the game as a copy for each. */ + players?: number[]; } export interface TestEvent { frame: number; text: string; file?: string; line?: number; player: number } @@ -81,6 +86,8 @@ export interface TestResult { events: TestEvent[]; frames: number; ms: number; + /** In the lifted game: what the test's frames cost, by frame and by line (TrigScript 4, A4). */ + cost?: TestCost; } export interface TestReport { @@ -112,6 +119,11 @@ export interface TestWorld { export interface TestRunOptions { world?: TestWorld; + /** + * A world other than the simulator's for each test (the lifted game's, `GameSim`): made fresh for every test, + * given the player the test asked to be (`{ as: P2 }`). Absent: the simulator. + */ + makeWorld?: (as?: number, test?: { id: string; players?: number[] }) => TestWorldLike; /** Run only these: tests of these files, or with these ids (a suite's id runs what is under it). Absent: all. */ files?: string[]; ids?: string[]; @@ -155,7 +167,11 @@ export class TestRegistry { if (typeof fn !== "function") throw new Error(`test("${name}"): the second argument is the test's function, (sim) => { … }.`); if (fn.constructor?.name === "AsyncFunction") throw new Error(`test("${name}"): a test is not async. Nothing in sim waits: sim.frames(n) and sim.until(…) have run by the time they return.`); const as = options && typeof options === "object" && typeof (options as { as?: unknown }).as === "number" ? (options as { as: number }).as : undefined; - this.current.children.push({ kind: "test", name, at: this.where(), mode, parent: this.current, fn: fn as TestFn, args, ...(as !== undefined ? { as } : {}) }); + const given = options && typeof options === "object" ? (options as { players?: unknown }).players : undefined; + if (given !== undefined && !(Array.isArray(given) && given.length >= 2 && given.length <= 8 && given.every((p) => Number.isInteger(p) && p >= 0 && p < 8) && new Set(given).size === given.length)) + throw new Error(`test("${name}", { players }): two to eight different players of P1 to P8, as [P1, P2].`); + const players = given as number[] | undefined; + this.current.children.push({ kind: "test", name, at: this.where(), mode, parent: this.current, fn: fn as TestFn, args, ...(as !== undefined ? { as } : {}), ...(players ? { players: [...players] } : {}) }); } private addSuite(mode: Mode, name: unknown, body: unknown): void { @@ -212,13 +228,57 @@ export class TestRegistry { /** The names `library()` gives: a script imports them, they are not globals (a script may well have a `test` of its own). */ export const TESTING_NAMES = ["test", "it", "describe", "beforeEach", "afterEach", "expect"] as const; +/* ── The world a test is handed ── */ + +/** What every world a test may be handed carries: the simulator's (`TestSim`) and the lifted game's (`GameSim`). */ +export const WORLD = Symbol.for("trigscript.world"); +export const isWorld = (v: unknown): v is TestWorldLike => typeof v === "object" && v !== null && (v as { [WORLD]?: unknown })[WORLD] === true; + +/** What a test can ask of its world, whichever it was handed. */ +export interface TestWorldLike { + readonly [WORLD]: true; + /** What the test's frames cost, where the world counts it (the lifted game's). */ + cost?(): TestCost | null; + faultsExpected: boolean; + readonly frame: number; + seed(n: number): this; + frames(n?: number): this; + seconds(n: number): this; + until(done: () => unknown, most?: number): this; + place(player: number, type: number, at: number, n?: number): SimUnit[]; + kill(unit: SimUnit, by?: number): this; + remove(unit: SimUnit): this; + give(unit: SimUnit, to: number): this; + morph(unit: SimUnit, type: number): this; + move(unit: SimUnit, to: number | { x: number; y: number }): this; + press(key: string, player?: number): this; + click(button?: "left" | "right" | "middle", player?: number): this; + type(line: string, player?: number): this; + moveMouse(x: number, y: number, player?: number): this; + moveCamera(x: number, y: number, player?: number): this; + selectUnits(units: readonly SimUnit[], player?: number): this; + count(player: number, type: number, at?: number): number; + units(filter?: { owner?: number; type?: number; at?: number }): SimUnit[]; + resources(player: number): { ore: number; gas: number }; + deaths(player: number, type: number): number; + kills(player: number, type: number): number; + switch(n: number): boolean; + fog(player: number, area?: { x: number; y: number; width: number; height: number }): string[] | null; + location(n: number): SimBounds | undefined; + program(name?: string | number, player?: number): Record; + printed(player?: number): string[]; + log(sourceOf?: (trigger: number) => Where | null): TestEvent[]; + readonly events: TestEvent[]; + readonly faults: { at: At; message: string; cycle: number; frame: number; program: number }[]; +} + /* ── expect ── */ const show = (v: unknown): string => { if (typeof v === "string") return JSON.stringify(v); if (typeof v === "function") return "a function"; if (v instanceof RegExp) return String(v); - if (v instanceof TestSim) return "sim"; + if (isWorld(v)) return "sim"; try { return JSON.stringify(v, (_, x) => (typeof x === "bigint" ? String(x) : x === undefined ? "undefined" : x)) ?? String(v); } catch { return String(v); } }; const brief = (v: unknown): string => { const s = show(v); return s.length > 60 ? `${s.slice(0, 57)}…` : s; }; @@ -237,11 +297,11 @@ function expect(actual: unknown) { /** `holds` is the matcher's answer; `what` finishes "expected …". */ const check = (holds: boolean, what: string, expected?: unknown) => { if (holds !== not) return; - const got = actual instanceof TestSim ? "" : `, got ${brief(actual)}`; - throw new ExpectError(`expected ${not ? "not " : ""}${what}${got}`, expected === undefined ? undefined : show(expected), actual instanceof TestSim ? undefined : show(actual)); + const got = isWorld(actual) ? "" : `, got ${brief(actual)}`; + throw new ExpectError(`expected ${not ? "not " : ""}${what}${got}`, expected === undefined ? undefined : show(expected), isWorld(actual) ? undefined : show(actual)); }; const number = (name: string): number => { if (typeof actual !== "number") throw new ExpectError(`${name}: ${brief(actual)} is not a number`); return actual; }; - const sim = (name: string): TestSim => { if (!(actual instanceof TestSim)) throw new ExpectError(`${name} is asked of the world: expect(sim).${name}(…)`); return actual; }; + const sim = (name: string): TestWorldLike => { if (!isWorld(actual)) throw new ExpectError(`${name} is asked of the world: expect(sim).${name}(…)`); return actual; }; return { toBe: (e: unknown) => check(Object.is(actual, e), brief(e), e), toEqual: (e: unknown) => check(same(actual, e), brief(e), e), @@ -293,8 +353,9 @@ const mulberry = (seed: number) => () => { return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; -/** The world a test is handed. */ -export class TestSim { +/** The world a test is handed: the simulator's. */ +export class TestSim implements TestWorldLike { + readonly [WORLD] = true as const; /** Set by `expect(sim).toHaveFaulted`: the faults are the test's business, and do not fail it by themselves. */ faultsExpected = false; private rng = mulberry(DEFAULT_SEED); @@ -351,7 +412,17 @@ export class TestSim { /** What a fight is in a test. `by`: the player the kill is counted for. */ kill(unit: SimUnit, by?: number): this { this.world.game.gone(unit, true, by); return this; } remove(unit: SimUnit): this { this.world.game.gone(unit, false); return this; } - give(unit: SimUnit, to: number): this { if (unit.alive) unit.owner = to; return this; } + give(unit: SimUnit, to: number): this { this.world.game.setOwner(unit, to); return this; } + /** A unit turns into another type and stays the same unit: the new type's hit points and shields, in the same part of them. */ + morph(unit: SimUnit, type: number): this { + if (!unit.alive) return this; + const s = this.world.game.stats(type) ?? {}; + const maxHp = Math.max(1, s.hp ?? unit.maxHp), maxShields = Math.max(0, s.shields ?? 0); + unit.hp = Math.max(1, Math.round((unit.hp * maxHp) / Math.max(1, unit.maxHp))); + unit.shields = unit.maxShields ? Math.round((unit.shields * maxShields) / unit.maxShields) : maxShields; + Object.assign(unit, { type, maxHp, maxShields }); + return this; + } /** A unit to a location's centre, or to a point. */ move(unit: SimUnit, to: number | { x: number; y: number }): this { const p = typeof to === "number" ? this.world.game.centreOf(to) : to; @@ -365,6 +436,8 @@ export class TestSim { click(button: "left" | "right" | "middle" = "left", player?: number): this { this.programs.click(button, player); return this; } type(line: string, player?: number): this { this.programs.type(line, player); return this; } moveMouse(x: number, y: number, player?: number): this { this.programs.moveMouse(x, y, player); return this; } + moveCamera(x: number, y: number, player?: number): this { this.programs.moveCamera(x, y, player); return this; } + selectUnits(units: readonly SimUnit[], player?: number): this { this.programs.selectUnits(units as SimUnit[], player); return this; } /* ── the world read back ── */ @@ -381,6 +454,8 @@ export class TestSim { deaths(player: number, type: number): number { return this.world.death(player, type); } kills(player: number, type: number): number { return this.world.game.kills(this.owners(player), type); } switch(n: number): boolean { return this.world.switches[n] === 1; } + /** The fog of war as the simulator draws it: of the units a program set the sight of (simulateIr.ts, fog). */ + fog(player: number, area?: { x: number; y: number; width: number; height: number }): string[] | null { return this.programs.fog(player, area); } /** Where a location is now. */ location(n: number): SimBounds | undefined { const b = this.world.game.locations.get(n); return b ? { ...b } : undefined; } @@ -449,7 +524,7 @@ export function listTests(registry: TestRegistry): { list: TestInfo[]; cases: Ma const n = (seen.get(id) ?? 0) + 1; seen.set(id, n); if (n > 1) id = `${id} #${n}`; - list.push({ id, kind: node.kind, name: node.name, path, file: node.at.file, line: node.at.line, mode: node.mode }); + list.push({ id, kind: node.kind, name: node.name, path, file: node.at.file, line: node.at.line, mode: node.mode, ...(node.kind === "test" && node.players ? { players: node.players } : {}) }); if (node.kind === "suite") walk(node, [...path, node.name]); else cases.set(id, node); } @@ -476,7 +551,7 @@ export function runTests(registry: TestRegistry, ctx: TestContext, options: Test for (let s: Suite | null = test.parent; s; s = s.parent) chain.unshift(s); const skipped = test.mode === "skip" || chain.some((s) => s.mode === "skip") || (narrowed.has(info.file) && test.mode !== "only" && !chain.some((s) => s.mode === "only")); if (skipped) { results.push({ id: info.id, status: "skipped", printed: [], events: [], frames: 0, ms: 0 }); continue; } - results.push(runOne(info.id, test, chain, ctx, options.world ?? {})); + results.push(runOne(info.id, test, chain, ctx, options.world ?? {}, options.makeWorld)); } } finally { registry.running = false; @@ -484,14 +559,19 @@ export function runTests(registry: TestRegistry, ctx: TestContext, options: Test return { list, results, only, ms: Date.now() - started }; } -function runOne(id: string, test: Case, chain: Suite[], ctx: TestContext, world: TestWorld): TestResult { +function costOf(sim: TestWorldLike | null): { cost?: TestCost } { + const cost = sim?.cost?.(); + return cost ? { cost } : {}; +} + +function runOne(id: string, test: Case, chain: Suite[], ctx: TestContext, world: TestWorld, makeWorld?: TestRunOptions["makeWorld"]): TestResult { const began = Date.now(); - let sim: TestSim | null = null; + let sim: TestWorldLike | null = null; const done = (extra: Partial): TestResult => ({ - id, status: "passed", printed: sim?.printed() ?? [], events: (sim?.log((t) => ctx.sources[t] ?? null) ?? []).slice(0, 500), frames: sim?.frame ?? 0, ms: Date.now() - began, ...extra, + id, status: "passed", printed: sim?.printed() ?? [], events: (sim?.log((t) => ctx.sources[t] ?? null) ?? []).slice(0, 500), frames: sim?.frame ?? 0, ms: Date.now() - began, ...costOf(sim), ...extra, }); try { - sim = new TestSim(ctx.triggers, ctx.ir, ctx.strings, world, test.as); + sim = makeWorld ? makeWorld(test.as, { id, players: test.players }) : new TestSim(ctx.triggers, ctx.ir, ctx.strings, world, test.as); for (const s of chain) for (const h of s.before) h(sim); const returned = test.fn(sim, ...test.args); if (returned && typeof (returned as { then?: unknown }).then === "function") throw new Error("A test is not async: what it returned is a promise. Nothing in sim waits."); diff --git a/compiler/thresholds.ts b/compiler/thresholds.ts new file mode 100644 index 0000000..a88bc49 --- /dev/null +++ b/compiler/thresholds.ts @@ -0,0 +1,99 @@ +/** + * A division whose result is small, worked out as comparisons (TrigScript 4, after the cost slices). + * + * `Math.min(H, Math.floor(C / x))` — the raycaster's wall height, a bar's length from a distance — divides by a variable, + * which the game does a bit at a time: about 7 µs of Remastered's time for a fixed number, where a comparison of a + * variable with a constant is about 0.13. For `x` above zero the quotient only falls as `x` grows, and `min` caps it at + * `H`, so the result is the largest `h` of 0 … H with `x ≤ T(h)`, `T(h)` the largest `x` whose quotient is still `h` or + * more: a tree of comparisons with constants worked out when the script is built, as deep as H has bits. + * + * The thresholds are found with the very arithmetic the game does (`fixedMath.ts`, which the simulator shares), and + * checked at both sides of each, so the result is the same bits as the division's for every `x`. Where that cannot + * hold — `x` at or below zero, or so close to it that `C × 65 536 / x` passes what 32 bits hold and wraps — the + * original division is kept, behind one comparison. + */ +import { fixedDiv } from "./fixedMath"; +import type { At, NumExpr } from "./ir"; + +/** The most a capped result may be: a tree of 2·H comparisons in the map, 8 deep. */ +export const THRESHOLDS_MOST = 256; + +const I32_MAX = 0x7fffffff; +/** The `min`s kept inside a tree, behind its comparison: never made into a tree again, if a pass meets them twice. */ +const kept = new WeakSet(); + +/** The quotient's shape, and how to work it out for one `x` above `from` (where nothing wraps). */ +interface Quotient { x: string; from: number; at: (x: number) => number } + +/** + * Of `q`, the shapes taken: `fdiv(C, x) >> 16` (Math.floor of a fixed quotient), `(fdiv(C, x) + c) >> 16` (Math.round, + * Math.ceil), and a whole `C / x`; `C` a constant above zero, `x` a variable. + */ +function quotientOf(q: NumExpr): Quotient | null { + if (q.kind === "binary" && q.op === "/" && !q.unsigned && q.left.kind === "const" && q.left.value > 0 && q.right.kind === "var") { + const c = q.left.value; + return { x: q.right.id, from: 1, at: (x) => Math.trunc(c / x) }; + } + if (q.kind !== "binary" || q.op !== ">>" || q.right.kind !== "const" || q.right.value !== 16) return null; + let inner = q.left, add = 0; + if (inner.kind === "binary" && inner.op === "+" && inner.right.kind === "const" && inner.right.value >= 0 && inner.right.value < 0x10000) { + add = inner.right.value; + inner = inner.left; + } + if (inner.kind !== "intrinsic" || inner.name !== "fdiv") return null; + const [n, d] = inner.args; + if (n.kind !== "const" || n.value <= 0 || d.kind !== "var") return null; + const c = BigInt(n.value) << 16n; + // The smallest x whose quotient plus `add` stays below 2³¹: below it the game's quotient wraps. + const from = Number(c / BigInt(I32_MAX + 1 - add)) + 1; + return { x: d.id, from, at: (x) => (fixedDiv(n.value, x) + add) >> 16 }; +} + +/** The largest x ≥ from with g(x) ≥ h (g falls as x grows), or from − 1 when none is. */ +function threshold(g: (x: number) => number, from: number, h: number): number { + if (g(from) < h) return from - 1; + let lo = from, hi = I32_MAX; + if (g(hi) >= h) return hi; + while (hi - lo > 1) { + const mid = lo + Math.floor((hi - lo) / 2); + if (g(mid) >= h) lo = mid; + else hi = mid; + } + return lo; +} + +/** + * `min` of a constant and a quotient as comparisons, or null when it is not of that shape. `e` is the `min` as the + * final pass of `numbers.ts` left it: whole numbers, signed. + */ +export function divisionByThresholds(e: Extract): NumExpr | null { + if (e.name !== "min" || e.args.length !== 2 || e.unsigned || kept.has(e)) return null; + const [a, b] = e.args; + const [cap, q] = a.kind === "const" ? [a, b] : b.kind === "const" ? [b, a] : [null, null]; + if (!cap || cap.kind !== "const" || cap.fixed || cap.value < 1 || cap.value > THRESHOLDS_MOST) return null; + const quotient = quotientOf(q!); + if (!quotient) return null; + const { x, from, at: g } = quotient; + const top = cap.value; + const t: number[] = [Infinity]; + for (let h = 1; h <= top; h++) t.push(threshold(g, from, h)); + // The thresholds as the game's arithmetic has them on both sides: what the tree answers is the division's. + for (let h = 1; h <= top; h++) { + if (t[h] >= from && g(t[h]) < h) return null; + if (t[h] < I32_MAX && t[h] + 1 >= from && g(t[h] + 1) >= h) return null; + if (t[h] > t[h - 1]) return null; + } + const where: { at: At; label: string } = { at: e.at, label: e.label }; + const v: NumExpr = { kind: "var", id: x }; + const k = (value: number): NumExpr => ({ kind: "const", value }); + // The result lies in lo … hi: is it at least mid, the middle? + const tree = (lo: number, hi: number): NumExpr => { + if (lo === hi) return k(lo); + const mid = Math.ceil((lo + hi) / 2); + if (t[mid] < from) return tree(lo, mid - 1); + // (Past the first comparison x is above zero, so these read it from 0 up: one condition each, where signed is a few.) + return { kind: "ternary", cond: { kind: "compare", op: "<=", left: v, right: k(t[mid]), unsigned: true, ...where }, whenTrue: tree(mid, hi), whenFalse: tree(lo, mid - 1), ...where }; + }; + kept.add(e); + return { kind: "ternary", cond: { kind: "compare", op: "<", left: v, right: k(from), ...where }, whenTrue: e, whenFalse: tree(0, top), ...where }; +} diff --git a/compiler/views.ts b/compiler/views.ts new file mode 100644 index 0000000..089d9f1 --- /dev/null +++ b/compiler/views.ts @@ -0,0 +1,158 @@ +/** + * A view of `ui.lines` is put together again only on a frame when something it reads was written (TrigScript 4, S2). + * The structured compiler lowers the view behind a flag of the program's (`dirty`); this pass finds what the view reads — + * the program's variables and arrays — and sets the flag after every statement that writes one of them, wherever it is: + * the program's body, its tasks, its functions. A view that reads the game itself (units, the players' input, a random + * number, the game's tables) cannot be followed this way and is put together every frame, as it would be without the flag. + * A call of a function of the script's own (`thousands(score)`, `clock(t)`) is followed into the function: what it reads + * of the program is what the view reads (its parameters and its own variables aside), and a function that reads the + * game makes the view one that does. + */ +import type { ArrayDecl, FuncDecl, Program, Stmt } from "./ir"; + +/** A view as the structured compiler leaves it: its flag, and the statements that put its text together. */ +export interface View { dirty: string; declare: Stmt; build: Stmt[] } + +/** What a view reads that only the game knows: never followed. */ +const GAME_READS: ReadonlySet = new Set(["input", "randomInt", "random", "read", "tableRead", "unitField", "cond", "edge", "running", "unitAlive", "unitFlag", "pick", "pop"]); + +/** The statements that hold lists of statements, by the fields that hold them. */ +const LISTS = ["body", "then", "else", "update"] as const; + +/** The array an array's cells are part of: a window (`slice`) is its whole's, and so on down. */ +function rootOf(arrays: Map, id: string): string { + let a = arrays.get(id); + const seen = new Set(); + while (a?.slice && !seen.has(a.id)) { seen.add(a.id); a = arrays.get(a.slice.of) ?? a; if (a.id === id) break; } + return a?.id ?? id; +} + +/** What a function reads of the program, by its id: the same as a view's, its parameters left out; null when it reads the game. */ +type FunctionReads = Map | null>; + +/** The variables and arrays a view reads (arrays by their whole), or null when it reads the game or calls a function that does. */ +function readsOf(build: Stmt[], arrays: Map, functions: Map = new Map(), known: FunctionReads = new Map()): Set | null { + const reads = new Set(); + const declared = new Set(); + let game = false; + /** A called function's reads, worked out once; one that calls itself, while it is being worked out, adds nothing more. */ + const ofFunction = (id: string): Set | null => { + if (known.has(id)) return known.get(id)!; + const f = functions.get(id); + if (!f) return null; + known.set(id, new Set()); + const r = readsOf(f.body, arrays, functions, known); + if (r) { for (const p of f.params) r.delete(p.id); if (f.result) r.delete(f.result.decl.id); } + known.set(id, r); + return r; + }; + const visit = (o: unknown): void => { + if (game || !o || typeof o !== "object") return; + if (Array.isArray(o)) { o.forEach(visit); return; } + const n = o as Record; + const kind = n.kind; + if (typeof kind === "string") { + if (GAME_READS.has(kind)) { game = true; return; } + const fn = (kind === "call" || kind === "textCall") ? (n.call as { fn?: string } | undefined)?.fn : undefined; + if (fn) { + const r = ofFunction(fn); + if (!r) { game = true; return; } + for (const id of r) reads.add(id); + } + if ((kind === "var" || kind === "textVar" || kind === "unitVar") && typeof n.id === "string") reads.add(n.id); + if (typeof n.array === "string") reads.add(rootOf(arrays, n.array)); + for (const f of ["addr", "block", "chars"]) if (kind === "textAt" && typeof n[f] === "string") reads.add(rootOf(arrays, n[f] as string)); + if (kind === "declare" && n.decl) declared.add((n.decl as { id: string }).id); + } + for (const v of Object.values(n)) if (v && typeof v === "object") visit(v); + }; + visit(build); + if (game) return null; + for (const d of declared) reads.delete(d); + return reads; +} + +/** What a statement writes itself — not what the lists inside it do, which are walked on their own. */ +function writes(s: Stmt, arrays: Map): string[] { + const array = (id: string) => rootOf(arrays, id); + switch (s.kind) { + case "declare": return [s.decl.id]; + case "assign": case "assignBool": case "assignUnit": case "assignText": return [s.target]; + case "store": case "push": case "pop": case "setLength": case "declareArray": return [array(s.array)]; + case "storeText": return [array(s.addr), array(s.block), array(s.chars)]; + case "releaseText": return [array(s.block)]; + case "textLoop": return [s.decl.id]; + case "unitLoop": return s.was ? [s.decl.id, s.was.id] : [s.decl.id]; + default: return []; + } +} + +/** Whether something inside a statement's own expressions (not its lists) takes from an array: `xs.pop()` as a value. */ +function popsInside(s: Stmt, arrays: Map, into: string[]): void { + const visit = (o: unknown, top: boolean): void => { + if (!o || typeof o !== "object") return; + if (Array.isArray(o)) { if (!top) o.forEach((x) => visit(x, false)); return; } + const n = o as Record; + if (n.kind === "pop" && typeof n.array === "string" && !top) into.push(rootOf(arrays, n.array)); + for (const [k, v] of Object.entries(n)) if (v && typeof v === "object" && !(top && (LISTS as readonly string[]).includes(k))) visit(v, false); + }; + visit(s, true); +} + +/** The flag of every view that reads what `s` writes, set after it. */ +function after(s: Stmt, views: { dirty: string; reads: Set }[], arrays: Map): Stmt[] { + const written = writes(s, arrays); + popsInside(s, arrays, written); + // A call that comes back into its function puts saved variables back when it returns. + if (s.kind === "call" && s.call.saves) written.push(...s.call.saves.vars, ...s.call.saves.arrays.map((a) => rootOf(arrays, a))); + if (!written.length) return []; + return views.filter((v) => written.some((id) => v.reads.has(id))).map((v) => ({ kind: "assign", target: v.dirty, value: { kind: "const", value: 1 }, at: s.at, label: "label" in s ? s.label : "" } as Stmt)); +} + +/** Every list of statements in the program, the flag set after each statement that writes what a view reads. */ +export function markViewWrites(program: Program, views: View[]): void { + if (!views.length) return; + // The flags first of all, set: a write anywhere before the ui.lines line already sets one. + program.body.unshift(...views.map((v) => v.declare)); + const arrays = new Map(program.arrays.map((a) => [a.id, a])); + const functions = new Map((program.functions ?? []).map((f) => [f.id, f])); + const known: FunctionReads = new Map(); + const followed = views.flatMap((v) => { + const reads = readsOf(v.build, arrays, functions, known); + return reads ? [{ dirty: v.dirty, reads }] : []; + }); + // A view that reads the game is put together every frame: its flag stays set. + for (const v of views) if (!followed.some((f) => f.dirty === v.dirty)) markAlways(v); + if (!followed.length) return; + const own = new Set(views.flatMap((v) => v.build)); + const list = (stmts: Stmt[]): Stmt[] => { + const out: Stmt[] = []; + for (const s of stmts) { + walk(s); + out.push(s); + // The view's own statements write only what they declare: nothing it reads. + if (!own.has(s)) out.push(...after(s, followed, arrays)); + } + return out; + }; + const walk = (o: unknown): void => { + if (!o || typeof o !== "object") return; + if (Array.isArray(o)) { o.forEach(walk); return; } + const n = o as Record; + for (const [k, v] of Object.entries(n)) { + if (Array.isArray(v) && (LISTS as readonly string[]).includes(k) && v.every(isStmt)) n[k] = list(v as Stmt[]); + else if (k === "iterations" || k === "branches") n[k] = (v as Stmt[][]).map((l) => list(l)); + else if (k === "cases") for (const c of v as { body: Stmt[] }[]) c.body = list(c.body); + else if (v && typeof v === "object") walk(v); + } + }; + program.body = list(program.body); + for (const f of program.functions ?? []) f.body = list(f.body); +} + +const isStmt = (x: unknown): boolean => !!x && typeof x === "object" && typeof (x as { kind?: unknown }).kind === "string"; + +/** A view whose flag nothing clears: the lowering clears it before it builds, so set it again first thing in the build. */ +function markAlways(v: View): void { + v.build.unshift({ kind: "assign", target: v.dirty, value: { kind: "const", value: 1 }, at: v.build[0]?.at ?? { file: "", line: 0, column: 0 }, label: "" } as Stmt); +} diff --git a/compiler/vorbis.ts b/compiler/vorbis.ts new file mode 100644 index 0000000..d2a5983 --- /dev/null +++ b/compiler/vorbis.ts @@ -0,0 +1,125 @@ +/** + * Ogg Vorbis for the clips a build puts in the map at two seconds or more (`sound.ts`'s `clipFile`): libvorbis and + * libogg compiled to WebAssembly by wasm-media-encoders (MIT; libvorbis and libogg BSD), its `ogg.wasm` copied to + * `dist/vorbis.wasm` by the build. The glue is this file's own, a few calls into the module's exports: the package's + * JavaScript carries both its encoders inline, and instantiates them only asynchronously. + * + * A compile only names a Vorbis clip (by its samples); it is encoded when the map is to get it — a build, not a check + * as the user types — by `finishSounds`, which the compile worker calls when asked, and `scripts/build-fixture.mts` + * calls for a probe map. Encoding three minutes of two channels takes about a second and a half. + */ +import { VORBIS_QUALITY, type ClipFile } from "./sound"; + +/** What the module exports, as wasm-media-encoders 0.7.0 builds it. */ +interface Exports { + memory: WebAssembly.Memory; + _initialize(): void; + malloc(bytes: number): number; + free(at: number): void; + enc_init(params: number): number; + enc_get_pcm(ref: number, frames: number): number; + enc_encode(ref: number, frames: number): number; + enc_flush(ref: number): number; + enc_get_out_buf(ref: number): number; + enc_free(ref: number): void; +} + +/** Samples handed to the encoder at a time. */ +const CHUNK = 8192; + +export interface VorbisEncoder { + /** One or two channels of samples (−1 … 1) at `rate`, as an Ogg Vorbis file: the same bytes for the same samples. */ + encode(channels: readonly Float32Array[], rate: number, quality?: number): Uint8Array; +} + +/** An encoder from the module's bytes (or the module), made at once: a worker, or Node, may compile a module this size synchronously. */ +export function vorbisEncoder(wasm: BufferSource | WebAssembly.Module): VorbisEncoder { + const module = wasm instanceof WebAssembly.Module ? wasm : new WebAssembly.Module(wasm); + const instance = new WebAssembly.Instance(module, { + wasi_snapshot_preview1: { proc_exit: (code: number) => { throw new Error(`The Vorbis encoder stopped (exit ${code}).`); } }, + env: { emscripten_notify_memory_growth: () => {} }, + }); + const x = instance.exports as unknown as Exports; + x._initialize(); + // The memory may grow inside any call: a view is made after the call it is for. + const bytesOut = (ref: number, n: number) => new Uint8Array(x.memory.buffer, x.enc_get_out_buf(ref), n).slice(); + return { + encode(channels, rate, quality = VORBIS_QUALITY) { + const n = channels.length; + if (n !== 1 && n !== 2) throw new Error("Vorbis here takes one channel or two."); + const params = x.malloc(16); + if (!params) throw new Error("The Vorbis encoder is out of memory."); + const view = new DataView(x.memory.buffer); + view.setUint32(params, n, true); + view.setUint32(params + 4, rate, true); + view.setFloat32(params + 8, quality, true); + // The stream's serial number: fixed, so a build writes the same file for the same samples. + view.setInt32(params + 12, 0x54726753, true); + const ref = x.enc_init(params); + x.free(params); + if (!ref) throw new Error("The Vorbis encoder would not start."); + try { + const parts: Uint8Array[] = []; + const frames = channels[0].length; + for (let i = 0; i < frames; i += CHUNK) { + const m = Math.min(CHUNK, frames - i); + const table = x.enc_get_pcm(ref, m); + if (!table) throw new Error("The Vorbis encoder is out of memory."); + const pointers = new Int32Array(x.memory.buffer, table, n); + for (let c = 0; c < n; c++) new Float32Array(x.memory.buffer, pointers[c], m).set(channels[c].subarray(i, i + m)); + const out = x.enc_encode(ref, m); + if (out < 0) throw new Error(`The Vorbis encoder failed (${out}).`); + if (out > 0) parts.push(bytesOut(ref, out)); + } + const last = x.enc_flush(ref); + if (last < 0) throw new Error(`The Vorbis encoder failed (${last}).`); + if (last > 0) parts.push(bytesOut(ref, last)); + const file = new Uint8Array(parts.reduce((s, p) => s + p.length, 0)); + let o = 0; + for (const p of parts) { file.set(p, o); o += p.length; } + return file; + } finally { + x.enc_free(ref); + } + }, + }; +} + +/** Files encoded already, by their names (which say what they hold): a build after a build of the same song is quick. */ +const encoded = new Map(); +const KEEP = 32; + +/** + * Give each sound of a compile its bytes: the Vorbis ones encoded, with `encoder` — or, with none, left as names only + * (a check, whose sounds the map does not get) and their samples dropped, so the result is small to send. + */ +export function finishSounds(sounds: ClipFile[], encoder: VorbisEncoder | null): void { + for (const s of sounds) { + if (!s.vorbis) continue; + if (encoder) { + let bytes = encoded.get(s.path); + if (!bytes) { + bytes = encoder.encode(s.vorbis.channels, s.vorbis.rate, s.vorbis.quality); + if (encoded.size >= KEEP) encoded.delete(encoded.keys().next().value!); + encoded.set(s.path, bytes); + } + s.bytes = bytes; + } + delete s.vorbis; + } +} + +/** The encoder, its module fetched from `url` once. */ +let loading: { url: string; encoder: Promise } | null = null; + +export function loadVorbis(url: string): Promise { + if (loading?.url !== url) { + const encoder = fetch(url).then(async (r) => { + if (!r.ok) throw new Error(`Could not load the Vorbis encoder from ${url} (${r.status}).`); + return vorbisEncoder(await r.arrayBuffer()); + }); + encoder.catch(() => { if (loading?.encoder === encoder) loading = null; }); + loading = { url, encoder }; + } + return loading.encoder; +} diff --git a/compiler/world.ts b/compiler/world.ts index 84e0304..ce49095 100644 --- a/compiler/world.ts +++ b/compiler/world.ts @@ -30,13 +30,16 @@ export interface SimUnit { hp: number; maxHp: number; shields: number; maxShields: number; energy: number; kills: number; orderId: number; cooldown: number; resources: number; stim: number; ensnare: number; plague: number; lockdown: number; maelstrom: number; irradiate: number; stasis: number; - hallucinated: boolean; cloaked: boolean; burrowed: boolean; invincible: boolean; underAttack: boolean; + buildTime: number; rank: number; matrix: number; speed: number; + hallucinated: boolean; cloaked: boolean; burrowed: boolean; invincible: boolean; underAttack: boolean; speedUpgrade: boolean; hasTarget: boolean; inTransport: boolean; /** Still on the map. */ alive: boolean; /** Its place in the unit table, from 0; another unit's once this one is gone. */ slot: number; /** The place's uniqueness byte while this unit is in it. */ uid: number; + /** Once it has died in a fight: the player whose attack killed it (on.killed()). */ + killedBy?: number; } export type SimUnitInit = Partial> & { type: number; owner: number }; export interface SimBounds { left: number; top: number; right: number; bottom: number } @@ -126,6 +129,11 @@ export class World { /** A unit died by `kill`: the death is the caller's to count, in the table its script's counters are in. */ onDeath: (unit: SimUnit) => void = () => {}; private readonly table: (SimUnit | null)[] = []; + /** + * The free places in the order the game hands them out (scm-sim docs/specs/unit-lists.md, section 3): at the start + * slot 0, then 1699, 1698 … 1; a place freed goes in right behind the first, so it is the one after the next. + */ + private readonly free: number[] = [0, ...Array.from({ length: UNIT_SLOTS - 1 }, (_, i) => UNIT_SLOTS - 1 - i)]; private readonly uids: number[] = []; private readonly classOf: (type: number, cls: number) => boolean; private readonly properties: (slot: number) => SimUnitProperties | undefined; @@ -143,11 +151,10 @@ export class World { /* ── the unit table ── */ - /** A unit on the map, in the lowest free place of the table. Null when the table is full, as the game makes no unit then. */ + /** A unit on the map, in the place the game hands out next. Null when the table is full, as the game makes no unit then. */ make(init: SimUnitInit): SimUnit | null { - let slot = 0; - while (slot < UNIT_SLOTS && this.table[slot]) slot++; - if (slot >= UNIT_SLOTS) return null; + const slot = this.free.shift(); + if (slot === undefined) return null; // A place used again is not the place it was: what kept the unit that was here compares this byte. const uid = this.uids[slot] === undefined ? 0 : (this.uids[slot] + 1) & 0xff; this.uids[slot] = uid; @@ -155,8 +162,8 @@ export class World { const shields = init.shields ?? init.maxShields ?? 0; const unit: SimUnit = { x: 0, y: 0, energy: 0, kills: 0, orderId: 3, cooldown: 0, resources: 0, - stim: 0, ensnare: 0, plague: 0, lockdown: 0, maelstrom: 0, irradiate: 0, stasis: 0, - hallucinated: false, cloaked: false, burrowed: false, invincible: false, underAttack: false, + stim: 0, ensnare: 0, plague: 0, lockdown: 0, maelstrom: 0, irradiate: 0, stasis: 0, buildTime: 0, rank: 0, matrix: 0, speed: 0, + hallucinated: false, cloaked: false, burrowed: false, invincible: false, underAttack: false, speedUpgrade: false, hasTarget: false, inTransport: false, ...init, hp, maxHp: init.maxHp ?? hp, shields, maxShields: init.maxShields ?? shields, alive: true, slot, uid, }; this.table[slot] = unit; @@ -164,6 +171,11 @@ export class World { return unit; } + /** A unit given to a player. */ + setOwner(unit: SimUnit, to: number): void { + if (unit.alive) unit.owner = to; + } + /** A unit of a type as the game makes one: the type's hit points and shields, at a point. */ create(type: number, owner: number, x: number, y: number, properties?: SimUnitProperties): SimUnit | null { const s = this.stats(type) ?? {}; @@ -181,12 +193,19 @@ export class World { }); } - /** Off the map, its place free for the next unit made. `killed`: it died — counted as a death, and as a kill of `by` when someone is named. */ - gone(unit: SimUnit, killed: boolean, by?: number): void { + /** + * Off the map, its place free for the next unit made. `killed`: it died. `fought`: it died in the game — shot, or + * killed by a spell — and so counts as a death, and as a kill of `by` when someone is named. A unit a trigger or a + * program kills (Kill Unit, a unit's kill(), hit points written down to 0) dies without counting: the lifted game + * says so for all three (TrigScript 4, A2, 2026-10-16), as the editor's trigger reference does for Kill Unit. + */ + gone(unit: SimUnit, killed: boolean, by?: number, fought = true): void { if (!unit.alive) return; unit.alive = false; - if (this.table[unit.slot] === unit) this.table[unit.slot] = null; - if (!killed) return; + if (this.table[unit.slot] === unit) { this.table[unit.slot] = null; this.free.splice(Math.min(1, this.free.length), 0, unit.slot); } + if (!killed || !fought) return; + // (The game keeps no attacker for a unit its own player killed — a storm on your own units — so there is no killer then.) + if (by !== undefined && by !== unit.owner) unit.killedBy = by; this.onDeath(unit); if (by !== undefined) this.killed.set(unit.type * 4096 + by, (this.killed.get(unit.type * 4096 + by) ?? 0) + 1); } @@ -198,11 +217,13 @@ export class World { return this.units.find((u) => u.slot === slot && u.uid === uid) ?? null; } - /** The units on the map, in the order of the table. */ + /** + * The units on the map, oldest first: the order of the game's table walked as it hands its places out (slot 0, then + * 1699 downwards), as long as no place freed has been handed out again — when the game frees a record, some frames after + * its unit is gone, is not something the simulator knows. + */ living(): SimUnit[] { - const out: SimUnit[] = []; - for (const u of this.table) if (u) out.push(u); - return out; + return this.units.filter((u) => u.alive && this.table[u.slot] === u); } private typed(u: SimUnit, type: number | undefined): boolean { @@ -216,7 +237,17 @@ export class World { /** The living units of these owners (any, when undefined), of a type or a class, inside a location — in table order. */ matching(owners: readonly number[] | undefined, type?: number, location?: number): SimUnit[] { - return this.living().filter((u) => (!owners || owners.includes(u.owner)) && this.typed(u, type) && this.inside(u, location)); + return this.living().filter((u) => this.fits(u, owners, type, location)); + } + + /** The living units a program's loop comes to (TrigScript 4): oldest first, as `living` says. */ + listed(owners: readonly number[] | undefined, type?: number, location?: number): SimUnit[] { + return this.matching(owners, type, location); + } + + /** Whether a unit, living or as it died, is of these owners (any, when undefined), of a type or a class, inside a location. */ + fits(u: SimUnit, owners: readonly number[] | undefined, type?: number, location?: number): boolean { + return (!owners || owners.includes(u.owner)) && this.typed(u, type) && this.inside(u, location); } centreOf(location: number): { x: number; y: number } { @@ -224,11 +255,11 @@ export class World { return b ? { x: Math.floor((b.left + b.right) / 2), y: Math.floor((b.top + b.bottom) / 2) } : { x: 0, y: 0 }; } - /** A location centred on a point, its size kept. */ - centre(location: number, x: number, y: number): void { + /** A location centred on a point, its size kept — or the size given. */ + centre(location: number, x: number, y: number, size?: { width: number; height: number }): void { const b = this.locations.get(location) ?? { left: 0, top: 0, right: 0, bottom: 0 }; - const w = b.right - b.left; - const h = b.bottom - b.top; + const w = size ? size.width : b.right - b.left; + const h = size ? size.height : b.bottom - b.top; const left = x - Math.floor(w / 2); const top = y - Math.floor(h / 2); this.locations.set(location, { left, top, right: left + w, bottom: top + h }); @@ -288,13 +319,13 @@ export class World { for (const owner of owners) if (owner < 12) for (let i = 0; i < Math.max(1, a.modifier); i++) { const u = this.create(a.unitId, owner, x, y, properties); if (u) made?.push(u); } return true; } - case ActionType.KillUnit: for (const u of this.matching(owners, a.unitId)) this.gone(u, true); return true; + case ActionType.KillUnit: for (const u of this.matching(owners, a.unitId)) this.gone(u, true, undefined, false); return true; case ActionType.RemoveUnit: for (const u of this.matching(owners, a.unitId)) this.gone(u, false); return true; - case ActionType.KillUnitAt: for (const u of this.some(this.matching(owners, a.unitId, a.location), a.modifier)) this.gone(u, true); return true; + case ActionType.KillUnitAt: for (const u of this.some(this.matching(owners, a.unitId, a.location), a.modifier)) this.gone(u, true, undefined, false); return true; case ActionType.RemoveUnitAt: for (const u of this.some(this.matching(owners, a.unitId, a.location), a.modifier)) this.gone(u, false); return true; case ActionType.GiveUnits: { const to = this.players.of(a.target, current)[0]; - if (to !== undefined && to < 12) for (const u of this.some(this.matching(owners, a.unitId, a.location), a.modifier)) u.owner = to; + if (to !== undefined && to < 12) for (const u of this.some(this.matching(owners, a.unitId, a.location), a.modifier)) this.setOwner(u, to); return true; } case ActionType.MoveUnit: { diff --git a/contract.d.ts b/contract.d.ts new file mode 100644 index 0000000..4e2eb2e --- /dev/null +++ b/contract.d.ts @@ -0,0 +1,62 @@ +/** + * The `trigscript.build` service, as other plugins see it (TrigScript 4): TrigScript as the library other plugins + * build their programs through. Types only: take this file with `import type` and reach the object through + * `api.services.watch("trigscript.build", …)`. The provider's `ServiceInfo.version` is 1 for this shape. + * + * A plugin whose rows need code in the map writes them as TrigScript source — `program(() => { … })` bodies, the + * language the guide describes — and *contributes* them: on Save, Test Map and an export TrigScript compiles them with + * the map's own script (the contributed files run as entries after it, so their programs come after the script's) and + * lays every program out into the saved map in one build. The map the user edits is never touched; the files never + * enter the archive. Magenta contributes its build rows this way. + */ + +/** A standing offer to take part in the map's build. */ +export interface TrigScriptBuildContribution { + /** The plugin's id ("magenta"): what the build's events name the contribution by. */ + id: string; + /** What the editor's notice calls it. */ + label: string; + /** Asked on every save: cheap, synchronous, no side effects. */ + applies(): boolean; + /** Your part of the build for the open map as it is now. Throw an `Error` worded for the user. */ + collect(ctx: { purpose: "save" | "test" | "export"; signal: AbortSignal }): Promise; +} + +/** + * The files a contribution brings: TrigScript source by path, each under a folder named after the plugin + * (`magenta/main.ts`) so that none can shadow the map's own files, and which of them run as entries, in order. A + * contribution holds programs only: a `trigger()` in it is a problem, since nothing writes it into the trigger list. + * A file may import another of the contribution's (`./rows`) and the standard library ("trigscript/…"). + */ +export interface TrigScriptContributedScript { files: Record; entries: string[] } + +/** What `check` says of a contribution's source, as a build would compile it. */ +export interface TrigScriptContributionCheck { + /** TypeScript's and the compiler's problems, a `trigger()` where only programs go, and what the build does not take, each at its file and 1-based line and column. */ + problems: { file: string; line: number; column: number; message: string }[]; + /** How many `program()`s the source holds. */ + programs: number; +} + +export type TrigScriptBuildEvent = + | { kind: "start"; purpose: "save" | "test" | "export"; contributors: string[] } + | { kind: "log"; line: string } + | { kind: "done"; purpose: "save" | "test" | "export"; contributors: string[]; log: string; chkBytes: number; ms: number } + /** `from`: the id of the contribution whose part failed, or "trigscript" for the script's own. */ + | { kind: "failed"; purpose: "save" | "test" | "export"; contributors: string[]; from: string | null; message: string; log: string } + /** What the map maker should hear of beyond the log: a trigger of the map's own left out or never run. */ + | { kind: "warning"; message: string }; + +export interface TrigScriptBuildService { + /** The plugin's version. */ + versions: { plugin: string }; + /** Take part in every build of the open map while the handle lives; the second contribution of one id replaces the first. */ + contribute(contribution: TrigScriptBuildContribution): { dispose(): void }; + /** Hear how each build goes: the log, and where a failure points. */ + onBuild(listener: (event: TrigScriptBuildEvent) => void): { dispose(): void }; + /** Compile a contribution's source against the open map without building: for a contributor's own preflight. */ + check(files: Record, entries: string[]): Promise; +} + +/** The service name, as `api.services.get` / `watch` take it. */ +export declare const TRIGSCRIPT_BUILD_SERVICE: "trigscript.build"; diff --git a/data/allowlist.json b/data/allowlist.json new file mode 100644 index 0000000..c4c62dc --- /dev/null +++ b/data/allowlist.json @@ -0,0 +1,16882 @@ +{ + "version": 1, + "entries": [ + { + "name": "max hit points of a unit type", + "base": 6693712, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.maxHp, verified by its probes" + ] + }, + { + "name": "max shields of a unit type", + "base": 6688256, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.maxShields, verified by its probes" + ] + }, + { + "name": "armor of a unit type", + "base": 6684360, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.armor, verified by its probes" + ] + }, + { + "name": "build time of a unit type", + "base": 6685736, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.buildTime, verified by its probes" + ] + }, + { + "name": "mineral cost of a unit type", + "base": 6699144, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.mineralCost, verified by its probes" + ] + }, + { + "name": "gas cost of a unit type", + "base": 6683904, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.gasCost, verified by its probes" + ] + }, + { + "name": "supply used by a unit type", + "base": 6700264, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.supplyRequired, verified by its probes" + ] + }, + { + "name": "supply provided by a unit type", + "base": 6702792, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.supplyProvided, verified by its probes" + ] + }, + { + "name": "sight range of a unit type", + "base": 6697528, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.sightRange, verified by its probes" + ] + }, + { + "name": "target acquisition range of a unit type", + "base": 6696376, + "width": 1, + "stride": 1, + "count": 228, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta unit.seekRange, not verified" + ] + }, + { + "name": "ground weapon of a unit type", + "base": 6698680, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.groundWeapon, verified by its probes" + ] + }, + { + "name": "air weapon of a unit type", + "base": 6690528, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.airWeapon, verified by its probes" + ] + }, + { + "name": "speed of a unit type", + "base": 7118584, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.speed, verified by its probes" + ] + }, + { + "name": "name of a unit type", + "base": 6685280, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.name, verified by its probes" + ] + }, + { + "name": "size of a unit type", + "base": 6693248, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.sizeClass, verified by its probes" + ] + }, + { + "name": "looks of a unit type", + "base": 6702328, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.graphics, verified by its probes" + ] + }, + { + "name": "detector flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.detector, verified by its probes" + ] + }, + { + "name": "permanent cloak flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.permanentCloak, verified by its probes" + ] + }, + { + "name": "cloak ability flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.cloakable, verified by its probes" + ] + }, + { + "name": "burrow ability flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.burrowable, verified by its probes" + ] + }, + { + "name": "regeneration flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.regeneratesHp, verified by its probes" + ] + }, + { + "name": "invincible flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.invincible, verified by its probes" + ] + }, + { + "name": "hero flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.hero, verified by its probes" + ] + }, + { + "name": "organic flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.organic, verified by its probes" + ] + }, + { + "name": "mechanical flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.mechanical, verified by its probes" + ] + }, + { + "name": "robotic flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.robotic, verified by its probes" + ] + }, + { + "name": "damage of a weapon", + "base": 6647472, + "width": 2, + "stride": 2, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.damage, verified by its probes" + ] + }, + { + "name": "damage bonus per upgrade of a weapon", + "base": 6649464, + "width": 2, + "stride": 2, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.bonus, verified by its probes" + ] + }, + { + "name": "cooldown of a weapon", + "base": 6647736, + "width": 1, + "stride": 1, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.cooldown, verified by its probes" + ] + }, + { + "name": "damage factor of a weapon", + "base": 6644960, + "width": 1, + "stride": 1, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.factor, verified by its probes" + ] + }, + { + "name": "range of a weapon", + "base": 6648944, + "width": 4, + "stride": 4, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.range, verified by its probes" + ] + }, + { + "name": "minimum range of a weapon", + "base": 6646296, + "width": 4, + "stride": 4, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.minRange, verified by its probes" + ] + }, + { + "name": "mineral cost of an upgrade", + "base": 6641472, + "width": 2, + "stride": 2, + "count": 61, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta upgrade.mineralCost, verified by its probes" + ] + }, + { + "name": "gas cost of an upgrade", + "base": 6641728, + "width": 2, + "stride": 2, + "count": 61, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta upgrade.gasCost, verified by its probes" + ] + }, + { + "name": "research time of an upgrade", + "base": 6642560, + "width": 2, + "stride": 2, + "count": 61, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta upgrade.time, verified by its probes" + ] + }, + { + "name": "maximum level of an upgrade", + "base": 6641408, + "width": 1, + "stride": 1, + "count": 61, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta upgrade.maxLevel, not verified" + ] + }, + { + "name": "mineral cost of a technology", + "base": 6644296, + "width": 2, + "stride": 2, + "count": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta tech.mineralCost, verified by its probes" + ] + }, + { + "name": "gas cost of a technology", + "base": 6644208, + "width": 2, + "stride": 2, + "count": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta tech.gasCost, verified by its probes" + ] + }, + { + "name": "research time of a technology", + "base": 6644696, + "width": 2, + "stride": 2, + "count": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta tech.time, verified by its probes" + ] + }, + { + "name": "energy cost of a technology", + "base": 6644608, + "width": 2, + "stride": 2, + "count": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta tech.energy, verified by its probes" + ] + }, + { + "name": "minerals of a player", + "base": 5763312, + "width": 4, + "stride": 4, + "count": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.minerals, verified by its probes" + ] + }, + { + "name": "gas of a player", + "base": 5763360, + "width": 4, + "stride": 4, + "count": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.gas, verified by its probes" + ] + }, + { + "name": "upgrade level of a player", + "base": 5821104, + "width": 552, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.upgradeLevel, verified by its probes" + ] + }, + { + "name": "technology researched by a player", + "base": 5820228, + "width": 288, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.techResearched, verified by its probes" + ] + }, + { + "name": "alliance of a player toward another", + "base": 5822004, + "width": 144, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.alliance, verified by its probes" + ] + }, + { + "name": "shared vision between players", + "base": 5763564, + "width": 4, + "stride": 4, + "count": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.vision, verified by its probes" + ] + }, + { + "name": "colour of a player", + "base": 5774710, + "width": 1, + "stride": 1, + "count": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.color, verified by its probes" + ] + }, + { + "name": "supply provided to a player", + "base": 5775684, + "width": 336, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.supplyProvided, verified by its probes" + ] + }, + { + "name": "supply used by a player", + "base": 5775732, + "width": 336, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.supplyUsed, verified by its probes" + ] + }, + { + "name": "supply cap of a player", + "base": 5775780, + "width": 336, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.supplyMax, verified by its probes" + ] + }, + { + "name": "slot of a player", + "base": 5763508, + "width": 1, + "stride": 1, + "count": 12, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta player.slotType, verified by its probes" + ] + }, + { + "name": "race of a player", + "base": 5763520, + "width": 1, + "stride": 1, + "count": 12, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta player.race, verified by its probes" + ] + }, + { + "name": "whether a player has left", + "base": 5774690, + "width": 1, + "stride": 1, + "count": 8, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta player.left, not verified" + ] + }, + { + "name": "game speed", + "base": 7135188, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.speed, verified by its probes" + ] + }, + { + "name": "trigger timer", + "base": 6621600, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta game.triggerTimer, verified by its probes" + ] + }, + { + "name": "local player", + "base": 5318276, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.localPlayer, verified by its probes" + ], + "local": true + }, + { + "name": "mouse x on screen", + "base": 7134660, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.mouseX, verified by its probes" + ], + "local": true + }, + { + "name": "mouse y on screen", + "base": 7134662, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.mouseY, verified by its probes" + ], + "local": true + }, + { + "name": "screen x on the map", + "base": 6456460, + "width": 4, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta game.screenX, not verified" + ], + "local": true + }, + { + "name": "screen y on the map", + "base": 6456488, + "width": 4, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta game.screenY, not verified" + ], + "local": true + }, + { + "name": "keyboard key state", + "base": 5859864, + "width": 1, + "stride": 1, + "count": 256, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.key, verified by its probes" + ], + "local": true + }, + { + "name": "frames the game has run", + "base": 5763644, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.frames, verified by its probes" + ] + }, + { + "name": "seconds on the game clock", + "base": 5822200, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.seconds, verified by its probes" + ] + }, + { + "name": "hit points of a placed unit", + "base": 5885104, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.hp, verified by its probes" + ] + }, + { + "name": "shields of a placed unit", + "base": 5885192, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.shields, verified by its probes" + ] + }, + { + "name": "energy of a placed unit", + "base": 5885258, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.energy, verified by its probes" + ] + }, + { + "name": "owner of a placed unit", + "base": 5885172, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.owner, verified by its probes" + ] + }, + { + "name": "type of a placed unit", + "base": 5885196, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta cunit.type, verified by its probes" + ] + }, + { + "name": "x position of a placed unit", + "base": 5885136, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta cunit.x, verified by its probes" + ] + }, + { + "name": "y position of a placed unit", + "base": 5885138, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta cunit.y, verified by its probes" + ] + }, + { + "name": "invincibility of a placed unit", + "base": 5885316, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.invincible, verified by its probes" + ] + }, + { + "name": "hallucination flag of a placed unit", + "base": 5885316, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.hallucination, verified by its probes" + ] + }, + { + "name": "cloak state of a placed unit", + "base": 5885316, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta cunit.cloaked, verified by its probes" + ] + }, + { + "name": "unit table: position", + "base": 5885136, + "stride": 336, + "count": 1700, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "the dump probe, steps A and B (2026-10-16): a unit's X and Y written, the game went on and nothing was seen to move", + "Magenta probe 6, key I (2026-09-14) ended the game writing the unit's position and its sprite's together; the dump probe wrote each alone and neither did" + ] + }, + { + "name": "sprite table: position", + "base": 6462892, + "stride": 36, + "count": 2500, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "the dump probe, step C (2026-10-16): a sprite's X and Y written, the game went on and nothing on the screen changed" + ] + }, + { + "name": "unit table: +0x74", + "base": 5885212, + "stride": 336, + "count": 1700, + "width": 4, + "read": "ends", + "write": "ends", + "evidence": [ + "the dump probe, step D (2026-10-16): read, the game ended \"EUD ERROR: 0xFFA632E3\" (the address, bits flipped), as the dump said" + ] + }, + { + "name": "unit table: cloak flags", + "base": 5885317, + "stride": 336, + "count": 1700, + "width": 1, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Magenta probe 6, keys Y and H (2026-09-14): status flags 0x100 and 0x200 written, no visible cloak" + ] + }, + { + "name": "game speed", + "base": 7135188, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Magenta probe 5, key 9 (2026-09-14): written, the game kept its speed" + ] + }, + { + "name": "0x512684", + "base": 5318276, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x512685", + "base": 5318277, + "width": 3, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a284", + "base": 5350020, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a290", + "base": 5350032, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a29c", + "base": 5350044, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2a8", + "base": 5350056, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2b4", + "base": 5350068, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2c0", + "base": 5350080, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2cc", + "base": 5350092, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2d8", + "base": 5350104, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57eeeb", + "base": 5762795, + "width": 7, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), strings (2026-09-19), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57ef0f", + "base": 5762831, + "width": 11, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes classes (2026-09-19), reads (2026-09-18), strings (2026-09-19), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f0d8", + "base": 5763288, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f0f0", + "base": 5763312, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f120", + "base": 5763360, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f1b4", + "base": 5763508, + "width": 3, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f1c0", + "base": 5763520, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f23c", + "base": 5763644, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x581d60", + "base": 5774688, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x581d76", + "base": 5774710, + "width": 1, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes reads (2026-09-18), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x581d7e", + "base": 5774718, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x581dd6", + "base": 5774806, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x582178", + "base": 5775736, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x5821d4", + "base": 5775828, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x582204", + "base": 5775876, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x582234", + "base": 5775924, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x587f94", + "base": 5799828, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58a364", + "base": 5808996, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58a388", + "base": 5809032, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58cf45", + "base": 5820229, + "width": 1, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58d2b7", + "base": 5821111, + "width": 1, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58dc60", + "base": 5823584, + "width": 16, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes classes (2026-09-19), leftovers (2026-09-19), units (2026-09-18), arrays (2026-09-19), callbacks (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58dc70", + "base": 5823600, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58dc74", + "base": 5823604, + "width": 16, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58dc84", + "base": 5823620, + "width": 1204, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58e138", + "base": 5824824, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58e14c", + "base": 5824844, + "width": 3820, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58f038", + "base": 5828664, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a49", + "base": 5859913, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a5d", + "base": 5859933, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a69", + "base": 5859945, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a6f", + "base": 5859951, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a8e", + "base": 5859982, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6283f8", + "base": 6456312, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x628438", + "base": 6456376, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x62848c", + "base": 6456460, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6284a8", + "base": 6456488, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6284b8", + "base": 6456504, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640b60", + "base": 6556512, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640c3a", + "base": 6556730, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640d14", + "base": 6556948, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640dee", + "base": 6557166, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640ec8", + "base": 6557384, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640fa2", + "base": 6557602, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x64107c", + "base": 6557820, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x641156", + "base": 6558038, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x641230", + "base": 6558256, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x64130a", + "base": 6558474, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6413e4", + "base": 6558692, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x641598", + "base": 6559128, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x64159c", + "base": 6559132, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6415a4", + "base": 6559140, + "width": 48, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6509a0", + "base": 6621600, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6509b0", + "base": 6621616, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x654887", + "base": 6637703, + "width": 15, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x654aa0", + "base": 6638240, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x656eb0", + "base": 6647472, + "width": 2, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x660260", + "base": 6685280, + "width": 2, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes strings (2026-09-19), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6602aa", + "base": 6685354, + "width": 2, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x661002", + "base": 6688770, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x66158c", + "base": 6690188, + "width": 2, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x66171a", + "base": 6690586, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x661998", + "base": 6691224, + "width": 8, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6620d2", + "base": 6693074, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x662350", + "base": 6693712, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x662948", + "base": 6695240, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x662df2", + "base": 6696434, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x66318a", + "base": 6697354, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x663272", + "base": 6697586, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6636f2", + "base": 6698738, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637a0", + "base": 6698912, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637c0", + "base": 6698944, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637c3", + "base": 6698947, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637c5", + "base": 6698949, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637da", + "base": 6698970, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x66380a", + "base": 6699018, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x663823", + "base": 6699043, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x663888", + "base": 6699144, + "width": 2, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x664081", + "base": 6701185, + "width": 1, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x664101", + "base": 6701313, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x664168", + "base": 6701416, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6644f8", + "base": 6702328, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x664532", + "base": 6702386, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6c98a6", + "base": 7116966, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6c9a68", + "base": 7117416, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6c9d14", + "base": 7118100, + "width": 2, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6ca030", + "base": 7118896, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6cddc0", + "base": 7134656, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6cddc4", + "base": 7134660, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x8", + "base": 5885104, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xc", + "base": 5885108, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x10", + "base": 5885112, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x28", + "base": 5885136, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes classes (2026-09-19), input (2026-09-18), leftovers (2026-09-19), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x34", + "base": 5885148, + "width": 4, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x4c", + "base": 5885172, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x4d", + "base": 5885173, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x4e", + "base": 5885174, + "width": 2, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x64", + "base": 5885196, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x68", + "base": 5885200, + "width": 8, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x8f", + "base": 5885239, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xa2", + "base": 5885258, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xa5", + "base": 5885261, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xdc", + "base": 5885316, + "width": 3, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xdf", + "base": 5885319, + "width": 1, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x117", + "base": 5885375, + "width": 1, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x200000", + "base": 2097152, + "width": 4096, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x201000", + "base": 2101248, + "width": 24576, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x41e0d0", + "base": 4317392, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x426918", + "base": 4352280, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x427900", + "base": 4356352, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x427cdc", + "base": 4357340, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x45134c", + "base": 4526924, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x451350", + "base": 4526928, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x46044c", + "base": 4588620, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4887c0", + "base": 4753344, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x489204", + "base": 4755972, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x492f90", + "base": 4796304, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4a3898", + "base": 4864152, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4a389c", + "base": 4864156, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4b995c", + "base": 4954460, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4bd5ec", + "base": 4969964, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4bd5f0", + "base": 4969968, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4d68f4", + "base": 5073140, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4d98bc", + "base": 5085372, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4f3288", + "base": 5190280, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4f3360", + "base": 5190496, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4fdfbc", + "base": 5234620, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4fe544", + "base": 5236036, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x4ff900", + "base": 5241088, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x50cdc0", + "base": 5295552, + "width": 260, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x50e064", + "base": 5300324, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5124d8", + "base": 5317848, + "width": 28, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x512678", + "base": 5318264, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51267c", + "base": 5318268, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x512684", + "base": 5318276, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x512688", + "base": 5318280, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x512708", + "base": 5318408, + "width": 76, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5128f8", + "base": 5318904, + "width": 160, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x513b68", + "base": 5323624, + "width": 52, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x514178", + "base": 5325176, + "width": 1092, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5145bc", + "base": 5326268, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5145c0", + "base": 5326272, + "width": 840, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x514908", + "base": 5327112, + "width": 320, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x514a48", + "base": 5327432, + "width": 688, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x514cf8", + "base": 5328120, + "width": 1316, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51521c", + "base": 5329436, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5152a8", + "base": 5329576, + "width": 84, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5152fc", + "base": 5329660, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515300", + "base": 5329664, + "width": 72, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515348", + "base": 5329736, + "width": 60, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515384", + "base": 5329796, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515388", + "base": 5329800, + "width": 96, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515af8", + "base": 5331704, + "width": 16, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515b08", + "base": 5331720, + "width": 16, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515b18", + "base": 5331736, + "width": 32, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515b38", + "base": 5331768, + "width": 32, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515b58", + "base": 5331800, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515b60", + "base": 5331808, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515b68", + "base": 5331816, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515b70", + "base": 5331824, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515be8", + "base": 5331944, + "width": 60, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515c24", + "base": 5332004, + "width": 60, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515c60", + "base": 5332064, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515c74", + "base": 5332084, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515c88", + "base": 5332104, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515c9c", + "base": 5332124, + "width": 40, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515cc4", + "base": 5332164, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515cd8", + "base": 5332184, + "width": 40, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515d00", + "base": 5332224, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515d14", + "base": 5332244, + "width": 60, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515d50", + "base": 5332304, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515d64", + "base": 5332324, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515d68", + "base": 5332328, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515dd0", + "base": 5332432, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515e34", + "base": 5332532, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515e38", + "base": 5332536, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515e9c", + "base": 5332636, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515ea0", + "base": 5332640, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515f2c", + "base": 5332780, + "width": 40, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515f54", + "base": 5332820, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x515f58", + "base": 5332824, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51600c", + "base": 5333004, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516020", + "base": 5333024, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5160d4", + "base": 5333204, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5160d8", + "base": 5333208, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516164", + "base": 5333348, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516168", + "base": 5333352, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5161e0", + "base": 5333472, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51626c", + "base": 5333612, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516270", + "base": 5333616, + "width": 160, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516310", + "base": 5333776, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51639c", + "base": 5333916, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5163a0", + "base": 5333920, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51642c", + "base": 5334060, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516430", + "base": 5334064, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5164e4", + "base": 5334244, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5164e8", + "base": 5334248, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51659c", + "base": 5334428, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5165a0", + "base": 5334432, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51662c", + "base": 5334572, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516630", + "base": 5334576, + "width": 200, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5166f8", + "base": 5334776, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5167ac", + "base": 5334956, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5167b0", + "base": 5334960, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516814", + "base": 5335060, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516818", + "base": 5335064, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5168cc", + "base": 5335244, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5168d0", + "base": 5335248, + "width": 160, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516970", + "base": 5335408, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516984", + "base": 5335428, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516988", + "base": 5335432, + "width": 80, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5169d8", + "base": 5335512, + "width": 60, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516a14", + "base": 5335572, + "width": 60, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516a50", + "base": 5335632, + "width": 40, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516a78", + "base": 5335672, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516adc", + "base": 5335772, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516ae0", + "base": 5335776, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516b44", + "base": 5335876, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516b48", + "base": 5335880, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516bac", + "base": 5335980, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516bb0", + "base": 5335984, + "width": 80, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516c00", + "base": 5336064, + "width": 60, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516c3c", + "base": 5336124, + "width": 60, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516c78", + "base": 5336184, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516d04", + "base": 5336324, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516d08", + "base": 5336328, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516dbc", + "base": 5336508, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516dc0", + "base": 5336512, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516e74", + "base": 5336692, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516e78", + "base": 5336696, + "width": 160, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516f18", + "base": 5336856, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516fa4", + "base": 5336996, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x516fa8", + "base": 5337000, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517034", + "base": 5337140, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517038", + "base": 5337144, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5170c4", + "base": 5337284, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5170c8", + "base": 5337288, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517154", + "base": 5337428, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517158", + "base": 5337432, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5171e4", + "base": 5337572, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5171e8", + "base": 5337576, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517260", + "base": 5337696, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5172ec", + "base": 5337836, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5172f0", + "base": 5337840, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517354", + "base": 5337940, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517358", + "base": 5337944, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5173d0", + "base": 5338064, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517448", + "base": 5338184, + "width": 40, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517470", + "base": 5338224, + "width": 80, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5174c0", + "base": 5338304, + "width": 160, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517560", + "base": 5338464, + "width": 80, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5175b0", + "base": 5338544, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51763c", + "base": 5338684, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517640", + "base": 5338688, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5176a4", + "base": 5338788, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5176a8", + "base": 5338792, + "width": 80, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5176f8", + "base": 5338872, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51770c", + "base": 5338892, + "width": 60, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517748", + "base": 5338952, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5177d4", + "base": 5339092, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5177d8", + "base": 5339096, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517850", + "base": 5339216, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5178dc", + "base": 5339356, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5178e0", + "base": 5339360, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517994", + "base": 5339540, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517998", + "base": 5339544, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517a4c", + "base": 5339724, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517a50", + "base": 5339728, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517ab4", + "base": 5339828, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517ab8", + "base": 5339832, + "width": 180, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517b6c", + "base": 5340012, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517b70", + "base": 5340016, + "width": 160, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517c10", + "base": 5340176, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517c88", + "base": 5340296, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517d14", + "base": 5340436, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517d18", + "base": 5340440, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517da4", + "base": 5340580, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517da8", + "base": 5340584, + "width": 160, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517e48", + "base": 5340744, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517ec0", + "base": 5340864, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x517f38", + "base": 5340984, + "width": 200, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518000", + "base": 5341184, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518014", + "base": 5341204, + "width": 40, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51803c", + "base": 5341244, + "width": 40, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518068", + "base": 5341288, + "width": 220, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518144", + "base": 5341508, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518148", + "base": 5341512, + "width": 240, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518238", + "base": 5341752, + "width": 240, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518328", + "base": 5341992, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51842c", + "base": 5342252, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518430", + "base": 5342256, + "width": 80, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518480", + "base": 5342336, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51850c", + "base": 5342476, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518510", + "base": 5342480, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518588", + "base": 5342600, + "width": 80, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5185d8", + "base": 5342680, + "width": 100, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51863c", + "base": 5342780, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518640", + "base": 5342784, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5186cc", + "base": 5342924, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5186d0", + "base": 5342928, + "width": 120, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x518748", + "base": 5343048, + "width": 140, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5187d4", + "base": 5343188, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5187e8", + "base": 5343208, + "width": 3000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5193a0", + "base": 5346208, + "width": 2736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a280", + "base": 5350016, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a28c", + "base": 5350028, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a298", + "base": 5350040, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a2a4", + "base": 5350052, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a2b0", + "base": 5350064, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a2bc", + "base": 5350076, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a2c8", + "base": 5350088, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a2d4", + "base": 5350100, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a424", + "base": 5350436, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51a440", + "base": 5350464, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51aab0", + "base": 5352112, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51aac8", + "base": 5352136, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51aae0", + "base": 5352160, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ab94", + "base": 5352340, + "width": 28, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51abb0", + "base": 5352368, + "width": 16, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51abc0", + "base": 5352384, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51abd4", + "base": 5352404, + "width": 28, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51abf0", + "base": 5352432, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51abfc", + "base": 5352444, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ac10", + "base": 5352464, + "width": 28, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ac2c", + "base": 5352492, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ac48", + "base": 5352520, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ac5c", + "base": 5352540, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ac74", + "base": 5352564, + "width": 16, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ad18", + "base": 5352728, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51baf8", + "base": 5356280, + "width": 256, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ca14", + "base": 5360148, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ce70", + "base": 5361264, + "width": 28, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ce8c", + "base": 5361292, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ce98", + "base": 5361304, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51cea0", + "base": 5361312, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51ced0", + "base": 5361360, + "width": 3996, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x51f2a8", + "base": 5370536, + "width": 19980, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5240d0", + "base": 5390544, + "width": 1000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x52e4c0", + "base": 5432512, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x52e5c8", + "base": 5432776, + "width": 3996, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x0", + "base": 5436776, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x4", + "base": 5436780, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x8", + "base": 5436784, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0xc", + "base": 5436788, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x10", + "base": 5436792, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x14", + "base": 5436796, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x18", + "base": 5436800, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x1c", + "base": 5436804, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x20", + "base": 5436808, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x24", + "base": 5436812, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x28", + "base": 5436816, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x2c", + "base": 5436820, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x30", + "base": 5436824, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x34", + "base": 5436828, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x38", + "base": 5436832, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "image table: +0x3c", + "base": 5436836, + "width": 4, + "stride": 64, + "count": 5000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x57eb68", + "base": 5761896, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x57eb70", + "base": 5761904, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x57eee0", + "base": 5762784, + "width": 432, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x57f090", + "base": 5763216, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x57f0b4", + "base": 5763252, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x57f0d8", + "base": 5763288, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x57f0f0", + "base": 5763312, + "width": 66396, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x58f44c", + "base": 5829708, + "width": 29604, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5967f8", + "base": 5859320, + "width": 144, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x596a18", + "base": 5859864, + "width": 48, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x596a18 +0x30", + "base": 5859912, + "width": 12, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x596a18 +0x3c", + "base": 5859924, + "width": 196, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597208", + "base": 5861896, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x59720c", + "base": 5861900, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597210", + "base": 5861904, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597214", + "base": 5861908, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597218", + "base": 5861912, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x59721c", + "base": 5861916, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597220", + "base": 5861920, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597224", + "base": 5861924, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597228", + "base": 5861928, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x59722c", + "base": 5861932, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597230", + "base": 5861936, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597234", + "base": 5861940, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x597238", + "base": 5861944, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x59723c", + "base": 5861948, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5973a0", + "base": 5862304, + "width": 8192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5993c4", + "base": 5870532, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x5993d4", + "base": 5870548, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x59c6c0", + "base": 5883584, + "width": 1024, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x59cb5c", + "base": 5884764, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x59cb60", + "base": 5884768, + "width": 256, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x59cc78", + "base": 5885048, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x59cc80", + "base": 5885056, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x0", + "base": 5885096, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x4", + "base": 5885100, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x8", + "base": 5885104, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xc", + "base": 5885108, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a sprite pointer: the game takes the address of a sprite; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x10", + "base": 5885112, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x14", + "base": 5885116, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x18", + "base": 5885120, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x1c", + "base": 5885124, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x20", + "base": 5885128, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x24", + "base": 5885132, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x28", + "base": 5885136, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x2c", + "base": 5885140, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x30", + "base": 5885144, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x34", + "base": 5885148, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x38", + "base": 5885152, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x3c", + "base": 5885156, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x40", + "base": 5885160, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x44", + "base": 5885164, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x48", + "base": 5885168, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x4c", + "base": 5885172, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x50", + "base": 5885176, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x54", + "base": 5885180, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x58", + "base": 5885184, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x5c", + "base": 5885188, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x60", + "base": 5885192, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x64", + "base": 5885196, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x68", + "base": 5885200, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x6c", + "base": 5885204, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x70", + "base": 5885208, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x74", + "base": 5885212, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x78", + "base": 5885216, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x7c", + "base": 5885220, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x80", + "base": 5885224, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x84", + "base": 5885228, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x88", + "base": 5885232, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x8c", + "base": 5885236, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x90", + "base": 5885240, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x94", + "base": 5885244, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x98", + "base": 5885248, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x9c", + "base": 5885252, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xa0", + "base": 5885256, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xa4", + "base": 5885260, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xa8", + "base": 5885264, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xac", + "base": 5885268, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xb0", + "base": 5885272, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xb4", + "base": 5885276, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xb8", + "base": 5885280, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xbc", + "base": 5885284, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xc0", + "base": 5885288, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back; differs between elements: ignored, shadow", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xc4", + "base": 5885292, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "differs between elements: allowed, ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xc8", + "base": 5885296, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "differs between elements: allowed, ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xcc", + "base": 5885300, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "differs between elements: allowed, ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xd0", + "base": 5885304, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "differs between elements: allowed, ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xd4", + "base": 5885308, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back; differs between elements: ignored, shadow", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xd8", + "base": 5885312, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "differs between elements: allowed, ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xdc", + "base": 5885316, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xe0", + "base": 5885320, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xe4", + "base": 5885324, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xe8", + "base": 5885328, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xec", + "base": 5885332, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xf0", + "base": 5885336, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xf4", + "base": 5885340, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xf8", + "base": 5885344, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0xfc", + "base": 5885348, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x100", + "base": 5885352, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x104", + "base": 5885356, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x108", + "base": 5885360, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x10c", + "base": 5885364, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x110", + "base": 5885368, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x114", + "base": 5885372, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x118", + "base": 5885376, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x11c", + "base": 5885380, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x120", + "base": 5885384, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x124", + "base": 5885388, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x128", + "base": 5885392, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x12c", + "base": 5885396, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x130", + "base": 5885400, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x134", + "base": 5885404, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x138", + "base": 5885408, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x13c", + "base": 5885412, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x140", + "base": 5885416, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x144", + "base": 5885420, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x148", + "base": 5885424, + "width": 4, + "stride": 336, + "count": 1700, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "unit table: +0x14c", + "base": 5885428, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6283f0", + "base": 6456304, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6283f8", + "base": 6456312, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6283fc", + "base": 6456316, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628400", + "base": 6456320, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628404", + "base": 6456324, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628408", + "base": 6456328, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62840c", + "base": 6456332, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628410", + "base": 6456336, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628414", + "base": 6456340, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628418", + "base": 6456344, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62841c", + "base": 6456348, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628420", + "base": 6456352, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628424", + "base": 6456356, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628428", + "base": 6456360, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62842c", + "base": 6456364, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628430", + "base": 6456368, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628434", + "base": 6456372, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628438", + "base": 6456376, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62843c", + "base": 6456380, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628444", + "base": 6456388, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628448", + "base": 6456392, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628454", + "base": 6456404, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628458", + "base": 6456408, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628470", + "base": 6456432, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62848c", + "base": 6456460, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628494", + "base": 6456468, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628498", + "base": 6456472, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284a8", + "base": 6456488, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284ac", + "base": 6456492, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284b8", + "base": 6456504, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284bc", + "base": 6456508, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284c0", + "base": 6456512, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284c4", + "base": 6456516, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284c8", + "base": 6456520, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284cc", + "base": 6456524, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284d0", + "base": 6456528, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284d4", + "base": 6456532, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284d8", + "base": 6456536, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284dc", + "base": 6456540, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284e0", + "base": 6456544, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284e4", + "base": 6456548, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284e8", + "base": 6456552, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284ec", + "base": 6456556, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284f0", + "base": 6456560, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284f4", + "base": 6456564, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284f8", + "base": 6456568, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6284fc", + "base": 6456572, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628500", + "base": 6456576, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628504", + "base": 6456580, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628508", + "base": 6456584, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62850c", + "base": 6456588, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628510", + "base": 6456592, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628514", + "base": 6456596, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628518", + "base": 6456600, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62851c", + "base": 6456604, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628520", + "base": 6456608, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628524", + "base": 6456612, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628528", + "base": 6456616, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62852c", + "base": 6456620, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628530", + "base": 6456624, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628534", + "base": 6456628, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628538", + "base": 6456632, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62853c", + "base": 6456636, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628540", + "base": 6456640, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628544", + "base": 6456644, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628548", + "base": 6456648, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62854c", + "base": 6456652, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628550", + "base": 6456656, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628554", + "base": 6456660, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628558", + "base": 6456664, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62855c", + "base": 6456668, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628560", + "base": 6456672, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628564", + "base": 6456676, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628568", + "base": 6456680, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62856c", + "base": 6456684, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628570", + "base": 6456688, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628574", + "base": 6456692, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628578", + "base": 6456696, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62857c", + "base": 6456700, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628580", + "base": 6456704, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628584", + "base": 6456708, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628588", + "base": 6456712, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62858c", + "base": 6456716, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628590", + "base": 6456720, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628594", + "base": 6456724, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628598", + "base": 6456728, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62859c", + "base": 6456732, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285a0", + "base": 6456736, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285a4", + "base": 6456740, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285a8", + "base": 6456744, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285ac", + "base": 6456748, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285b0", + "base": 6456752, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285b4", + "base": 6456756, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285b8", + "base": 6456760, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285bc", + "base": 6456764, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285c0", + "base": 6456768, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285c4", + "base": 6456772, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285c8", + "base": 6456776, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285cc", + "base": 6456780, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285d0", + "base": 6456784, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285d4", + "base": 6456788, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285d8", + "base": 6456792, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285dc", + "base": 6456796, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285e0", + "base": 6456800, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285e4", + "base": 6456804, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285e8", + "base": 6456808, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285ec", + "base": 6456812, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285f0", + "base": 6456816, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285f4", + "base": 6456820, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285f8", + "base": 6456824, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6285fc", + "base": 6456828, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628600", + "base": 6456832, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628604", + "base": 6456836, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628608", + "base": 6456840, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62860c", + "base": 6456844, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628610", + "base": 6456848, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628614", + "base": 6456852, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628618", + "base": 6456856, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62861c", + "base": 6456860, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628620", + "base": 6456864, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628624", + "base": 6456868, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628628", + "base": 6456872, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62862c", + "base": 6456876, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628630", + "base": 6456880, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628634", + "base": 6456884, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628638", + "base": 6456888, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62863c", + "base": 6456892, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628640", + "base": 6456896, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628644", + "base": 6456900, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628648", + "base": 6456904, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62864c", + "base": 6456908, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628650", + "base": 6456912, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628654", + "base": 6456916, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628658", + "base": 6456920, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x62865c", + "base": 6456924, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628660", + "base": 6456928, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x628664", + "base": 6456932, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x629c90", + "base": 6462608, + "width": 256, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "sprite table: +0x0", + "base": 6462872, + "width": 4, + "stride": 36, + "count": 2500, + "read": "allowed", + "write": "allowed", + "note": "a sprite pointer: the game takes the address of a sprite; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "sprite table: +0x4", + "base": 6462876, + "width": 4, + "stride": 36, + "count": 2500, + "read": "allowed", + "write": "allowed", + "note": "a sprite pointer: the game takes the address of a sprite; anything else is kept beside it and only read back", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "sprite table: +0x8", + "base": 6462880, + "width": 4, + "stride": 36, + "count": 2500, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; differs between elements: allowed, shadow", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "sprite table: +0xc", + "base": 6462884, + "width": 4, + "stride": 36, + "count": 2500, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "sprite table: +0x10", + "base": 6462888, + "width": 4, + "stride": 36, + "count": 2500, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "sprite table: +0x14", + "base": 6462892, + "width": 4, + "stride": 36, + "count": 2500, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "sprite table: +0x18", + "base": 6462896, + "width": 4, + "stride": 36, + "count": 2500, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "sprite table: +0x1c", + "base": 6462900, + "width": 4, + "stride": 36, + "count": 2500, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "sprite table: +0x20", + "base": 6462904, + "width": 4, + "stride": 36, + "count": 2500, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x63fe30", + "base": 6553136, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x63fe34", + "base": 6553140, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x63fed8", + "base": 6553304, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x63ff40", + "base": 6553408, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x63ff78", + "base": 6553464, + "width": 828, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6402b8", + "base": 6554296, + "width": 656, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x640550", + "base": 6554960, + "width": 796, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x64086c", + "base": 6555756, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x640b58", + "base": 6556504, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x640b5c", + "base": 6556508, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x640b60", + "base": 6556512, + "width": 2836, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x64169c", + "base": 6559388, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "order queue: +0x0", + "base": 6559392, + "width": 4, + "stride": 20, + "count": 2000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "order queue: +0x4", + "base": 6559396, + "width": 4, + "stride": 20, + "count": 2000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "order queue: +0x8", + "base": 6559400, + "width": 4, + "stride": 20, + "count": 2000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "order queue: +0xc", + "base": 6559404, + "width": 4, + "stride": 20, + "count": 2000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "order queue: +0x10", + "base": 6559408, + "width": 4, + "stride": 20, + "count": 2000, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x64b2e0", + "base": 6599392, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x64b2e4", + "base": 6599396, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x0", + "base": 6599400, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x4", + "base": 6599404, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x8", + "base": 6599408, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0xc", + "base": 6599412, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x10", + "base": 6599416, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x14", + "base": 6599420, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x18", + "base": 6599424, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x1c", + "base": 6599428, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x20", + "base": 6599432, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x24", + "base": 6599436, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x28", + "base": 6599440, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x2c", + "base": 6599444, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x30", + "base": 6599448, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x34", + "base": 6599452, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x38", + "base": 6599456, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x3c", + "base": 6599460, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x40", + "base": 6599464, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x44", + "base": 6599468, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x48", + "base": 6599472, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x4c", + "base": 6599476, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x50", + "base": 6599480, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x54", + "base": 6599484, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x58", + "base": 6599488, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x5c", + "base": 6599492, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x60", + "base": 6599496, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x64", + "base": 6599500, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x68", + "base": 6599504, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "bullet table: +0x6c", + "base": 6599508, + "width": 4, + "stride": 112, + "count": 100, + "read": "allowed", + "write": "ignored", + "note": "kept beside the game: read back, never used by it; tried on elements not in use", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x64debc", + "base": 6610620, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x64eed8", + "base": 6614744, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x64eedc", + "base": 6614748, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6509a0", + "base": 6621600, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6509b0", + "base": 6621616, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x654880", + "base": 6637696, + "width": 512, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x654aa0", + "base": 6638240, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6556e4", + "base": 6641380, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655700", + "base": 6641408, + "width": 64, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655740", + "base": 6641472, + "width": 124, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6557bc", + "base": 6641596, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6557c0", + "base": 6641600, + "width": 124, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65583c", + "base": 6641724, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655840", + "base": 6641728, + "width": 124, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6558c0", + "base": 6641856, + "width": 124, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655940", + "base": 6641984, + "width": 124, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6559bc", + "base": 6642108, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6559c0", + "base": 6642112, + "width": 124, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655a40", + "base": 6642240, + "width": 124, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655ac0", + "base": 6642368, + "width": 124, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655b3c", + "base": 6642492, + "width": 64, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655b7c", + "base": 6642556, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655b80", + "base": 6642560, + "width": 124, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655bfc", + "base": 6642684, + "width": 64, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655c58", + "base": 6642776, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x655e80", + "base": 6643328, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656198", + "base": 6644120, + "width": 88, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6561f0", + "base": 6644208, + "width": 88, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656248", + "base": 6644296, + "width": 88, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6562a0", + "base": 6644384, + "width": 88, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6562f8", + "base": 6644472, + "width": 88, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656350", + "base": 6644560, + "width": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65637c", + "base": 6644604, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656380", + "base": 6644608, + "width": 88, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6563d8", + "base": 6644696, + "width": 88, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656430", + "base": 6644784, + "width": 88, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656488", + "base": 6644872, + "width": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6564b4", + "base": 6644916, + "width": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6564e0", + "base": 6644960, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656568", + "base": 6645096, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656670", + "base": 6645360, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6566f4", + "base": 6645492, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6566f8", + "base": 6645496, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65677c", + "base": 6645628, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656780", + "base": 6645632, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656884", + "base": 6645892, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656888", + "base": 6645896, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65698c", + "base": 6646156, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656990", + "base": 6646160, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656a14", + "base": 6646292, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656a18", + "base": 6646296, + "width": 520, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656c20", + "base": 6646816, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656ca4", + "base": 6646948, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656ca8", + "base": 6646952, + "width": 520, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656eb0", + "base": 6647472, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656fb4", + "base": 6647732, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x656fb8", + "base": 6647736, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65703c", + "base": 6647868, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657040", + "base": 6647872, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6570c4", + "base": 6648004, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6570c8", + "base": 6648008, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6571cc", + "base": 6648268, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6571d0", + "base": 6648272, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657254", + "base": 6648404, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657258", + "base": 6648408, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6572dc", + "base": 6648540, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6572e0", + "base": 6648544, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6573e4", + "base": 6648804, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6573e8", + "base": 6648808, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65746c", + "base": 6648940, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657470", + "base": 6648944, + "width": 520, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657678", + "base": 6649464, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657780", + "base": 6649728, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657888", + "base": 6649992, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65790c", + "base": 6650124, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657910", + "base": 6650128, + "width": 132, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657998", + "base": 6650264, + "width": 260, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657a9c", + "base": 6650524, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x657aa0", + "base": 6650528, + "width": 4096, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65fc18", + "base": 6683672, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65fd00", + "base": 6683904, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65fec8", + "base": 6684360, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x65ffb0", + "base": 6684592, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x660178", + "base": 6685048, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x660260", + "base": 6685280, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x660428", + "base": 6685736, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6605f0", + "base": 6686192, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6606d8", + "base": 6686424, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6607c0", + "base": 6686656, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x660988", + "base": 6687112, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x660a70", + "base": 6687344, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x660c38", + "base": 6687800, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x660e00", + "base": 6688256, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x660fc8", + "base": 6688712, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6610ac", + "base": 6688940, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6610b0", + "base": 6688944, + "width": 912, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x661440", + "base": 6689856, + "width": 212, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x661514", + "base": 6690068, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x661518", + "base": 6690072, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6616e0", + "base": 6690528, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6617c4", + "base": 6690756, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6617c8", + "base": 6690760, + "width": 1824, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x661ee8", + "base": 6692584, + "width": 212, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x661fbc", + "base": 6692796, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x661fc0", + "base": 6692800, + "width": 212, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662094", + "base": 6693012, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662098", + "base": 6693016, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66217c", + "base": 6693244, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662180", + "base": 6693248, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662264", + "base": 6693476, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662268", + "base": 6693480, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662350", + "base": 6693712, + "width": 912, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6626e0", + "base": 6694624, + "width": 384, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662860", + "base": 6695008, + "width": 912, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662bf0", + "base": 6695920, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662db8", + "base": 6696376, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662e9c", + "base": 6696604, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662ea0", + "base": 6696608, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662f84", + "base": 6696836, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x662f88", + "base": 6696840, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663150", + "base": 6697296, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663234", + "base": 6697524, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663238", + "base": 6697528, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66331c", + "base": 6697756, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663320", + "base": 6697760, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663404", + "base": 6697988, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663408", + "base": 6697992, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6635d0", + "base": 6698448, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6636b4", + "base": 6698676, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6636b8", + "base": 6698680, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66379c", + "base": 6698908, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6637a0", + "base": 6698912, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663884", + "base": 6699140, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663888", + "base": 6699144, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663a50", + "base": 6699600, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663b38", + "base": 6699832, + "width": 212, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663c0c", + "base": 6700044, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663c10", + "base": 6700048, + "width": 212, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663ce4", + "base": 6700260, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663ce8", + "base": 6700264, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663dcc", + "base": 6700492, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663dd0", + "base": 6700496, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x663eb8", + "base": 6700728, + "width": 456, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664080", + "base": 6701184, + "width": 912, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664410", + "base": 6702096, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6644f4", + "base": 6702324, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6644f8", + "base": 6702328, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6645dc", + "base": 6702556, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6645e0", + "base": 6702560, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6646c4", + "base": 6702788, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6646c8", + "base": 6702792, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6647ac", + "base": 6703020, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6647b0", + "base": 6703024, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664894", + "base": 6703252, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664898", + "base": 6703256, + "width": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66497c", + "base": 6703484, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664980", + "base": 6703488, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664a40", + "base": 6703680, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664b00", + "base": 6703872, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664bc0", + "base": 6704064, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664c80", + "base": 6704256, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664d40", + "base": 6704448, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664e00", + "base": 6704640, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x664ec0", + "base": 6704832, + "width": 380, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665040", + "base": 6705216, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665100", + "base": 6705408, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6651c0", + "base": 6705600, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665280", + "base": 6705792, + "width": 380, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665400", + "base": 6706176, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6654c0", + "base": 6706368, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665580", + "base": 6706560, + "width": 380, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665700", + "base": 6706944, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6657c0", + "base": 6707136, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665880", + "base": 6707328, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665940", + "base": 6707520, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665a00", + "base": 6707712, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665ac0", + "base": 6707904, + "width": 388, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665c48", + "base": 6708296, + "width": 520, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665e50", + "base": 6708816, + "width": 388, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x665fd8", + "base": 6709208, + "width": 388, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x666160", + "base": 6709600, + "width": 1028, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x666160 +0x404", + "base": 6710628, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x666160 +0x408", + "base": 6710632, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x666570", + "base": 6710640, + "width": 520, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x666778", + "base": 6711160, + "width": 3996, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x667718", + "base": 6715160, + "width": 1000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x667b00", + "base": 6716160, + "width": 3996, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x668aa0", + "base": 6720160, + "width": 3996, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x669a40", + "base": 6724160, + "width": 1000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x669e28", + "base": 6725160, + "width": 1000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66a210", + "base": 6726160, + "width": 3996, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66b1b0", + "base": 6730160, + "width": 3996, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66c150", + "base": 6734160, + "width": 1000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66c538", + "base": 6735160, + "width": 3996, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66d4d8", + "base": 6739160, + "width": 1000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66d8c0", + "base": 6740160, + "width": 3996, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66e860", + "base": 6744160, + "width": 1000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66ec48", + "base": 6745160, + "width": 3748, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66ec48 +0xea4", + "base": 6748908, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66ec48 +0xea8", + "base": 6748912, + "width": 244, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66fd0c", + "base": 6749452, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x66ff78", + "base": 6750072, + "width": 27200, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68ac74", + "base": 6859892, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68ac8c", + "base": 6859916, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c104", + "base": 6865156, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c108", + "base": 6865160, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c10c", + "base": 6865164, + "width": 52, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c140", + "base": 6865216, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c144", + "base": 6865220, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c148", + "base": 6865224, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c14c", + "base": 6865228, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c1e0", + "base": 6865376, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c1f0", + "base": 6865392, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c1f4", + "base": 6865396, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c1fc", + "base": 6865404, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c204", + "base": 6865412, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c208", + "base": 6865416, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c224", + "base": 6865444, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c234", + "base": 6865460, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x68c238", + "base": 6865464, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6bb210", + "base": 7057936, + "width": 1824, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6bd3d0", + "base": 7066576, + "width": 6804, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6c9858", + "base": 7116888, + "width": 212, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6c9930", + "base": 7117104, + "width": 836, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6c9c78", + "base": 7117944, + "width": 420, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6c9e20", + "base": 7118368, + "width": 212, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6c9ef8", + "base": 7118584, + "width": 836, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ca240", + "base": 7119424, + "width": 212, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ca318", + "base": 7119640, + "width": 420, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ca4bc", + "base": 7120060, + "width": 48, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ca4ec", + "base": 7120108, + "width": 48, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ca51c", + "base": 7120156, + "width": 48, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ca550", + "base": 7120208, + "width": 252, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ca64c", + "base": 7120460, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ca94c", + "base": 7121228, + "width": 48, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6caf98", + "base": 7122840, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6cafa0", + "base": 7122848, + "width": 10752, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6cd9a0", + "base": 7133600, + "width": 516, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6cdba8", + "base": 7134120, + "width": 516, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6cddc0", + "base": 7134656, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6cddc4", + "base": 7134660, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6cdfd4", + "base": 7135188, + "width": 36, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ce000", + "base": 7135232, + "width": 192, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ce320", + "base": 7136032, + "width": 1024, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ce720", + "base": 7137056, + "width": 1024, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ceb20", + "base": 7138080, + "width": 28, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ceff0", + "base": 7139312, + "width": 8, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ceff8", + "base": 7139320, + "width": 1200, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d0f14", + "base": 7147284, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d0f24", + "base": 7147300, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d0f30", + "base": 7147312, + "width": 636, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200", + "base": 7148032, + "width": 52, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x34", + "base": 7148084, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1238", + "base": 7148088, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x3c", + "base": 7148092, + "width": 36, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x60", + "base": 7148128, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1260", + "base": 7148128, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x64", + "base": 7148132, + "width": 18380, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4830", + "base": 7166512, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d5a30", + "base": 7166512, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4834", + "base": 7166516, + "width": 56, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x486c", + "base": 7166572, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4870", + "base": 7166576, + "width": 616, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4ad8", + "base": 7167192, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d5cd8", + "base": 7167192, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4adc", + "base": 7167196, + "width": 312, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4c14", + "base": 7167508, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d5e14", + "base": 7167508, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4c18", + "base": 7167512, + "width": 4, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4c1c", + "base": 7167516, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4c20", + "base": 7167520, + "width": 168, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4cc8", + "base": 7167688, + "width": 4, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d5ec8", + "base": 7167688, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x4ccc", + "base": 7167692, + "width": 1224, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x5194", + "base": 7168916, + "width": 24, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d6394", + "base": 7168916, + "width": 24, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6d1200 +0x51ac", + "base": 7168940, + "width": 19576, + "read": "ends", + "write": "ends", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6de000", + "base": 7200768, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6de4c0", + "base": 7201984, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6de980", + "base": 7203200, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ef320", + "base": 7271200, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x6ef7e0", + "base": 7272416, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x700180", + "base": 7340416, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x700640", + "base": 7341632, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x710fe0", + "base": 7409632, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7114a0", + "base": 7410848, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x721e40", + "base": 7478848, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x722300", + "base": 7480064, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x732ca0", + "base": 7548064, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x733160", + "base": 7549280, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x743b00", + "base": 7617280, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x743fc0", + "base": 7618496, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x754960", + "base": 7686496, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x754e20", + "base": 7687712, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7657c0", + "base": 7755712, + "width": 1216, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x765c80", + "base": 7756928, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x776620", + "base": 7824928, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x776900", + "base": 7825664, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x776be0", + "base": 7826400, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x787580", + "base": 7894400, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x787860", + "base": 7895136, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x798200", + "base": 7963136, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7984e0", + "base": 7963872, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7a8e80", + "base": 8031872, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7a9160", + "base": 8032608, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7b9b00", + "base": 8100608, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7b9de0", + "base": 8101344, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7ca780", + "base": 8169344, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7caa60", + "base": 8170080, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7db400", + "base": 8238080, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7db6e0", + "base": 8238816, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7ec080", + "base": 8306816, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7ec360", + "base": 8307552, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7fcd00", + "base": 8375552, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x7fcfe0", + "base": 8376288, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x80d980", + "base": 8444288, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x80dc60", + "base": 8445024, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x81e600", + "base": 8513024, + "width": 736, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x81e8e0", + "base": 8513760, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x82f280", + "base": 8581760, + "width": 796, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x82f5a0", + "base": 8582560, + "width": 796, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x82f8c0", + "base": 8583360, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x840260", + "base": 8651360, + "width": 796, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x840580", + "base": 8652160, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x850f20", + "base": 8720160, + "width": 796, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x851240", + "base": 8720960, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x861be0", + "base": 8788960, + "width": 796, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x861f00", + "base": 8789760, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8728a0", + "base": 8857760, + "width": 796, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x872bc0", + "base": 8858560, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x883560", + "base": 8926560, + "width": 796, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x883880", + "base": 8927360, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x894220", + "base": 8995360, + "width": 796, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x894540", + "base": 8996160, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8a4ee0", + "base": 9064160, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8a50a0", + "base": 9064608, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8a5260", + "base": 9065056, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8b5c00", + "base": 9133056, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8b5dc0", + "base": 9133504, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8c6760", + "base": 9201504, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8c6920", + "base": 9201952, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8d72c0", + "base": 9269952, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8d7480", + "base": 9270400, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8e7e20", + "base": 9338400, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8e7fe0", + "base": 9338848, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8f8980", + "base": 9406848, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x8f8b40", + "base": 9407296, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9094e0", + "base": 9475296, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9096a0", + "base": 9475744, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x91a040", + "base": 9543744, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x91a200", + "base": 9544192, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x92aba0", + "base": 9612192, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x92ad60", + "base": 9612640, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x93b700", + "base": 9680640, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x93b8c0", + "base": 9681088, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x94c260", + "base": 9749088, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x94c420", + "base": 9749536, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x95cdc0", + "base": 9817536, + "width": 440, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x95cf80", + "base": 9817984, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x96d920", + "base": 9885984, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x96d9e0", + "base": 9886176, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x96daa0", + "base": 9886368, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x97e440", + "base": 9954368, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x97e500", + "base": 9954560, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x98eea0", + "base": 10022560, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x98ef60", + "base": 10022752, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x99f900", + "base": 10090752, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x99f9c0", + "base": 10090944, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9b0360", + "base": 10158944, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9b0420", + "base": 10159136, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9c0dc0", + "base": 10227136, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9c0e80", + "base": 10227328, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9d1820", + "base": 10295328, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9d18e0", + "base": 10295520, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9e2280", + "base": 10363520, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9e2340", + "base": 10363712, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9f2ce0", + "base": 10431712, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x9f2da0", + "base": 10431904, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa03740", + "base": 10499904, + "width": 184, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa03800", + "base": 10500096, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa141a0", + "base": 10568096, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa141b0", + "base": 10568112, + "width": 272, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa142c0", + "base": 10568384, + "width": 272, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa143d0", + "base": 10568656, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa24d70", + "base": 10636656, + "width": 272, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa24e80", + "base": 10636928, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa35820", + "base": 10704928, + "width": 272, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa35930", + "base": 10705200, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa462d0", + "base": 10773200, + "width": 272, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa463e0", + "base": 10773472, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa56d80", + "base": 10841472, + "width": 272, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa56e90", + "base": 10841744, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa67830", + "base": 10909744, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa67840", + "base": 10909760, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa67850", + "base": 10909776, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa67860", + "base": 10909792, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa67870", + "base": 10909808, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xa67880", + "base": 10909824, + "width": 301056, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xab1080", + "base": 11210880, + "width": 131072, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xad1080", + "base": 11341952, + "width": 131072, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xaf1080", + "base": 11473024, + "width": 262144, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xb31080", + "base": 11735168, + "width": 3145728, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xe31080", + "base": 14880896, + "width": 204800, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xe63080", + "base": 15085696, + "width": 81920, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xe77080", + "base": 15167616, + "width": 48, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0xe770b0", + "base": 15167664, + "width": 4828, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1505adf8", + "base": 352693752, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1505e670", + "base": 352708208, + "width": 1024, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19018f38", + "base": 419532600, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x190452c8", + "base": 419713736, + "width": 128, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19046134", + "base": 419717428, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19046138", + "base": 419717432, + "width": 256, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x190913d8", + "base": 420025304, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x190913e0", + "base": 420025312, + "width": 306904, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x190dc2c0", + "base": 420332224, + "width": 48716, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x190e8110", + "base": 420380944, + "width": 51208, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x190f4920", + "base": 420432160, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x190f4930", + "base": 420432176, + "width": 5000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x190f5cc0", + "base": 420437184, + "width": 5000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x190f7050", + "base": 420442192, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191079f0", + "base": 420510192, + "width": 5000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19108d80", + "base": 420515200, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19119720", + "base": 420583200, + "width": 5000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1911aab0", + "base": 420588208, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1912b450", + "base": 420656208, + "width": 5000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1912c7e0", + "base": 420661216, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1913d180", + "base": 420729216, + "width": 5000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1913e510", + "base": 420734224, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1914eeb0", + "base": 420802224, + "width": 5000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19150240", + "base": 420807232, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19160be0", + "base": 420875232, + "width": 5000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19161f70", + "base": 420880240, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19172910", + "base": 420948240, + "width": 5000, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19173ca0", + "base": 420953248, + "width": 68000, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19184640", + "base": 421021248, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19184650", + "base": 421021264, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19184660", + "base": 421021280, + "width": 64864, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191943c0", + "base": 421086144, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191943c8", + "base": 421086152, + "width": 143004, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191b7270", + "base": 421229168, + "width": 29244, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191be4b0", + "base": 421258416, + "width": 86624, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191d3710", + "base": 421345040, + "width": 51032, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191dfe70", + "base": 421396080, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191dfe70 +0x8", + "base": 421396088, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e07e0", + "base": 421398496, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e07e0 +0x8", + "base": 421398504, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e1150", + "base": 421400912, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e1150 +0x8", + "base": 421400920, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e1ac0", + "base": 421403328, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e1ac0 +0x8", + "base": 421403336, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e2430", + "base": 421405744, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e2430 +0x8", + "base": 421405752, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e2da0", + "base": 421408160, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e2da0 +0x8", + "base": 421408168, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e3710", + "base": 421410576, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e3710 +0x8", + "base": 421410584, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e4080", + "base": 421412992, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e4080 +0x8", + "base": 421413000, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e49f0", + "base": 421415408, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e49f0 +0x8", + "base": 421415416, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e5360", + "base": 421417824, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e5360 +0x8", + "base": 421417832, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e5cd0", + "base": 421420240, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e5cd0 +0x8", + "base": 421420248, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e6640", + "base": 421422656, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e6640 +0x8", + "base": 421422664, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e6fb0", + "base": 421425072, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e6fb0 +0x8", + "base": 421425080, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e7920", + "base": 421427488, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e7920 +0x8", + "base": 421427496, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e8290", + "base": 421429904, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e8290 +0x8", + "base": 421429912, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e8c00", + "base": 421432320, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e8c00 +0x8", + "base": 421432328, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e9570", + "base": 421434736, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e9570 +0x8", + "base": 421434744, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e9ee0", + "base": 421437152, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191e9ee0 +0x8", + "base": 421437160, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ea850", + "base": 421439568, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ea850 +0x8", + "base": 421439576, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191eb1c0", + "base": 421441984, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191eb1c0 +0x8", + "base": 421441992, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ebb30", + "base": 421444400, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ebb30 +0x8", + "base": 421444408, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ec4a0", + "base": 421446816, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ec4a0 +0x8", + "base": 421446824, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ece10", + "base": 421449232, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ece10 +0x8", + "base": 421449240, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ed780", + "base": 421451648, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ed780 +0x8", + "base": 421451656, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ee0f0", + "base": 421454064, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ee0f0 +0x8", + "base": 421454072, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191eea60", + "base": 421456480, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191eea60 +0x8", + "base": 421456488, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ef3d0", + "base": 421458896, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ef3d0 +0x8", + "base": 421458904, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191efd40", + "base": 421461312, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191efd40 +0x8", + "base": 421461320, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f06b0", + "base": 421463728, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f06b0 +0x8", + "base": 421463736, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f1020", + "base": 421466144, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f1020 +0x8", + "base": 421466152, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f1990", + "base": 421468560, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f1990 +0x8", + "base": 421468568, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f2300", + "base": 421470976, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f2300 +0x8", + "base": 421470984, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f2c70", + "base": 421473392, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f2c70 +0x8", + "base": 421473400, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f35e0", + "base": 421475808, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f35e0 +0x8", + "base": 421475816, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f3f50", + "base": 421478224, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f3f50 +0x8", + "base": 421478232, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f48c0", + "base": 421480640, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f48c0 +0x8", + "base": 421480648, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f5230", + "base": 421483056, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f5230 +0x8", + "base": 421483064, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f5ba0", + "base": 421485472, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f5ba0 +0x8", + "base": 421485480, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f6510", + "base": 421487888, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f6510 +0x8", + "base": 421487896, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f6e80", + "base": 421490304, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f6e80 +0x8", + "base": 421490312, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f77f0", + "base": 421492720, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f77f0 +0x8", + "base": 421492728, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f8160", + "base": 421495136, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f8160 +0x8", + "base": 421495144, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f8ad0", + "base": 421497552, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f8ad0 +0x8", + "base": 421497560, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f9440", + "base": 421499968, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f9440 +0x8", + "base": 421499976, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f9db0", + "base": 421502384, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191f9db0 +0x8", + "base": 421502392, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fa720", + "base": 421504800, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fa720 +0x8", + "base": 421504808, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fb090", + "base": 421507216, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fb090 +0x8", + "base": 421507224, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fba00", + "base": 421509632, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fba00 +0x8", + "base": 421509640, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fc370", + "base": 421512048, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fc370 +0x8", + "base": 421512056, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fcce0", + "base": 421514464, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fcce0 +0x8", + "base": 421514472, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fd650", + "base": 421516880, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fd650 +0x8", + "base": 421516888, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fdfc0", + "base": 421519296, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fdfc0 +0x8", + "base": 421519304, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fe930", + "base": 421521712, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191fe930 +0x8", + "base": 421521720, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ff2a0", + "base": 421524128, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ff2a0 +0x8", + "base": 421524136, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ffc10", + "base": 421526544, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x191ffc10 +0x8", + "base": 421526552, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19200580", + "base": 421528960, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19200580 +0x8", + "base": 421528968, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19200ef0", + "base": 421531376, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19200ef0 +0x8", + "base": 421531384, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19201860", + "base": 421533792, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19201860 +0x8", + "base": 421533800, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192021d0", + "base": 421536208, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192021d0 +0x8", + "base": 421536216, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19202b40", + "base": 421538624, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19202b40 +0x8", + "base": 421538632, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192034b0", + "base": 421541040, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192034b0 +0x8", + "base": 421541048, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19203e20", + "base": 421543456, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19203e20 +0x8", + "base": 421543464, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19204790", + "base": 421545872, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19204790 +0x8", + "base": 421545880, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19205100", + "base": 421548288, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19205100 +0x8", + "base": 421548296, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19205a70", + "base": 421550704, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19205a70 +0x8", + "base": 421550712, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192063e0", + "base": 421553120, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192063e0 +0x8", + "base": 421553128, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19206d50", + "base": 421555536, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19206d50 +0x8", + "base": 421555544, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192076c0", + "base": 421557952, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192076c0 +0x8", + "base": 421557960, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19208030", + "base": 421560368, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19208030 +0x8", + "base": 421560376, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192089a0", + "base": 421562784, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192089a0 +0x8", + "base": 421562792, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19209310", + "base": 421565200, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19209310 +0x8", + "base": 421565208, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19209c80", + "base": 421567616, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19209c80 +0x8", + "base": 421567624, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920a5f0", + "base": 421570032, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920a5f0 +0x8", + "base": 421570040, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920af60", + "base": 421572448, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920af60 +0x8", + "base": 421572456, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920b8d0", + "base": 421574864, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920b8d0 +0x8", + "base": 421574872, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920c240", + "base": 421577280, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920c240 +0x8", + "base": 421577288, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920cbb0", + "base": 421579696, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920cbb0 +0x8", + "base": 421579704, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920d520", + "base": 421582112, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920d520 +0x8", + "base": 421582120, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920de90", + "base": 421584528, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920de90 +0x8", + "base": 421584536, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920e800", + "base": 421586944, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920e800 +0x8", + "base": 421586952, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920f170", + "base": 421589360, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920f170 +0x8", + "base": 421589368, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920fae0", + "base": 421591776, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1920fae0 +0x8", + "base": 421591784, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19210450", + "base": 421594192, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19210450 +0x8", + "base": 421594200, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19210dc0", + "base": 421596608, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19210dc0 +0x8", + "base": 421596616, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19211730", + "base": 421599024, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19211730 +0x8", + "base": 421599032, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192120a0", + "base": 421601440, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192120a0 +0x8", + "base": 421601448, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19212a10", + "base": 421603856, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19212a10 +0x8", + "base": 421603864, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19213380", + "base": 421606272, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19213380 +0x8", + "base": 421606280, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19213cf0", + "base": 421608688, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19213cf0 +0x8", + "base": 421608696, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19214660", + "base": 421611104, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19214660 +0x8", + "base": 421611112, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19214fd0", + "base": 421613520, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19214fd0 +0x8", + "base": 421613528, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19215940", + "base": 421615936, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19215940 +0x8", + "base": 421615944, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192162b0", + "base": 421618352, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192162b0 +0x8", + "base": 421618360, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19216c20", + "base": 421620768, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19216c20 +0x8", + "base": 421620776, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19217590", + "base": 421623184, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19217590 +0x8", + "base": 421623192, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19217f00", + "base": 421625600, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19217f00 +0x8", + "base": 421625608, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19218870", + "base": 421628016, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19218870 +0x8", + "base": 421628024, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192191e0", + "base": 421630432, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192191e0 +0x8", + "base": 421630440, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19219b50", + "base": 421632848, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19219b50 +0x8", + "base": 421632856, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921a4c0", + "base": 421635264, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921a4c0 +0x8", + "base": 421635272, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921ae30", + "base": 421637680, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921ae30 +0x8", + "base": 421637688, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921b7a0", + "base": 421640096, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921b7a0 +0x8", + "base": 421640104, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921c110", + "base": 421642512, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921c110 +0x8", + "base": 421642520, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921ca80", + "base": 421644928, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921ca80 +0x8", + "base": 421644936, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921d3f0", + "base": 421647344, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921d3f0 +0x8", + "base": 421647352, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921dd60", + "base": 421649760, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921dd60 +0x8", + "base": 421649768, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921e6d0", + "base": 421652176, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921e6d0 +0x8", + "base": 421652184, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921f040", + "base": 421654592, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921f040 +0x8", + "base": 421654600, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921f9b0", + "base": 421657008, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1921f9b0 +0x8", + "base": 421657016, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19220320", + "base": 421659424, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19220320 +0x8", + "base": 421659432, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19220c90", + "base": 421661840, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19220c90 +0x8", + "base": 421661848, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19221600", + "base": 421664256, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19221600 +0x8", + "base": 421664264, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19221f70", + "base": 421666672, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19221f70 +0x8", + "base": 421666680, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192228e0", + "base": 421669088, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192228e0 +0x8", + "base": 421669096, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19223250", + "base": 421671504, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19223250 +0x8", + "base": 421671512, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19223bc0", + "base": 421673920, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19223bc0 +0x8", + "base": 421673928, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19224530", + "base": 421676336, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19224530 +0x8", + "base": 421676344, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19224ea0", + "base": 421678752, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19224ea0 +0x8", + "base": 421678760, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19225810", + "base": 421681168, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19225810 +0x8", + "base": 421681176, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19226180", + "base": 421683584, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19226180 +0x8", + "base": 421683592, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19226af0", + "base": 421686000, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19226af0 +0x8", + "base": 421686008, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19227460", + "base": 421688416, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19227460 +0x8", + "base": 421688424, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19227dd0", + "base": 421690832, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19227dd0 +0x8", + "base": 421690840, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19228740", + "base": 421693248, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19228740 +0x8", + "base": 421693256, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192290b0", + "base": 421695664, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192290b0 +0x8", + "base": 421695672, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19229a20", + "base": 421698080, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x19229a20 +0x8", + "base": 421698088, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1922a390", + "base": 421700496, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1922a390 +0x8", + "base": 421700504, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1922ad00", + "base": 421702912, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1922ad00 +0x8", + "base": 421702920, + "width": 2400, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1922b670", + "base": 421705328, + "width": 32, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1922b690", + "base": 421705360, + "width": 2896, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1922c1e0", + "base": 421708256, + "width": 262144, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1926c1e0", + "base": 421970400, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1926c1e0 +0x8", + "base": 421970408, + "width": 316, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1926c330", + "base": 421970736, + "width": 32, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1926c350", + "base": 421970768, + "width": 14896, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1926fd80", + "base": 421985664, + "width": 65536, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1927fd80", + "base": 422051200, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1927fd80 +0x8", + "base": 422051208, + "width": 316, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1927fed0", + "base": 422051536, + "width": 32, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1927fef0", + "base": 422051568, + "width": 46800, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1928b5c0", + "base": 422098368, + "width": 65536, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1929b5c0", + "base": 422163904, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1929b5c0 +0x8", + "base": 422163912, + "width": 316, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1929b710", + "base": 422164240, + "width": 32, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x1929b730", + "base": 422164272, + "width": 20032, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192a0570", + "base": 422184304, + "width": 32768, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192a8570", + "base": 422217072, + "width": 324, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192a86c0", + "base": 422217408, + "width": 32, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192a86e0", + "base": 422217440, + "width": 48, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192a8710", + "base": 422217488, + "width": 512, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192a8910", + "base": 422218000, + "width": 8, + "read": "allowed", + "write": "ignored", + "note": "read back, but kept beside the game", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + }, + { + "name": "0x192a8910 +0x8", + "base": 422218008, + "width": 316, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Remastered 1.23.10.13515's own EUD emulator, run on a dump of the game (2026-10-16)" + ], + "from": "dump" + } + ], + "outside": { + "verdict": "ends", + "evidence": [ + "no region of Remastered 1.23.10.13515's EUD emulator covers it (a dump of the game, 2026-10-16); the game ends with \"EUD ERROR\" and the address with its bits flipped" + ] + } +} diff --git a/data/remastered-regions.json b/data/remastered-regions.json new file mode 100644 index 0000000..a236ac7 --- /dev/null +++ b/data/remastered-regions.json @@ -0,0 +1 @@ +{"build":"1.23.10.13515","source":"scm-sim-reader remastered/regions.py from the emulator's tree in a dump (2026-10-16)","strings":421086152,"pools":[{"name":"images","kind":57,"base":5436776,"stride":64,"count":5000},{"name":"units","kind":52,"base":5885096,"stride":336,"count":1700},{"name":"kind 0x33","kind":51,"base":6456504,"stride":4,"count":108},{"name":"sprites","kind":62,"base":6462872,"stride":36,"count":2500},{"name":"pool 0x40","kind":64,"base":6559392,"stride":20,"count":2000},{"name":"bullets","kind":56,"base":6599400,"stride":112,"count":100}],"regions":[[2097152,4096,7],[2101248,24576,7],[4317392,4,7],[4352280,4,7],[4356352,4,7],[4357340,4,7],[4526924,4,7],[4526928,4,7],[4588620,4,7],[4753344,4,7],[4755972,4,7],[4796304,4,7],[4864152,4,7],[4864156,4,7],[4954460,4,7],[4969964,4,7],[4969968,4,7],[5073140,4,7],[5085372,4,7],[5190280,4,7],[5190496,4,7],[5234620,4,7],[5236036,4,0],[5241088,12,7],[5295552,257,36],[5300324,4,0],[5317848,28,0],[5318264,4,39],[5318268,4,39],[5318276,4,39],[5318280,4,39],[5318408,76,34],[5318904,160,7],[5323624,49,0],[5325176,1090,0],[5326268,4,0],[5326272,838,0],[5327112,318,0],[5327432,688,0],[5328120,1316,0],[5329436,8,7],[5329576,84,7],[5329660,4,0],[5329664,72,7],[5329736,60,7],[5329796,4,0],[5329800,96,7],[5331704,16,0],[5331720,16,0],[5331736,32,0],[5331768,32,0],[5331800,8,0],[5331808,8,0],[5331816,8,0],[5331824,120,0],[5331944,60,6],[5332004,60,6],[5332064,20,6],[5332084,20,6],[5332104,20,6],[5332124,40,6],[5332164,20,6],[5332184,40,6],[5332224,20,6],[5332244,60,6],[5332304,20,6],[5332324,4,0],[5332328,100,6],[5332432,100,6],[5332532,4,0],[5332536,100,6],[5332636,4,0],[5332640,140,6],[5332780,40,6],[5332820,4,0],[5332824,180,6],[5333004,20,6],[5333024,180,6],[5333204,4,0],[5333208,140,6],[5333348,4,0],[5333352,120,6],[5333472,140,6],[5333612,4,0],[5333616,160,6],[5333776,140,6],[5333916,4,0],[5333920,140,6],[5334060,4,0],[5334064,180,6],[5334244,4,0],[5334248,180,6],[5334428,4,0],[5334432,140,6],[5334572,4,0],[5334576,200,6],[5334776,180,6],[5334956,4,0],[5334960,100,6],[5335060,4,0],[5335064,180,6],[5335244,4,0],[5335248,160,6],[5335408,20,6],[5335428,4,0],[5335432,80,6],[5335512,60,6],[5335572,60,6],[5335632,40,6],[5335672,100,6],[5335772,4,0],[5335776,100,6],[5335876,4,0],[5335880,100,6],[5335980,4,0],[5335984,80,6],[5336064,60,6],[5336124,60,6],[5336184,140,6],[5336324,4,0],[5336328,180,6],[5336508,4,0],[5336512,180,6],[5336692,4,0],[5336696,160,6],[5336856,140,6],[5336996,4,0],[5337000,140,6],[5337140,4,0],[5337144,140,6],[5337284,4,0],[5337288,140,6],[5337428,4,0],[5337432,140,6],[5337572,4,0],[5337576,120,6],[5337696,140,6],[5337836,4,0],[5337840,100,6],[5337940,4,0],[5337944,120,6],[5338064,120,6],[5338184,40,6],[5338224,80,6],[5338304,160,6],[5338464,80,6],[5338544,140,6],[5338684,4,0],[5338688,100,6],[5338788,4,0],[5338792,80,6],[5338872,20,6],[5338892,60,6],[5338952,140,6],[5339092,4,0],[5339096,120,6],[5339216,140,6],[5339356,4,0],[5339360,180,6],[5339540,4,0],[5339544,180,6],[5339724,4,0],[5339728,100,6],[5339828,4,0],[5339832,180,6],[5340012,4,0],[5340016,160,6],[5340176,120,6],[5340296,140,6],[5340436,4,0],[5340440,140,6],[5340580,4,0],[5340584,160,6],[5340744,120,6],[5340864,120,6],[5340984,200,6],[5341184,20,6],[5341204,40,6],[5341244,40,6],[5341288,220,6],[5341508,4,0],[5341512,240,6],[5341752,240,6],[5341992,260,6],[5342252,4,0],[5342256,80,6],[5342336,140,6],[5342476,4,0],[5342480,120,6],[5342600,80,6],[5342680,100,6],[5342780,4,0],[5342784,140,6],[5342924,4,0],[5342928,120,6],[5343048,140,6],[5343188,20,6],[5343208,3000,9],[5346208,2736,0],[5350016,12,4],[5350028,12,4],[5350040,12,4],[5350052,12,4],[5350064,12,4],[5350076,12,4],[5350088,12,4],[5350100,12,4],[5350436,22,7],[5350464,23,7],[5352112,23,7],[5352136,23,7],[5352160,23,7],[5352340,25,7],[5352368,16,7],[5352384,20,7],[5352404,26,7],[5352432,12,7],[5352444,20,0],[5352464,25,7],[5352492,24,7],[5352520,18,7],[5352540,23,7],[5352564,16,7],[5352728,22,7],[5356280,256,7],[5360148,4,0],[5361264,28,0],[5361292,4,0],[5361304,4,0],[5361312,4,0],[5361360,3996,0],[5370536,19980,0],[5390544,999,0],[5432512,4,0],[5432776,3996,0],[5761896,4,58],[5761904,4,58],[5762784,432,8],[5763216,4,0],[5763252,1,0],[5763288,4,0],[5763312,66396,11],[5829708,29604,12],[5859320,141,0],[5859864,255,43],[5861896,4,51],[5861900,4,51],[5861904,4,51],[5861908,4,51],[5861912,4,51],[5861916,4,51],[5861920,4,51],[5861924,4,51],[5861928,4,51],[5861932,4,51],[5861936,4,51],[5861940,4,51],[5861944,4,0],[5861948,1,0],[5862304,8192,7],[5870532,4,26],[5870548,4,1],[5883584,1024,7],[5884764,4,44],[5884768,256,7],[5885048,4,0],[5885056,4,0],[6456304,4,0],[6456312,4,51],[6456316,4,51],[6456320,4,51],[6456324,4,51],[6456328,4,51],[6456332,4,51],[6456336,4,51],[6456340,4,51],[6456344,4,51],[6456348,4,51],[6456352,4,51],[6456356,4,51],[6456360,4,51],[6456364,4,51],[6456368,4,51],[6456372,4,51],[6456376,4,51],[6456380,4,51],[6456388,4,30],[6456392,4,0],[6456404,4,29],[6456408,4,33],[6456432,4,0],[6456460,4,0],[6456468,4,27],[6456472,2,0],[6456488,4,0],[6456492,2,0],[6462608,256,0],[6553136,4,60],[6553140,4,60],[6553304,4,0],[6553408,4,0],[6553464,828,46],[6554296,656,46],[6554960,794,46],[6555756,4,0],[6556504,1,0],[6556508,4,0],[6556512,2834,40],[6559388,4,0],[6599392,4,65],[6599396,4,65],[6610620,4,0],[6614744,4,54],[6614748,4,54],[6621600,2,0],[6621616,4,0],[6637696,512,0],[6638240,4,0],[6641380,4,0],[6641408,61,0],[6641472,122,0],[6641596,4,0],[6641600,122,0],[6641724,4,0],[6641728,122,0],[6641856,122,0],[6641984,122,0],[6642108,4,0],[6642112,122,0],[6642240,122,0],[6642368,122,0],[6642492,61,0],[6642556,4,0],[6642560,122,0],[6642684,61,0],[6642776,440,7],[6643328,440,7],[6644120,88,0],[6644208,88,0],[6644296,88,0],[6644384,88,0],[6644472,88,0],[6644560,44,0],[6644604,4,0],[6644608,88,0],[6644696,88,0],[6644784,88,0],[6644872,44,0],[6644916,44,0],[6644960,130,0],[6645096,260,0],[6645360,130,0],[6645492,4,0],[6645496,130,0],[6645628,4,0],[6645632,260,0],[6645892,4,0],[6645896,260,0],[6646156,4,0],[6646160,130,0],[6646292,4,0],[6646296,520,0],[6646816,130,0],[6646948,4,0],[6646952,520,0],[6647472,260,0],[6647732,4,0],[6647736,130,0],[6647868,4,0],[6647872,130,0],[6648004,4,0],[6648008,260,0],[6648268,4,0],[6648272,130,0],[6648404,4,0],[6648408,130,0],[6648540,4,0],[6648544,260,0],[6648804,4,0],[6648808,130,0],[6648940,4,0],[6648944,520,0],[6649464,260,0],[6649728,260,0],[6649992,130,0],[6650124,4,0],[6650128,130,0],[6650264,260,0],[6650524,1,0],[6650528,4096,7],[6683672,228,0],[6683904,456,0],[6684360,228,0],[6684592,456,0],[6685048,228,0],[6685280,456,0],[6685736,456,0],[6686192,228,0],[6686424,228,0],[6686656,456,0],[6687112,228,0],[6687344,456,0],[6687800,456,0],[6688256,456,0],[6688712,228,0],[6688940,4,0],[6688944,912,0],[6689856,212,0],[6690068,4,0],[6690072,456,0],[6690528,228,0],[6690756,4,0],[6690760,1824,0],[6692584,212,0],[6692796,4,0],[6692800,212,0],[6693012,4,0],[6693016,228,0],[6693244,4,0],[6693248,228,0],[6693476,4,0],[6693480,228,0],[6693712,912,0],[6694624,384,0],[6695008,912,0],[6695920,456,0],[6696376,228,0],[6696604,4,0],[6696608,228,0],[6696836,4,0],[6696840,456,0],[6697296,228,0],[6697524,4,0],[6697528,228,0],[6697756,4,0],[6697760,228,0],[6697988,4,0],[6697992,456,0],[6698448,228,0],[6698676,4,0],[6698680,228,0],[6698908,4,0],[6698912,228,0],[6699140,4,0],[6699144,456,0],[6699600,228,0],[6699832,212,0],[6700044,4,0],[6700048,212,0],[6700260,4,0],[6700264,228,0],[6700492,4,0],[6700496,228,0],[6700728,456,0],[6701184,912,0],[6702096,228,0],[6702324,4,0],[6702328,228,22],[6702556,4,0],[6702560,228,0],[6702788,4,0],[6702792,228,0],[6703020,4,0],[6703024,228,0],[6703252,4,0],[6703256,228,0],[6703484,4,0],[6703488,192,0],[6703680,189,0],[6703872,189,0],[6704064,189,0],[6704256,189,0],[6704448,189,0],[6704640,189,0],[6704832,378,0],[6705216,189,0],[6705408,189,0],[6705600,189,0],[6705792,378,0],[6706176,189,0],[6706368,189,0],[6706560,378,0],[6706944,189,0],[6707136,189,0],[6707328,189,0],[6707520,189,0],[6707712,189,0],[6707904,387,0],[6708296,517,0],[6708816,387,0],[6709208,387,0],[6709600,1034,17],[6710640,517,0],[6711160,3996,0],[6715160,999,0],[6716160,3996,0],[6720160,3996,0],[6724160,999,0],[6725160,999,18],[6726160,3996,0],[6730160,3996,0],[6734160,999,0],[6735160,3996,0],[6739160,999,0],[6740160,3996,0],[6744160,999,0],[6745160,3996,23],[6749452,4,0],[6750072,27200,49],[6859892,1,0],[6859916,4,44],[6865156,4,24],[6865160,4,25],[6865164,50,0],[6865216,4,44],[6865220,1,0],[6865224,4,44],[6865228,2,0],[6865376,4,0],[6865392,4,44],[6865396,4,21],[6865404,4,20],[6865412,4,19],[6865416,24,7],[6865444,4,44],[6865460,4,44],[6865464,4,0],[7057936,1824,0],[7066576,6804,45],[7116888,209,0],[7117104,836,0],[7117944,418,0],[7118368,209,0],[7118584,836,0],[7119424,209,0],[7119640,418,0],[7120060,48,0],[7120108,48,0],[7120156,48,0],[7120208,252,7],[7120460,24,7],[7121228,48,0],[7122840,4,0],[7122848,10752,0],[7133600,516,0],[7134120,516,0],[7134656,2,0],[7134660,8,66],[7135188,36,7],[7135232,192,7],[7136032,1024,7],[7137056,1024,7],[7138080,26,7],[7139312,8,0],[7139320,1200,31],[7147284,4,0],[7147300,4,0],[7147312,633,0],[7148032,40482,35],[7148084,4,0],[7148088,4,0],[7148128,4,28],[7166512,4,0],[7166572,4,0],[7167192,4,3],[7167508,4,42],[7167516,4,0],[7167688,4,32],[7168916,24,7],[7200768,1214,44],[7201984,1214,44],[7203200,68000,44],[7271200,1214,44],[7272416,68000,44],[7340416,1214,44],[7341632,68000,44],[7409632,1214,44],[7410848,68000,44],[7478848,1214,44],[7480064,68000,44],[7548064,1214,44],[7549280,68000,44],[7617280,1214,44],[7618496,68000,44],[7686496,1214,44],[7687712,68000,44],[7755712,1214,44],[7756928,68000,44],[7824928,734,44],[7825664,734,44],[7826400,68000,44],[7894400,734,44],[7895136,68000,44],[7963136,734,44],[7963872,68000,44],[8031872,734,44],[8032608,68000,44],[8100608,734,44],[8101344,68000,44],[8169344,734,44],[8170080,68000,44],[8238080,734,44],[8238816,68000,44],[8306816,734,44],[8307552,68000,44],[8375552,734,44],[8376288,68000,44],[8444288,734,44],[8445024,68000,44],[8513024,734,44],[8513760,68000,44],[8581760,794,44],[8582560,794,44],[8583360,68000,44],[8651360,794,44],[8652160,68000,44],[8720160,794,44],[8720960,68000,44],[8788960,794,44],[8789760,68000,44],[8857760,794,44],[8858560,68000,44],[8926560,794,44],[8927360,68000,44],[8995360,794,44],[8996160,68000,44],[9064160,438,44],[9064608,438,44],[9065056,68000,44],[9133056,438,44],[9133504,68000,44],[9201504,438,44],[9201952,68000,44],[9269952,438,44],[9270400,68000,44],[9338400,438,44],[9338848,68000,44],[9406848,438,44],[9407296,68000,44],[9475296,438,44],[9475744,68000,44],[9543744,438,44],[9544192,68000,44],[9612192,438,44],[9612640,68000,44],[9680640,438,44],[9681088,68000,44],[9749088,438,44],[9749536,68000,44],[9817536,438,44],[9817984,68000,44],[9885984,184,44],[9886176,184,44],[9886368,68000,44],[9954368,184,44],[9954560,68000,44],[10022560,184,44],[10022752,68000,44],[10090752,184,44],[10090944,68000,44],[10158944,184,44],[10159136,68000,44],[10227136,184,44],[10227328,68000,44],[10295328,184,44],[10295520,68000,44],[10363520,184,44],[10363712,68000,44],[10431712,184,44],[10431904,68000,44],[10499904,184,44],[10500096,68000,44],[10568096,4,41],[10568112,270,44],[10568384,270,44],[10568656,68000,44],[10636656,270,44],[10636928,68000,44],[10704928,270,44],[10705200,68000,44],[10773200,270,44],[10773472,68000,44],[10841472,270,44],[10841744,68000,44],[10909744,4,50],[10909760,4,37],[10909776,4,63],[10909792,4,38],[10909808,4,48],[10909824,301056,29],[11210880,131072,27],[11341952,131072,26],[11473024,262144,28],[11735168,3145728,30],[14880896,204800,33],[15085696,81920,32],[15167616,48,42],[15167664,4826,42],[352693752,12,13],[352708208,1024,10],[419532600,4,7],[419713736,128,7],[419717428,4,0],[419717432,256,7],[420025304,4,0],[420025312,306901,19],[420332224,48714,20],[420380944,51207,21],[420432160,4,59],[420432176,5000,44],[420437184,5000,44],[420442192,68000,44],[420510192,5000,44],[420515200,68000,44],[420583200,5000,44],[420588208,68000,44],[420656208,5000,44],[420661216,68000,44],[420729216,5000,44],[420734224,68000,44],[420802224,5000,44],[420807232,68000,44],[420875232,5000,44],[420880240,68000,44],[420948240,5000,44],[420953248,68000,44],[421021248,4,55],[421021264,4,61],[421021280,64864,16]]} \ No newline at end of file diff --git a/data/remastered.json b/data/remastered.json new file mode 100644 index 0000000..73d6500 --- /dev/null +++ b/data/remastered.json @@ -0,0 +1,9645 @@ +{ +"build": "1.23.10.13515", +"source": "Remastered's own EUD emulator, run on a dump of the running game (2026-10-16): every region it emulates, each dword read and written through its own code", +"outside": "ends", +"entries": [ +{ +"name": "0x200000", +"base": 2097152, +"width": 4096, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x201000", +"base": 2101248, +"width": 24576, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x41e0d0", +"base": 4317392, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x426918", +"base": 4352280, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x427900", +"base": 4356352, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x427cdc", +"base": 4357340, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x45134c", +"base": 4526924, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x451350", +"base": 4526928, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x46044c", +"base": 4588620, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4887c0", +"base": 4753344, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x489204", +"base": 4755972, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x492f90", +"base": 4796304, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4a3898", +"base": 4864152, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4a389c", +"base": 4864156, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4b995c", +"base": 4954460, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4bd5ec", +"base": 4969964, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4bd5f0", +"base": 4969968, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4d68f4", +"base": 5073140, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4d98bc", +"base": 5085372, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4f3288", +"base": 5190280, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4f3360", +"base": 5190496, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4fdfbc", +"base": 5234620, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4fe544", +"base": 5236036, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x4ff900", +"base": 5241088, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x50cdc0", +"base": 5295552, +"width": 260, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x50e064", +"base": 5300324, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5124d8", +"base": 5317848, +"width": 28, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x512678", +"base": 5318264, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51267c", +"base": 5318268, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x512684", +"base": 5318276, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x512688", +"base": 5318280, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x512708", +"base": 5318408, +"width": 76, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x5128f8", +"base": 5318904, +"width": 160, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x513b68", +"base": 5323624, +"width": 52, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x514178", +"base": 5325176, +"width": 1092, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5145bc", +"base": 5326268, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5145c0", +"base": 5326272, +"width": 840, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x514908", +"base": 5327112, +"width": 320, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x514a48", +"base": 5327432, +"width": 688, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x514cf8", +"base": 5328120, +"width": 1316, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51521c", +"base": 5329436, +"width": 8, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5152a8", +"base": 5329576, +"width": 84, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5152fc", +"base": 5329660, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515300", +"base": 5329664, +"width": 72, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515348", +"base": 5329736, +"width": 60, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515384", +"base": 5329796, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515388", +"base": 5329800, +"width": 96, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515af8", +"base": 5331704, +"width": 16, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515b08", +"base": 5331720, +"width": 16, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515b18", +"base": 5331736, +"width": 32, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515b38", +"base": 5331768, +"width": 32, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515b58", +"base": 5331800, +"width": 8, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515b60", +"base": 5331808, +"width": 8, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515b68", +"base": 5331816, +"width": 8, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515b70", +"base": 5331824, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515be8", +"base": 5331944, +"width": 60, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515c24", +"base": 5332004, +"width": 60, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515c60", +"base": 5332064, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515c74", +"base": 5332084, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515c88", +"base": 5332104, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515c9c", +"base": 5332124, +"width": 40, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515cc4", +"base": 5332164, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515cd8", +"base": 5332184, +"width": 40, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515d00", +"base": 5332224, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515d14", +"base": 5332244, +"width": 60, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515d50", +"base": 5332304, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515d64", +"base": 5332324, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515d68", +"base": 5332328, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515dd0", +"base": 5332432, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515e34", +"base": 5332532, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515e38", +"base": 5332536, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515e9c", +"base": 5332636, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515ea0", +"base": 5332640, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515f2c", +"base": 5332780, +"width": 40, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515f54", +"base": 5332820, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x515f58", +"base": 5332824, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51600c", +"base": 5333004, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516020", +"base": 5333024, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5160d4", +"base": 5333204, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5160d8", +"base": 5333208, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516164", +"base": 5333348, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516168", +"base": 5333352, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5161e0", +"base": 5333472, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51626c", +"base": 5333612, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516270", +"base": 5333616, +"width": 160, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516310", +"base": 5333776, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51639c", +"base": 5333916, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5163a0", +"base": 5333920, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51642c", +"base": 5334060, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516430", +"base": 5334064, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5164e4", +"base": 5334244, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5164e8", +"base": 5334248, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51659c", +"base": 5334428, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5165a0", +"base": 5334432, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51662c", +"base": 5334572, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516630", +"base": 5334576, +"width": 200, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5166f8", +"base": 5334776, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5167ac", +"base": 5334956, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5167b0", +"base": 5334960, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516814", +"base": 5335060, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516818", +"base": 5335064, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5168cc", +"base": 5335244, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5168d0", +"base": 5335248, +"width": 160, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516970", +"base": 5335408, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516984", +"base": 5335428, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516988", +"base": 5335432, +"width": 80, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5169d8", +"base": 5335512, +"width": 60, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516a14", +"base": 5335572, +"width": 60, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516a50", +"base": 5335632, +"width": 40, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516a78", +"base": 5335672, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516adc", +"base": 5335772, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516ae0", +"base": 5335776, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516b44", +"base": 5335876, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516b48", +"base": 5335880, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516bac", +"base": 5335980, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516bb0", +"base": 5335984, +"width": 80, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516c00", +"base": 5336064, +"width": 60, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516c3c", +"base": 5336124, +"width": 60, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516c78", +"base": 5336184, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516d04", +"base": 5336324, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516d08", +"base": 5336328, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516dbc", +"base": 5336508, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516dc0", +"base": 5336512, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516e74", +"base": 5336692, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516e78", +"base": 5336696, +"width": 160, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516f18", +"base": 5336856, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516fa4", +"base": 5336996, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x516fa8", +"base": 5337000, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517034", +"base": 5337140, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517038", +"base": 5337144, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5170c4", +"base": 5337284, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5170c8", +"base": 5337288, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517154", +"base": 5337428, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517158", +"base": 5337432, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5171e4", +"base": 5337572, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5171e8", +"base": 5337576, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517260", +"base": 5337696, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5172ec", +"base": 5337836, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5172f0", +"base": 5337840, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517354", +"base": 5337940, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517358", +"base": 5337944, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5173d0", +"base": 5338064, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517448", +"base": 5338184, +"width": 40, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517470", +"base": 5338224, +"width": 80, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5174c0", +"base": 5338304, +"width": 160, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517560", +"base": 5338464, +"width": 80, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5175b0", +"base": 5338544, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51763c", +"base": 5338684, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517640", +"base": 5338688, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5176a4", +"base": 5338788, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5176a8", +"base": 5338792, +"width": 80, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5176f8", +"base": 5338872, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51770c", +"base": 5338892, +"width": 60, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517748", +"base": 5338952, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5177d4", +"base": 5339092, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5177d8", +"base": 5339096, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517850", +"base": 5339216, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5178dc", +"base": 5339356, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5178e0", +"base": 5339360, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517994", +"base": 5339540, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517998", +"base": 5339544, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517a4c", +"base": 5339724, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517a50", +"base": 5339728, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517ab4", +"base": 5339828, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517ab8", +"base": 5339832, +"width": 180, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517b6c", +"base": 5340012, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517b70", +"base": 5340016, +"width": 160, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517c10", +"base": 5340176, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517c88", +"base": 5340296, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517d14", +"base": 5340436, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517d18", +"base": 5340440, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517da4", +"base": 5340580, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517da8", +"base": 5340584, +"width": 160, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517e48", +"base": 5340744, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517ec0", +"base": 5340864, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x517f38", +"base": 5340984, +"width": 200, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518000", +"base": 5341184, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518014", +"base": 5341204, +"width": 40, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51803c", +"base": 5341244, +"width": 40, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518068", +"base": 5341288, +"width": 220, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518144", +"base": 5341508, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518148", +"base": 5341512, +"width": 240, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518238", +"base": 5341752, +"width": 240, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518328", +"base": 5341992, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51842c", +"base": 5342252, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518430", +"base": 5342256, +"width": 80, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518480", +"base": 5342336, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51850c", +"base": 5342476, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518510", +"base": 5342480, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518588", +"base": 5342600, +"width": 80, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5185d8", +"base": 5342680, +"width": 100, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51863c", +"base": 5342780, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518640", +"base": 5342784, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5186cc", +"base": 5342924, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5186d0", +"base": 5342928, +"width": 120, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x518748", +"base": 5343048, +"width": 140, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5187d4", +"base": 5343188, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5187e8", +"base": 5343208, +"width": 3000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5193a0", +"base": 5346208, +"width": 2736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a280", +"base": 5350016, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a28c", +"base": 5350028, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a298", +"base": 5350040, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a2a4", +"base": 5350052, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a2b0", +"base": 5350064, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a2bc", +"base": 5350076, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a2c8", +"base": 5350088, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a2d4", +"base": 5350100, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a424", +"base": 5350436, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51a440", +"base": 5350464, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51aab0", +"base": 5352112, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51aac8", +"base": 5352136, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51aae0", +"base": 5352160, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ab94", +"base": 5352340, +"width": 28, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51abb0", +"base": 5352368, +"width": 16, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51abc0", +"base": 5352384, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51abd4", +"base": 5352404, +"width": 28, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51abf0", +"base": 5352432, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51abfc", +"base": 5352444, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ac10", +"base": 5352464, +"width": 28, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ac2c", +"base": 5352492, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ac48", +"base": 5352520, +"width": 20, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ac5c", +"base": 5352540, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ac74", +"base": 5352564, +"width": 16, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ad18", +"base": 5352728, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51baf8", +"base": 5356280, +"width": 256, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ca14", +"base": 5360148, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ce70", +"base": 5361264, +"width": 28, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x51ce8c", +"base": 5361292, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51ce98", +"base": 5361304, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x51cea0", +"base": 5361312, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x51ced0", +"base": 5361360, +"width": 3996, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x51f2a8", +"base": 5370536, +"width": 19980, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x5240d0", +"base": 5390544, +"width": 1000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x52e4c0", +"base": 5432512, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x52e5c8", +"base": 5432776, +"width": 3996, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "image table: +0x0", +"base": 5436776, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x4", +"base": 5436780, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x8", +"base": 5436784, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0xc", +"base": 5436788, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x10", +"base": 5436792, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x14", +"base": 5436796, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x18", +"base": 5436800, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x1c", +"base": 5436804, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x20", +"base": 5436808, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x24", +"base": 5436812, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x28", +"base": 5436816, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x2c", +"base": 5436820, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x30", +"base": 5436824, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x34", +"base": 5436828, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x38", +"base": 5436832, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "image table: +0x3c", +"base": 5436836, +"width": 4, +"stride": 64, +"count": 5000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "0x57eb68", +"base": 5761896, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x57eb70", +"base": 5761904, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x57eee0", +"base": 5762784, +"width": 432, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x57f090", +"base": 5763216, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x57f0b4", +"base": 5763252, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x57f0d8", +"base": 5763288, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x57f0f0", +"base": 5763312, +"width": 66396, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x58f44c", +"base": 5829708, +"width": 29604, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5967f8", +"base": 5859320, +"width": 144, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x596a18", +"base": 5859864, +"width": 48, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x596a18 +0x30", +"base": 5859912, +"width": 12, +"read": "allowed", +"write": "unknown" +}, +{ +"name": "0x596a18 +0x3c", +"base": 5859924, +"width": 196, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x597208", +"base": 5861896, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x59720c", +"base": 5861900, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x597210", +"base": 5861904, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x597214", +"base": 5861908, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x597218", +"base": 5861912, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x59721c", +"base": 5861916, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x597220", +"base": 5861920, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x597224", +"base": 5861924, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x597228", +"base": 5861928, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x59722c", +"base": 5861932, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x597230", +"base": 5861936, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x597234", +"base": 5861940, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x597238", +"base": 5861944, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x59723c", +"base": 5861948, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5973a0", +"base": 5862304, +"width": 8192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5993c4", +"base": 5870532, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x5993d4", +"base": 5870548, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x59c6c0", +"base": 5883584, +"width": 1024, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x59cb5c", +"base": 5884764, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x59cb60", +"base": 5884768, +"width": 256, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x59cc78", +"base": 5885048, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x59cc80", +"base": 5885056, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x0", +"base": 5885096, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x4", +"base": 5885100, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x8", +"base": 5885104, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xc", +"base": 5885108, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a sprite pointer: the game takes the address of a sprite; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x10", +"base": 5885112, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x14", +"base": 5885116, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x18", +"base": 5885120, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x1c", +"base": 5885124, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x20", +"base": 5885128, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x24", +"base": 5885132, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x28", +"base": 5885136, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x2c", +"base": 5885140, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x30", +"base": 5885144, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x34", +"base": 5885148, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x38", +"base": 5885152, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x3c", +"base": 5885156, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x40", +"base": 5885160, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x44", +"base": 5885164, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x48", +"base": 5885168, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x4c", +"base": 5885172, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x50", +"base": 5885176, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x54", +"base": 5885180, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x58", +"base": 5885184, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x5c", +"base": 5885188, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x60", +"base": 5885192, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x64", +"base": 5885196, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x68", +"base": 5885200, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x6c", +"base": 5885204, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x70", +"base": 5885208, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x74", +"base": 5885212, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x78", +"base": 5885216, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x7c", +"base": 5885220, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x80", +"base": 5885224, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x84", +"base": 5885228, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x88", +"base": 5885232, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x8c", +"base": 5885236, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x90", +"base": 5885240, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x94", +"base": 5885244, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x98", +"base": 5885248, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x9c", +"base": 5885252, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xa0", +"base": 5885256, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xa4", +"base": 5885260, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xa8", +"base": 5885264, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xac", +"base": 5885268, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xb0", +"base": 5885272, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0xb4", +"base": 5885276, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0xb8", +"base": 5885280, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0xbc", +"base": 5885284, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0xc0", +"base": 5885288, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back; differs between elements: ignored, shadow" +}, +{ +"name": "unit table: +0xc4", +"base": 5885292, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "differs between elements: allowed, ignored" +}, +{ +"name": "unit table: +0xc8", +"base": 5885296, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "differs between elements: allowed, ignored" +}, +{ +"name": "unit table: +0xcc", +"base": 5885300, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "differs between elements: allowed, ignored" +}, +{ +"name": "unit table: +0xd0", +"base": 5885304, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "differs between elements: allowed, ignored" +}, +{ +"name": "unit table: +0xd4", +"base": 5885308, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back; differs between elements: ignored, shadow" +}, +{ +"name": "unit table: +0xd8", +"base": 5885312, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "differs between elements: allowed, ignored" +}, +{ +"name": "unit table: +0xdc", +"base": 5885316, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xe0", +"base": 5885320, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xe4", +"base": 5885324, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xe8", +"base": 5885328, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xec", +"base": 5885332, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0xf0", +"base": 5885336, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0xf4", +"base": 5885340, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0xf8", +"base": 5885344, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0xfc", +"base": 5885348, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x100", +"base": 5885352, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x104", +"base": 5885356, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x108", +"base": 5885360, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x10c", +"base": 5885364, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x110", +"base": 5885368, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x114", +"base": 5885372, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x118", +"base": 5885376, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x11c", +"base": 5885380, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed", +"note": "a unit pointer: the game takes the address of a unit; anything else is kept beside it and only read back" +}, +{ +"name": "unit table: +0x120", +"base": 5885384, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x124", +"base": 5885388, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x128", +"base": 5885392, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x12c", +"base": 5885396, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "unit table: +0x130", +"base": 5885400, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x134", +"base": 5885404, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x138", +"base": 5885408, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x13c", +"base": 5885412, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x140", +"base": 5885416, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x144", +"base": 5885420, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x148", +"base": 5885424, +"width": 4, +"stride": 336, +"count": 1700, +"read": "ends", +"write": "ends" +}, +{ +"name": "unit table: +0x14c", +"base": 5885428, +"width": 4, +"stride": 336, +"count": 1700, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6283f0", +"base": 6456304, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6283f8", +"base": 6456312, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6283fc", +"base": 6456316, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628400", +"base": 6456320, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628404", +"base": 6456324, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628408", +"base": 6456328, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62840c", +"base": 6456332, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628410", +"base": 6456336, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628414", +"base": 6456340, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628418", +"base": 6456344, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62841c", +"base": 6456348, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628420", +"base": 6456352, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628424", +"base": 6456356, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628428", +"base": 6456360, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62842c", +"base": 6456364, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628430", +"base": 6456368, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628434", +"base": 6456372, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628438", +"base": 6456376, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62843c", +"base": 6456380, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628444", +"base": 6456388, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x628448", +"base": 6456392, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x628454", +"base": 6456404, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x628458", +"base": 6456408, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x628470", +"base": 6456432, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x62848c", +"base": 6456460, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x628494", +"base": 6456468, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x628498", +"base": 6456472, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6284a8", +"base": 6456488, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6284ac", +"base": 6456492, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6284b8", +"base": 6456504, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284bc", +"base": 6456508, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284c0", +"base": 6456512, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284c4", +"base": 6456516, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284c8", +"base": 6456520, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284cc", +"base": 6456524, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284d0", +"base": 6456528, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284d4", +"base": 6456532, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284d8", +"base": 6456536, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284dc", +"base": 6456540, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284e0", +"base": 6456544, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284e4", +"base": 6456548, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284e8", +"base": 6456552, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284ec", +"base": 6456556, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284f0", +"base": 6456560, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284f4", +"base": 6456564, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284f8", +"base": 6456568, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6284fc", +"base": 6456572, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628500", +"base": 6456576, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628504", +"base": 6456580, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628508", +"base": 6456584, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62850c", +"base": 6456588, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628510", +"base": 6456592, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628514", +"base": 6456596, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628518", +"base": 6456600, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62851c", +"base": 6456604, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628520", +"base": 6456608, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628524", +"base": 6456612, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628528", +"base": 6456616, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62852c", +"base": 6456620, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628530", +"base": 6456624, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628534", +"base": 6456628, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628538", +"base": 6456632, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62853c", +"base": 6456636, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628540", +"base": 6456640, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628544", +"base": 6456644, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628548", +"base": 6456648, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62854c", +"base": 6456652, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628550", +"base": 6456656, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628554", +"base": 6456660, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628558", +"base": 6456664, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62855c", +"base": 6456668, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628560", +"base": 6456672, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628564", +"base": 6456676, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628568", +"base": 6456680, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62856c", +"base": 6456684, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628570", +"base": 6456688, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628574", +"base": 6456692, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628578", +"base": 6456696, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62857c", +"base": 6456700, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628580", +"base": 6456704, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628584", +"base": 6456708, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628588", +"base": 6456712, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62858c", +"base": 6456716, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628590", +"base": 6456720, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628594", +"base": 6456724, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628598", +"base": 6456728, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62859c", +"base": 6456732, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285a0", +"base": 6456736, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285a4", +"base": 6456740, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285a8", +"base": 6456744, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285ac", +"base": 6456748, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285b0", +"base": 6456752, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285b4", +"base": 6456756, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285b8", +"base": 6456760, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285bc", +"base": 6456764, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285c0", +"base": 6456768, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285c4", +"base": 6456772, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285c8", +"base": 6456776, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285cc", +"base": 6456780, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285d0", +"base": 6456784, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285d4", +"base": 6456788, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285d8", +"base": 6456792, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285dc", +"base": 6456796, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285e0", +"base": 6456800, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285e4", +"base": 6456804, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285e8", +"base": 6456808, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285ec", +"base": 6456812, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285f0", +"base": 6456816, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285f4", +"base": 6456820, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285f8", +"base": 6456824, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6285fc", +"base": 6456828, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628600", +"base": 6456832, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628604", +"base": 6456836, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628608", +"base": 6456840, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62860c", +"base": 6456844, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628610", +"base": 6456848, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628614", +"base": 6456852, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628618", +"base": 6456856, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62861c", +"base": 6456860, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628620", +"base": 6456864, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628624", +"base": 6456868, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628628", +"base": 6456872, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62862c", +"base": 6456876, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628630", +"base": 6456880, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628634", +"base": 6456884, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628638", +"base": 6456888, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62863c", +"base": 6456892, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628640", +"base": 6456896, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628644", +"base": 6456900, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628648", +"base": 6456904, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62864c", +"base": 6456908, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628650", +"base": 6456912, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628654", +"base": 6456916, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628658", +"base": 6456920, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x62865c", +"base": 6456924, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628660", +"base": 6456928, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x628664", +"base": 6456932, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x629c90", +"base": 6462608, +"width": 256, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "sprite table: +0x0", +"base": 6462872, +"width": 4, +"stride": 36, +"count": 2500, +"read": "allowed", +"write": "allowed", +"note": "a sprite pointer: the game takes the address of a sprite; anything else is kept beside it and only read back" +}, +{ +"name": "sprite table: +0x4", +"base": 6462876, +"width": 4, +"stride": 36, +"count": 2500, +"read": "allowed", +"write": "allowed", +"note": "a sprite pointer: the game takes the address of a sprite; anything else is kept beside it and only read back" +}, +{ +"name": "sprite table: +0x8", +"base": 6462880, +"width": 4, +"stride": 36, +"count": 2500, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; differs between elements: allowed, shadow" +}, +{ +"name": "sprite table: +0xc", +"base": 6462884, +"width": 4, +"stride": 36, +"count": 2500, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it" +}, +{ +"name": "sprite table: +0x10", +"base": 6462888, +"width": 4, +"stride": 36, +"count": 2500, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "sprite table: +0x14", +"base": 6462892, +"width": 4, +"stride": 36, +"count": 2500, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it" +}, +{ +"name": "sprite table: +0x18", +"base": 6462896, +"width": 4, +"stride": 36, +"count": 2500, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it" +}, +{ +"name": "sprite table: +0x1c", +"base": 6462900, +"width": 4, +"stride": 36, +"count": 2500, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it" +}, +{ +"name": "sprite table: +0x20", +"base": 6462904, +"width": 4, +"stride": 36, +"count": 2500, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it" +}, +{ +"name": "0x63fe30", +"base": 6553136, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x63fe34", +"base": 6553140, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x63fed8", +"base": 6553304, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x63ff40", +"base": 6553408, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x63ff78", +"base": 6553464, +"width": 828, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6402b8", +"base": 6554296, +"width": 656, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x640550", +"base": 6554960, +"width": 796, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x64086c", +"base": 6555756, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x640b58", +"base": 6556504, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x640b5c", +"base": 6556508, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x640b60", +"base": 6556512, +"width": 2836, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x64169c", +"base": 6559388, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "order queue: +0x0", +"base": 6559392, +"width": 4, +"stride": 20, +"count": 2000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "order queue: +0x4", +"base": 6559396, +"width": 4, +"stride": 20, +"count": 2000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "order queue: +0x8", +"base": 6559400, +"width": 4, +"stride": 20, +"count": 2000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "order queue: +0xc", +"base": 6559404, +"width": 4, +"stride": 20, +"count": 2000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "order queue: +0x10", +"base": 6559408, +"width": 4, +"stride": 20, +"count": 2000, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "0x64b2e0", +"base": 6599392, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x64b2e4", +"base": 6599396, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "bullet table: +0x0", +"base": 6599400, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x4", +"base": 6599404, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x8", +"base": 6599408, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0xc", +"base": 6599412, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x10", +"base": 6599416, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x14", +"base": 6599420, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x18", +"base": 6599424, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x1c", +"base": 6599428, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x20", +"base": 6599432, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x24", +"base": 6599436, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x28", +"base": 6599440, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x2c", +"base": 6599444, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x30", +"base": 6599448, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x34", +"base": 6599452, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x38", +"base": 6599456, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x3c", +"base": 6599460, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x40", +"base": 6599464, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x44", +"base": 6599468, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x48", +"base": 6599472, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x4c", +"base": 6599476, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x50", +"base": 6599480, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x54", +"base": 6599484, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x58", +"base": 6599488, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x5c", +"base": 6599492, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x60", +"base": 6599496, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x64", +"base": 6599500, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x68", +"base": 6599504, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "bullet table: +0x6c", +"base": 6599508, +"width": 4, +"stride": 112, +"count": 100, +"read": "allowed", +"write": "ignored", +"note": "kept beside the game: read back, never used by it; tried on elements not in use" +}, +{ +"name": "0x64debc", +"base": 6610620, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x64eed8", +"base": 6614744, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x64eedc", +"base": 6614748, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6509a0", +"base": 6621600, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6509b0", +"base": 6621616, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x654880", +"base": 6637696, +"width": 512, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x654aa0", +"base": 6638240, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6556e4", +"base": 6641380, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655700", +"base": 6641408, +"width": 64, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655740", +"base": 6641472, +"width": 124, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6557bc", +"base": 6641596, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6557c0", +"base": 6641600, +"width": 124, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65583c", +"base": 6641724, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655840", +"base": 6641728, +"width": 124, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6558c0", +"base": 6641856, +"width": 124, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655940", +"base": 6641984, +"width": 124, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6559bc", +"base": 6642108, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6559c0", +"base": 6642112, +"width": 124, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655a40", +"base": 6642240, +"width": 124, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655ac0", +"base": 6642368, +"width": 124, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655b3c", +"base": 6642492, +"width": 64, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655b7c", +"base": 6642556, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655b80", +"base": 6642560, +"width": 124, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655bfc", +"base": 6642684, +"width": 64, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655c58", +"base": 6642776, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x655e80", +"base": 6643328, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656198", +"base": 6644120, +"width": 88, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6561f0", +"base": 6644208, +"width": 88, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656248", +"base": 6644296, +"width": 88, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6562a0", +"base": 6644384, +"width": 88, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6562f8", +"base": 6644472, +"width": 88, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656350", +"base": 6644560, +"width": 44, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65637c", +"base": 6644604, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656380", +"base": 6644608, +"width": 88, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6563d8", +"base": 6644696, +"width": 88, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656430", +"base": 6644784, +"width": 88, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656488", +"base": 6644872, +"width": 44, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6564b4", +"base": 6644916, +"width": 44, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6564e0", +"base": 6644960, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656568", +"base": 6645096, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656670", +"base": 6645360, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6566f4", +"base": 6645492, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6566f8", +"base": 6645496, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65677c", +"base": 6645628, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656780", +"base": 6645632, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656884", +"base": 6645892, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656888", +"base": 6645896, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65698c", +"base": 6646156, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656990", +"base": 6646160, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656a14", +"base": 6646292, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656a18", +"base": 6646296, +"width": 520, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656c20", +"base": 6646816, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656ca4", +"base": 6646948, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656ca8", +"base": 6646952, +"width": 520, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656eb0", +"base": 6647472, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656fb4", +"base": 6647732, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x656fb8", +"base": 6647736, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65703c", +"base": 6647868, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657040", +"base": 6647872, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6570c4", +"base": 6648004, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6570c8", +"base": 6648008, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6571cc", +"base": 6648268, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6571d0", +"base": 6648272, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657254", +"base": 6648404, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657258", +"base": 6648408, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6572dc", +"base": 6648540, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6572e0", +"base": 6648544, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6573e4", +"base": 6648804, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6573e8", +"base": 6648808, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65746c", +"base": 6648940, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657470", +"base": 6648944, +"width": 520, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657678", +"base": 6649464, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657780", +"base": 6649728, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657888", +"base": 6649992, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65790c", +"base": 6650124, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657910", +"base": 6650128, +"width": 132, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657998", +"base": 6650264, +"width": 260, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657a9c", +"base": 6650524, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x657aa0", +"base": 6650528, +"width": 4096, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65fc18", +"base": 6683672, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65fd00", +"base": 6683904, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65fec8", +"base": 6684360, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x65ffb0", +"base": 6684592, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x660178", +"base": 6685048, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x660260", +"base": 6685280, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x660428", +"base": 6685736, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6605f0", +"base": 6686192, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6606d8", +"base": 6686424, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6607c0", +"base": 6686656, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x660988", +"base": 6687112, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x660a70", +"base": 6687344, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x660c38", +"base": 6687800, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x660e00", +"base": 6688256, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x660fc8", +"base": 6688712, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6610ac", +"base": 6688940, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6610b0", +"base": 6688944, +"width": 912, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x661440", +"base": 6689856, +"width": 212, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x661514", +"base": 6690068, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x661518", +"base": 6690072, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6616e0", +"base": 6690528, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6617c4", +"base": 6690756, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6617c8", +"base": 6690760, +"width": 1824, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x661ee8", +"base": 6692584, +"width": 212, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x661fbc", +"base": 6692796, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x661fc0", +"base": 6692800, +"width": 212, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662094", +"base": 6693012, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662098", +"base": 6693016, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66217c", +"base": 6693244, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662180", +"base": 6693248, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662264", +"base": 6693476, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662268", +"base": 6693480, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662350", +"base": 6693712, +"width": 912, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6626e0", +"base": 6694624, +"width": 384, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662860", +"base": 6695008, +"width": 912, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662bf0", +"base": 6695920, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662db8", +"base": 6696376, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662e9c", +"base": 6696604, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662ea0", +"base": 6696608, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662f84", +"base": 6696836, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x662f88", +"base": 6696840, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663150", +"base": 6697296, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663234", +"base": 6697524, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663238", +"base": 6697528, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66331c", +"base": 6697756, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663320", +"base": 6697760, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663404", +"base": 6697988, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663408", +"base": 6697992, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6635d0", +"base": 6698448, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6636b4", +"base": 6698676, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6636b8", +"base": 6698680, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66379c", +"base": 6698908, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6637a0", +"base": 6698912, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663884", +"base": 6699140, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663888", +"base": 6699144, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663a50", +"base": 6699600, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663b38", +"base": 6699832, +"width": 212, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663c0c", +"base": 6700044, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663c10", +"base": 6700048, +"width": 212, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663ce4", +"base": 6700260, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663ce8", +"base": 6700264, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663dcc", +"base": 6700492, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663dd0", +"base": 6700496, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x663eb8", +"base": 6700728, +"width": 456, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664080", +"base": 6701184, +"width": 912, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664410", +"base": 6702096, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6644f4", +"base": 6702324, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6644f8", +"base": 6702328, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6645dc", +"base": 6702556, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6645e0", +"base": 6702560, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6646c4", +"base": 6702788, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6646c8", +"base": 6702792, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6647ac", +"base": 6703020, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6647b0", +"base": 6703024, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664894", +"base": 6703252, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664898", +"base": 6703256, +"width": 228, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66497c", +"base": 6703484, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664980", +"base": 6703488, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664a40", +"base": 6703680, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664b00", +"base": 6703872, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664bc0", +"base": 6704064, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664c80", +"base": 6704256, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664d40", +"base": 6704448, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664e00", +"base": 6704640, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x664ec0", +"base": 6704832, +"width": 380, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665040", +"base": 6705216, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665100", +"base": 6705408, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6651c0", +"base": 6705600, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665280", +"base": 6705792, +"width": 380, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665400", +"base": 6706176, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6654c0", +"base": 6706368, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665580", +"base": 6706560, +"width": 380, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665700", +"base": 6706944, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6657c0", +"base": 6707136, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665880", +"base": 6707328, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665940", +"base": 6707520, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665a00", +"base": 6707712, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665ac0", +"base": 6707904, +"width": 388, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x665c48", +"base": 6708296, +"width": 520, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x665e50", +"base": 6708816, +"width": 388, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x665fd8", +"base": 6709208, +"width": 388, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x666160", +"base": 6709600, +"width": 1028, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x666160 +0x404", +"base": 6710628, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x666160 +0x408", +"base": 6710632, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x666570", +"base": 6710640, +"width": 520, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x666778", +"base": 6711160, +"width": 3996, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x667718", +"base": 6715160, +"width": 1000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x667b00", +"base": 6716160, +"width": 3996, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x668aa0", +"base": 6720160, +"width": 3996, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x669a40", +"base": 6724160, +"width": 1000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x669e28", +"base": 6725160, +"width": 1000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66a210", +"base": 6726160, +"width": 3996, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x66b1b0", +"base": 6730160, +"width": 3996, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x66c150", +"base": 6734160, +"width": 1000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66c538", +"base": 6735160, +"width": 3996, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x66d4d8", +"base": 6739160, +"width": 1000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66d8c0", +"base": 6740160, +"width": 3996, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x66e860", +"base": 6744160, +"width": 1000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66ec48", +"base": 6745160, +"width": 3748, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66ec48 +0xea4", +"base": 6748908, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x66ec48 +0xea8", +"base": 6748912, +"width": 244, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66fd0c", +"base": 6749452, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x66ff78", +"base": 6750072, +"width": 27200, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x68ac74", +"base": 6859892, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68ac8c", +"base": 6859916, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c104", +"base": 6865156, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c108", +"base": 6865160, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c10c", +"base": 6865164, +"width": 52, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c140", +"base": 6865216, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c144", +"base": 6865220, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c148", +"base": 6865224, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c14c", +"base": 6865228, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c1e0", +"base": 6865376, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c1f0", +"base": 6865392, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c1f4", +"base": 6865396, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c1fc", +"base": 6865404, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c204", +"base": 6865412, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c208", +"base": 6865416, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c224", +"base": 6865444, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c234", +"base": 6865460, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x68c238", +"base": 6865464, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6bb210", +"base": 7057936, +"width": 1824, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6bd3d0", +"base": 7066576, +"width": 6804, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6c9858", +"base": 7116888, +"width": 212, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6c9930", +"base": 7117104, +"width": 836, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6c9c78", +"base": 7117944, +"width": 420, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6c9e20", +"base": 7118368, +"width": 212, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6c9ef8", +"base": 7118584, +"width": 836, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ca240", +"base": 7119424, +"width": 212, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ca318", +"base": 7119640, +"width": 420, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ca4bc", +"base": 7120060, +"width": 48, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ca4ec", +"base": 7120108, +"width": 48, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ca51c", +"base": 7120156, +"width": 48, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ca550", +"base": 7120208, +"width": 252, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ca64c", +"base": 7120460, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ca94c", +"base": 7121228, +"width": 48, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6caf98", +"base": 7122840, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6cafa0", +"base": 7122848, +"width": 10752, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6cd9a0", +"base": 7133600, +"width": 516, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6cdba8", +"base": 7134120, +"width": 516, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6cddc0", +"base": 7134656, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6cddc4", +"base": 7134660, +"width": 8, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6cdfd4", +"base": 7135188, +"width": 36, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ce000", +"base": 7135232, +"width": 192, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ce320", +"base": 7136032, +"width": 1024, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ce720", +"base": 7137056, +"width": 1024, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ceb20", +"base": 7138080, +"width": 28, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ceff0", +"base": 7139312, +"width": 8, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6ceff8", +"base": 7139320, +"width": 1200, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6d0f14", +"base": 7147284, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6d0f24", +"base": 7147300, +"width": 4, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6d0f30", +"base": 7147312, +"width": 636, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6d1200", +"base": 7148032, +"width": 52, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6d1200 +0x34", +"base": 7148084, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6d1238", +"base": 7148088, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6d1200 +0x3c", +"base": 7148092, +"width": 36, +"read": "ends", +"write": "ends" +}, +{ +"name": "0x6d1200 +0x60", +"base": 7148128, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6d1260", +"base": 7148128, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6d1200 +0x64", +"base": 7148132, +"width": 18380, +"read": "ends", +"write": "ends" +}, +{ +"name": "0x6d1200 +0x4830", +"base": 7166512, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6d5a30", +"base": 7166512, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6d1200 +0x4834", +"base": 7166516, +"width": 56, +"read": "ends", +"write": "ends" +}, +{ +"name": "0x6d1200 +0x486c", +"base": 7166572, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6d1200 +0x4870", +"base": 7166576, +"width": 616, +"read": "ends", +"write": "ends" +}, +{ +"name": "0x6d1200 +0x4ad8", +"base": 7167192, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6d5cd8", +"base": 7167192, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6d1200 +0x4adc", +"base": 7167196, +"width": 312, +"read": "ends", +"write": "ends" +}, +{ +"name": "0x6d1200 +0x4c14", +"base": 7167508, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6d5e14", +"base": 7167508, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6d1200 +0x4c18", +"base": 7167512, +"width": 4, +"read": "ends", +"write": "ends" +}, +{ +"name": "0x6d1200 +0x4c1c", +"base": 7167516, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6d1200 +0x4c20", +"base": 7167520, +"width": 168, +"read": "ends", +"write": "ends" +}, +{ +"name": "0x6d1200 +0x4cc8", +"base": 7167688, +"width": 4, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6d5ec8", +"base": 7167688, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6d1200 +0x4ccc", +"base": 7167692, +"width": 1224, +"read": "ends", +"write": "ends" +}, +{ +"name": "0x6d1200 +0x5194", +"base": 7168916, +"width": 24, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x6d6394", +"base": 7168916, +"width": 24, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6d1200 +0x51ac", +"base": 7168940, +"width": 19576, +"read": "ends", +"write": "ends" +}, +{ +"name": "0x6de000", +"base": 7200768, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6de4c0", +"base": 7201984, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6de980", +"base": 7203200, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x6ef320", +"base": 7271200, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x6ef7e0", +"base": 7272416, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x700180", +"base": 7340416, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x700640", +"base": 7341632, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x710fe0", +"base": 7409632, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x7114a0", +"base": 7410848, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x721e40", +"base": 7478848, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x722300", +"base": 7480064, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x732ca0", +"base": 7548064, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x733160", +"base": 7549280, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x743b00", +"base": 7617280, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x743fc0", +"base": 7618496, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x754960", +"base": 7686496, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x754e20", +"base": 7687712, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x7657c0", +"base": 7755712, +"width": 1216, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x765c80", +"base": 7756928, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x776620", +"base": 7824928, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x776900", +"base": 7825664, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x776be0", +"base": 7826400, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x787580", +"base": 7894400, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x787860", +"base": 7895136, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x798200", +"base": 7963136, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x7984e0", +"base": 7963872, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x7a8e80", +"base": 8031872, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x7a9160", +"base": 8032608, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x7b9b00", +"base": 8100608, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x7b9de0", +"base": 8101344, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x7ca780", +"base": 8169344, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x7caa60", +"base": 8170080, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x7db400", +"base": 8238080, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x7db6e0", +"base": 8238816, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x7ec080", +"base": 8306816, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x7ec360", +"base": 8307552, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x7fcd00", +"base": 8375552, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x7fcfe0", +"base": 8376288, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x80d980", +"base": 8444288, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x80dc60", +"base": 8445024, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x81e600", +"base": 8513024, +"width": 736, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x81e8e0", +"base": 8513760, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x82f280", +"base": 8581760, +"width": 796, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x82f5a0", +"base": 8582560, +"width": 796, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x82f8c0", +"base": 8583360, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x840260", +"base": 8651360, +"width": 796, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x840580", +"base": 8652160, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x850f20", +"base": 8720160, +"width": 796, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x851240", +"base": 8720960, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x861be0", +"base": 8788960, +"width": 796, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x861f00", +"base": 8789760, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x8728a0", +"base": 8857760, +"width": 796, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x872bc0", +"base": 8858560, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x883560", +"base": 8926560, +"width": 796, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x883880", +"base": 8927360, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x894220", +"base": 8995360, +"width": 796, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x894540", +"base": 8996160, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x8a4ee0", +"base": 9064160, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x8a50a0", +"base": 9064608, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x8a5260", +"base": 9065056, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x8b5c00", +"base": 9133056, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x8b5dc0", +"base": 9133504, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x8c6760", +"base": 9201504, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x8c6920", +"base": 9201952, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x8d72c0", +"base": 9269952, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x8d7480", +"base": 9270400, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x8e7e20", +"base": 9338400, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x8e7fe0", +"base": 9338848, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x8f8980", +"base": 9406848, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x8f8b40", +"base": 9407296, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x9094e0", +"base": 9475296, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x9096a0", +"base": 9475744, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x91a040", +"base": 9543744, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x91a200", +"base": 9544192, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x92aba0", +"base": 9612192, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x92ad60", +"base": 9612640, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x93b700", +"base": 9680640, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x93b8c0", +"base": 9681088, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x94c260", +"base": 9749088, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x94c420", +"base": 9749536, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x95cdc0", +"base": 9817536, +"width": 440, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x95cf80", +"base": 9817984, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x96d920", +"base": 9885984, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x96d9e0", +"base": 9886176, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x96daa0", +"base": 9886368, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x97e440", +"base": 9954368, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x97e500", +"base": 9954560, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x98eea0", +"base": 10022560, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x98ef60", +"base": 10022752, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x99f900", +"base": 10090752, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x99f9c0", +"base": 10090944, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x9b0360", +"base": 10158944, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x9b0420", +"base": 10159136, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x9c0dc0", +"base": 10227136, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x9c0e80", +"base": 10227328, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x9d1820", +"base": 10295328, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x9d18e0", +"base": 10295520, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x9e2280", +"base": 10363520, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x9e2340", +"base": 10363712, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x9f2ce0", +"base": 10431712, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x9f2da0", +"base": 10431904, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xa03740", +"base": 10499904, +"width": 184, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa03800", +"base": 10500096, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xa141a0", +"base": 10568096, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa141b0", +"base": 10568112, +"width": 272, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa142c0", +"base": 10568384, +"width": 272, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa143d0", +"base": 10568656, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xa24d70", +"base": 10636656, +"width": 272, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa24e80", +"base": 10636928, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xa35820", +"base": 10704928, +"width": 272, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa35930", +"base": 10705200, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xa462d0", +"base": 10773200, +"width": 272, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa463e0", +"base": 10773472, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xa56d80", +"base": 10841472, +"width": 272, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa56e90", +"base": 10841744, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xa67830", +"base": 10909744, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa67840", +"base": 10909760, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa67850", +"base": 10909776, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa67860", +"base": 10909792, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa67870", +"base": 10909808, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xa67880", +"base": 10909824, +"width": 301056, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xab1080", +"base": 11210880, +"width": 131072, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xad1080", +"base": 11341952, +"width": 131072, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xaf1080", +"base": 11473024, +"width": 262144, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xb31080", +"base": 11735168, +"width": 3145728, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xe31080", +"base": 14880896, +"width": 204800, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xe63080", +"base": 15085696, +"width": 81920, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0xe77080", +"base": 15167616, +"width": 48, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0xe770b0", +"base": 15167664, +"width": 4828, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x1505adf8", +"base": 352693752, +"width": 12, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1505e670", +"base": 352708208, +"width": 1024, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x19018f38", +"base": 419532600, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x190452c8", +"base": 419713736, +"width": 128, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19046134", +"base": 419717428, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19046138", +"base": 419717432, +"width": 256, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x190913d8", +"base": 420025304, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x190913e0", +"base": 420025312, +"width": 306904, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x190dc2c0", +"base": 420332224, +"width": 48716, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x190e8110", +"base": 420380944, +"width": 51208, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x190f4920", +"base": 420432160, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x190f4930", +"base": 420432176, +"width": 5000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x190f5cc0", +"base": 420437184, +"width": 5000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x190f7050", +"base": 420442192, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x191079f0", +"base": 420510192, +"width": 5000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19108d80", +"base": 420515200, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x19119720", +"base": 420583200, +"width": 5000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1911aab0", +"base": 420588208, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x1912b450", +"base": 420656208, +"width": 5000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1912c7e0", +"base": 420661216, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x1913d180", +"base": 420729216, +"width": 5000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1913e510", +"base": 420734224, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x1914eeb0", +"base": 420802224, +"width": 5000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19150240", +"base": 420807232, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x19160be0", +"base": 420875232, +"width": 5000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19161f70", +"base": 420880240, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x19172910", +"base": 420948240, +"width": 5000, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19173ca0", +"base": 420953248, +"width": 68000, +"read": "allowed", +"write": "ignored" +}, +{ +"name": "0x19184640", +"base": 421021248, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19184650", +"base": 421021264, +"width": 4, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19184660", +"base": 421021280, +"width": 64864, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191943c0", +"base": 421086144, +"width": 8, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191943c8", +"base": 421086152, +"width": 143004, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191b7270", +"base": 421229168, +"width": 29244, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191be4b0", +"base": 421258416, +"width": 86624, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191d3710", +"base": 421345040, +"width": 51032, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191dfe70", +"base": 421396080, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191dfe70 +0x8", +"base": 421396088, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e07e0", +"base": 421398496, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e07e0 +0x8", +"base": 421398504, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e1150", +"base": 421400912, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e1150 +0x8", +"base": 421400920, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e1ac0", +"base": 421403328, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e1ac0 +0x8", +"base": 421403336, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e2430", +"base": 421405744, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e2430 +0x8", +"base": 421405752, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e2da0", +"base": 421408160, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e2da0 +0x8", +"base": 421408168, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e3710", +"base": 421410576, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e3710 +0x8", +"base": 421410584, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e4080", +"base": 421412992, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e4080 +0x8", +"base": 421413000, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e49f0", +"base": 421415408, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e49f0 +0x8", +"base": 421415416, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e5360", +"base": 421417824, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e5360 +0x8", +"base": 421417832, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e5cd0", +"base": 421420240, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e5cd0 +0x8", +"base": 421420248, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e6640", +"base": 421422656, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e6640 +0x8", +"base": 421422664, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e6fb0", +"base": 421425072, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e6fb0 +0x8", +"base": 421425080, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e7920", +"base": 421427488, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e7920 +0x8", +"base": 421427496, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e8290", +"base": 421429904, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e8290 +0x8", +"base": 421429912, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e8c00", +"base": 421432320, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e8c00 +0x8", +"base": 421432328, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e9570", +"base": 421434736, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e9570 +0x8", +"base": 421434744, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191e9ee0", +"base": 421437152, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191e9ee0 +0x8", +"base": 421437160, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191ea850", +"base": 421439568, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191ea850 +0x8", +"base": 421439576, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191eb1c0", +"base": 421441984, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191eb1c0 +0x8", +"base": 421441992, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191ebb30", +"base": 421444400, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191ebb30 +0x8", +"base": 421444408, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191ec4a0", +"base": 421446816, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191ec4a0 +0x8", +"base": 421446824, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191ece10", +"base": 421449232, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191ece10 +0x8", +"base": 421449240, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191ed780", +"base": 421451648, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191ed780 +0x8", +"base": 421451656, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191ee0f0", +"base": 421454064, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191ee0f0 +0x8", +"base": 421454072, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191eea60", +"base": 421456480, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191eea60 +0x8", +"base": 421456488, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191ef3d0", +"base": 421458896, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191ef3d0 +0x8", +"base": 421458904, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191efd40", +"base": 421461312, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191efd40 +0x8", +"base": 421461320, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f06b0", +"base": 421463728, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f06b0 +0x8", +"base": 421463736, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f1020", +"base": 421466144, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f1020 +0x8", +"base": 421466152, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f1990", +"base": 421468560, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f1990 +0x8", +"base": 421468568, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f2300", +"base": 421470976, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f2300 +0x8", +"base": 421470984, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f2c70", +"base": 421473392, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f2c70 +0x8", +"base": 421473400, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f35e0", +"base": 421475808, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f35e0 +0x8", +"base": 421475816, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f3f50", +"base": 421478224, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f3f50 +0x8", +"base": 421478232, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f48c0", +"base": 421480640, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f48c0 +0x8", +"base": 421480648, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f5230", +"base": 421483056, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f5230 +0x8", +"base": 421483064, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f5ba0", +"base": 421485472, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f5ba0 +0x8", +"base": 421485480, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f6510", +"base": 421487888, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f6510 +0x8", +"base": 421487896, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f6e80", +"base": 421490304, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f6e80 +0x8", +"base": 421490312, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f77f0", +"base": 421492720, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f77f0 +0x8", +"base": 421492728, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f8160", +"base": 421495136, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f8160 +0x8", +"base": 421495144, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f8ad0", +"base": 421497552, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f8ad0 +0x8", +"base": 421497560, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f9440", +"base": 421499968, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f9440 +0x8", +"base": 421499976, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191f9db0", +"base": 421502384, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191f9db0 +0x8", +"base": 421502392, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191fa720", +"base": 421504800, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191fa720 +0x8", +"base": 421504808, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191fb090", +"base": 421507216, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191fb090 +0x8", +"base": 421507224, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191fba00", +"base": 421509632, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191fba00 +0x8", +"base": 421509640, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191fc370", +"base": 421512048, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191fc370 +0x8", +"base": 421512056, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191fcce0", +"base": 421514464, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191fcce0 +0x8", +"base": 421514472, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191fd650", +"base": 421516880, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191fd650 +0x8", +"base": 421516888, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191fdfc0", +"base": 421519296, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191fdfc0 +0x8", +"base": 421519304, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191fe930", +"base": 421521712, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191fe930 +0x8", +"base": 421521720, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191ff2a0", +"base": 421524128, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191ff2a0 +0x8", +"base": 421524136, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x191ffc10", +"base": 421526544, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x191ffc10 +0x8", +"base": 421526552, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19200580", +"base": 421528960, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19200580 +0x8", +"base": 421528968, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19200ef0", +"base": 421531376, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19200ef0 +0x8", +"base": 421531384, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19201860", +"base": 421533792, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19201860 +0x8", +"base": 421533800, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192021d0", +"base": 421536208, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192021d0 +0x8", +"base": 421536216, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19202b40", +"base": 421538624, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19202b40 +0x8", +"base": 421538632, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192034b0", +"base": 421541040, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192034b0 +0x8", +"base": 421541048, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19203e20", +"base": 421543456, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19203e20 +0x8", +"base": 421543464, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19204790", +"base": 421545872, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19204790 +0x8", +"base": 421545880, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19205100", +"base": 421548288, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19205100 +0x8", +"base": 421548296, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19205a70", +"base": 421550704, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19205a70 +0x8", +"base": 421550712, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192063e0", +"base": 421553120, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192063e0 +0x8", +"base": 421553128, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19206d50", +"base": 421555536, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19206d50 +0x8", +"base": 421555544, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192076c0", +"base": 421557952, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192076c0 +0x8", +"base": 421557960, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19208030", +"base": 421560368, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19208030 +0x8", +"base": 421560376, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192089a0", +"base": 421562784, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192089a0 +0x8", +"base": 421562792, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19209310", +"base": 421565200, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19209310 +0x8", +"base": 421565208, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19209c80", +"base": 421567616, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19209c80 +0x8", +"base": 421567624, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920a5f0", +"base": 421570032, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920a5f0 +0x8", +"base": 421570040, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920af60", +"base": 421572448, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920af60 +0x8", +"base": 421572456, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920b8d0", +"base": 421574864, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920b8d0 +0x8", +"base": 421574872, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920c240", +"base": 421577280, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920c240 +0x8", +"base": 421577288, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920cbb0", +"base": 421579696, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920cbb0 +0x8", +"base": 421579704, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920d520", +"base": 421582112, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920d520 +0x8", +"base": 421582120, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920de90", +"base": 421584528, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920de90 +0x8", +"base": 421584536, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920e800", +"base": 421586944, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920e800 +0x8", +"base": 421586952, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920f170", +"base": 421589360, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920f170 +0x8", +"base": 421589368, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1920fae0", +"base": 421591776, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1920fae0 +0x8", +"base": 421591784, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19210450", +"base": 421594192, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19210450 +0x8", +"base": 421594200, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19210dc0", +"base": 421596608, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19210dc0 +0x8", +"base": 421596616, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19211730", +"base": 421599024, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19211730 +0x8", +"base": 421599032, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192120a0", +"base": 421601440, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192120a0 +0x8", +"base": 421601448, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19212a10", +"base": 421603856, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19212a10 +0x8", +"base": 421603864, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19213380", +"base": 421606272, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19213380 +0x8", +"base": 421606280, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19213cf0", +"base": 421608688, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19213cf0 +0x8", +"base": 421608696, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19214660", +"base": 421611104, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19214660 +0x8", +"base": 421611112, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19214fd0", +"base": 421613520, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19214fd0 +0x8", +"base": 421613528, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19215940", +"base": 421615936, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19215940 +0x8", +"base": 421615944, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192162b0", +"base": 421618352, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192162b0 +0x8", +"base": 421618360, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19216c20", +"base": 421620768, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19216c20 +0x8", +"base": 421620776, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19217590", +"base": 421623184, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19217590 +0x8", +"base": 421623192, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19217f00", +"base": 421625600, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19217f00 +0x8", +"base": 421625608, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19218870", +"base": 421628016, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19218870 +0x8", +"base": 421628024, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192191e0", +"base": 421630432, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192191e0 +0x8", +"base": 421630440, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19219b50", +"base": 421632848, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19219b50 +0x8", +"base": 421632856, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921a4c0", +"base": 421635264, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921a4c0 +0x8", +"base": 421635272, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921ae30", +"base": 421637680, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921ae30 +0x8", +"base": 421637688, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921b7a0", +"base": 421640096, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921b7a0 +0x8", +"base": 421640104, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921c110", +"base": 421642512, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921c110 +0x8", +"base": 421642520, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921ca80", +"base": 421644928, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921ca80 +0x8", +"base": 421644936, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921d3f0", +"base": 421647344, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921d3f0 +0x8", +"base": 421647352, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921dd60", +"base": 421649760, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921dd60 +0x8", +"base": 421649768, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921e6d0", +"base": 421652176, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921e6d0 +0x8", +"base": 421652184, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921f040", +"base": 421654592, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921f040 +0x8", +"base": 421654600, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1921f9b0", +"base": 421657008, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1921f9b0 +0x8", +"base": 421657016, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19220320", +"base": 421659424, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19220320 +0x8", +"base": 421659432, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19220c90", +"base": 421661840, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19220c90 +0x8", +"base": 421661848, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19221600", +"base": 421664256, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19221600 +0x8", +"base": 421664264, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19221f70", +"base": 421666672, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19221f70 +0x8", +"base": 421666680, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192228e0", +"base": 421669088, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192228e0 +0x8", +"base": 421669096, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19223250", +"base": 421671504, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19223250 +0x8", +"base": 421671512, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19223bc0", +"base": 421673920, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19223bc0 +0x8", +"base": 421673928, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19224530", +"base": 421676336, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19224530 +0x8", +"base": 421676344, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19224ea0", +"base": 421678752, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19224ea0 +0x8", +"base": 421678760, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19225810", +"base": 421681168, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19225810 +0x8", +"base": 421681176, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19226180", +"base": 421683584, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19226180 +0x8", +"base": 421683592, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19226af0", +"base": 421686000, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19226af0 +0x8", +"base": 421686008, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19227460", +"base": 421688416, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19227460 +0x8", +"base": 421688424, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19227dd0", +"base": 421690832, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19227dd0 +0x8", +"base": 421690840, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19228740", +"base": 421693248, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19228740 +0x8", +"base": 421693256, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192290b0", +"base": 421695664, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192290b0 +0x8", +"base": 421695672, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x19229a20", +"base": 421698080, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x19229a20 +0x8", +"base": 421698088, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1922a390", +"base": 421700496, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1922a390 +0x8", +"base": 421700504, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1922ad00", +"base": 421702912, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1922ad00 +0x8", +"base": 421702920, +"width": 2400, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1922b670", +"base": 421705328, +"width": 32, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1922b690", +"base": 421705360, +"width": 2896, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1922c1e0", +"base": 421708256, +"width": 262144, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1926c1e0", +"base": 421970400, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1926c1e0 +0x8", +"base": 421970408, +"width": 316, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1926c330", +"base": 421970736, +"width": 32, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1926c350", +"base": 421970768, +"width": 14896, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1926fd80", +"base": 421985664, +"width": 65536, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1927fd80", +"base": 422051200, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1927fd80 +0x8", +"base": 422051208, +"width": 316, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1927fed0", +"base": 422051536, +"width": 32, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1927fef0", +"base": 422051568, +"width": 46800, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1928b5c0", +"base": 422098368, +"width": 65536, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1929b5c0", +"base": 422163904, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x1929b5c0 +0x8", +"base": 422163912, +"width": 316, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1929b710", +"base": 422164240, +"width": 32, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x1929b730", +"base": 422164272, +"width": 20032, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192a0570", +"base": 422184304, +"width": 32768, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192a8570", +"base": 422217072, +"width": 324, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192a86c0", +"base": 422217408, +"width": 32, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192a86e0", +"base": 422217440, +"width": 48, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192a8710", +"base": 422217488, +"width": 512, +"read": "allowed", +"write": "allowed" +}, +{ +"name": "0x192a8910", +"base": 422218000, +"width": 8, +"read": "allowed", +"write": "ignored", +"note": "read back, but kept beside the game" +}, +{ +"name": "0x192a8910 +0x8", +"base": 422218008, +"width": 316, +"read": "allowed", +"write": "allowed" +} +] +} \ No newline at end of file diff --git a/debugger.ts b/debugger.ts new file mode 100644 index 0000000..eb13849 --- /dev/null +++ b/debugger.ts @@ -0,0 +1,551 @@ +/** + * The debugger in the game (TrigScript 4, A5), as the workspace shows it: a Debug view in the panel over a test's + * recorded run (`compiler/gameDebug.ts`, walked by `compiler/debugModel.ts`), and what it puts in the editor — the + * line about to run, the test's line, breakpoints, and the values each line wrote beside it. + * + * Everything is the record, so stepping back is as quick as stepping forward; the one thing asked of the engine as + * the cursor moves is a picture of the game at the cursor's frame, and only when a view to draw it is there. + * + * Keys, as VS Code has them where it can: F5 continue, F6 continue backwards, F10 the next line, Shift+F10 the line + * before, F11 the next frame, Shift+F11 the frame before, Shift+F5 stop, F9 a breakpoint. + */ +import type { PluginApi } from "@scm-js/plugin-api"; +import type { Translate } from "./i18n"; +import type { IconName, Shell } from "./shell"; +import type { LineHint } from "./compiler/compiler"; +import { STEP_BUILD, STEP_TEST, STEP_UNSEEN, type DebugRecording } from "./compiler/gameDebug"; +import { DebugCursor, show, type DebugPlace, type DebugStop } from "./compiler/debugModel"; +import type { VariableMap } from "./compiler/gameVariables"; +import { shortCount } from "./compiler/gameCost"; + +type El = PluginApi["ui"]["el"]; + +/** The engine's side of a session: pictures of the run's frames (scm-sim's `ScmSimDebugSession`). */ +export interface DebugPictures { + picture(frame: number): Promise<{ frame: number; kept: unknown }>; + close(): void; + readonly closed: boolean; +} + +/** Where a picture is drawn: a view of the game the editor made for the session (scm-sim's view), when there is one. */ +export interface DebugGameView { + readonly element: HTMLElement; + show(picture: { frame: number; kept: unknown }): void; + dispose(): void; +} + +export interface DebuggerDeps { + t: Translate; + el: El; + shell: Shell; + /** Shows the line in the editor (opening its file), without taking the keyboard from the panel. */ + reveal(file: string, line: number): void; + /** What the debugger puts in the editor changed: draw it again. */ + changed(): void; + setStatus(kind: "info" | "ok" | "error" | "busy", text: string, timeout?: number): void; +} + +export interface DebugStart { + recording: DebugRecording; + variables: VariableMap | null; + pictures: DebugPictures | null; + /** A view to draw the pictures in, or why there is none (in words). */ + view: DebugGameView | string | null; + /** The script's files as the run was built from them: lines are only meant while the files are the same. */ + files: Record; +} + +export const DEBUG_STYLE = ` +.tsd .tsd-debug:focus { outline: none; } +.tsd .tsd-debug { box-sizing: border-box; height: 100%; display: flex; flex-direction: column; min-height: 0; font-size: var(--fs-md); } +.tsd .tsd-debug-bar { flex: none; display: flex; align-items: center; gap: 2px; padding: 2px 8px; border-bottom: 1px solid var(--border); white-space: nowrap; overflow: hidden; } +.tsd .tsd-debug-bar .tsd-icon-button { display: inline-flex; } +.tsd .tsd-debug-bar .tsd-sep { width: 1px; height: 16px; margin: 0 6px; background: var(--border); } +.tsd .tsd-debug-bar .tsd-where { margin-left: 8px; font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--text); } +.tsd .tsd-debug-bar .tsd-why { margin-left: 10px; overflow: hidden; text-overflow: ellipsis; color: var(--text-dim); font-size: var(--fs-sm); } +.tsd .tsd-debug-bar .tsd-why.tsd-failed { color: var(--danger); } +.tsd .tsd-debug-bar label { display: inline-flex; align-items: center; gap: 4px; margin-left: auto; padding-left: 10px; color: var(--text-dim); font-size: var(--fs-sm); cursor: pointer; } +.tsd .tsd-debug-time { flex: none; height: 40px; margin: 4px 8px 2px; cursor: ew-resize; touch-action: none; } +.tsd .tsd-debug-time svg { display: block; width: 100%; height: 100%; } +.tsd .tsd-debug-time .cost { fill: var(--teal); fill-opacity: 0.3; stroke: var(--teal); stroke-width: 1px; vector-effect: non-scaling-stroke; } +.tsd .tsd-debug-time .hit { fill: var(--danger); } +.tsd .tsd-debug-time .said { fill: var(--gold); } +.tsd .tsd-debug-time .cut { fill: var(--text-faint); fill-opacity: 0.25; } +.tsd .tsd-debug-time .now { fill: var(--text); } +.tsd .tsd-debug-time .end { fill: var(--danger); fill-opacity: 0.6; } +.tsd .tsd-debug-cols { flex: 1; min-height: 0; display: flex; border-top: 1px solid var(--border); } +.tsd .tsd-debug-col { flex: 1 1 0; min-width: 0; overflow: auto; border-right: 1px solid var(--border); } +.tsd .tsd-debug-col:last-child { border-right: none; } +.tsd .tsd-debug-col h5 { position: sticky; top: 0; z-index: 1; margin: 0; padding: 3px 10px; background: var(--bg-1); font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--text-dim); } +.tsd .tsd-debug-col ul { margin: 0; padding: 2px 0; list-style: none; } +.tsd .tsd-debug-col li { display: flex; align-items: baseline; gap: 6px; padding: 1px 10px; line-height: 18px; white-space: nowrap; } +.tsd .tsd-debug-col li.tsd-click { cursor: pointer; } +.tsd .tsd-debug-col li.tsd-click:hover { background: var(--bg-3); } +.tsd .tsd-debug-col li.tsd-here { background: var(--bg-4); } +.tsd .tsd-debug-col li.tsd-dim { color: var(--text-faint); } +.tsd .tsd-debug-col li.tsd-program { padding-top: 4px; color: var(--text-dim); font-size: var(--fs-sm); } +.tsd .tsd-debug-col .k { flex: none; color: var(--text-dim); font-family: var(--font-mono); font-size: var(--fs-sm); } +.tsd .tsd-debug-col .v { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--text); } +.tsd .tsd-debug-col .v.tsd-changed { color: var(--gold); } +.tsd .tsd-debug-col .n { flex: none; margin-left: auto; color: var(--text-faint); font-size: var(--fs-sm); } +.tsd .tsd-debug-col li .tsd-icon-button { visibility: hidden; flex: none; } +.tsd .tsd-debug-col li:hover .tsd-icon-button, .tsd .tsd-debug-col li .tsd-icon-button.tsd-on { visibility: visible; } +.tsd .tsd-debug-col .tsd-icon-button.tsd-on { color: var(--gold); } +.tsd .tsd-debug-col .tsd-empty { padding: 6px 10px; color: var(--text-faint); white-space: normal; line-height: 1.45; } +.tsd .tsd-debug-game { flex: 1.3 1 0; display: flex; flex-direction: column; overflow: hidden; } +.tsd .tsd-debug-game .tsd-view { position: relative; flex: 1; min-height: 0; background: #000; } +.tsd .tsd-debug-overlay { position: absolute; z-index: 4; display: none; background: #000; } +.tsd .tsd-debug-overlay > div, .tsd .tsd-debug-overlay iframe { display: block; width: 100%; height: 100%; border: 0; } +.tsd .tsd-debug-game .tsd-shown { flex: none; max-height: 40%; overflow: auto; padding: 2px 10px; font-family: var(--font-mono); font-size: var(--fs-sm); color: var(--text-dim); } +.tsd .tsd-debug-game .tsd-shown div { white-space: pre-wrap; } +.trigscript-debug-line { background: rgba(214, 178, 74, 0.18); } +.trigscript-debug-arrow::before { content: "\\eb89"; font: normal normal normal 14px/18px codicon; color: var(--gold); padding-left: 2px; } +.trigscript-debug-test { background: rgba(78, 201, 176, 0.12); } +.trigscript-debug-test-arrow::before { content: "\\eb8a"; font: normal normal normal 14px/18px codicon; color: var(--teal); padding-left: 2px; } +.trigscript-bp { cursor: pointer; } +.trigscript-bp::before { content: "\\ea71"; font: normal normal normal 14px/18px codicon; color: #e51400; } +.trigscript-debug-hint { color: var(--gold) !important; opacity: 0.9; } +`; + +const NS = "http://www.w3.org/2000/svg"; + +/** How tall the panel is made for a session: the bar, the timeline, and room for the columns and the game. */ +const DEBUG_PANEL = 340; +/** How long the cursor must stay before a picture of its frame is asked for (scrubbing asks for none on the way). */ +const PICTURE_MS = 60; + +interface Session extends DebugStart { + cursor: DebugCursor; + costs: Int32Array; + /** What the variables read at the last cursor, to colour what changed. */ + last: Map; + /** Why the cursor stands where it does (the last continue), for the bar. */ + stop: DebugStop | null; + picture: { timer: ReturnType | null; asked: number; shown: number }; +} + +export class Debugger { + readonly breakpoints = new Set(); + /** Variables watched: a continue stops where one changes ("wave", or "waves.wave"). */ + readonly watch: string[] = []; + /** The build's own steps (and the test's, and the unseen) are stopped at too. */ + everyStep = false; + private session: Session | null = null; + private readonly d: DebuggerDeps; + private readonly body: HTMLElement; + private readonly bar: HTMLElement; + private readonly time: HTMLElement; + private readonly cols: HTMLElement; + private readonly root: HTMLElement; + /** The three columns, made once a session (the game's view is a frame: moved, it would load again). */ + private readonly varsCol: HTMLElement; + private readonly stepsCol: HTMLElement; + private readonly gameCol: HTMLElement; + private readonly viewBox: HTMLElement; + private readonly shownBox: HTMLElement; + /** + * Where the game's view lives: over the workspace, laid over `viewBox` while the Debug view shows and hidden + * otherwise. The view is a frame, which loads again whenever it is moved or taken out of the page, and the panel + * takes a view's body out when another is shown. + */ + private readonly overlay: HTMLElement; + private placing = 0; + private placed = ""; + + constructor(deps: DebuggerDeps) { + this.d = deps; + const { el, t, shell } = deps; + const view = shell.view({ id: "debug", title: t("Debug") }); + this.body = view.body; + this.bar = el("div", { className: "tsd-debug-bar" }); + this.time = el("div", { className: "tsd-debug-time" }); + this.cols = el("div", { className: "tsd-debug-cols" }); + this.varsCol = el("div", { className: "tsd-debug-col" }); + this.stepsCol = el("div", { className: "tsd-debug-col" }); + this.viewBox = el("div"); + this.shownBox = el("div", { className: "tsd-shown", title: t("The text lines up to this frame") }); + this.gameCol = el("div", { className: "tsd-debug-col tsd-debug-game" }, el("h5", {}, t("The game")), this.viewBox, this.shownBox); + this.overlay = el("div", { className: "tsd-debug-overlay" }); + shell.root.append(this.overlay); + // (Focusable, so that a click anywhere in the view leaves the keys with the workspace: F10 and the rest.) + this.root = el("div", { className: "tsd-debug", tabIndex: -1 }, this.bar, this.time, this.cols); + this.body.append(this.root); + this.time.addEventListener("pointerdown", (e) => { + if (!this.session) return; + // (The graph is drawn anew under the pointer, so the browser would give the focus to the page: the view takes it.) + e.preventDefault(); + this.root.focus({ preventScroll: true }); + this.time.setPointerCapture(e.pointerId); + this.scrub(e); + const move = (m: PointerEvent) => this.scrub(m); + const up = () => { this.time.removeEventListener("pointermove", move); this.time.removeEventListener("pointerup", up); }; + this.time.addEventListener("pointermove", move); + this.time.addEventListener("pointerup", up); + }); + this.render(); + } + + get active(): boolean { return this.session !== null; } + + /** + * Where a view of the game for the next session goes (`DebugStart.view`'s element must be this): in the page from + * the start, since a frame made outside it never loads. + */ + gameHost(): HTMLElement { + const host = this.d.el("div"); + this.overlay.replaceChildren(host); + return host; + } + + /** Keeps the game's view over its place in the Debug view, and out of sight while that is not shown. */ + private place = () => { + this.placing = 0; + const s = this.session; + if (!s || !s.view || typeof s.view === "string") { this.overlay.style.display = "none"; this.placed = ""; return; } + const root = this.d.shell.root, box = this.viewBox; + const b = box.isConnected ? box.getBoundingClientRect() : null; + let key = "none"; + if (b) { + // (Only what of it shows: every box around it that clips, up to the workspace, cuts it.) + let left = b.left, top = b.top, right = b.right, bottom = b.bottom; + for (let e = box.parentElement; e && e !== root; e = e.parentElement) { + if (getComputedStyle(e).overflow === "visible") continue; + const c = e.getBoundingClientRect(); + left = Math.max(left, c.left); top = Math.max(top, c.top); right = Math.min(right, c.right); bottom = Math.min(bottom, c.bottom); + } + const o = root.getBoundingClientRect(); + if (right - left > 0 && bottom - top > 0) key = `${Math.round(left - o.left)},${Math.round(top - o.top)},${Math.round(right - left)},${Math.round(bottom - top)}`; + } + if (key !== this.placed) { + this.placed = key; + if (key === "none") this.overlay.style.display = "none"; + else { + const [x, y, w, h] = key.split(","); + Object.assign(this.overlay.style, { display: "block", left: `${x}px`, top: `${y}px`, width: `${w}px`, height: `${h}px` }); + } + } + this.placing = requestAnimationFrame(this.place); + }; + + /** A recorded run to walk: the cursor at its start, or at its end when the test failed (where debugging begins). */ + start(s: DebugStart): void { + const keep = s.view && typeof s.view !== "string" ? s.view.element : null; + this.end(keep); + const cursor = new DebugCursor(s.recording, s.variables); + this.session = { ...s, cursor, costs: cursor.frameCosts(), last: new Map(), stop: null, picture: { timer: null, asked: -1, shown: -1 } }; + const { el, t } = this.d; + this.viewBox.replaceChildren(...(keep ? [] : [el("div", { className: "tsd-empty" }, typeof s.view === "string" ? s.view : t("No picture of the game here."))])); + this.viewBox.className = keep ? "tsd-view" : ""; + if (!keep) this.overlay.replaceChildren(); + if (keep && !this.placing) this.placing = requestAnimationFrame(this.place); + this.cols.replaceChildren(this.varsCol, this.stepsCol, this.gameCol); + if (s.recording.result.status === "failed" && s.recording.frames) cursor.toFrame(s.recording.frames - 1); + else this.firstLine(); + this.d.shell.showPanel("debug"); + this.d.shell.growPanel(DEBUG_PANEL); + this.moved(); + } + + /** The session is over: the engine's game let go, the editor's marks taken away (`keep`: a view made for the next). */ + end(keep: HTMLElement | null = null): void { + if (!keep) { this.overlay.replaceChildren(); this.overlay.style.display = "none"; this.placed = ""; } + if (this.placing) { cancelAnimationFrame(this.placing); this.placing = 0; } + const s = this.session; + if (!s) return; + if (s.picture.timer) clearTimeout(s.picture.timer); + s.pictures?.close(); + if (s.view && typeof s.view !== "string" && s.view.element !== keep) s.view.dispose(); + this.viewBox.replaceChildren(); + this.session = null; + this.render(); + this.d.changed(); + } + + /* ── what the person does ── */ + + toggleBreakpoint(file: string, line: number): void { + const k = `${file}:${line}`; + if (!this.breakpoints.delete(k)) this.breakpoints.add(k); + this.d.changed(); + if (this.session) this.renderTime(); + } + + toggleWatch(name: string): void { + const at = this.watch.indexOf(name); + if (at >= 0) this.watch.splice(at, 1); else this.watch.push(name); + this.render(); + } + + continueForward(): void { this.go((c) => c.continueForward(this.breakpoints, this.watch)); } + continueBack(): void { this.go((c) => c.continueBack(this.breakpoints, this.watch)); } + stepForward(): void { this.go((c) => { c.stepForward(this.everyStep); return null; }); } + stepBack(): void { this.go((c) => { c.stepBack(this.everyStep); return null; }); } + nextFrame(): void { this.go((c) => { c.nextFrame(); return null; }); } + prevFrame(): void { this.go((c) => { c.prevFrame(); return null; }); } + restart(): void { this.go(() => { this.firstLine(); return null; }); } + + /** The run's first line of the script (frames with none run, such as the first, are passed over). */ + private firstLine() { + const c = this.session!.cursor; + c.moveTo({ k: 0, s: c.frameStart(0) }); + if (this.everyStep) return; + while (!c.line()) { + const was = c.where; + c.stepForward(false); + const now = c.where; + if (now.k === was.k && now.s === was.s) { c.moveTo({ k: 0, s: c.frameStart(0) }); return; } + } + } + + private go(move: (c: DebugCursor) => DebugStop | null): void { + const s = this.session; + if (!s) return; + s.stop = move(s.cursor); + this.moved(); + } + + private scrub(e: PointerEvent) { + const s = this.session; + if (!s || !s.recording.frames) return; + const r = this.time.getBoundingClientRect(); + const k = Math.floor(((e.clientX - r.left) / Math.max(1, r.width)) * s.recording.frames); + s.cursor.toFrame(Math.max(0, Math.min(s.recording.frames - 1, k))); + s.stop = null; + this.moved(); + } + + /** The cursor moved: the editor's line, the view, the picture. */ + private moved() { + const s = this.session; + if (!s) return; + const at = s.cursor.line() ?? s.cursor.testLine(); + if (at) this.d.reveal(at.file, at.line); + this.render(); + this.d.changed(); + this.askPicture(); + } + + /* ── what goes in the editor ── */ + + /** The editor's marks: breakpoints, the line about to run, the test's line. */ + marks(): { breakpoints: DebugPlace[]; at: DebugPlace | null; test: DebugPlace | null } { + const breakpoints = [...this.breakpoints].map((k) => { const i = k.lastIndexOf(":"); return { file: k.slice(0, i), line: Number(k.slice(i + 1)) }; }); + const s = this.session; + if (!s || !this.current()) return { breakpoints, at: null, test: null }; + return { breakpoints, at: s.cursor.line(), test: s.cursor.testLine() }; + } + + /** Beside each line run so far in the frame: the variables it wrote, at the cursor, and how often it ran. */ + hints(): LineHint[] { + const s = this.session; + if (!s || !this.current() || !s.cursor.known) return []; + const { t } = this.d; + const values = new Map(); + for (const p of s.cursor.programs()) for (const v of p.values) if (!values.has(v.name)) values.set(v.name, show(v.value, 40)); + return s.cursor.frameLines().map((l) => { + const said = l.names.map((n) => `${n} = ${values.get(n) ?? "?"}`); + if (l.steps > 1) said.push(`×${l.steps}`); + return said.length ? { file: l.place.file, line: l.place.line, label: said.join(" "), note: t("What this line wrote in frame {frame}, as it stands at the debugger's cursor; ×n: how many times it ran in the frame so far.", { frame: s.cursor.frame() }) } : null; + }).filter((h): h is LineHint => h !== null); + } + + /** The script is as the run was built from: lines mean what they meant. */ + private current(): boolean { + const s = this.session; + return !!s && Object.keys(s.files).length > 0 && this.filesNow !== null && Object.entries(s.files).every(([f, text]) => this.filesNow![f] === text); + } + private filesNow: Record | null = null; + /** The editor's files now (the debugger's marks show only while they are the run's). */ + setFiles(files: Record): void { + const was = this.current(); + this.filesNow = files; + if (this.session && was !== this.current()) { this.render(); this.d.changed(); } + } + + /* ── the picture ── */ + + private askPicture() { + const s = this.session; + if (!s || !s.pictures || !s.view || typeof s.view === "string") return; + if (s.picture.timer) clearTimeout(s.picture.timer); + s.picture.timer = setTimeout(() => { + s.picture.timer = null; + const c = s.cursor; + // (Mid-frame, the game as the frame found it: the frame before's end.) + const frame = c.atEnd() ? c.frame() : c.frame() - 1; + if (frame === s.picture.shown || frame === s.picture.asked || s.pictures!.closed) return; + s.picture.asked = frame; + s.pictures!.picture(frame).then( + (p) => { + if (this.session !== s || typeof s.view === "string" || !s.view) return; + s.picture.asked = -1; + s.picture.shown = p.frame; + s.view.show(p); + // The cursor moved on while it was made: the next. + this.askPicture(); + }, + (err: Error) => { s.picture.asked = -1; if (this.session === s) this.d.setStatus("error", this.d.t("No picture of the game: {message}", { message: err.message })); }, + ); + }, PICTURE_MS); + } + + /* ── the view ── */ + + private button(icon: IconName, title: string, run: () => void, disabled = false): HTMLElement { + const b = this.d.shell.iconButton({ icon, title, run }); + b.set({ disabled }); + return b.element; + } + + private render() { + // (A button pressed here is drawn anew: the keys stay with the view rather than going to the page.) + const focused = this.root.contains(document.activeElement); + this.draw(); + if (focused && !this.root.contains(document.activeElement)) this.root.focus({ preventScroll: true }); + } + + private draw() { + const { el, t } = this.d; + const s = this.session; + if (!s) { + this.bar.replaceChildren(el("span", { className: "tsd-why" }, t("No test is being debugged. Debug one from its row in the Testing view (the bug icon) or with Debug Test at Cursor: it runs once in the game with everything it did written down, and here you walk it, forwards and back."))); + this.time.replaceChildren(); + this.cols.replaceChildren(); + return; + } + if (this.cols.childElementCount !== 3) this.cols.replaceChildren(this.varsCol, this.stepsCol, this.gameCol); + const c = s.cursor, rec = s.recording; + const none = !rec.frames; + const why = (() => { + if (!this.current()) return { text: t("The script changed since this run: its lines may have moved. Debug the test again to walk the script as it is."), failed: true }; + if (s.stop?.kind === "breakpoint") return { text: t("Stopped at the breakpoint on line {line}.", { line: s.stop.place.line }), failed: false }; + if (s.stop?.kind === "watch") return { text: t("{name} changed: {before} → {after}", { name: s.stop.name, before: s.stop.before, after: s.stop.after }), failed: false }; + if (s.stop?.kind === "start") return { text: t("The start of the run."), failed: false }; + if (s.stop?.kind === "end") return { text: rec.result.status === "failed" ? t("The end of the run: {message}", { message: rec.result.message ?? t("the test failed") }) : t("The end of the run: the test passed."), failed: rec.result.status === "failed" }; + if (rec.cut && !c.known) return { text: t("The record stops at frame {frame} ({why}): the variables are not known past it.", { frame: rec.cut.frame, why: rec.cut.why }), failed: true }; + return rec.result.status === "failed" ? { text: t("{test} failed: {message}", { test: rec.test.name, message: rec.result.message ?? "" }), failed: true } : { text: t("{test} passed.", { test: rec.test.name }), failed: false }; + })(); + const line = c.line(); + const where = none ? t("no frames") : `${t("frame {frame}", { frame: c.frame() })} ${(c.frame() / 24).toFixed(2)} s ${line ? `${line.file}:${line.line}` : c.atEnd() ? t("end of frame") : t("the build's own triggers")}`; + const every = el("input", { type: "checkbox", checked: this.everyStep }) as HTMLInputElement; + every.addEventListener("change", () => { this.everyStep = every.checked; this.render(); }); + this.bar.replaceChildren( + this.button("debug-reverse-continue", t("Continue backwards (F6): to the breakpoint or the change of a watched variable before this, or the start"), () => this.continueBack(), none), + this.button("debug-step-back", t("Back a line (Shift+F10)"), () => this.stepBack(), none), + this.button("debug-step-over", t("On a line (F10)"), () => this.stepForward(), none), + this.button("debug-continue", t("Continue (F5): to the next breakpoint or change of a watched variable, or the end"), () => this.continueForward(), none), + el("span", { className: "tsd-sep" }), + this.button("arrow-left", t("Back a frame (Shift+F11)"), () => this.prevFrame(), none), + this.button("arrow-right", t("On a frame (F11): to the end of this one"), () => this.nextFrame(), none), + this.button("debug-restart", t("To the start of the run"), () => this.restart(), none), + this.button("debug-stop", t("Stop debugging (Shift+F5)"), () => this.end()), + el("span", { className: "tsd-where" }, where), + el("span", { className: why.failed ? "tsd-why tsd-failed" : "tsd-why", title: why.text }, why.text), + el("label", { title: t("Stop at every step, the build's own triggers and what the game wrote by itself included, not only at the script's lines") }, every, t("Every step")), + ); + this.renderTime(); + this.renderCols(); + } + + private renderTime() { + const s = this.session; + if (!s) return; + const rec = s.recording, n = Math.max(rec.frames, 1), c = s.cursor; + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${n} 100`); + svg.setAttribute("preserveAspectRatio", "none"); + const rect = (x: number, y: number, w: number, h: number, cls: string, title?: string) => { + const r = document.createElementNS(NS, "rect"); + r.setAttribute("x", String(x)); r.setAttribute("y", String(y)); r.setAttribute("width", String(w)); r.setAttribute("height", String(h)); r.setAttribute("class", cls); + if (title) { const tt = document.createElementNS(NS, "title"); tt.textContent = title; r.append(tt); } + svg.append(r); + }; + const top = Math.max(1, ...s.costs); + const area = document.createElementNS(NS, "path"); + const pts: string[] = []; + s.costs.forEach((v, k) => { const y = (100 - (v / top) * 70).toFixed(2); pts.push(`${k},${y} ${k + 1},${y}`); }); + area.setAttribute("d", `M0,100 L${pts.join(" L")} L${n},100 Z`); + area.setAttribute("class", "cost"); + svg.append(area); + const { t } = this.d; + if (rec.cut) { const k = rec.cut.frame - rec.first; rect(k, 0, n - k, 100, "cut", t("Not recorded past frame {frame}: {why}", { frame: rec.cut.frame, why: rec.cut.why })); } + const w = Math.max(n / 600, 0.6); + for (const b of this.marks().breakpoints) for (const k of c.hits(b)) rect(k, 0, w, 14, "hit", t("Line {line} runs in frame {frame}", { line: b.line, frame: rec.first + k })); + for (const x of rec.shown) rect(x.frame - rec.first, 88, w, 12, "said", x.text); + if (rec.result.status === "failed") rect(n - w, 0, w, 100, "end", rec.result.message ?? ""); + rect(c.where.k, 0, Math.max(w, n / 300), 100, "now"); + const title = document.createElementNS(NS, "title"); + title.textContent = t("Frames {from} to {to}: the script's conditions and actions a frame ({peak} at the most). Red: a breakpoint's line runs; gold: a line of text was shown. Click or drag to go to a frame.", { from: rec.first, to: rec.first + rec.frames - 1, peak: shortCount(top) }); + svg.prepend(title); + this.time.replaceChildren(svg); + } + + private renderCols() { + const { el, t } = this.d; + const s = this.session!; + const c = s.cursor, rec = s.recording; + + // Watched variables and every program's, at the cursor, what changed since the last stop in gold. + const vars = this.varsCol; + vars.replaceChildren(el("h5", {}, t("Variables"))); + const list = el("ul"); + const now = new Map(); + if (!c.known) list.append(el("li", { className: "tsd-empty" }, t("Not known here: the record stops at frame {frame}.", { frame: rec.cut!.frame }))); + else { + const programs = c.programs(); + if (this.watch.length) { + list.append(el("li", { className: "tsd-program" }, t("Watched"))); + for (const name of this.watch) list.append(this.varRow(name, c.watchText(name), s.last.get(`watch:${name}`), true, () => this.toggleWatch(name))); + for (const name of this.watch) now.set(`watch:${name}`, c.watchText(name)); + } + if (!programs.length) list.append(el("li", { className: "tsd-empty" }, t("The build did not say where the programs' variables are."))); + for (const p of programs) { + list.append(el("li", { className: "tsd-program" }, p.label)); + for (const v of p.values) { + const text = show(v.value, 400), id = `${p.label}:${v.name}`; + now.set(id, text); + const watchName = programs.filter((x) => x.values.some((y) => y.name === v.name)).length > 1 ? `${p.label.split(" ")[0]}.${v.name}` : v.name; + list.append(this.varRow(v.name, text, s.last.get(id), this.watch.includes(watchName), () => this.toggleWatch(watchName))); + } + } + } + s.last = now; + vars.append(list); + + // The frame's steps, the cursor's highlighted; a click goes there. + const steps = this.stepsCol; + steps.replaceChildren(el("h5", {}, t("Frame {frame}", { frame: c.frame() }))); + const rows = el("ul"); + const k = c.where.k; + const first = c.frameStart(k), end = c.frameEnd(k); + const most = 400; + for (let i = first; i < Math.min(end, first + most); i++) { + const l = rec.stepLine[i], place = c.placeOf(i); + if (!this.everyStep && l === STEP_BUILD && rec.stepWrites[i + 1] === rec.stepWrites[i]) continue; + const label = place ? `${place.file === "main.ts" ? "" : `${place.file} `}${t("line {line}", { line: place.line })}` : l === STEP_TEST ? t("the test") : l === STEP_UNSEEN ? t("the game itself") : t("the build's own triggers"); + const wrote = rec.stepWrites[i + 1] - rec.stepWrites[i]; + const row = el("li", { className: `tsd-click${i === c.where.s ? " tsd-here" : ""}${place ? "" : " tsd-dim"}`, onClick: () => { c.moveTo({ k, s: i }); s.stop = null; this.moved(); } }, + el("span", { className: "k" }, label), + el("span", { className: "v" }, wrote ? t("{n, plural, one {# write} other {# writes}}", { n: wrote }) : ""), + el("span", { className: "n", title: t("conditions and actions") }, String(rec.stepRecords[i]))); + rows.append(row); + } + if (end - first > most) rows.append(el("li", { className: "tsd-dim" }, t("… and {n} more steps", { n: end - first - most }))); + rows.append(el("li", { className: `tsd-click${c.atEnd() ? " tsd-here" : ""} tsd-dim`, onClick: () => { c.moveTo({ k, s: end }); s.stop = null; this.moved(); } }, el("span", { className: "k" }, t("end of frame")), el("span", { className: "n" }, String(s.costs[k] ?? 0)))); + steps.append(rows); + + // What was shown on the game's text lines up to the cursor's frame (the picture is drawn into its own box). + const shown = c.shown(); + this.shownBox.replaceChildren(...(shown.length ? shown.map((x) => el("div", {}, `${x.frame} ${x.text}`)) : [el("div", {}, t("Nothing shown yet."))])); + } + + private varRow(name: string, text: string, before: string | undefined, watched: boolean, toggle: () => void): HTMLElement { + const { el, t } = this.d; + const eye = this.d.shell.iconButton({ icon: "eye", title: watched ? t("Stop watching {name}", { name }) : t("Watch {name}: Continue stops where it changes", { name }), run: toggle }); + if (watched) eye.element.classList.add("tsd-on"); + return el("li", { title: text }, + el("span", { className: "k" }, name), + el("span", { className: before !== undefined && before !== text ? "v tsd-changed" : "v" }, text), + eye.element); + } +} diff --git a/docs/ir.md b/docs/ir.md index cdd8982..335bd1e 100644 --- a/docs/ir.md +++ b/docs/ir.md @@ -1,9 +1,14 @@ # The TrigScript IR What a `program(() => { … })` body means, written down as data. The compiler's front end -(`compiler/structured.ts`) turns the TypeScript into this, `python/trigscript.py` lowers -it to eudplib when the map is saved, and `compiler/simulateIr.ts` interprets it for -Simulate and the tests. **Version 14** (13 had no text in the cells of an array: no `textAt`, `storeText`, `releaseText`; 12 had no texts: a text was the parts of a `print` and nothing else; 11 had no `slice` and no `through`: every array was cells of its own; 10 had no recursion: no `recursive`, no `saves`, and the heap's top set aside for a stack nothing used; 9 had no functions that are called: every `call` carried a body of its own; 8 had no `unitAt` / `unitPart`, so no array could hold a unit; 7 had no arrays that grow; 6 had no arrays; 5 had unsigned numbers only, the two-sided reading of `+` and `−`, no `>>>` and no `unsigned` anywhere; 4 had no input, no `centerLocation`, and one `variable` on an action where 5 has a list; 3 had no units and no tables; 2 had no reads, no `random(n)`, no bitwise operators +(`compiler/structured.ts`) turns the TypeScript into this, the backend (`compiler/backend/`, +the map of our own) lays it out into the map when it is saved, and `compiler/simulateIr.ts` +interprets it for Simulate and the tests. Until TrigScript 4's step 17 a second backend read it +too — `python/trigscript.py`, which lowered it to eudplib — and the IR carried a version number +for the two to agree on; now the front end, the backend and the simulator ship together, and +there is none. The history, for what each addition was: **26** a `graph` statement, a loop the +emitter had lowered for the Python side to make as it was (gone with the Python); **25** value +ranges; **24** (23 had no `held` on a variable, and no function kept as a value used a variable of the block it was written in; 22 had nothing only one computer knows: no `syncSend`, no `select`, no `selected` unit and no `sync` input; 21 had no errors: no `try`, `throw` or `uncaught`, no `errors` on a program, and a stack that ran out ended the program; 20 had no functions as values: no `fn` on a `const`, and every variable a function used was declared before the function was first called; 19 had no fixed numbers: no `fixed` on a variable, an array, a `const` or a printed number, and no `fmul`, `fdiv`, `sqrt` or `atan2`; 18 had only two kinds of event: a `unitLoop`'s `source` was `created` or `died`, with no `was` and no `event`; 17 had no events: no `source` on a `unitLoop` and no `listen` on a `start`; 16 had no `buildText`, no `built` and no `char` part: a text was made whole or not at all; 15 had no tasks: no `start`, `cancel`, `running`, and no `task` on a declaration; 14 had no `parallel`: nothing ran beside a program's body; 13 had no text in the cells of an array: no `textAt`, `storeText`, `releaseText`; 12 had no texts: a text was the parts of a `print` and nothing else; 11 had no `slice` and no `through`: every array was cells of its own; 10 had no recursion: no `recursive`, no `saves`, and the heap's top set aside for a stack nothing used; 9 had no functions that are called: every `call` carried a body of its own; 8 had no `unitAt` / `unitPart`, so no array could hold a unit; 7 had no arrays that grow; 6 had no arrays; 5 had unsigned numbers only, the two-sided reading of `+` and `−`, no `>>>` and no `unsigned` anywhere; 4 had no input, no `centerLocation`, and one `variable` on an action where 5 has a list; 3 had no units and no tables; 2 had no reads, no `random(n)`, no bitwise operators and no `print`; 1 had the map's string indices in the records and a `cyclesPerSecond` on the program, for the death-counter backend 3.0 removed). The types are in `compiler/ir.ts`; this is the reference for anyone reading the lowering or writing @@ -45,7 +50,7 @@ What is *not* settled is anything the lowering decides: what a temporary is, how ## Programs ``` -Program { version, name?, owner, owners, perPlayer, arrays: ArrayDecl[], functions?: FuncDecl[], body: Stmt[], at } +Program { name?, owner, owners, perPlayer, arrays: ArrayDecl[], functions?: FuncDecl[], errors?: { thrown, message, names }, body: Stmt[], at } ``` `owner` is the first player slot among the owners (what a simulation runs the program @@ -60,7 +65,7 @@ the statement as the source has it ("L12: while (x < 3)"), for a log or a debugg ## Variables ``` -VarDecl { id, name, kind: "number" | "boolean" | "unit" | "text", text?: "id" | "made", shared, bits?: 8 | 16, unsigned?, temp?, at } +VarDecl { id, name, kind: "number" | "boolean" | "unit" | "text", text?: "id" | "made", shared, bits?: 8 | 16, unsigned?, temp?, task?, held?, at } ``` `id` is unique within the program (`name#n`); `name` is the source's. `shared` is a @@ -69,12 +74,16 @@ VarDecl { id, name, kind: "number" | "boolean" | "unit" | "text", text?: "id" | call's result). A variable exists from its `declare` statement on; a backend allocates storage in that order. A `unit` variable holds a unit of the game or none; its `declare`, a parameter's `init` and a `return` carry a unit expression (below). A `text` variable -is kept the way `text` says (*Texts*, below). +is kept the way `text` says (*Texts*, below). `task` marks a variable of a task's body (*Tasks*, +below): it has a cell for each slot of that `start`, within each player's row in a per-player program. +`held` (since version 24) marks a variable that holds a reference the closures count (*Closures*, below): its own +`declare` reads it first, to let go of what it held, so before it was ever declared it reads 0 — what a backend's cell +holds anyway. ## Arrays ``` -ArrayDecl { id, name, kind: "number" | "boolean", length, shared, bits?: 8 | 16, unsigned?, values?: number[], dynamic?, slice?, through?, at } +ArrayDecl { id, name, kind: "number" | "boolean", length, shared, bits?: 8 | 16, unsigned?, values?: number[], dynamic?, slice?, through?, task?, at } ``` `Program.arrays` lists every array of the program, those of inlined functions included, so a @@ -109,8 +118,8 @@ size class — a cell each, or a row of twelve each in a per-player program. a block given back waits in its size's list for whoever wants that size next (its first cell links the next), and new ground is taken from the bottom of the heap up. Nothing splits or joins blocks. When there is no block to take, nothing is pushed and the game says so once, in red; the interpreter - records a fault. `compiler/simulateIr.ts` counts blocks exactly as `python/trigscript.py` hands them - out, so both run out at the same push — a probe expects the same number from each. + records a fault. `compiler/simulateIr.ts` counts blocks exactly as the backend (`compiler/backend/heap.ts`) + hands them out, so both run out at the same push — a probe expects the same number from each. - `declareArray` of a growing array first gives back the block the handle holds. So an array declared in a loop, or in a function called again, holds one block at a time, and what the heap can lose is bounded by the number of declarations; there is no other freeing and no collector. @@ -119,6 +128,15 @@ size class — a cell each, or a row of twelve each in a per-player program. (`xs.pop() ?? d` arrives as a `ternary` on the length). `setLength` only cuts. - `length` of an array that does not grow never reaches a backend: `numbers.ts` makes it a constant on its final pass, when every push has been met. +- **Where the backend keeps an array's cells.** Blocks are counted and handed out as above, but a growing + array's cells are not the heap's dwords: they are a region of cell triggers as long as the heap (a cell every 72 + bytes, which a read jumps into; `compiler/backend/heap.ts`); the dwords are left to texts, whose copies need bytes. A + block that moves is copied a cell at a time. Past `CELL_HEAP_MOST` (65 536) heap cells, 72 bytes a cell is too much + memory and arrays keep the dwords, read a bit at a time, as before. A variable whose every store is a constant or + itself plus or minus one, read anywhere else only as a value, keeps its offset into such cells beside it, so a read + or a store at it costs a few triggers whatever the array's length; and inside `for (…; i < xs.length; …)` whose body + changes no length, calls nothing and does not wait, `xs[i]` is not checked again, and another growing array at `i` + is checked once on the way in. None of this is in the IR: the simulator counts blocks and bounds as it always did. - The heap is the arrays' alone. Until version 11 its top was set aside for recursion's stack, growing down towards the blocks; the stack is an array of its own now (*Recursion*, below), so that how deep a function may go does not depend on what the arrays hold at that moment. @@ -167,7 +185,7 @@ frame's `saves`, and a `slice` is never declared. | `store` | `array`, `index`, `value` | `hp[i] = value`; `hp[i] += v` arrives as a store of `hp[i] + v`. The value is evaluated, then the index; nothing is stored past either end. | | `assignBool` | `target`, `value` | `target = value` for a boolean. | | `assignUnit` | `target`, `value` | `target = value` for a unit variable. | -| `unitLoop` | `decl`, `filter`, `body` | `for (const u of unitsAt(…))`: the body once for every unit the filter matches, in the order of the game's unit table, within the frame (the compiler refuses a `sleep` inside). `decl` is the unit of the turn; `break` and `continue` are the loop's. | +| `unitLoop` | `decl`, `filter`, `source?`, `was?`, `event?`, `body` | `for (const u of unitsAt(…))`: the body once for every unit the filter matches, oldest first — the game's unit table from slot 0 and then from 1699 downwards, stopping once every unit in use has been met — within the frame (the compiler refuses a `sleep` inside). `decl` is the unit of the turn; `break` and `continue` are the loop's. With `source` (`on.created` / `on.died`, since version 18; `killed`, `ordered`, `morphed` since 19) the units are this frame's events pass's instead, `was` a number variable set at each turn and `event` a filter of the event's own (*Events*, below). | | `unitWrite` | `unit`, `field`, `value` | `u.hp = 40`, `u.invincible = true`. Nothing happens when the unit is none or gone. Hit points at 0 kill. | | `unitDo` | `unit`, `verb` | `{ do: "kill" }`, `{ do: "remove" }`, `{ do: "give", to }`, `{ do: "order", order, target }` (a location), `{ do: "damage" \| "heal", amount, percent }`, `{ do: "locate", location }`. Nothing happens when the unit is none or gone; an amount is evaluated either way. | | `tableWrite` | `cell`, `value`, `scaled?`, `boolean?` | A cell of the game's tables. `value` is a number expression, a boolean one (`boolean`), or `{ kind: "text", text }` for a name. `scaled`: the number is already what the cell stores. | @@ -179,21 +197,94 @@ frame's `saves`, and a `slice` is never declared. | `switch` | `value`, `cases: { value, body }[]` | Cases tested in order; `value` null is `default`, `NaN` a case whose value could not be read (no test, still fallen into). Bodies fall through unless they `break`. | | `break`, `continue` | | Of the nearest loop (`break` also of a `switch`). | | `return` | `value?` | Inside a `call` body: writes the result and leaves the call. | -| `sleep` | `ms?`, `cycles?` | Park the program: a duration in milliseconds, or in frames (`cycles`). `cycles: 1` goes on in the very next frame. | +| `sleep` | `ms?`, `cycles?` | Park the program: a duration in milliseconds, or in frames (`cycles`). `cycles: 1` goes on in the very next frame. Inside a `parallel` branch it parks that branch alone. | +| `parallel` | `mode`, `branches` | `await Promise.all([…])` (`all`) or `await Promise.race([…])` / `Promise.any` (`race`). Each branch is a coroutine of its own with its own place to go on from and its own wait: started here, in order, in this frame; then, every frame the program is at this statement, each branch that has not ended goes on, in order. The program goes on past it in the frame the last branch ends (`all`) or the first (`race`, which leaves the others where they are: they never run again). A branch is never left by `break`, `continue` or `return`. Not inside a called function, a `unitLoop` or a `textLoop`. Since version 15. | +| `start` | `task`, `max`, `call`, `handle?`, `listen?` | `start(wave(n))`, `void wave(n)`: a task that runs beside what started it (*Tasks*, below). `call` is the async function's body inlined, its `params` copies of this start's arguments, worked out here; the body runs in the task. `handle` takes the task's number, or 0 when every slot was running. Since version 16. `listen` (`{ source }` or `{ inputs }`, since version 18): a listener's start (*Events*, below). | +| `cancel` | `task` (a number) | `t.cancel()`: the task stops where it is; nothing happens for a number that is no running task's. Since version 16. | +| `buildText` | `buffer`, `parts`, `reset?` | The parts written onto the end of buffer `buffer` — emptied first when `reset` — which a `built` reads back (*Texts put together a piece at a time*, below). Since version 17. | | `print` | `parts`, `to`, `position` | Text with values in it. `parts` are `{ kind: "text", text }`, `{ kind: "number", expr, unsigned? }` (its digits, with a minus sign when it is below zero and not `unsigned`), `{ kind: "name", player }` and `{ kind: "color", player }` (the colour code of the player's colour), a player being a slot or 13 for the current player. `to` is who sees it: a slot, 13, All Players (17) or a force (18–21). `position` is `chat` or `center`, the line the game's own errors use. Every number is evaluated before anything is shown. | | `action` | `record`, `variables?` | A trigger action. Each of `variables` names a field of the record that takes an expression's value: `{ field, bits: 8, 16 or 32, name, expr }` — the unit count of `createUnit` and friends is an 8-bit field, a unit type a 16-bit one (the lowering stops it at 228), an amount with a modifier a 32-bit one. Version 4 had one, as `variable`. | | `centerLocation` | `location`, `x`, `y` | Centre a location (1-based) on a point of the map in pixels, its size kept. | +| `syncSend` | `slot`, `player`, `value` | `sync()`'s first half: on every computer, the `sync` input of `slot` from `player` cleared to 0; on `player`'s own computer `value` + 1 (0 … 1 048 574 + 1) put where MSQC's `val` sends it from. `value` may read what only that computer knows. Since version 23. | +| `select` | `player`, `units` | On `player`'s own computer: its selection becomes the units still there (up to 12), the panel told it changed, and a Select command of them queued, which every computer runs. Nothing on the others. Since version 23. | | `call` | `call` | A function as a statement (its result, if any, unused): inlined, or with `fn` called. | | `block` | `body` | Scoping only. | +| `try` | `body`, `catch` | `body` runs; an error thrown inside it stops it there and `catch` runs (*Errors*, below). Since version 22. | +| `throw` | `rethrow?` | The error in flight goes to the nearest `try` around this statement, out of the called functions it is in. Since version 22. | +| `uncaught` | | The error in flight reached the end of a program, a task or a handler: said in red, and given up. Since version 22. | | `remark` | `text`, `short?` | A word for the editor about the line: a loop unrolled when the script was applied. | +### Events + +`on.created`, `on.died`, `on.killed`, `on.order`, `on.morphed`, `on.chat` and `on.key` are each a `start` with `max` 1 and `listen`, whose body is +`while (true) { sleep 1; … }`: the first turn, in the frame it is started in, only waits, so a listener hands +on what happens from the next frame. What it hands on is a `unitLoop` with a `source`, or for a typed line or +a key an `if` on the `input` for each player it listens to, unrolled (the player is a value of the script in +each copy). A handler that is not async is inlined there; an async one is a `start` of its own each time, its +`params` copies of what it is handed. Once a listener has begun, the lowering may skip its turn in a frame +nothing it listens for happened (`listen.source`: the pass found none of those; `listen.inputs`: none +arrived), since such a turn does nothing; the simulator gives it every turn. A listener written at the top of +a file is a program of its own (owner Player 1, not per player) whose body is that one `start`. + +The **events pass** runs once a frame before the programs, when the IR has a `unitLoop` with a `source`. It +walks two of the game's lists (1.16.1's addresses: the visible units at 0x628430 and the dying ones at +0x62842C). The visible list is walked from its head along "next": it links a record in just behind its head, +so the records linked in since the pass before lie between the head and the record that was behind it then. +The dying list is walked whole, along "next" and then "previous" from the record its address gives, since +Remastered's EUD layer gives the list's other end there. A cell for each unit +slot holds the uniqueness byte of the unit last seen in it, and once that unit's death has been handed on, +one more: a unit on the visible list whose cell is not its byte came on (`created`); a unit on the dying +list whose cell is its byte has died or been removed (`died`), and is handed on once. A unit made and gone +between two passes, a turret, a scanner sweep and a unit that never left its transport are neither. When the +head and the record behind it are both as the pass before left them, the visible walk is passed by; when that +record has left the list, the walk goes to the end. At most 512 of each a frame; the rest come in the next. + +A `created` turn is a unit still on the map (one gone since the pass is passed over); a `died` turn is the +dying record, whose fields read as the unit was when it died — its slot is not given to another unit before +the next frame's dying pass — and the front end only reads its fields. The visible list is the latest first; +the order of a frame's deaths is the dying list's, which is not the same in the two games. + +The other three sources (version 19): + +- `killed`: the `died` turns whose record has no hit points (+0x08 is 0) and one of the eight players as the + last whose attack hurt the unit (+0x90, a byte; 8 when none has). That is what a death in a fight leaves; Kill + Unit, Remove Unit, a program's `kill()` and a unit that went down with its transport leave the hit points and, + unless the unit was hurt before, 8. `was` is that player. `event.by`: only the units that player killed (13, + the current player). The front end copies the handler for each of the eight killers when it takes the killer, + so that it is a value of the script in each copy. +- `ordered` and `morphed`: when the IR has either, the pass first walks the whole visible list and holds each + unit's order byte (+0x4D) and type (its low byte) against a table of its own, a cell a slot as the uniqueness + cells are. A unit whose cell differs and whose slot was seen before this pass is handed on, `was` the cell's old + value; a unit not seen before only has its cells set. So an order or a type held for less than a frame is not + seen, and a unit's first order is not a change. `event.order` (`ordered`): only the units whose order is now that + one. This walk costs about 100 conditions and actions a unit on the map every frame for one of the two, 140 for + both (lifted game, 2026-10-17). + +### Tasks + +Each `start` statement is a body of its own — a loop unrolled when the script is built has one +for each turn — numbered from 1 in the order the front end meets them (`task`; one inside +another's body comes after it), with `max` slots. A slot is a row of the cells of everything +the body declares (`VarDecl.task`, `ArrayDecl.task`), plus its place to go on from, its wait, +its generation and the frame it last had its turn in. A `start` works its arguments out, takes +the lowest free slot — one more generation, its parameters set, its place the body's start — +and gives `slot | task << 8 | generation << 16`. With every slot running nothing starts, and +the game says so once. + +Each frame, after the program's own turn (whether it slept, waited or ended), every running +task goes on until it sleeps, waits or ends: by `task`, then by slot. A task started during +that turn has its first turn in the same frame — the slots are gone through again until none +was started. Tasks run on after the program has ended. A task's body may read the program's +variables, but not those of a task it was started from (the front end refuses that), and a +`sleep`, an `await` or a `parallel` in it is the task's own. + ## Expressions Numbers (`NumExpr`): | Kind | Fields | | --- | --- | -| `const` | `value` (a whole number) | +| `const` | `value` (a whole number); `fixed` (the raw cell of a 16.16 number); `fn` (the number is a function's, *Functions as values*) | | `var` | `id` | | `element` | `array`, `index` — a cell of an array of numbers; 0 past either end | | `length` | `array` — the cells in use of an array that grows | @@ -225,6 +316,7 @@ Booleans (`BoolExpr`): | `not` | `expr` | | `random` | a coin toss, fresh at every evaluation | | `unitAlive` | `unit` — `if (target)`: there is a unit and it is still on the map | +| `running` | `task` (a number) — `t.running`: the task that number stands for has not ended or been cancelled | | `unitSame` | `left`, `right` — both name one unit of the game (none is never the same as anything) | | `unitFlag` | `unit`, `flag: hallucinated | cloaked | burrowed | invincible | underAttack`; false when the unit is none or gone | | `edge` | `edge: rose | once`, `cond` | @@ -276,6 +368,47 @@ the program's body the hint for a function inlined more than once, with the reas Every pass that walks a program walks `bodiesOf(program)` — the body, then each function's — and `programDeclarations(program)` lists the functions' variables with the rest. +### Functions as values + +Since version 21. A function a program keeps — in a variable, a cell, a field, a `Map`, what +another function returns — is a number: a `const` with `fn`, the id of the `FuncDecl` it runs. +The numbers start at 1, so 0 is no function (`undefined`). A function the front end knows when +the script is built — handed to a parameter, kept in a `const` — is never a number: its calls +are calls of its body, inlined or called as any function's. + +A call through a number is a `Call` of a *dispatcher*, a function the front end made for each +shape of call (what it is handed, in order, and what it gives): its first parameter is the +number, the rest are the call's arguments, and its body is a `switch` on the number with a +case for each function kept as a value whose shape fits, each calling that function and, when +the call gives something, returning it. A number no case has (0) does nothing and gives 0. The +`fn` marks are how the front end knows which functions to give cases: one whose number +nothing reachable holds is left out. + +A dispatcher is lowered where it is first called, and with it every function among its cases, +which may be before a variable one of them uses is declared — a field of an instance the +program makes later. A backend makes a variable where it is first used, and its `declare` +then sets that one. + +### Closures + +Since version 24, a function kept as a value may use variables of the function or the block it was written in. +Nothing of it is new to a backend: it is arrays and statements the front end writes (`compiler/closures.ts`). + +- **An environment** is a row of a pool of arrays the program has (1 023 rows): a variable a kept function uses is a + cell of an array of its own at that row, and the block it is declared in makes a row each time it runs (a `declare` + of a variable holding the row, the value of a call of `(make an environment)`). The `let` of a `for` gets a new row, + a copy of the last, at the end of each turn, as JavaScript makes one. +- **A closure** is a row too: the function's number and the row it was made in. Its value is the row, 1 to 1 023; a + function that keeps nothing is `1 048 576` plus its id, as before but moved out of the pool's way. A dispatcher + starts by taking a row apart — the function to run and, as that function's last parameter, its environment — and a + function reaches the variables it keeps through that parameter, a `parent` cell for each scope further out. +- **Counting.** Each row has a count of what holds it — variables (`held`), cells of arrays, environments around + another — kept by the statements that store into those (one more for the new value, one fewer for the old). A row + whose count reaches 0 is queued, and the program's next `sleep` gives back every queued row still at 0, with what it + held. A closure only just made, on its way into a variable, is never given back under it. Parameters and a call's + result are not counted: they live no longer than the call. A function that calls itself may not declare a variable + a kept function uses. + ### Recursion ``` @@ -356,6 +489,47 @@ it is made from may be the parameter itself). Setting the parameter gives back w from the call before. The function's result moves to the call's own result variable, from where a `textCall` takes it; a text result is not reset when a call starts. +### Errors + +``` +Program { …, errors: { thrown: "(error thrown)#…", message: "(error message)#…", names: "(error classes)#…" } } +Stmt { kind: "try", body, catch } | { kind: "throw", rethrow? } | { kind: "uncaught" } +``` + +Since version 22. A program that throws or catches anything has `errors`: two of its variables, both `shared` and +declared at the start of its body, that hold the error *in flight* between a `throw` and the catch that takes it. +`thrown` is the code of its class — 0 when nothing is in flight; 1 `Error`, 2 `RangeError`, 3 `TypeError`; 16 and up +the program's own classes, in the order the front end met them — and `message` its message, a made text. `names` is +an array of texts, the classes' names by code, which `uncaught` says. No error is ever in flight across a frame, so +one of each serves every player and every task. A class's own fields (`need` of `class OutOfGold extends Error`) go +through variables of the program the front end makes, one for each name and kind; a backend knows only the two. + +- **`throw`** jumps to the nearest `try`'s `catch` in the body being lowered. Inside a called function with no `try` + around it, it jumps to the function's end: the function returns at once, the error still in flight. A call of a + function that may come back so — one that throws, calls itself, or calls one that may — is followed by a look at + `thrown`, and on anything but 0 it goes on in the same way, before its result is used. The statements before a + `throw` are the front end's: the message, the fields, and `thrown` last. `rethrow`: the error is one a catch + took, thrown on, so the line it came from stays the first throw's. +- **`try`**: the first statements of `catch` are the front end's — copies of what the body reads (`e.message`, + `e.name`, a field, the code for `instanceof`), then `thrown` set to 0 — so an error thrown and caught inside the + catch changes none of them. `break`, `continue` and `return` leave a `try`'s body as they leave a block. +- **`finally`** is not in the IR. The front end copies it before every `break`, `continue` and `return` that leaves + the try, after the try, and inside a `try` around the whole whose catch puts the error in flight aside, runs the + finally, puts it back and throws on (`rethrow`). +- **A stack that runs out** (*Recursion*) is a `RangeError`, "Maximum call stack size exceeded", thrown where the call + is, before anything of it is kept, when the program has `errors`. Without them it ends the program as before. +- **`uncaught`**: the front end puts the program's body and every task's body inside a `try` whose catch is this, and + each handler of an event that runs within the frame inside one of its own. It says the error in red — the class's + name, the message and the line it was thrown at, as the game's own faults are said — and sets `thrown` to 0. A + program or a task then ends; a listener goes on, only that turn of the handler given up. + +**`taskHandle`** (a number) is the number of the task it runs in, as `start` gave it, or 0 in the program's own turn. +The front end keeps a task's error by it, so that an `await` of the task throws it (arrays named `(task errors …)`, a +cell for each slot of each start). + +A `parallel` branch that throws leaves its statement, and the branches still running stop where they are, as a +race's losers do. A task's error never reaches what started it. + ## Texts A text is a value, kept one of two ways, a variable at a time (`VarDecl.text`): @@ -382,6 +556,7 @@ TextExpr = | { kind: "textPad", of, side: "start" | "end", width, with, at, label } | { kind: "textRepeat", of, count, at, label } | { kind: "textCall", call } a call whose result is a text + | { kind: "built", buffer } what a buffer of buildText holds (version 17) ``` The first three, and a `textTernary` between such, *have an id* whatever happens in the @@ -441,6 +616,30 @@ lets a made text into those actions only. A `tableWrite` of a unit type's name t type, on every computer. The places a `textSlice` is given are inside 0 … the length already: the front end counts from the end and clamps. +### Texts put together a piece at a time + +Version 17, for JSX (TrigScript 4, S2). A JSX text has conditions and loops in it, so it +cannot be one `template`; made as templates joined with `+` it would be copied whole at every +piece. Instead it has a **buffer** of its own, named by `buffer` (`jsx L#`): `BUILD_BYTES` +(2 047) bytes and where the writing has got to, one for the whole game. A `buildText` with +`reset` starts it empty; every other `buildText` writes its parts at the end; `built` is the +text there, only looked at — whatever keeps it takes a copy, since the buffer is built again +the next time the same JSX runs. A buffer is used up within the frame it is built in: the +front end never lets an `await` come between a JSX text's first `buildText` and its use. Past +`BUILD_BYTES` the text is cut, never inside a character, and the game says so once in red +where Simulate records a fault. + +Parts gain `{ kind: "char", expr }`: one byte, the number's low eight bits — a colour code +the program chooses (``). 0 writes nothing. Simulate's log drops it, +as it drops a player's colour. + +`ui.lines(player, view)` is a `start` whose body loops: when a flag of the program says the +view is stale, or 24 frames after it was last shown, it builds the view and prints `built`. +The flag is a variable declared first in the program body, set; after every statement anywhere +in the program (its tasks and its functions too) that writes a variable or an array the view +reads, the front end adds `flag = 1` (`views.ts`). A view that reads the game — units, input, +`random`, the game's tables, a function that is called — is built every frame. + ## Units Units (`UnitExpr`): @@ -450,8 +649,9 @@ Units (`UnitExpr`): | `unitNull` | none | | `unitVar` | `id` of a unit variable | | `unitAt` | `ptr`, `epd`, `uid` — the unit three numbers name, which `unitPart` gave of one and the program kept in cells of its own. None when `ptr` is 0; re-checked before use like any kept unit | -| `pick` | `by: first | nearest | random`, `filter`, `near?`, `mouse?`, `within?` — one of the units the filter matches: the first in table order, the nearest to the centre of location `near` by \|dx\| + \|dy\| (the first of equals), or one drawn at random; none when nothing matches. With `mouse` (a player: a slot, or 13) in place of `near`, the nearest to that player's mouse and no farther from it than `within` pixels | +| `pick` | `by: first | nearest | random`, `filter`, `near?`, `mouse?`, `within?` — one of the units the filter matches: the first in that order (the oldest), the nearest to the centre of location `near` by \|dx\| + \|dy\| (the first of equals), or one drawn at random; none when nothing matches. With `mouse` (a player: a slot, or 13) in place of `near`, the nearest to that player's mouse and no farther from it than `within` pixels | | `call` | `call` whose result is a unit | +| `selected` | `index` — the unit in that slot of this computer's selection (0 is the one the panel shows), or none. Differs from one computer to the next. Since version 23 | ``` UnitFilter { type?, owner?, at? } @@ -520,6 +720,7 @@ In these two `player` is a slot or 13, the player the program is running as. { source: "click", button: "left" | "right" | "middle", player } { source: "mouse", axis: "x" | "y", player } map pixels { source: "chat", pattern, capture: number | null, player } +{ source: "sync", slot, player } what syncSend sent from player's computer, + 1, on the frame it arrives; 0 or 0xFFFFFFFF otherwise ``` What a player does happens on one computer; two euddraft plugins the build adds bring it to @@ -533,6 +734,7 @@ input: { unitNames?: [lowerCaseName, unitType][], only when a pattern reads a unit's name qcLocation, the 0-based location slot MSQC keeps for itself mouseBase the 1-based number of the first of eight locations MSQC keeps the mice in, or null + syncs? how many sync slots, numbered across the build; MSQC sends tsin_sync into tsin_sync_in } ``` @@ -548,7 +750,7 @@ The build's plugin sections, in the order they run (`buildPlugins`): **chatEvent messages of its own — it only finds the line the local player typed and leaves its address and length; **trigscript**, which before the triggers matches that line against the patterns on the computer it was typed on, into a number for the pattern and up to three values; -**MSQC**, which sends those, the keys (`KeyPress(K); NotTyping`) and the clicks +**MSQC**, which sends those, the keys (`KeyDown(K); NotTyping`: the frame a key goes down; `KeyPress` is every frame it is down, and a tap is two or three) and the clicks (`MouseDown(B)`) to every computer as the player they came from, and keeps each human's mouse in a location; **eudTurbo**. Every cell is an `EUDArray(12)` or an `EUDVariable` the lowering registers by name (`tsin_key0`, `tsin_chat_in`, …), which is how the other two @@ -557,6 +759,17 @@ clears its cells every frame, so an input lasts the frame it arrives in. It make command units of one unit type (58, the Valkyrie) owned by Player 12, which the map must leave alone. +A map of our own (`compiler/backend/input.ts`, TrigScript 4, step 12) carries the same +inputs itself, with no plugin: the `input` reads become reads of cells the map's receive +pass fills before the programs' turns, from a ring of hidden carrier units a human player +(a Select and a Move to a point each send, the point the value), and a send pass after the +programs' turns (`Program.after`) queues what this computer's player did. Keys and clicks +travel as a state that is sent when it changes and paid out a frame at a time; the mouse +as a point of its own; `syncSend` leaves its value for the send pass. A typed line is read +from the game's text lines on the computer it was typed on, matched there against the +patterns, and sent as the pattern's number and its values. The trip is two frames in the +lifted game. + ## Numbers 32 bits. A `number` reads them signed, −2³¹ to 2³¹ − 1; a `u32` (`VarDecl.unsigned`) from 0 up. @@ -595,12 +808,19 @@ interpreter agree on every case: - An action's variable `modifier` (a unit count) means that many units: the lowering does the action once for each, so 0 is none and 300 is 300. - A `switch` compares bits: a case of −1 is 0xFFFFFFFF. +- **Ranges** (version 25). Any number may carry `range: [lo, hi]`: `compiler/ranges.ts` found its + signed reading never below `lo` nor above `hi`. A backend may use it to take only the bits a + value can have — the backend does for a product by a constant, a quotient and a + remainder by a constant or by a variable, a shift, and a `min` / `max` / `abs` one side of + which always wins — and may ignore it. The same pass reads a comparison whose sides are + never below zero as `unsigned`, and turns one whose ranges settle it (wherever it is met, + its sides doing nothing but give a value) into a constant: the program means what it did. ## Records `ActionRecord` and `ConditionRecord` are the map's own trigger records, as `vendor/triggers.ts` names their fields. Inside the compiler an action's `text` and -`wav` hold *local* string ids (into the compile's `strings`); `serializeIr` in -`compiler/eud.ts` writes the JSON with the text itself in their place — eudplib adds it -to the built map's string table, so a program's strings never enter the map the user -edits — or a number where the script named an index of the map's own. +`wav` hold *local* string ids (into the compile's `strings`); the build interns the text +itself into the built map's string table (`compiler/backend/build.ts`), so a program's +strings never enter the map the user edits — or keeps a number where the script named an +index of the map's own. diff --git a/docs/v4-plan.md b/docs/v4-plan.md new file mode 100644 index 0000000..311039d --- /dev/null +++ b/docs/v4-plan.md @@ -0,0 +1,6652 @@ +# TrigScript 4 — the plan + +Written 2026-10-15. Status: Part A built (A0–A5, each with its *As built*), and Remastered's +emulator read from a dump (*R0*, 2026-10-16); Part B steered by three showpiece maps +(*Showpieces*, 2026-10-16), B1 and B2 built (2026-10-16/17), and an EUD error code taken back to its line +(*EUD error code to line*, 2026-10-17); a fourth showpiece, Bad Apple, with fog of war found to work as a +framebuffer in the lifted game (*S4*, 2026-10-17); B6, fixed numbers and Math, built with a fifth showpiece, a +raycaster in the fog (*S5*, 2026-10-17); B3, functions as values, and B4, `try` / `catch` / `throw`, built +(2026-10-17); closures over locals, and the text and array methods a program was missing, built (*Closures and the +missing methods*, 2026-10-17); JSX components, functions from any file and the first two libraries, `trigscript/ui` and +`trigscript/screen`, with Snake as their showpiece (*Components, functions from any file, and the first libraries*, +2026-10-17); generics and `timers`, `signals`, `random` and `math` (*timers, signals, random and math, and generics*, 2026-10-17); a sweep of what is still missing, B9, references and values, and generators finished with `trigscript/cutscene` (*What is still missing*, *B9*, *Generators finished*, 2026-10-18). The question it answers: now that there is a +lifted copy of the game's own code (scm-sim's exact engine) beside eudplib, what does +TrigScript become if it uses the game itself as its checker, and how far can the language +go towards the TypeScript people actually write. + +The plan has three parts, in the order they would be built: + +- **A. The game as the checker.** Tests, the address check, desyncs, costs and the + debugger, run in the lifted game instead of (or beside) the simulator. +- **B. The language.** `async`/`await`, events, function values, `try`/`catch`, + `Local` and `sync()`, fixed-point numbers, typed memory. +- **C. The game, pushed.** What a map can do with that language, one probe at a time. + +A comes first because it makes every later slice cheaper to check: most of what a probe +answers today, the lifted game answers in the editor. It cannot answer everything (see +*What the lifted game cannot tell us*), so probes stay, fewer and narrower. + +## Where 3.12 stands + +TrigScript 3.12 compiles a `program()` to an IR, `python/trigscript.py` lowers the IR +with eudplib inside the eudplib plugin, and Save writes the built map. The language has +signed numbers, arrays and a heap, records, `Map`/`Set`, strings, classes, called +functions, recursion, callbacks, `Unit` objects, input through MSQC and chatEvent, and +`test()` blocks run by `compiler/simulateIr.ts` on the game's trigger schedule. + +Two costs have stayed with every slice since 3.0: + +1. **Each slice that touches the game has a probe the user plays by hand.** The probe's + lines say "(expect …)" and a person reads them in the game. Sixteen probes so far. +2. **The simulator is a model.** It answers what it was written to answer. A read it + does not model gives 0, and 9½ made that visible rather than fixing it. + +## What scm-sim changes + +scm-sim's exact engine is StarCraft 1.16.1's executable and storm.dll lifted from x86 to +TypeScript (`src/exact/`, the generated module under `data/lifted/`, never committed). +What is already measured there, and what this plan leans on: + +- It plays maps as well as replays: single player, UMS, the game's own computers, and + several people seated on one game instance (`src/exact/net.ts`). Node runs 2,200–4,100 + frames a second; a start takes 0.7 s. +- The whole of its state is one `DataView` at the game's own addresses (`LiftedGame.m`). + Reading a variable of a built map is a read at an address. +- Snapshots cost about 45 ms and 0.6 MB each, and `rewind.ts` seeks backwards from the + nearest one. That is time travel, already built for the replay viewer. +- Routines can be hooked by address when the module is generated (`liftgame.py --hook`), + as the two sound routines are, and any routine can be called between turns + (`L.callAt`), as the placement check is. +- Trigger text, countdown and outcome are read from memory (`ExactLiveEngine.triggerView`). +- **EUD, measured 2026-10-12:** extended-player Deaths *reads* work; `SetDeaths` *writes* + past the table are refused by 1.16.1's own bounds check. So an eudplib-built map, + which writes past the table from its first frame, does not run in the lifted game as + it stands. Lifting that check is the first job (slice 0). + +### What the lifted game cannot tell us + +The lifted game is 1.16.1. TrigScript programs build for Remastered, whose EUD support +is an emulation of 1.16.1's memory, not the memory itself. The two differ in ways we +have already met: + +| Probe | 1.16.1 meaning | Remastered | +| --- | --- | --- | +| Magenta 6, I | a unit's position written | ~~ends the game~~ — the dump probe (R0) wrote it alone: the game goes on, nothing moves | +| Magenta 10, 1–3 | the tile buffer written through the MTXM pointer | nothing changes on screen | +| Magenta 10, 4 | sprite image flags (tint) | ends the game | +| Magenta 6, J K | a unit's own top speed | no effect; only the type's table counts | +| Magenta 6, Y H | the cloak status flags | no visible cloak | +| Magenta 5, 9 | the game speed written | no effect | +| Magenta 7 | a key held | Remastered sends one event a press, no key-up | + +Remastered's executable is packed and encrypted on disk, so its emulation table cannot +be read from the file. The user agreed on 2026-10-15 that Remastered may be taken +through Ghidra to settle rules the compiler needs, provided none of it ships. The way in +would be a dump of the running process, once it has unpacked itself, read in Ghidra; the +rules found go into the allowlist with "Remastered, disassembled" as their evidence, and +a probe still confirms each family before a library offers it. That was done on +2026-10-16 (*R0* below): the allowlist now knows, for every address, whether Remastered +lets a read or a write through, and a probe is needed only to learn what the game *does* +with it. The lifted game tells us **what an address means and what the triggers did with +it**; the dump tells us **whether Remastered allows it**; a played probe tells us **what it +looks like**. Part A turns that split +into a file (the allowlist) and a check, so the gap is seen at the line that falls into +it rather than in a game that ends. + +## Principles + +1. **It is TypeScript** (unchanged from the Remastered plan). A construct valid in + TypeScript with a sensible meaning in the game works; one without says why and what + to write instead. +2. **The simulator is the fast path; the game is the judge.** Every test still runs in + the simulator on each compile. *Run in the game* runs the same tests in the lifted + game. When the two disagree, that is a bug in one of them, and the Testing view says + both answers. +3. **The allowlist is a file of facts with sources.** Every address a built map touches + is on it with the probe and date that showed it working in Remastered, or the build + says so at the line. An `unsafe` block can go past it, with a reason the author writes. +4. **A desync is a compile error where it can be, a test failure where it cannot.** +5. **Only verified facts ship** (unchanged). A new family of addresses still gets a probe + the user plays; the lifted game shrinks what the probe has to ask. +6. **The engine stays behind the plugin edge.** The engine is a separate work (the + decision of 2026-10-12): TrigScript never vendors or imports it. TrigScript uses + `scm-sim.engine` when that plugin is installed and the person's own copy of the game is + there, and works as 3.12 does when it is not. CI has neither, so engine tests skip + there, as the game-data suites in scm-js do. + +## Part A — the game as the checker + +### A0. Spike: a built map runs in the lifted game + +What it has to show, in order: + +1. **A lifted game that lets EUD writes through.** The bounds check is code, and the + lifted code is TypeScript, so patching bytes in memory changes nothing. (As built, the + routine is replaced at run time; no second build was needed.) +2. **eudplib's output loads in 1.16.1.** Things to check: the CHK version the build + writes, `STRx` against `STR`, anything eudplib does only for Remastered. If 1.16.1 + refuses the map, can a load-time rewrite (as scm-sim does for 1.21 replays) fix it? +3. **eudTurbo holds.** It writes the countdown to 0 after each pass; 1.16.1 sets it to 30 + before running the pass, so every frame should run (verified for 3.12's schedule). +4. **The probes run headless.** `probes/spike.ts` first, then the other fifteen. Their + printed lines are read from `triggerView` and checked against their own "(expect …)" + text. A probe that has no input steps can then run without a person. + +Done when: every probe without input steps prints what it expects in the lifted game, and +each one that does not has a written reason (a 1.16.1/Remastered difference, or a bug). + +Size: 2–4 days, most of it in scm-sim. **No release.** + +**As built (2026-10-15, one day; scm-sim branch `eud-spike`, uncommitted).** Every probe +runs headless in the lifted game and says what it expected. + +- **No second build of the module.** The lifted module keeps a table of the routines + reached through a pointer, and `setEntry` replaces one at run time. The trigger loop + reaches the Deaths condition (0x42CB60) and the Set Deaths action (0x4C6CC0) that way, so + `src/exact/eud.ts` replaces those two and nothing is regenerated. Replays and Test Map + never see the replacements. +- **The scenario.** eudplib writes `VER` 206, `STRx`, and an eight-byte section of + negative length at the end. `classicScenario` writes `VER` 205, turns `STRx` into a `STR` + of the same size with every byte past the header in place, and drops the last section. +- **Where the strings are.** eudplib counts on Remastered keeping the strings section at + 0x191943C8. Its payload is relocated against that address, and it reads string offsets + from the four-byte table there. The game reads a two-byte table from wherever 0x5993D4 + points. `placeStrings` puts the `STRx` bytes at 0x191943C8 and a two-byte table for the + game just below them, both pointing at the same text. Then a text eudplib writes in place + is the text the game shows. Two layouts that seemed simpler were each wrong in one half: + table strings came out empty under one, made texts under the other. +- **The mask rule** (Remastered's `'SC'` records, which eudplib uses everywhere). The field + is `value & mask` and the number is `number & mask`. Set To writes the field, Add adds to + it and drops the carry, Subtract stops at 0. A condition compares `value & mask` with the + unmasked number. Two eudplib tricks fix this rule, and `tests/exact-eud.test.ts` holds + both: its divide-by-four (Subtract 3 << t under 7 << t) and its text decoding (Add a key + under 0xAA, then under 0x55, which is an exclusive or). The first rule I tried added the + unmasked number, and every made text came out scrambled. +- **Results.** All 15 probes were rebuilt with the 3.12 compiler and run for 3,000 frames, + 1–5 s each. The runner (`scripts/exact-eud.ts`) checks every "(expect …)" by its numbers: + **137 hold**. Of the 9 it flags, 7 hold when read by hand and 2 are one wrong + expectation (both below). It leaves 12 lines for a person, those with words in the + expectation (a name, "about 21"); all 12 read right. Not one address fell outside the + machine. +- **Read by hand, and right:** numbers I, reads E and input A put words between the value + and the bracket. The input probe stops after A, because nobody presses its keys. +- **One expectation is wrong, and the lifted game found it:** units K expects F's Marine at + 15 as the weakest of the Men. But the Zergling handed over at H is a Man too, and the + Marines hurt it to 5 during G. A person found a first version of the same mistake on + 2026-09-18; the fix made then was never replayed. +- **Not checked headless:** made texts in the objectives, a leaderboard label and a unit + name (strings L, M, O). The game shows them, but the runner reads only the text lines. + Anything that needs a person (the input probe, "select a Marine") is not checked either. +- **Found on the way:** scm-sim's reader of the game's text lines missed a line identical + to the one it overwrote ("Tick" each second, once the 13-line ring had gone round). It + now counts the slots the ring moved over as new. That was a bug in Test Map in Browser + too. + +### A1. The address tracer and the allowlist (4.0.0) + +**Tracing.** Hook the routines that run a trigger's conditions and actions. On entry, the +hook reads the condition or action record and works out the address it touches; for the +Deaths family, that is the table's base plus 4 × (player + 12 × unit). It records read or +write, the address, the frame and the trigger's own address. This costs a hook call per +condition, not a check per memory access. + +**Back to the line.** A trigger address has to become an IR node, and then a line. +Candidate: `trigscript.py` records, per IR node, the eudplib objects it made; after the +build eudplib gives each object its address (`GetObjectAddr`), and the build returns a +table `{ start, end, at }` beside the map. Addresses that eudplib's own runtime touches +(the triggers rewriting each other's next pointers) are kept as their own kind, which +Remastered is known to allow. + +**The allowlist.** `data/allowlist.json` in this repository: address ranges, read and/or +write, what the range is (from the disassembly), and the evidence (`"magenta 10, key 4, +2026-09-15: ends the game"`). It is filled first from Magenta's played probes and the 16 +TrigScript probes, so it starts with what is already known. + +**The check.** *Run in the game* traces every address the run touches and holds each one +against the allowlist: + +- on the list, allowed: nothing said; +- on the list, refused: an error at the line ("Remastered ends the game on this write: + Magenta probe 6, key I"); +- not on the list: a warning at the line ("not seen in Remastered: needs a probe"). The + map still builds. + +The trace only sees code the run reached, so the check is only as complete as the tests' +coverage. A1 therefore also shows coverage: lines that ran in the game, lines that did not. + +Ships as 4.0.0, the first version with *Run in the game*, because it adds a whole mode. + +**As built (2026-10-15, under Node; the editor's half comes with A2).** One command does it: +`npx tsx scripts/game-check.mts probes/units.ts`. It builds the script, plays it in the lifted +game with nobody at it, prints the probe's own checks, then lists each access Remastered was +seen to refuse or ignore, or has never been seen to do, by line. + +- **Back to the line** works as the candidate said, without touching eudplib. + `python/trigscript.py`, when the build sets `triggerMap`, wraps `RawTrigger.__init__`, + `Lowering.statement` and `EUDFuncN._create_func_body`, so every trigger is tagged with + the statement being lowered (or the eudplib function whose body it is). It also wraps + eudplib's module-level `_construct_payload` to log where each tagged trigger landed: + offset, conditions, actions, origin, 400 a line. The runner turns those offsets into the + address of every condition and action record (the payload sits at the end of `STRx`), and + the hooks already see each record's address. Triggers eudplib made at its own import, and + its variables (trigger-shaped, but not `RawTrigger`s), are put down as "eudplib". Only + eudplib's start code in the scenario's own TRIG stays unnamed. +- **The trace** (`scm-sim scripts/exact-eud.ts --trace`) is one row per origin, dword and + direction, with the union of the masks, so a check is byte by byte. A run of the units + probe has 42,815 rows. +- **The allowlist** (`data/allowlist.json`, made by `scripts/allowlist.mts`, 191 entries) + has three sources: + - Magenta's catalogue: 73 entries, a verified yes counts as allowed. + - Three failures seen in the game: unit position write ends the game; cloak bits and game + speed writes are ignored. + - The traces of the 15 played probes: 115 runs of bytes, the unit table folded into one + entry per field. Each probe passed in Remastered, so what its build touches was + allowed there. The traces come from today's builds, not the ones played, so this is + evidence by family of address, and a probe played again settles a new one. + + The map's own memory (strings and payload) is always allowed. eudplib's walk of the + game's heap (its trigger lists) is allowed; a line of the script in the heap is not seen. +- **What it showed with Magenta's catalogue alone** (an allowlist built without the + probes): nearly every line of every probe touches something Magenta never verified. + Examples are 0x6509B0 (eudplib's current-player trick, written around every action that + takes a player), the trigger lists at 0x51A284, and unit-table fields read by unit + loops. So the played probes are the larger part of the evidence. The check names each + such line, and an unseen field of the unit table is one finding however many units the + loop read. +- **Not yet:** the check sees only what the run reached (no coverage view yet), and runs only + under Node. The editor needs scm-sim's `run` (A2) before it can show a finding at its line. +- **Found on the way:** `spawnSync` keeps 1 MB of a child's output by default, and + plugin-eudplib's `build-map.mts` exits before a long log has gone down its pipe. It now + writes the log to `EUDPLIB_LOG_FILE` when asked, and `build-fixture.mts` reads it from + there. + +### A2. Tests in the game (4.0.0, with A1) + +`test()` blocks run in the lifted game as well as the simulator. + +- **A test becomes a program.** The test body, with its world set-up, is compiled into a + test copy of the map as one more program, and its assertions record into an array the + runner reads from memory when the run ends. So the test runs with the game's own + trigger schedule, unit table and rules, and no new mechanism is needed inside the game. +- **Input.** `sim.type` becomes the text a chat command carries; `sim.click` becomes the + game commands a click sends; `sim.press` becomes a write of the key state the local + machine reads (MSQC reads keys locally). All three need checking against what MSQC + actually reads. +- **What the Testing view shows.** Each test gets two marks, simulator and game. When + they differ, the view says both answers, at the frame they parted. +- **Where it runs.** In the editor through `scm-sim.engine`; the service needs a new + method, roughly `run(map, { frames, seat, inputs, watch, trace })`, which means + contract version 2. Locally from a terminal: `npm run test:game` in this repository, + using `data/lifted/` when it is on the machine. + +A headless run draws nothing, and is allowed from any copy that unlocks the editor +(decided 2026-10-15, see *Open decisions*). + +**As built (2026-10-16).** One part of the design above was wrong and was not built: a test does +*not* become a program. A test is synchronous JavaScript that steps frames and reads between them +(`until(() => …)` among it), which cannot be compiled into triggers ahead of time. Instead the test +stays JavaScript and is handed a world backed by the game, and it runs where the game is. + +- **`GameSim`** (`compiler/gameSim.ts`) has `TestSim`'s surface over a small synchronous + `GameHandle`. scm-sim supplies the handle (`src/exact/testGame.ts`), and TrigScript never imports + the engine. + - Every read is the game's memory. + - `place`, `kill`, `remove`, `give` and `move` are the game's own trigger actions, run between + frames as a player's trigger would run them, through a scratch location (255; 254 for a + destination) whose twenty bytes are put back afterwards. + - `kill` is a fight, as `TestSim` has it: the game's death-counting routine (0x488AF0) is called + first, so `by` gets the kill. + - A typed line is the game's own chat command (0x5C). chatEvent reads it from the text lines as + in play, and MSQC sends it on. + - A key and a mouse button are the local machine's memory (0x596A18, 0x6CDDC0), held for one + frame. + - `program()` reads a program's variables where the build put them (below). Not yet: what + another player was shown. +- **The same tests, twice.** `TestRunOptions.makeWorld` hands each test a world other than the + simulator's. A compile also returns the script as JavaScript (`CompileResult.modules`). + `compiler/gameTests.ts`, bundled as `dist/gametest.js` (198 KB, no TypeScript in it), runs those + modules again beside the game, registers the tests and runs each with a `GameSim`. The game is + put back to frame 0 before each test. +- **Where it runs.** + - Under Node: `npx tsx scripts/test-game.mts ` prints each test's game and simulator + marks side by side. + - In the editor: **Run All Tests in the Game** in the Testing view and the palette. It builds + the map as Save does, exports it whole (a bare `.chk` has no room for what the build steps + make), and hands it, with `gametest.js`'s text and the modules, to scm-sim's analysis page + through the plugin's new `test()` (contract unchanged in version; `status().tests`). The page + reads the scenario out of the map, starts it in a worker of its own with EUD let through, + loads the module from its text and answers with the report. Each test then shows a second + mark, "game", which turns red when the two disagree. Nothing is drawn, so under the decision + of 2026-10-15 any copy of the game that unlocks the editor is enough. +- **Traps met.** + - eudplib keeps the map's locations scrambled until its first frames put them right (by frame 2 + on a probe's map). So an action at frame 0 goes through a scratch location given the map's + real bounds, read from the game once (`readLocations`), with the elevation flags cleared, + since scrambled flags keep units out of a location. + - The game's snapshot covers only the pages it had mapped at the start. eudplib's payload lies + outside them, so a reset left eudplib's variables and its self-rewriting triggers as the last + test left them, and typed lines and keys went unanswered from the second test on. The handle + now keeps that range itself. +- **What the first run found:** a simulator bug. Kill Unit, Kill Unit At Location, a unit's + `kill()` and hit points written down to 0 all counted a death in the simulator. The game counts + none of them, and the editor's trigger reference, checked in the real game, says the same of + Kill Unit. Fixed in the simulator (`World.gone(…, fought)`), with three tests corrected to the + game's rule. +- **Checked:** + - `probes/game-tests.ts` (time, a typed line, a key; units placed, killed, given and moved) + passes in the game and in the simulator under Node, and through scm-sim's analysis page in + headless Chromium (3.3 s, game start included). + - plugin-scm-sim's bridge has two new tests. + - **End to end in a built editor (2026-10-16):** scm-js `main` built under `/editor/` and + served by scm-sim's dev server beside its analysis page, with the local TrigScript and + scm-sim plugin builds in place of the released ones, driven in headless Chromium: + 1. The probe map is opened and the script written into it. + 2. **Run all tests in the game** is clicked. + 3. eudplib asks for its one-time runtime and gets it, then builds the 721 KB map in 3.7 s. + 4. The status bar says "In the game: 4 of 5 tests passed. The simulator disagrees on 1 + test." That came about 9 seconds after the click. + + The fifth test reads `sim.program()`, which the game could not answer yet, and it showed as + meant: a ringed red mark beside the simulator's green one, and the game's reason on its own + line in Test Results. The game's mark sits beside the simulator's icon rather than at the + row's end, where a narrow sidebar cut it off in the first run. +- **`sim.program()` in the game (2026-10-16).** + - The build says where every variable is when asked (the `variableMap` setting, which + **Run All Tests in the Game** and `scripts/test-game.mts` set). Once eudplib has laid the + payload out, the lowering works out each cell's address, and it prints them once + `initialize_payload` has placed the payload at the end of STRx (0x191943C8 plus the + section's length): a `TRIGSCRIPT-VARS` line, then a `TRIGSCRIPT-PROGRAM` line a program. + The editor keeps these lines out of Output. + - `compiler/gameVariables.ts` reads them, and reads a program's variables out of the game's + memory by the same rules as the simulator's lookup. That covers numbers (signed, `u8`/`u16`, + `u32`), booleans, texts of the table and texts that are made, units (null once the slot's + uniqueness byte has moved on), fixed and growing arrays, records and lists of records, and a + per-player program's row by player. + - Two differences, both because the game has every cell from the start. A variable not yet + declared reads as its zero. Of two declarations with one name, the later in the source wins. + A list inside a list and a row of a grid are not read. + - Traps: an `EUDArray` is a proxy of its data's EPD, not of its address (relocation mode 1, so + `4·offset + 0x58A364`). eudplib works addresses out only while it writes the payload, so the + lowering sets that phase again for as long as it reads them. + - Checked: `probes/game-program.ts` (every kind above, a unit, a per-player program by player) + passes in the game and in the simulator under Node, and so does `probes/game-tests.ts`. In a + built editor the five tests of the end-to-end run now pass in the game: "In the game: 5 of 5 + tests passed.", about 10 seconds after the click. + - plugin-scm-sim released 0.9.0 with the Replay view's zoom in the meantime, so `test()` + ships in 0.10, and TrigScript's message now asks for that. + +### A3. The desync detector (4.1.0) + +Run two to eight copies of the game seated as different local players +(`seatPlayers`, local-player words set per copy, as `net.ts` does for multiplayer), feed +them identical turns, and hash each game's state every frame. At the first frame the +hashes differ, report the frame, the last traced trigger in each copy, and its line. + +A test can ask for it: `test("shop", { players: 4 }, …)`. The Testing view then shows +"desync at frame 312, line 48: the copies took different branches here". + +To check first: UMS with several people on one instance (melee is measured, UMS is not). + +**As built (2026-10-16, under Node and in the editor).** The check was already done: +scm-sim's `tests/exact-net-ums` plays two people on one instance in UMS. Two parts of the design +above changed. The option is a list (`{ players: [P1, P2] }`), because a test's inputs name the +player anyway. And the hash is not the game's own. + +- **Copies.** scm-sim `src/exact/copies.ts`, `copyWorker.ts` and `copiesNode.ts`: one worker + thread a copy (one game a process), each seated as a different person, 0.9 GB each. + - Each turn, inside the game's own request for every player's turn (`TurnFeed.exchange`), the + copies trade what their games queued, over a SharedArrayBuffer barrier. Every machine's + commands reach every game in the same turn, as on a real network. Before this, `TurnFeed` sent + only the checksum command, so a typed line or MSQC's commands from the map's own code went + nowhere. + - After every frame the copies also trade what each was shown, so `printed(P2)` reads the same + at every copy. +- **The digest.** One FNV word per 256-byte block of the memory every machine must hold alike: + units, deaths, kills, resources, supply, switches, locations, the seed. + - The game's 0x37 checksum command does not do: it is built at 0x47CC50 from a ring at + 0x65EB30, filled at 0x47CFC0, and hashed at 0x47CF10. Depending on a mode byte (0x65FBF0) it + hashes the units on some turns and the resources, seed and frame on others. It always folds + in a rolling chunk of the map's tile flags. It never hashes death counts. And it mixes in the + local player (0x47CF93), so machines that agree send different bytes. Measured: deaths, ore, + hit points, position, the seed and a switch changed at frame 40 left 80 turns of it as they + were. + - Two unit fields are each machine's own, found by copies in step that disagreed, and left + out: + - +0x87, the under-attack alert's quiet time (0x48EB30 when the owner is 0x512684, then + 0x48EAE0); + - +0x8A and +0x8B, the minimap blink (0x48F230, 0x45F08B). + - eudplib's payload is **watched, not compared**. chatEvent's line and the unit MSQC selects + for this computer's player differ on purpose, from frame 2 of every map that reads input. So + a payload difference is noted with its frame, and is reported only once what the game holds + splits. + - The strings are never digested: TrigScript's made text for one player is written on that + player's computer only (`IsUserCP`). +- **The report.** At the first turn the digests differ, every copy runs the same detail rounds and + reports the same thing: + - the dwords, named (unit slot and field, deaths of a unit for a player); + - each copy's lines that wrote them that turn; + - each copy's lines that read the local machine's memory (0x512684, the keys, the mouse, the + screen); + - the script's variables that had come apart before, named from the build's variable map. + + In the test it fails the test at that frame, in every copy alike: "the players' games stopped + agreeing at frame 5: deaths of unit 7 for P1 is 1 at P1's computer, 0 at P2's computer. At P1's + computer: line 12 wrote …". +- **TrigScript.** + - `test(…, { players: [P1, P2] }, …)` runs in one copy for each player. `press`, `click`, + `type` and `moveMouse` with a player act at that player's copy; with none, at the first + player's. + - A one-game run lists such a test as skipped ("runs as a copy of the game for each"). + - `scripts/test-game.mts` builds the map again with those slots open to people + (build-fixture's `PEOPLE=0,1`), then runs them through `runCopies` with + `dist/gametest.js`'s `drive`. +- **Measured.** + - `probes/game-players.ts`: a key and a line from P2's computer reach P1's, and a text made for + P2 is seen at P2's only. 3/3 pass in two copies with no desync, beside the one-computer test. + - scm-sim `tests/exact-copies`: a classic EUD trigger on 0x512684 splits two copies at its + first pass, and both name the trigger. + - A 3000-frame fight with a computer player stays in step with 2 and with 4 copies (8 s, + 3.5 GB for four). +- **The editor.** "Run All Tests in the Game" runs a test of several players through the scm-sim + plugin's `testCopies`. The analysis page runs one copy each in workers of its own + (`web/copyWorker.ts`, sharing `src/exact/copy.ts` with Node), and the reports merge into one + mark (`compiler/gameCopies.ts`). The build is asked for its trigger map beside the variable + map, so a desync names lines. + - The copies need SharedArrayBuffer, so the page must be cross-origin isolated. COOP/COEP + cannot do it: the page is a frame inside the editor, isolation needs the top-level page too, + and COOP `same-origin` on the editor cuts scmjs.dev's sign-in popup off from the editor + (it posts back through `window.opener`). + - `Document-Isolation-Policy: isolate-and-credentialless` isolates the one document instead: + `/play/analyse.html`, sent by the site's worker (cloudflare-link-previews + `ISOLATED_PLAY_PAGES`) and by the dev server. Chromium 137 on; elsewhere, or without the + header, the tests are marked not run, and the mark says why. + - Measured in headless Chromium 153 (`web/smoke-copies.mjs`, then a built editor): + - the editor stays as it is; + - `postMessage`, OPFS and the moments BroadcastChannel cross the boundary; + - replay analysis, pathing and measure still pass under the header; + - 4/4 tests in the game from the Testing view, three of them as two copies (11 s). +- **Not done.** + - Dwords that come apart later in a payload block already watched are not reported. + - `sim.place` and the other actions between frames run at every copy, as a trigger for that + player would. + +### A4. What a frame costs (4.1.0) + +From A1's hooks: conditions and actions run per frame, by IR node and by line. Lifted +wall time is not the game's, so the count is the measure. The editor shows it per line +like the existing hints ("~1,200 actions a frame"), and as a graph over a test's run. + +Open: what count, on what machine, actually lags Remastered. A probe with a dial (run +k × 1,000 actions a frame, the user says when it stutters) gives the first number. + +**As built (2026-10-16, under Node and in the editor; the dial not yet played).** + +- **The count.** scm-sim `src/exact/cost.ts`. The game runs a trigger's conditions at 0x489200 and its actions at + 0x489130, each through a table of routines by type: conditions at 0x515A98, actions at 0x512800, the record in + ecx. Those routines are reached by pointer. `installCost` therefore wraps every one of them with a count, after + `installEud`, so the two EUD routines are counted too. It also wraps the engine's own stand-ins behind import + cells (Center View and Minimap Ping, from `live.ts`). + - What is not counted: a switched-off condition and an empty slot cost nothing, because the game calls nothing + for them. + - What is counted every time: an action that waits (Wait, Transmission) is counted on every frame it runs, as + the game does the work every frame. + - A check: on the units probe the tap's Deaths and Set Deaths totals agree with `EudTap`'s. +- **Records to lines.** A group function turns a record into its line, through the build's map of triggers (A1). + `CARRY` puts a record under the line that ran just before it in the frame. eudplib's variables, its functions + and the triggers no statement made are carried. A variable is a trigger the line's triggers jump through, so a + line's work includes the variables it touches. Measured on `for (…) { x += 1; }`, one turn of the loop is: + - the compare: 21; + - `i++`: 9; + - `x += 1`: 9 (7 of its own and 2 in the variable). + + That is 39 conditions and actions a turn. +- **What is apart from the lines (`TestCost.other`).** Records carried before any line in a frame, and records + outside the payload, count under no line: + - eudplib's start, once: about 82–89 thousand at frame 2 on any map, mostly payload set-up reached from the + scenario's own TRIG; + - eudplib's work at the top of every frame: about 580 a frame on the game-tests probe (payloadMain, ReceiveQC + for MSQC, RunTrigTrigger), more than that probe's own lines (about 225). + + Without this split, eudplib's start was every test's peak. +- **Where it shows.** + - `scripts/exact-eud.ts` prints the totals and the costliest origins, and writes them with `--cost=`. + `scripts/game-check.mts` passes `--cost` through. + - The test handle carries the tap. It is paused for a test's own actions between frames, and cleared on + `reset()`. + - `GameSim.cost()` reads it after each test, so `TestResult.cost` holds a series a frame and the lines by their + peak. + - `scripts/test-game.mts` prints each test's average, peak and three costliest lines. The copies of A3 count too. + A merged result keeps the first copy's. + - The editor: + - A line that ran 10,000 or more in one frame of any test gets a hint at its end, such as "31k a frame". The + note names the test and frame. + - The hint shows only while the file's text is what was measured. + - Test Results shows a "cost" row (average and peak), a graph of the frames and the three costliest lines. +- **Measured.** + - The units probe peaks at 103,639 in one frame (frame 842): three unit-loop lines of about 30 thousand each. + - The game-tests probe runs about 225 a frame of its own. + - **End to end in a built editor:** a program with `for (let i = 0; i < 200; i++) { x += 1; }` every frame and + two tests. The status bar said "In the game: 2 of 2 tests passed." about 8 s after the click. Test Results + showed: + - the cost row: "1,834 conditions and actions a frame on average, 1,837 at the most (frame 49)"; + - the graph; + - the line at 1,800 a frame. + + The line's hint read "1.8k a frame" after "unrolled ×200", the same numbers as under Node. A loop with bounds + known at build time is unrolled, so it costs 9 a turn (only `x += 1`), not 39. The graph's colour first used a + token the editor does not have (`--accent`); it now uses `--teal`. +- **The dial.** `probes/frame-cost.ts` climbs a ladder by itself, one step every 10 game seconds. Keys: W up, + Q down, E holds. The steps aim at 1, 2, 5, 10, 20, 50, 100, 200, 500 and 1,000 thousand a frame. Measured in the + lifted game (`--cost`), each step lands at its target plus about 630 (eudplib's own and the program's). + 1,004,714 at the top. + - Built to `fixtures/eud/frame-cost.scx` and copied to the Remastered maps folder (`TrigScript/`). + - **Played in Remastered (2026-10-16, single player, Fastest):** + - steps 1–9, up to 500 thousand a frame: no visible effect; + - step 10, a million: the screen flickers slightly, enough to distract, and the game does not slow. + + So the budget is 500 thousand a frame, about 12,800 turns of a plain loop. The editor's hint starts at 10,000 a + line (2% of it). The cost row and the hint's note give a count as a share of the 500 thousand. + - Steps 11 and 12 were added (2 and 5 million; measured 2,000,640 and 5,000,643) and played the same day: + - 2 million: the flicker, no slowing; + - 5 million: the game slows. + - The machine: AMD Ryzen 9 7900X (12 cores, 4.7 GHz), a fast one. A slower computer reaches each step sooner, + so the budget stays at 500 thousand and the note says it was a fast computer. Remastered 1.23.10.13515. + +### A5. The debugger in the game (4.2.0) + +9½'s debugger gets a second backend. + +- **Variables** are read from memory: the build's address table (A1) knows where each + `EUDVariable` and array lives. +- **Back as well as forward.** `rewind.ts` keeps a snapshot every N frames; a step back + restores the nearest snapshot and plays forward to the frame. +- **Watchpoints.** "Stop when this changes" compares a dword once a frame, which is cheap. +- **The game beside the code.** scm-sim renders the frame the debugger stands at. + +**As built (2026-10-16, under Node; the editor end to end below).** There was no first backend: 9½ was never +built, and 3.11.0 became the Korean release. So this is the debugger itself, with the game as its only backend. +It also does not stop a running game. A test is synchronous JavaScript and a frame is the game's trigger loop run +to its end, so neither can pause in the middle. Instead the test runs once with everything it did written down, +and the debugger walks the record. + +- **The record** (`compiler/gameDebug.ts`, `RecordingHandle`). It wraps the host's test handle for one test, and + writes down each frame of the run: + - *Steps.* scm-sim's cost tap now also says each record as it runs (`CostTap.trace`), with the line A4's grouping + gives it. Records of one line in a row make one step. A loop's turns come out as its lines over and over. + - *Writes.* `EudTap.listen` now says what a dword held before. Every dword of the script's memory a step changed + is kept with its value before and after. "The script's memory" is the start of `STRx` to the end of the payload: + variables, arrays, the heap, made texts and the string table. With both values, a step back puts writes back, + so it is as quick as a step forward. + - *What no record said.* At each frame's end the memory is compared with the record's copy. A difference becomes + the frame's last step ("the game itself"), so the copy cannot drift. None has turned up on the probes. + - *The test's line* that asked for the frame. This is read from V8's call sites, not from stack text: under Node + with source maps, formatting a stack every frame cost ten times what the game did. That took a 216-frame + record from 4.6 s to 0.5 s. + - *What was shown* on the text lines. +- **Pictures.** These stay on the host's side and are never sent with the record: + - The handle can now keep the game and put it back (`keep()` / `restore()`): the engine's snapshot plus the + payload range it does not cover. + - A game is kept every 48 frames, and when the kept games pass 256 MB, half go and the spacing doubles. + - `GameSim` now tells the handle of its own writes between frames (`wrote()`: keys, the mouse, a scratch + location, hit points), and the recorder keeps those with the test's actions, typed lines and calls. + - A picture of frame F is then the nearest kept game, those things done again at their frames, and the frames + played. Mid-frame it is the frame before's end. + - The analysis page holds the run in a session (`scmjs.sim.debug`, `picture`; the plugin's `debug()`). The + viewer page starts the same map the same way and draws a kept game put back over it (`scmjs.view.debug`, + `show`). It refuses one whose frame-0 state differs. +- **The walk** (`compiler/debugModel.ts`, `DebugCursor`): + - a line or a frame, either way; + - continue to a breakpoint or a watched variable's change, either way; + - the variables at the cursor, read as `sim.program()` reads them, over the record's copy of the memory; + - what each line wrote in the frame so far. +- **In the workspace** (`debugger.ts`): + - *Starting.* *Debug this test in the game* is on each test's row in Testing, and *Debug Test at Cursor* is in + the palette and the editor's menu. A failed test opens at its failure. + - *The Debug view* has a bar of controls, a timeline (the frames' cost, breakpoint hits in red, shown text in + gold; click or drag), the variables (changes in gold, an eye to watch one), the frame's steps, and the game + with its text lines. + - *The editor* shows breakpoints in the margin (F9 or a click), the line about to run and the test's line lit, + and beside each line run in the frame, the values it wrote. + - *Keys:* F5 and F6 continue forwards and back; F10 and F11 step a line and a frame, with Shift for back; + Shift+F5 stops. + - The marks show only while the script is the text that was run. +- **Found on the way:** the build's variable map crashed on any program with a loop over units (`LoopUnit` has no + `decl`). Fixed in `trigscript.py`. The units probe could not run in the game before this. +- **Measured** (under Node, `scripts/debug-game.mts`, `probes/game-debug.ts`): recording costs about 2.5 ms a + frame, kept games included. + + | Test | Frames | Steps | Writes | Record | Time | + | --- | --- | --- | --- | --- | --- | + | waves | 216 | 268 | 121,213 | 1.7 MB | 536 ms | + | the failing test | 146 | 183 | 96,936 | 1.4 MB | 349 ms | + + Most writes are eudplib's own. A picture takes 45 ms (played on from the last) to 110 ms (from a kept game). + At six frames of each test, both ways, the picture's memory was what the record said, and the variables read + from each agreed. +- **End to end in a built editor (2026-10-16).** scm-js `main` was built under `/editor/` with scm-sim's dev server + beside it and the local plugin builds, on `probes/game-debug.ts`. + - *Debugging the failing test* took about 11 s from the click (the eudplib build was 3.4 s of it) and opened at + frame 146: "failed: expected 0, got 5". + - *Keys:* every key moved the cursor as it should. F6 with `wave` watched stopped at "wave changed: 2 → 3", then + at "1 → 2". + - *Breakpoints:* a click in the margin set one, and F5 stopped before its line. + - *The picture* was drawn in the game column. The two workers' frame-0 states matched. + - *Picture latency:* 210–250 ms a move, 60 ms of it the debounce. + - *Fixed in that run:* + - The view's frame was made inside a detached box and never loaded. It now lives in an overlay over the game + column. + - The keys stopped after a click in the view or the timeline. + - The panel is now made 340 px tall for a session. + - A second backwards continue on a watched variable stood still. + - A passing test opened on frame 1, where no line runs. + - *Seen:* the simulator and the game disagree on this test (6 Zerglings at Home against 5). That is the kind of + finding A2 was for. +- **Not done:** + - The picture's camera stays where the viewer put it first. + - A test of several players. The debugger runs one game. + - Units are known only by their slot: the record keeps the script's memory, not the unit table. + - Conditional breakpoints, hover values, a call stack. The stack's frames are cells too, so the record has + them, but nothing reads them yet. + - On a copy that may only watch (StarEdit's download), there is no picture: a picture of a map's run is the map + played. Everything else works. + +### R0. Remastered's emulator, read from a dump (2026-10-16) + +Asked for on 2026-10-16 ("start with the Remastered dump"), before B1, so that the +allowlist stops saying "not known" for most of the game. + +- **The dump.** Remastered 1.23.10.13515, the 64-bit executable, in a single-player game + on an EUD map. A small read-only dumper (Windows Python, `ReadProcessMemory`) wrote the + executable's image and every readable region of the heap (2 GB). Its code section is + encrypted on disk and plain in memory. The dump, the tools and everything read from it + stay in a private folder outside every repository. +- **What Remastered's EUD support is.** An emulator object, reached from one global, + holding a tree of 12,290 *regions*: each a 1.16.1 address range, a class (34 of them, + each with its own read and write) and the game's own object behind it. The Deaths + condition and the Set Deaths action look the address up; a dword in no region, or one + whose class refuses it, ends the game. The object tables (1,700 units, 2,500 sprites, + 5,000 images, 100 bullets, the order queue) are a region per object. Writes are checked + before the class sees them: unaligned dwords are split, a flag makes a region's writes + accepted and dropped, and the mask is applied as A0 found by trying + (`(old & ~mask) | (op(old & mask, value & mask) & mask)`). +- **Run, not read.** Rather than read 34 classes of decompiled code, the emulator itself was + run under Unicorn from the dump: the image at its own address, heap pages mapped from the + dump when touched, the game's main thread block for its thread-local data, and Windows + stood in for (locks, the heap; the one other call is `GetKeyState`, for the keyboard + region). Every dword of every region (the object tables sampled at four objects) was read + and then written through Remastered's own entry points, and the memory before and after + compared: 1,581,605 dwords in 20 minutes. A write lands in **the game's own object**, in + **memory the emulator keeps beside it** (read back, never used by the game), or nowhere. +- **Pointers are typed.** A unit's pointer fields (+0x00, +0x04, +0x14, +0x5C order target, + +0x68 … +0x11C) take the address of a unit and set the game's real pointer; +0x0C takes a + sprite. Anything else written there is kept beside the game. So "make this unit target + that one" works through memory. +- **Fields that end the game when read:** unit +0x74, +0x78, +0x88, +0xB0–0xBC, +0xF0, + +0xF4, +0x100, +0x108, +0x10C, +0x130–0x148; and most of the block from 0x6D1200. + Writes to the terrain buffers behind the tile pointers are dropped, as Magenta probe 10 + found by playing. +- **The probe** (`probes/remastered-dump.py`, played 2026-10-16) took Magenta's `nudge` + apart: A, B (a unit's X, then Y) and C (its sprite's X and Y) went through with nothing + seen to move; D (a read of unit +0x74) ended the game, as the dump said, with "EUD ERROR: + 0xFFA632E3", which is 0x0059CD1C with its bits flipped. **The error code is the refused + address**, so a line can be named from it. Probe 6's "a position write ends the game" + was wrong; what ended it is not known. +- **In the allowlist.** `data/remastered.json` (1,285 entries: addresses, widths, verdicts, + notes; no code or bytes of the game) is the fourth source of `scripts/allowlist.mts`, + each entry `from: "dump"`. What was played wins over it byte by byte; where they differ + it is "played: ignored, dump: allowed" (the write reaches the game and nothing visible + happens), never the other way. An address no region covers is `outside`: the game ends. + The units probe's trace now has no finding at all, where before most unit fields were + "not known". `verdictOf` indexes entries by 256-byte block (190 ms for 42,745 rows). +- **Still open:** the keyboard region (filled from Windows on each read, so local by + construction); images, bullets and orders were sampled at objects not in use; the + error-code-to-line feature; the dump is one build, so a Remastered patch means a new dump + (the tools take an hour end to end). Ghidra's project of the dump exists for B7's struct + names. + +## Part B — the language + +Each slice states what changes in the IR, the simulator and the Python, and has a probe. +After A2, a probe without input steps also runs headless, and the user plays only what +needs Remastered's answer. + +### B1. `async` / `await` (4.3.0) + +The biggest change to the model since 3.0. Today a `sleep()` may only stand in a +program's own body or in an inlined function; a called function never sleeps. + +```ts +async function wave(n: number) { + createUnit(P8, units.ZergZergling, n * 4, locations.Gate); + await until(() => countUnits(P8, units.ZergZergling) === 0); + print(`Wave ${n} cleared`); +} + +program(async () => { + for (let n = 1; n <= 10; n++) { + const done = await Promise.race([wave(n), sleep(seconds(90)).then(() => false)]); + if (done === false) defeat(); + } +}); +``` + +- **A task** is a frame record on the heap (its locals, and which `await` it is parked at) + plus a resume point. The lowering is the one 3.0 uses for a program's top-level + `sleep`, a switch on the resume point, applied to every `async` function. +- **The scheduler** keeps the live tasks in a list on the heap, and each frame runs every + ready one until it awaits or ends. A `program()` is a task, so its meaning does not + change. +- **What can be awaited:** `sleep(…)`, `until(() => condition)`, any `async` call, + `Promise.all`, `Promise.race`, `Promise.any`. +- **Generators** (`function*`, `yield`) use the same machinery, for wave scripts and + for anything that hands out one step at a time. +- **Recursion with `await`** — the frame is already on the heap, so the recursion stack + of slice 8 may not be needed for an async function. To decide while building. +- **Errors:** `sleep()` outside an `async` function stays an error, with a fix that adds + `async`. An `async` function that never awaits is a hint. + +Open: whether `Promise.race` cancels the tasks that lost. JavaScript does not cancel +them, and a wave that keeps running after its race was lost is rarely what the author +meant. See *Open decisions*. + +**As built, first part (2026-10-16, under Node and in the lifted game; branch `b1-async`).** +The design above was built in a smaller form. No task lives on the heap: every task is +known when the script is built, so its place and its wait are two cells, like a program's. + +- **An async function is inlined where it is awaited.** A function that sleeps has been + inlined since 3.0, so `await wave(n)` puts `wave`'s body in the program. Where it waits, + the program waits. `await f()` reads as `f()` in the front end. The type checker's + awaited type gives a call's kind, so `Promise` is a number. An async function is + never one called copy. +- **`until(test)`** is `while (!test()) sleep(frames(1))`. It asks once at once, then once + a frame, and goes on in the frame the answer is true. A condition (`until(bring(…))`) + is taken as it is. No new IR. +- **`Promise.all` / `race` / `any`** are the one new statement, `parallel` (IR 15). Each + item is a branch, and each branch is a coroutine of its own: its state and wait cells + (rows of twelve in a per-player program), its own jump table, and its own frame end. A + branch's `sleep` parks that branch alone. The program waits at the statement through a + resume point of its own, and the statement *is* that point: the first frame falls into + it and later frames jump to it. There, each branch that has not ended goes on, in + order. `all` goes on when every branch has ended. `race` goes on at the first one, and + the others are set to "ended". `any` is a race, since nothing in a program fails. An + item may be an async call, `sleep()`, `until()`, another `Promise.all` / `race`, an + async arrow called where it is written, or any of these with `.then(() => value)`. + `const v = await Promise.race([…])` keeps the winner's value: each branch sets a + variable as it ends, and only the winner gets to the end. +- **The lowering.** `python/trigscript.py` gained a `Coroutine` (state, wait, resume points, + frame end). The program's frame is the first coroutine, and a branch is another, lowered + with the same code. The simulator runs each branch as a generator of its own and hands + up any body run apart (recursion) from inside a branch. +- **What is said.** In an async function, a promise nothing awaits is an error that names + the `await` to write. Without async, an async call says to make the program async. + `sleep()` on its own in a program that is not async still pauses, as in 3.x. A race + whose value is kept and has a branch without one says to add `.then(() => …)`. + `Promise.all` has no value yet. `await` inside a loop over units or a text is refused, + as `sleep` is. +- **Checked.** `tests/async.test.ts` (12) and two eudplib build fixtures (`tasks`, + `tasksPerPlayer`). `probes/game-async.ts` runs in the lifted game and in the simulator, + and both pass all three tests at the same frames: the branches side by side, `until()` + in the frame its answer turns, a race's value and its losers stopped, and a wave whose + Zerglings the test kills. Cost: a waiting `Promise.all` of two branches is about 60 + conditions and actions a frame. +- **Decided while building, confirmed by the user (2026-10-16):** a race's losers stop where they + are. JavaScript does not cancel them, but here a branch is a fixed pair of cells, so a + race met again in a loop could not run a second copy beside the first. Stopping is also + what the plan's own example means. (Open decisions, below.) +- **Not yet** in the first part: starting a task and leaving it running, generators, + `const [a, b] = await Promise.all(…)`, async methods, a parameter for `.then`'s + function, recursion through `await`, the hint for an async function that never awaits. + All but recursion are the second part, below. + +**As built, second part (2026-10-16, under Node and in the lifted game; branch `b1-tasks`).** + +- **Tasks: `start()`, not `spawn()`.** Scripts already call their own wave functions + `spawn` (the guide's examples do, and so do the tests), so the library's name is + `start`: `start(wave(3))`, `start(async () => { … })`, and `void wave(3)` as JavaScript + leaves a promise nobody keeps. A bare `wave(3);` in an async function stays an error, + which now names both `await` and `start()`. +- **No heap after all.** A task is not a frame on the heap. Each `start` is a body of its + own, inlined once, with `max` slots (16 unless `{ max }` says, up to 255). A slot is a row + of everything the body declares (`VarDecl.task`, `ArrayDecl.task` in IR 16), the same + rows a per-player program has, one level down. In a per-player program they are rows + within each player's. The arguments are worked out at the start, and the parameters + are always copies, never the caller's variables. A start with every slot running starts + nothing, and the game says so once. +- **When a task runs.** It starts in the frame it is started in, once the program has + given the frame back. After that it goes on every frame after the program's turn, by + start and then by slot, and it runs on after the program ends. A task started during + the tasks' turn gets its first turn in the same frame: the slots are gone through + again. Each start keeps a count of its running tasks, so a start with none costs a few + conditions a frame, and the slots after the last running one are not looked at. That + took the probe from about 7,700 conditions and actions a frame to about 1,000. +- **A Task.** `start()` gives a number: + `slot | start << 8 | generation << 16`. `t.running`, `t.cancel()` and `await t` work + through it, and a number of a slot that has been taken again finds nothing. A task + that cancels itself stops at its next wait. +- **What is refused.** A task that reads a variable of the task it was started from is + refused, because which of that task's copies it would find is not settled. So is a task + closing over a variable that the loop around the `start()` changes (JavaScript gives + each turn its own). A function that starts a task is never one called copy. A function + that starts itself, directly or round about, is the inline limit's error, and the + message now says to write it as a loop. +- **Generators** are walked by `for…of` (`for await` for an `async function*`). The + generator's body is inlined, and at each `yield` sits a copy of the loop's body. The + loop variable is what was yielded: a value the script knows stays one, as in an + unrolled loop, and anything else goes in a variable of that turn. `continue` goes on + after the yield, and `break` leaves the generator. A generator kept in a `const` and + stepped with `.next()` is not supported, nor is `yield*` or a `return` in the loop's + body. Each says so. +- **The rest.** `const [a, , c] = await Promise.all([…])` gives each name its item's + value, set as the item ends. `.then((k) => …)` is handed what the promise gives. A + class may have async methods. An async function that never awaits gets a hint on its + name. Unrelated, but met on the way: a loop that has a `break` or a `return` in it is + no longer reported as freezing the game. +- **Recursion through `await`: decided, not built.** An async function that waits keeps + its place where it waits, one place for each copy, so a call of itself would need the + whole chain of places kept and brought back. A loop does the same job, and the error + says so. +- **Checked.** `tests/tasks.test.ts` (15) and three eudplib build fixtures (`started`, + `startedPerPlayer`, `generators`). `probes/game-tasks.ts` (4) and + `probes/game-generators.ts` (3) pass in the lifted game and in the simulator at the + same frames. Every older game probe still passes. + +### Classes from any file (built 2026-10-16, between B1 and B2) + +Asked for while S2's Tetris grew a library (`probes/lib/unit-screen.ts`): a class declared at +the top of any of the script's files is the program's wherever a program makes an instance of +it, with no `game()` around it, and stays an ordinary class when the script is built +(`const AT_BUILD = new V(21).double()` beside `new V(n)` in a program). + +- **Planned as a game function is** (`hoist.ts#planClass`): its parameters and variables are + the game's, and what its field values and bodies compute when the script is built is + hoisted. The emitter adds `__class(C, { at, pos, hoisted })` after each such class, so the + run hands its own file's values — its imports, its constants — to the lowering. +- **In a program**, a script class is never hoisted (`new C(1)` is an instance, whatever its + arguments). Before the program's first statement the lowering declares every script class + the program names, the ones they name and extend, and those of the `game()` functions it + calls — their static fields — then walks their bodies in their own files. +- **Errors wait for use:** a class only ever used when the script is built reports nothing. +- **The rules of a program's class still hold:** an array field has its first value where it + is declared, a field holds no build-time object, and an action's player and location are + known when the script is built — so the unit screen takes them with each `draw(board, look)`. +- Checked: same file, another file with its own imports, `extends` across files with a static + field, a class used at build time and in a program, errors only where used; Tetris's + `UnitScreen` follows the board in the lifted game as the function did. + +### B2. Events (4.4.0) + +```ts +on.created(units.TerranMarine, (u) => { u.energy = 50; }); +on.died({ owner: P8 }, async (u) => { await sleep(frames(24)); createUnit(P8, u.type, 1, locations.Spawn); }); +on.order(units.ProtossHighTemplar, orders.CastPsionicStorm, (u) => …); +on.chat("-give {n}", (p, { n }) => …); +``` + +- **created**: eudplib's `EUDLoopNewCUnit` walks the units made since the last frame. +- **died**: a unit is kept in a table by the identity 8½'s unit keys already use (pointer + plus the slot's use count, which the leftovers probe showed holding in the game) and is + found gone when its slot no longer matches. Who killed it: scm-sim's analysis found a + killer's kill count rises one frame before the victim leaves the lists, which may give + `u.killedBy` on the same rule. That needs its own check. +- **order**: compares each unit's order with the last frame's; the cost is a scan, and the + hint says so. +- A handler is a task (B1), so it may await. + +**As built, first part (2026-10-16, under Node and in the lifted game; branch `b2-events`).** `on.created`, +`on.died`, `on.chat` and `on.key`; `on.order` and who killed a unit are the second part. + +```ts +on.created(units.ZergZergling, (u) => { u.energy = 50; }); +on.died({ owner: P8 }, async (u) => { await sleep(seconds(2)); createUnit(P8, u.type, 1, locations.Spawn); }); +on.chat("-give {n}", (p, { n }) => setResources(p, "add", n, "ore")); +on.key("F2", (p) => print("Help", { to: p }), { from: P1 }); +``` + +- **A listener is a task.** Each `on.…` is a `start` of one slot whose body is `while (true) { sleep 1; … }`, + inside an async program, as `ui.lines` is; it gives a Task, so `cancel()` stops it. The first turn only waits: + a listener hands on what happens from the next frame, as a JavaScript listener added now hears nothing from + before. A handler that is not async is inlined in the loop; an async one is a `start` of its own each time + (`{ max }`, 16 unless said), its parameters copies of what it was handed. +- **Where the units come from: the frame's events pass**, once a frame before the programs. It walks two of the + game's lists from their heads, the visible units (0x628430) and the dying ones (0x62842C). The reader's spec + `scm-sim docs/specs/unit-lists.md` says every list links a record in just behind its head, so what came in + since the pass before lies between the head and the record that was behind it then: the walk is as long as + what happened, and a frame where nothing did costs three conditions a list. A table of a cell for each unit slot, + 336 bytes apart as the slots are (eudplib's EUDLoopNewUnit keeps its marks that way), holds the uniqueness byte of + the unit seen in the slot, and one more once its death has been handed on. Nothing of the game is written. +- **Remastered's dying list runs the other way (Ghidra, 2026-10-17).** Its kill routine unlinks the unit from the + visible list (img+0xe77fc8 head, +0xe78078 tail) and links it into the dying list with the same link-in routine + (img+0x1956a0: the record goes behind the first pointer it is given), passing img+0xe78080 as the end it links + behind and img+0xe77fd0 as the other; the free step unlinks it the same way round. The EUD emulator maps 1.16.1's + 0x62842C to img+0xe77fd0 (traced under Unicorn: the region handler 0x2e1e50 reads that global), so a map reading + the dying list's "head" gets Remastered's far end, whose "next" is 0, and 1.16.1's 0x59CC98 (where Remastered's + linking end would be) is not readable. A walk along "next" from 0x62842C would see one death a frame. The visible + list maps the right way (0x628430 → img+0xe77fc8, linked behind it); unit +0x00 / +0x04 map to Remastered's + +0x00 / +0x08, previous and next, the same as 1.16.1. (The hidden and sweep lists are mapped the other way round + too: 0x628428 and 0x628434, tails in 1.16.1, give Remastered's linking ends.) So the dying list is walked whole, + both ways from the record at 0x62842C, every frame it is not empty; the cell says which records are new deaths. + A record stays on it until its sprite is gone: 14 frames for a Zergling, and 200 of them cost about 21k a frame + for those frames. The order a frame's deaths are handed on in is the list's, so it differs between the games. +- **What counts.** A unit that comes onto the visible list for the first time is made: made by an action or a + program, trained (when it comes out, not when it is queued), placed by a test; a unit in a transport is made when + it first comes out, and unloading it again is nothing new. A unit on the dying list that was seen is dead, + killed or removed (both go through the dying list; Remove Unit releases the sprite at once), handed on once. So + every unit `on.created` hands on gets one `on.died`, and a turret, a scanner sweep, a unit made and gone between + two passes, or one that never left its transport is neither. A unit that turns into another — an egg hatching, a + Drone becoming a building, a Siege Tank sieging — is the same unit: it was made once, as what it was then. +- **What a handler is handed.** `on.created`: the Unit. `on.died`: a `DeadUnit` record, `{ type, owner, x, y }`, + read from the dying record as the unit died (the slot is not given to another unit before the next dying pass), + and only the fields the handler reads; what the filter says for certain is a value of the script, so + `on.died({ owner: P8 }, (u) => createUnit(u.owner, …))` has its player known. `on.chat` / `on.key`: the player, + a value of the script in each of eight unrolled copies (P1 … P8, or `{ from }`, or CurrentPlayer in a program for + several players), so an action can be for them; `on.chat` also the pattern's values, as `chatted()` gives them. +- **What it costs** (lifted game, Binary Burghs): a frame where nothing happened, about 46 conditions and actions for + the pass and a listener, 2 more for each other listener; about 100 for each unit made and 80 for each death. Two + things made it so: the task runner passes a listener's whole start by when it has nothing to hand on and no task + that has not had its first turn (`start.listen` in IR 18; a skipped turn would have done nothing), and the pass + passes the visible list's walk by when its head and the record behind it are as it left them (the dying list costs + three conditions when empty, about 100 a record otherwise). Before either, two idle + listeners cost about 1,240 a frame. `on.chat` and `on.key` for all eight players idle at about 1,290, where the + same polled with `chatted()` / `keyPressed()` in a loop costs about 1,730. +- **Simulator.** The same pass over its world's units: a unit is seen at the first pass it is alive at, its death + found by the first pass after, each list latest first. +- **Found on the way:** the build-time evaluator had no object literals, so `print(t, { to: p })` with `p` known + failed; and a record's known field (`u.owner` above) was not a value. Both are fixed. The lifted game's + `sim.printed()` folds a text shown twice in one frame (its text-line reader); the probe counts the actions. +- **Checked.** `tests/events.test.ts` (7), eudplib fixtures `events` and `eventsPerPlayer`, `probes/game-events.ts` + (4) and `probes/events.ts` (3) in the lifted game and the simulator, at the same frames; every older game probe + still passes. **Probe for Remastered:** `Maps/TrigScript/trigscript-b2-events.scx` with its answer sheet: whether + every death reaches `on.died` in Remastered (rebuilt 2026-10-17 with the walk both ways), a trained SCV, unloading, 200 made and killed in a frame, and chat and a key in a multiplayer game. Not played yet. +- **Played in Remastered, 2026-10-17** (multiplayer, alone): every step passes but the key. A: three "made 0". + B: three "died 0 at x, y" near the base, then three "back" and three new Marines, each "made 0". C: two Firebats + made and removed, "died 32" twice. D: "made 7" when the SCV came out, not when queued (loading not tried: nothing + to load). E: "E: made 200, died 200", no stutter. F: `-give 100` gave 100 and printed "gave 100" to the player. + F2 moved the camera (the game's own key) and said nothing; a second probe (Q, F2–F5, F7, F9, each through + `on.key`, and F2 through `keyPressed`) heard only Q, F7 and F9. So F2–F5 join F6 as keys Remastered keeps: they + are not a `Key`, and the examples use F7. F1, F10–F12 not played. +**As built, second part (2026-10-17, branch `b2-events-2`).** `on.killed`, `on.order`, `on.morphed`, and a listener +at the top of a script; IR 19. + +```ts +on.killed({ owner: P8 }, (u, by) => setResources(by, "add", 25, "ore")); // the killer, a player the action can be for +on.order(units.ProtossHighTemplar, orders.CastPsionicStorm, (u) => print("Storm!")); +on.morphed(units.TerranSiegeTankSiegeMode, (u, was) => print(`sieged from ${was}`)); +on.died(units.TerranMarine, (u) => print("Marine down")); // at the top of the file: listens from the start +``` + +- **Who killed a unit: the dying record says.** Measured in the lifted game first (a fight, Kill Unit, Remove Unit, + marines inside a dropship killed by trigger, a marine hurt and later killed by trigger): a death in a fight leaves + hit points 0 and the killer's player at +0x90 (lastAttackingPlayer, as BWAPI and scm-sim name it); Kill Unit leaves + the hit points and +0x90 as they were (8 when nobody ever hurt the unit); Remove Unit the same with the sprite gone. + So `on.killed` is the `died` turns with hp 0 and +0x90 below 8, the killer read from +0x90. The plan's other way + (the killer's kill count rising a frame before) is not needed. A unit a program damages to death (`u.damage`) + with an earlier attacker would count for that attacker: not seen yet, said here. +- **`removed` is not built: the game does not tell.** A Remove Unit and a unit killed inside a transport leave the + same record — no sprite, hit points as they were, +0x90 untouched; 336 bytes compared, nothing else differs but + timers. A flag that is wrong for a hero dying in a dropship is worse than none, so `on.killed` takes its place: + "died in a fight" is what a map maker asking "was it removed?" usually wants. An Archon's partner leaves the same + way (one High Templar turns into the Archon in its own slot, the other goes without a sprite). +- **The killer as a value of the script.** `setResources(by, …)` needs its player when the map is built, so a handler + that takes `by` is copied for each of the eight killers (`if (was == p)`), as `on.chat` is for each typer; with + `{ by: P1 }` in the filter it is known and not copied. First found when the eudplib fixture refused `setResources(by, + …)`: the obvious way to reward a killer did not compile. +- **Orders and morphs: one walk of the visible list.** A unit's order byte and type are compared every frame with a + table of the events pass's own (a cell a slot, as the uniqueness cells). The type is the same unit changing in + place, measured in the lifted game: a Siege Tank goes 5 → 30 in its slot at the frame it finishes turning (order 98, + Sieging, the frame before); one High Templar of two becomes the Archon in its slot. `on.order` hands the unit, the + order it took up and the one it had (`{ order }` in the filter, or the plan's three-argument form); `on.morphed` the + unit and the type it was, its filter on the new type. Each verb's order, from the lifted game: `move` 6 (Move), + `patrol` 152, `attack` 14 (AttackMove); the simulator sets them. New units take up Guard (2) then PlayerGuard (3) + in the game, which `on.order` hears; their first order is not a change. +- **What the walk costs** (lifted game, about 115 units, idle): 178 conditions and actions a frame without it; one + of `on.order` / `on.morphed` about 11,000 (≈ 95 a unit), both about 16,000 (≈ 140). First built at 16,000 / 21,000: + reading the uniqueness byte only when something changed, and the type as its low byte, took a third off. The + pointer chase is about half of what is left. A hint on the line says it. +- **Listeners at the top of a script.** `on.…(…)` as a statement at the top of a file is planned as a program whose + body is that statement (a block made around it, `hoist.ts#planListener`), and the transformer replaces it with + `__listen(descriptor)`: a program of its own, owner Player 1, listening from the start. +- **Handlers return what they like.** The declarations had `=> void | Promise`, which refuses `(p) => + setResources(…)` (an expression body that gives an action) — the examples in the declarations were written that + way. Now `=> void`, which an async function also is. +- **`orders`**: a name table (`orders.Move`, `orders.CastPsionicStorm`; the community's names, as BWAPI and scm-sim + have them), the `Order` brand, and `u.orderId` an `Order`. Typed chat patterns (`{n}` a number, `{u:unit}` a unit + type, `{k:ore|gas}` the word's place) were already in part 1, from `chatted()`. +- **Tests:** `sim.morph(unit, type)` for the simulator (the lifted game turns a unit into another type only through + a player's command, which a test cannot give yet: `GameSim.morph` says so); `GameSim.kill(unit, by)` now leaves the + record a fight does (hp 0, +0x90 = by), so `on.killed` hears it in both. +- **Checked.** `tests/events.test.ts` (12), eudplib fixture `eventsMore` (three programs, two at the top of the + file), `probes/game-events-2.ts` (3) in the lifted game and the simulator at the same frames, every older game + probe; `game-check` of the Remastered probe: 0 accesses that end the game. **Probe for Remastered:** + `Maps/TrigScript/trigscript-b2-events-2.scx` with its answer sheet — a fight (killer both ways), a program's kill, + Psionic Storm (order and kills), an Archon, a Siege Tank both ways, a Drone into a Hatchery and a Larva into an + egg into a Drone, a move order, 300 Overlords with the walk on. **Played in Remastered, 2026-10-17, A and B:** the fight said "killed 37 by P1" for each of the four Zerglings and gave 25 minerals each (75 after three, 100 after four), and for the one Marine that fell "killed 0 by P2" and the top-of-file listener's "top: Marine down"; the program's two Firebat kills said "died 32" twice and no "killed". So Remastered's dying record has hit points 0 and the killer at +0x90 after a fight, as 1.16.1's does. C and D (same day): "Storm!" when a Templar began casting (on.order, CastPsionicStorm); merging the Archon said "died 67" and "morphed 67 -> 68" while it was still summoning — the type changes when the merge starts; the tank said "morphed 5 -> 30" sieging and "morphed 30 -> 5" unsieging. The storm, played again on Zerglings of the player's own (the computer's had been made out of sight): every unit it killed — eight Zerglings, two Marines, both Templar — was handed to `on.died` and none to `on.killed`, minerals unchanged. **Friendly fire leaves no killer:** the lifted game with a storm cast through player commands gives the computer's Zerglings hp 0 and +0x90 = P1, the player's own hp 0 and +0x90 = 8 (one bitten first by a computer Zergling: 1). The game keeps no attacker for a unit its own player hurts, as it counts no kill for it (scm-sim's T-30). So `on.killed` hears a unit killed by another player; the simulator's `kill(u, owner)` and `GameSim.kill(u, owner)` now leave no killer either, and the declarations say so. E, first half (first build): `-zerg` made the two Drones, and a Drone told to mutate into a Hatchery said "morphed 41 -> 131" as it began. On the first build `-storm`'s computer Zerglings went to Away, past the edge of what the player could see (they are made there in the lifted game; not looked for in Remastered); the second build's Player 12 Zerglings at Home were not seen, whether made or not unknown. Rest of E, F, G (same day): a Larva into a Drone said "morphed 35 -> 36" when it became an egg and "morphed 36 -> 41" when it hatched (an Overlord egg "36 -> 42"); a Marine told to move said "move from 3". G: no lag, but `-many` made only about 23 of its 300 Overlords — Remastered said "WARNING: Unit unplaceable! (Zerg Overlord)" for the rest, as the lifted game does not (it put 168 in a 64-pixel box): Remastered keeps a created air unit inside the location, and Home was full. So the walk with ~60 units on the map; 300 not played. **Every event of the second part works in Remastered** but the storm on an enemy, which the lifted game covers. +- **Not yet:** a morph in a test in the lifted game (scm-sim's test handle would take a player's command); `on.order` + only for units of the filter's types (skip the order read for the rest: cheaper, but a unit that morphs into a + watched type would bring a stale order once); whether a program's `u.damage()` to 0 credits an earlier attacker. + +### B3. Function values (4.5.0) + +```ts +const effects: ((u: Unit) => void)[] = [heal, slow, (u) => { u.hp -= 10; }]; +effects[random(effects.length)](target); +``` + +- A function value is a number, a function id. A call through it is a switch on the id + (or `EUDTypedFuncPtr`, which eudplib 0.81 has; the build may choose). +- **What a closure captures:** a `const` captured by value is exactly JavaScript, since + it cannot change. A captured `let` is the hard part: there is no garbage collector, and + the heap's rule is "the holder owns the block". Proposed: a `let` may be captured only by + a function that does not outlive the frame it was made in (passed down, never stored); + otherwise an error that says to make it `const` or a field. See *Open decisions*. +- This also gives 8½'s callbacks a way to work when they are not inlined: a callback + passed through two functions. + +**As built (2026-10-17).** Two kinds of function value, chosen by what the build knows, so that what costs nothing +stays free: + +```ts +function twice(f: () => void) { f(); f(); } +twice(() => { n++; }); // known when built: inlined, nothing kept +let state: () => void = idle; // kept: a number, and a call through it a switch +function idle() { ticks++; if (ticks == 3) state = busy; } +const handlers: ((p: number) => void)[] = []; +handlers.push((p) => { gold += p; }); +for (const h of handlers) h(5); +``` + +- **Known when the script is built: no value at all.** An arrow handed to a parameter, a function named by its name, a + `const f = (x) => …`, a parameter handed one — a call of it is a call of its body, inlined or called as any + function's (`Binding` kind `fn`). A function that is called and takes a function is one copy for each function it is + handed, as it is one for each array. Such a function sees every variable where it was written, a function's own + included, since it can only run while that function does. `compose(f, g)` and `adder(3)` return functions whose + captured `f`, `g`, `k` are known, so each is a function of its own with them folded in. +- **Kept: a number.** In a `let`, a cell of an array, a field (of a record or an instance: `b.onClick = () => …`), a + `Map`, a function's result, a side of `?:`. Its number is the id of a function the build makes of it (one copy, + parameters as variables, walked once the program's body has been, so `let state = idle` before `function idle()` + sees `state`). A call through a number is a call of a **dispatcher**, one for each shape of call — its parameters' + kinds in order and its result — whose body is a `switch` on the number over every function kept as a value that fits. + 0 is `undefined`: `f?.()`, `if (f)`, `f !== undefined`. No new IR kind; IR 21 adds `fn` to a `const`, which is how + only the functions whose numbers the program can reach become cases. +- **What a kept function may use from outside it:** the program's own variables (one cell each, for good), values the + script knows, and other functions known when built — checked as the body is walked (`Scope.watch`), with the variable + named in the error. A variable of the function or block it was written in is refused, `const` too, where the plan + proposed allowing a `const` by value: one by value needs somewhere to keep the value per function made, and that is an + environment on the heap with an owner (see *Open decisions*). A `for` with bounds the build knows is unrolled, so + `for (let i = 0; i < 3; i++) hs.push(() => i)` works, each function with its own `i`. +- **Not kept as a value:** an `async` function or a generator (call it where it is written, or `start()` it), one that + takes an array or a record (it is a copy an array), one that sleeps or holds a `rose()` / `once()`. +- **Python lowering:** a dispatcher is lowered where it is first called, with every function among its cases, which can + come before a variable one of them uses is declared (the instance a later line makes); the lowering now makes a + variable where it is first used (`Lowering.early`), and its `declare` sets that one. Found by the lifted game, not by + the simulator, which makes its variables as it goes. + +Cost: a call through a number is a chain of compares, one a case before the one it takes (the state machine with its +loop: five calls in 515 conditions and actions). A jump table when the cases are many is for later. **Checked:** +`tests/function-values.test.ts` (24: each body also run as JavaScript and the prints compared, and the refusals); +`probes/game-function-values.ts`, six tests, the same lines from the lifted game and the simulator; game-check: no +access Remastered ends the game for. Found on the way: `flatMap` and the other methods a program does not have now say +so instead of "A call's arguments must be known…"; an optional function parameter left out folds `g?.()` and `if (g)`. + +### B4. `try` / `catch` / `throw` (4.5.0, with B3) + +- `throw` unwinds the frames on the heap, task frames and recursion frames both, to the + nearest `catch`. +- The faults the game reports today (out of memory, recursion too deep, an index out of + range) become thrown `RangeError`s that a program can catch. When nothing catches one, + it is said in red, as now. +- `finally` runs on every way out, `await` included. + +**As built (2026-10-17, IR 22).** What a script throws is an error, and everything else is what JavaScript does with one: + +```ts +class OutOfGold extends Error { constructor(public need: number) { super(`need ${need} more`); } } +function buy(cost: number) { if (cost > gold) throw new OutOfGold(cost - gold); gold -= cost; } +try { buy(price); } +catch (e) { if (e instanceof OutOfGold) print(`${e.message} (${e.need})`); else throw e; } +finally { busy = false; } +``` + +- **What is thrown:** `new Error(…)`, `new RangeError(…)`, `new TypeError(…)`, an instance of a class that extends one of + them (made where it is thrown or earlier), or what a catch took (`throw e`). Anything else is refused at the `throw`, + saying so. A class of error has a `message` and a `name` (the `name` it gives itself, else what it extends gives, as + JavaScript has it) and fields of its own: numbers, booleans, units and texts. +- **In flight:** an error between its `throw` and its catch is two shared variables of the program (`Program.errors`: + its class's code, its message) and one more for each field name and kind any class has. Nothing is in flight across a + frame, so one set serves every player and every task. The codes: 1 `Error`, 2 `RangeError`, 3 `TypeError`, 16 and up + the program's own classes. +- **`catch (e)`:** `e` is a record whose fields are made as the body reads them — `e.message`, `e.name`, `e.need` — each a + copy taken at the catch's start, so an error thrown and caught inside the catch changes none of them. `e instanceof + OutOfGold` is a test of the code against that class and every class that extends it, filled in once the program has + been walked and every class that is thrown is known. `` `${e}` `` and `String(e)` are "RangeError: too far", or the name + alone with no message. `catch { }` takes nothing. A caught error handed to a function that is inlined is itself. +- **Through functions:** inlined, a throw is a jump to the catch. A called function returns at once with the error in + flight, and a call of one that may (it throws, it calls itself, it calls one that may) is followed by a look at the + code. Recursion needs nothing of its own: each frame comes back as on a return, and the look goes on up. +- **`finally`** is not in the IR. It is copied before each `break`, `continue` and `return` that leaves the try (a + `return`'s value worked out first, as JavaScript does), after the try, and into a `try` around the whole that puts the + error in flight aside, runs the finally, puts it back and throws on. A finally that would `return`, `break` or + `continue` out of itself is refused: one copy of it runs on an error's way, where there is nowhere to go. +- **What nothing catches** is said in red, as the game's faults are — "TrigScript: uncaught OutOfGold: need 20 more, line + 12." — and in the simulator a fault at the throw's line. A program or a task then ends, as an async function does in + JavaScript; a handler of an event that runs within the frame gives up that turn only, and the listener goes on. +- **Waiting:** a try may span `await` and `sleep`. An error in a branch of `Promise.all` / `race` leaves the statement, + and the other branches stop where they are, as a race's losers do. A task's error never reaches what started it. +- **A stack that runs out** is a `RangeError`, "Maximum call stack size exceeded", which a catch takes — when the program + throws or catches anything; one that does neither stops, as in 3.x. The other faults stay as they were: an index past + either end is not an error in JavaScript either, and a heap that runs out is said once and the push does nothing. +- **Unwinding what is held:** a loop over a text gives the copy it walks back to the heap, and a loop over a `Map` or a + `Set` counts itself out, on an error's way as on a `break`. +- **Generators:** a `break` out of a `for…of` over one runs the generator's finally blocks, as `return()` does. The loop's + body is walked where the `yield` is, inside the generator's `try`, so an error of the body is put aside there, the + generator leaves through its finally blocks, and the error is thrown on after the loop: the generator's own catch never + takes it (it did, before this was found). +- **A caught error's methods and getters:** `e.describe()`, `e.short`. Every class `e` may be (what `instanceof` narrowed + it to, and what extends that) is asked for the member; when they share one it is inlined with `this` the caught error, + and when one overrides it each is, behind a test of the code. +- **`e.stack`:** "OutOfGold: need 20 more\n at main.ts:12:5", made at each `throw` of a new error, and only when some + catch reads a stack. A stack that runs out leaves the last one there (the lowering throws it, not the front end). +- **`await t` of a task that ended on an error throws it**, and so does `Promise.all` over tasks. Each slot of each + start keeps the error its task ended on, beside the task's number (IR `taskHandle`), in arrays a cell a slot. A task + that fails while something awaits it says nothing in red; one that fails while nothing does is said, as an unhandled + rejection is in a browser, and an `await` of it later still throws. + +Cost (lifted game, 100 a frame): a throw caught in the same body, about 37 conditions and actions; a `try` around calls +of a function that may throw, about 19, plus the look after each call; a `try` / `finally` that nothing leaves, about 18. +**Checked:** `tests/errors.test.ts` (40, each body also run as JavaScript and the prints compared, and the refusals; a +leak test that fails without the unwinding); `eud-build` fixture `errors` through eudplib; `probes/game-errors.ts`, six +tests (seven since: generators, methods, `await t`), the same lines from the lifted game and the simulator, among them +4 800 errors out of loops that hold something, and the stack run out at 1 024; game-check: no access Remastered ends the game for. The probes of B1–B3 and B6 still pass. + +**Not yet:** an error kept in a variable, an array or a field (`last = e`, `errors.push(e)`): an error would be a new +kind of value in every kind of storage — its class, its message and every field of every class — for little a map does +with one; `new Error(m, { cause })`, refused, saying to put what it needs in a field (a cause is an error inside an +error, which the same storage would need); a throw while a text +is being put together (`${made()}${fails()}`) does not give back the block of a text made for an earlier part, in the +game or the simulator (both lose the same block, so they still run out at the same text). + +### B5. `Local` and `sync()` (4.6.0) + +Some values are true only on one machine: the local player, where its screen is, what it +has selected, what it is typing. TrigScript gives none of them out today; all input goes +through MSQC, which sends it to every machine. B5 offers them, typed so that a desync +cannot compile: + +```ts +const sel = localSelection(); // Local +centerView(sel[0]); // fine: the camera is the local machine's own +units.push(...sel); // error: a local value cannot change the game +const picked = await sync(sel[0]); // Unit, the same on every machine, a few frames later +``` + +- **The check** is a taint pass over the IR. `Local` reaches what only the local machine + sees (the camera, text shown to the current player, a sound for one player) and nothing + that changes the game state for all: a store into a shared variable, an action's target, + a condition of a branch that does either. +- **`sync()`** sends the value as a game command, as chatEvent's numbers already go + through MSQC's `val`, and resolves when every machine has it. It is `async` by nature, + which is why B5 comes after B1. +- **What can be local:** each read needs its address from the disassembly and a probe in + Remastered. A3 checks the taint pass's answers in practice. + +**Probed (2026-10-17), before building.** First a correction to the above: the mouse is not a B5 value. +`mouse(p)`, `clicked(p, button)` and `underMouse(p)` have reached every machine through MSQC since 3.4, and +`keyHeld` since S2, so S3's aim and movement need nothing from B5. What is left is what only one machine knows: +its camera, its selection, the local player, and the cursor on the screen without the trip to the others. + +The dump of Remastered's emulator (R0, read in Ghidra) says what each of those reads, and +`probes/local-reads.ts` + `.py` was played four times, in single player and in a multiplayer game hosted alone: + +| Value | Remastered's emulator | Played | +| --- | --- | --- | +| Local player `0x512684` | the real local player number; a write is kept in a copy beside the game | 0 | +| Camera `0x62848C` / `0x6284A8` | the real camera, read by the drawing code; writes ignored | follows scrolling. The view is **853** wide in Remastered graphics (16:9) and **640** in Classic: the camera stops at 2219 and 2432 on a 3072-wide map | +| Mouse `0x6CDDC4` | Remastered's input record | screen pixels ÷ 3 at 2560 × 1440, so 853 × 480 in widescreen, not 640 × 480. A write changes what the game thinks (the cursor's icon, what MSQC sends) until the mouse moves; the drawn cursor does not move | +| Buttons `0x6CDDC0` | live | left 2, right 8, middle 32 | +| Selection `0x6284B8` | the local selection, read as 1.16.1 unit addresses; a write of a unit's address puts the real unit in the slot | read: right. Written: the circles and the panel stay where they were (the panel's copy is refreshed only when the game's "selection changed" flag is set: 1.16.1 `0x4C38B0`, Remastered `+0x400100`), but orders go to the written unit | +| A click's trip through MSQC | — | 5 to 6 frames, single player and multiplayer alone alike; a game between machines adds its turns | + +**Why orders followed a written selection, and why it is safe.** Both games keep the local selection apart from +the one each player's orders run over (`0x6284E8 + player × 48`, which every machine holds alike and only a Select +command changes): 1.16.1's walk over a command's units, `0x49A850`, reads it, as does Remastered's `+0x3D8800`. A +right-click is sent with no units (1.16.1 `0x4563A0`), and 1.16.1 sends a Select only where the selection is set by +the player (`0x4C0860`, seven callers). What sent the written Marine was **MSQC**: each time it carries input it +saves the local selection from `0x6284B8`, selects its own Valkyrie, and puts the saved units back with a real +Select command (`RestoreSelUnits`). The probe's line for the selection every machine holds showed the Marine the +frame after F8, before any click. With MSQC in the map a written selection is sent to everyone; without it, the +write stays on the one machine and orders go to the old selection everywhere. Neither splits the game. So +`select(player, units)` is possible: write the local selection, send a Select command as MSQC does, and set the +flag that refreshes the circles and the panel. + +**As built, first part (2026-10-17, IR 23, branch `b5-local`).** Differs from the above in these ways: + +- **The values:** `local.player`, `local.camera.{x,y}` and `local.mouse.{x,y}` (on the screen), each a cell read as + `memory`'s are (`memory.ts`: `LOCAL_FIELDS`), typed `Local`. `Local` is only a name for `T`: TypeScript + loses a mark at the first sum, so the check is the compiler's, as planned. The selection waits for the second part. +- **The check** (`compiler/local.ts`) is a pass over each program's IR, gone round until nothing more is found. A cell + — variable, array, text buffer, parameter, a function's result — given a Local value is Local, and so is a cell + assigned under a test of one. Refused, at the line and naming where the value came from (`k (from local.camera.x)`): + an action that is not only shown (Play WAV, Display Text, Center View, Minimap Ping and the objectives are), a unit + changed or ordered, a location moved, a table of the game written; and under a test of one, waiting, starting or + cancelling a task, throwing, `sync()`, and a random draw (each draw moves the game's random numbers on — also + `local.mouse.x > 3 && random()`, where whether it is drawn depends on one). Leaving early is not refused: a loop, + a switch, a function or the program that a `break`, `continue` or `return` under a Local test leaves on some + computers only is walked again as a whole under the test, and what it does after is said where it does it. A called + function run under a test is walked as if all of it were. A `memory(address, …)` condition on a local address + counts. Not followed: the made texts' heap (a Local text may be longer on one computer). +- **`await sync(player, value)`**: `syncSend` clears the slot's arrival on every computer and, on the player's own + (`f_getuserplayerid()`), puts value + 1 where MSQC's `val` sends it from; the program then waits a frame at a time + until a value from 1 to 1 048 575 has arrived (MSQC's cell reads 0xFFFFFFFF on a frame with nothing: the first + build waited for "not 0" and ended at once with 0, found by the lifted game), or the player is no person or has + left (0). Only where it is assigned, in an async program. Slots are numbered across the build; the Python clears + the sent cell after MSQC has sent it. +- **Checked:** `tests/local.test.ts` (30), an eudplib build fixture, `probes/game-local.ts` in the lifted game and + the simulator, and `probes/game-local-players.ts` in two copies kept in step: each computer shows its own camera, + P2's synced mouse reaches both, and the copies agree every frame. A deliberate leak through a `memory()` condition + (before the check followed one) split the copies at frame 2, the detector naming the line that read the screen + position — so the passing test is worth something. +- **The simulator** reads a program's local values at the computer of the player it runs for, and a `sync()`'s value + at the sender's; an arrival lasts one frame, as MSQC's cell does. Tests gained `moveCamera(x, y, player?)`. + `GameSim.moveMouse` wrote the mouse's y at 0x6CDDC6; the game keeps x and y in 32 bits each from the screen's corner + (0x6CDDC4, 0x6CDDC8), now written so. +- **Played (2026-10-17, recording 21.11.18.13):** `probes/local-ui.tsx`, a live panel and Q / W sending a place with + `sync()`. It works in Remastered: the panel follows the mouse every frame, MSQC's copy of the mouse trails it while + it moves (779 against 666) and catches up when it stops; Q put a Marine at the screen's top left corner (64, 2688 + with the camera at 0, 2624) and W one under the cursor, each "sent in 6 frames" for two sync()s in a row. Hosted as a + multiplayer game alone (21.12.36.14): the same, "sent in 12 frames" — about 6 a sync() against 3 in single player, + the multiplayer game's turns. Found by it: two sync()s in a row read their values 6 frames apart, so a moving + mouse's x and y come from two different places. A point wants both sent on the same frame. + +**As built, second part (2026-10-17).** + +- **`const [x, y] = await sync(P1, a, b)`**: up to four values, read and sent on the same frame, each caught on the + frame it arrives (MSQC's cell holds an arrival for that frame only, so a value that comes first is kept while the + others are waited for). One trip for a point, and one point. +- **`local.selected`**: the unit in slot 0 of this computer's selection, the one the panel shows — a `Unit`, so its + fields read as any unit's, and re-checked before use like any kept unit (its uniqueness byte taken when read). A + new kind of unit in the IR, `selected`. **`local.selectionCount`**: the slots that hold one. Both Local: `u.kill()` + of the selected unit is refused, showing its hit points is not. +- **`select(player, units)`**, a unit or up to twelve written out. On the player's computer: the twelve slots written, + the panel's flag set — 0x59723C, which Remastered's emulator backs with `+0x10A4450`, the flag its panel refresh + (`+0x400100`) tests before copying the selection into the panel — and a Select command of the units' tags (place + + 1, uniqueness byte << 11) queued, as MSQC's `RestoreSelUnits` does. Since it travels as a command, every + computer runs it alike: it is allowed under a Local test, and of Local units. In the lifted game it selects, and + two copies with P2 selecting stay in step. +- Found on the way: a field of the selected unit was worked out when the script was built (the build-time + evaluator read `.type` off the marker object); it now stops there. `u?.type ?? -1` on a unit, and `if (a && b)` of + two units, are not supported — older than B5. +- **To play:** `probes/local-select.tsx`: a live panel of the selection; S selects the Command Center, D the unit + under the cursor; W walks the Marines to the cursor with one `sync()` of x and y. What playing answers: whether the + circles follow a select() (nothing in the code says they do), whether the panel does, and who obeys a right-click. +- **Played (2026-10-17, single player, recording 21.35.21.15):** the panel at the top followed every click and drag; + S with nothing selected made the bottom panel show the Command Center, D the Marine under the cursor (then the + Command Center), each at once — the flag at 0x59723C works. **The selection circles do not follow**: no circle + under the unit select() chose (the green swirl in the recording is the cursor's hover). W walked the Marines to the + cursor, "sent in 3 frames (x and y together)", half of the two sync()s in a row of the first map. Hosted as a + multiplayer game alone (21.38.15.16): with a Marine selected by hand, S turned the bottom panel to the Command + Center, and the S key itself then went to the Command Center's buttons ("Not enough minerals", its Build SCV) — the + command card follows too; right-clicks on the ground after it moved no Marine (the order went to the Command + Center); the Marine kept its circle and its bar. W: "sent in 6 frames (x and y together)", half of the 12 of two in + a row. So select() moves the panel, the buttons and the orders, in single player and in multiplayer; not the circles. + +### B6. `fixed` and `Math` (4.7.0) + +- `fixed` is a 16.16 number with `+ - * /` and comparisons; mixing it with `number` + converts as TypeScript would round. +- `Math.sin`, `Math.cos`, `Math.atan2` work on the game's 256 directions. The tables are + computed at build time from the formula, and A2 checks them against the game's own + table. `Math.sqrt` and `Math.hypot` use an integer square root. +- Movement uses the actions Remastered allows: a location centred on a point and Move + Unit to it. A unit's position is never written; that ends the game. + +**As built (2026-10-17).** Differs from the above in four ways, each for the TypeScript people write: + +- **A number is fixed when a fraction is stored into it**, not only when declared so: `let x = 1.5`, `x += 0.25`, + `const d = Math.cos(a)`. `numbers.ts` finds which variables, arrays, parameters and results are fixed by going over + the program until no more turn fixed (as it finds the never-below-zero ones), and remembers the line that made each + one fixed for the error that names it. `fixed` (the type, and `fixed(x)`) says so before anything does; a value typed + `fixed` is fixed even when whole (`fixed(2)`, a parameter). `u8` / `u16` / `u32` stay whole: a fraction into one is + an error. +- **Whole numbers still divide as whole numbers**: `7 / 2` is 3, as since 3.5; `7 / fixed(2)` is 3.5. Mixing converts as + TypeScript computes (`<< 16`, a constant times 65 536), except that a fixed number times or divided by a whole one is + the raw cell's own `*` / `/`. Where only a whole number goes (an index, a count, a place, a shift, a unit's field) a + fixed one is an error naming `Math.floor` / `round` / `trunc`; `x | 0` is `Math.trunc`. +- **Angles are radians**, as in JavaScript, not the game's 256 directions: `Math.sin(Math.PI / 6)` is 0.5. `sin` / + `cos` read a table of 4 096 cells and `atan2` one of 1 025, each between its cells, added to the program by + `numbers.ts`, so the game and the simulator read the same numbers: within 1/65 536 of JavaScript's. `tan`, `atan`, + `hypot`, `sqrt` (of a whole number, the root is still fixed, so `Math.hypot` of distances in pixels does not + overflow), `floor` / `ceil` / `round` / `trunc`, `abs` / `min` / `max`. +- **Printed as a decimal**, up to four places without trailing zeros (`${x}` of 1.25 is "1.25"). + +IR 20: `VarDecl.fixed`, `ArrayDecl.fixed`, `const.fixed` (the raw cell), the intrinsics `fmul` / `fdiv` / `sqrt` / +`atan2` / `sin` / `cos`, a printed number's `fixed`. A product and a quotient are rounded towards zero through the +magnitudes, wrapping at 32 bits, in Python (`fixed_fns()`: the four 16-bit partial products; eudplib's division then +sixteen bits one at a time) and in `compiler/fixedMath.ts` alike. In the lifted game, conditions and actions: +multiply 461, divide 562, `sqrt` 1 068, `sin` 856, `atan2` 960, a fixed times a whole number 164. **Checked:** +`probes/game-fixed.ts` (six tests: the same lines printed by the lifted game and the simulator), `tests/fixed.test.ts` +(16). Found on the way, fixed in the language: a switch over a value known when the script is built keeps the case that +applies (was an error); `??` `||` `&&` and the comparisons fold over known values; a `const` of a game function set from +a known parameter is a value; an optional parameter left out is `undefined`, and `xs !== undefined` of an array given is +known; `x * 2^n` is a shift in the lowering (`w * 32` cost 144, `w << 5` 30). + +### B7. Typed memory (4.8.0) + +```ts +unsafe("the guard's cooldown, read for the HUD; probe 23", () => { + const cd = memory.unit(u).groundWeaponCooldown; // u8, at +0x… +}); +``` + +- The declarations for `memory.unit`, `memory.player`, `memory.sprite` and the others + are generated from the disassembly's structs, with each field's comment and its + allowlist state ("read: probe 12", "write: not seen", "write: ends the game"). +- Reading a field that is on the allowlist needs no `unsafe`. Writing one, or reading one + that is not on it, needs `unsafe` with a reason, and the reason is shown in the + map-requirements view (3.12). +- This is the door the crazy tier walks through, so it comes after the allowlist and the + tracer are settled. + +**Started 2026-10-17, steered by S4** (the user: "a fog screen library for awesome DX — +developer experience first, enjoyable and easy to use"). Bad Apple in TrigScript is the +first program that needs memory the language has no word for (the vision countdown), so B7 +starts from what it needs, and the fog screen (Part C) is built on it in the same slice. +What a map maker should write: + +```tsx +import { program, fog, playSound, nextFrame, P1 } from "trigscript"; +import badApple from "./bad-apple.webm"; // B8: frames at the game's rate, as fog pictures +import song from "./bad-apple.ogg"; // put in the map's archive + +program(async () => { + fog.reveal(P1); // light the whole board once: dark is fog from now on + playSound(song); + for (const frame of badApple) { // one frame of video a game frame + fog.draw(P1, frame); + await nextFrame(); + } +}); +``` + +and, for anything drawn while the game runs (the raycaster's columns, a radar, a ripple): + +```ts +program(async () => { + while (true) { + for (let i = 0; i < 8; i++) fog.light(P1, cx + dx[i], cy + dy[i], 3); // this frame only + await nextFrame(); + } +}); +``` + +- **The model is a canvas cleared every frame**: what a program lights this frame is what + shows this frame, and nothing else needs saying. The library's machinery is the + compiler's: a map that uses `fog` gets the countdown written after every trigger pass + and the brushes' sight ranges set every frame. The unit types the brushes are made of are + heroes no ordinary map places (Remastered takes a sight write a frame late, so each size + is a type of its own), and a free location is taken from the top of the first 63, as + input does — the map maker never chooses either. A map that places or makes one of those + heroes gets an error that says which and why. +- **Coordinates are tiles**, because fog is kept per tile, and a size is a sight range, + 1 to 11; the hover shows each size's shape (the game's own masks, lopsided by a row). +- `fog.draw(player, picture)` draws a picture made when the script is built: + `fog.picture(["..##..", ".####.", "######"])` from rows of text, an imported image or a + video's frames (B8). The encoder is `bad-apple-encode.mts`'s, moved into the compiler. +- **Memory** (`memory.vision.countdown` and the rest) is the general door the library walks + through: fields from a list like `tables.ts`, each with Remastered's verdict from R0 and + the played probes in its hover; a read-only field is `readonly` in the declarations, so a + write is a type error before anything is built. + +The order: the `memory` list and its lowering (a cell with no index, through the table +read and write that exist) → `fog.light`, the canvas machinery and `fog.reveal` → pictures +from text → B8's imports (an image, then a video, decoded in the editor where the script is +built; the plugin's 4 MB limit on data files is planned for) → Bad Apple written as above, +then its lyrics with S2's JSX and the never-seen opening. + +**As built, first part (2026-10-17).** `memory` (`compiler/memory.ts`: vision.countdown, +game.frame, map.width/height, each with Remastered's verdict in its hover; a namespace merged +with the trigger level's `memory()` condition) and **the standard library**: modules of +TrigScript shipped with the compiler, `stdlib/.ts`, embedded by `npm run embed`, joining +a script's files only when imported as `"trigscript/"` (the compiler maps the path, the +linker runs it, Monaco reads it). `trigscript/fog` is the first: `light`, `draw`, `picture`, +`reveal` and a canvas program of its own. What it needed of the language, each general: +`reserveLocation(why)`, `nextFrame()`, `centerLocation(…, width, height)`, a `const` of a game +cell kept in the program, an `if` on two constants compiled to its live side. In the lifted +game every size lights its mask exactly, a picture and a disc draw where asked, `reveal` +explores every tile, ~280 conditions and actions a frame for the probe (`probes/fog-lib.ts`). +Found: Arcturus Mengsk's and Gerard DuGalle's Battlecruisers and Raszagal cannot be made by +a trigger; the brushes are Kukulza ×2, Yggdrasill, Magellan, Gantrithor, Hyperion, Norad II, +Tom Kazansky, Danimoth, Mojo and Artanis. Not yet: played in Remastered with the heroes; an +error when a map places one of them; `import` of a video (B8, built: see B8's As built). + +### B8. Built from the game (4.9.0) + +Tables a map could not afford to compute while it runs, computed by running the lifted +game when the map is built: + +- `ground.distance(a, b)` and `ground.region(x, y)`, from the game's own regions for this + map (Walkability already reads them out of the lifted engine). +- Anything else a function of the map: `atBuild(() => …)` runs in the lifted game at build + time and its result becomes a constant table. + +Cost: map size. The hint says how many bytes each table adds. + +**As built, first part: imported videos (2026-10-17).** Started with what S4 needed rather than the game's tables: +a video a script imports, and the tables made from it when the script is built. + +```ts +import { fog } from "trigscript/fog"; +import badApple from "./bad-apple.video"; + +program(async () => { + fog.reveal(P1); + await nextFrame(); + playWav("staredit\\wav\\badapple.ogg", 0); + await fog.play(P1, badApple); // or fog.light(P1, x, y, badApple, frame) each frame +}); +``` + +- **The `.video` file** (`compiler/media.ts`) is one of the script's files, kept in the map with the others: the + frames the editor decoded, a tile a pixel, ink or not, a frame for every frame of the game (42 ms, Fastest). The + source video is not kept: what the build reads is the same on every computer, whichever browser or codec decoded it. + One line of JSON, the frames compressed as JBIG compresses a fax — LZMA's binary range coder, the odds from 11 tiles + around each (six of this frame, five of the frame before). Bad Apple, 5,216 frames of 128 × 96: **559 KB** of text, + where runs of tiles were 2.1 MB; read back in 0.7 s, every tile exact. `import x from "./x.video"` is typed by a + `declare module "*.video"` (a `Video`: sizes, length, `frameMs`, `seconds`, `frame(i)` as rows of text); the + linker hands the value over, kept while the file's text is the same. A `.video` is no TypeScript: the compiler, + Monaco and the archive keep it beside the code (and `.tsx` files, which the archive dropped on reopening since 2.0, + are kept now too). +- **`cover(picture, shapes)`** (`compiler/cover.ts`), new in the language: the brushes whose union is a picture's ink, + each a shape on ink only, greedy, largest gain first. Lazy greedy over one candidate a tile — the largest shape that + fits there (the game's sight ranges nest, so a smaller one on the same tile never gains more; found by halves, a fit + tested a row at a time on prefix sums), candidates a larger fitting neighbour holds dropped, gains kept as prefix + sums of covered tiles. **2.6 ms a 128 × 96 frame**, where S4's encoder took 130. Ink thinner than any shape gets the + smallest shape placed, of every place it covers the tile from, inside the picture and on the fewest dark tiles: + 0.36% of Bad Apple's tiles lit that are dark, where it was 0.80%. Of a video, every frame's brushes, worked out once + while the file and the shapes stay the same (Bad Apple: 11.6 s the first compile, 285 ms after). `fog.picture` is + `cover` now. +- **The fog library** draws a video: `fog.light(player, x, y, video, frame)` reads the frame's brushes out of two + tables made when the script is built (a word a brush, `x | y << 8 | size << 16`, and where each frame starts), so a + frame can be a variable; `fog.play(player, video, x?, y?)` is `game(async …)`, a frame each game frame. +- **Tables a program only reads** may hold 1,048,576 entries (4,096 before), and the IR carries one of 256 or more + packed — base64 of the narrowest little-endian width (1–4 bytes) — which the lowering widens back with slices and + puts in the map as one `Db` (`EUDArray(_from=EPD(Db(…)))`, no Python list of a value at a time). Bad Apple's IR is + 2.77 MB, within the eudplib plugin's 4 MB of data. +- **Language:** `"key" in value` folds when the value is known when the script is built (TypeScript's other way of + narrowing a union of objects; `fog.light` tells a picture from a video so). The map's sounds are a name table, + `sounds.BadApple` (the Sound Editor's list, by file name or path), and a WAV argument takes one. +- **Import Video…** (`videoImport.ts`, the Explorer's camera button, the More menu, the palette, or a video or picture + dropped on the Explorer): a preview frame, the size in tiles (the map's width, the video's shape), the threshold, which + parts are ink, the name. A picture becomes a `.ts` file of rows of text — editable, what `fog.picture` takes. Clicking + a `.video` opens a viewer: play or scrub, the tiles or **as the fog draws it** (the brushes `cover` makes, tiles off + the video in red, brushes a frame). +- **Decoding** (`videoDecode.ts`): the file taken apart — WebM and Matroska (EBML), MP4 and QuickTime (boxes, with + composition offsets and the edit list for B-frames) — its samples handed to WebCodecs' `VideoDecoder`, every frame + with its own time; each game frame takes the video's frame nearest its time, as ffmpeg's fps filter does. Bad Apple + (AV1 in WebM) in **12 s** in the editor, 3.3 tiles a frame off the source frame nearest in time (ffmpeg's import: + 24.9). Where that cannot be done, the video plays in an element and frames not shown are sought. Found on the way, + in a busy editor page in headless Chromium: the browser shows 7.5 frames a second, `requestVideoFrameCallback`'s + `mediaTime` names an earlier frame than the element holds when the callback draws it (`VideoFrame.timestamp` and + `.duration` are the frame's own), and a seek takes 66 ms — the first three ways tried came out 300 tiles a frame off. +- **Under Node:** `scripts/import-video.mts` makes a `.video` with ffmpeg; `scripts/script-files.mts` follows imports + of videos. + +**Checked:** `tests/media.test.ts` (the format both ways and cut short, `cover` exact on ink a shape wide and a thin +line kept inside, the library's shapes the same as `compiler/fogShapes.ts`, packing at each width, a script playing a +video whose tables light what `cover` meant frame by frame, a missing file, sounds, `.tsx` and `.video` in the +archive); `tests/video-decode.test.ts` (H.264 with B-frames in MP4, MOV and MKV, HEVC, VP8, VP9, AV1 in WebM and MP4, +made by ffmpeg on the spot). **In the lifted game** (`probes/bad-apple.ts`, built with `scripts/build-fixture.mts` on +S4's white board): all 5,176 frames from the first with ink equal their cover, nothing lit off the board, 0.49% of +tiles off the video (S4's hand-written map: 1.17%); 54k conditions and actions a frame on average, 151k at most — about +twice the hand-written eudplib, within the 500k budget. **In the editor** (headless Chromium, the local build): +Import Video… on the real file, the script's "Cannot find ./bad-apple.video" gone after it, 0 problems, the viewer. +**Played in Remastered (2026-10-17): it works**, and felt in time to the player. `trigscript-b8-bad-apple.scx`, +Classic graphics: the countdown lines 24 frames (1.008 s) apart, the picture on time after it, matching the source at +0.998 with no drift over 3:39. The recording put the song 0.7 s ahead of the picture throughout; the map plays both on +frame 241 (the simulator and the lifted game agree), and that recording's audio track is 0.86 s shorter than its +video, both said to start at 0 — the recorder's, not the map's. **Not yet:** grey levels and colour (a +renderer that needs them — the raycaster's textures — will say which); the game's own tables (`ground.distance`, +`atBuild`), the rest of B8. + +**Images (built 2026-10-19, the build-time half).** A picture can now be kept as pixels rather than as ink: Import +Video… on a picture asks how to keep it — pixels in colour or in grey (an `.image` file), or rows of ink to edit (the +`.ts` as before) — and `import logo from "./logo.image"` gives an `Image` worked on when the script is built: +`width`, `height`, `color`, `transparent`, `grey/rgb/alpha(x, y)`, `crop`, `scale` (each pixel the average of what it +covers, colour weighted by alpha; a block of itself growing by a whole number), `flip`, `rotate`, `invert`, and at the +end `threshold(level, { ink })` (rows for `fog.picture`) or `tones(n)` (rows of digits for a library's own renderer). +A pixel under half opaque is never ink. Nothing reaches the game but what the script makes of it. +- **The file** (`compiler/image.ts`): one line of JSON, 1 to 1024 pixels each way, planes grey or green / red − green / + blue − green, then alpha if any pixel is less than opaque; each plane predicted pixel by pixel by JPEG-LS's median + edge detector, the miss coded with LZMA's range coder (now `compiler/rangeCoder.ts`, shared with `.video`) under odds + chosen by how busy the neighbourhood is. Real pictures at 128 and 256 pixels came back exact and 15–40% smaller than + PNG at its strongest, both in base64. The decoded pixels are kept, not the PNG or JPEG, for the reason a video's are. +- **The editor:** the dialog's preview shows the pixels (in grey when grey is chosen); its size defaults to the + picture's own, 256 the longer way at most, 1024 at most typed. An `.image` in the Explorer has its own icon and hover; + clicking it opens a viewer — the image, or a threshold as the fog draws it (brushes, tiles off in red) with the + `fog.picture(logo.threshold(…))` line that makes it — from the same `Image` the build has. +- **Left out:** a dither. Floyd–Steinberg made 4,066 brushes of a 256 × 192 logo (the game creates about 950 in a + frame), and scattered single tiles are thinner than the smallest sight shape, so the fog cannot draw them. +- **Halftone instead** (`image.halftone(cellWidth, cellHeight = width + 1, { diffuse })`): the fog's own way of + showing grey. Each cell gets one brush or none — the fog's shapes that fit, the largest a tile past the cell so + neighbours run together into white, moved in from the picture's edges, never past them — chosen by the light it + adds that nothing lit before (spill into a neighbour counts for the cell that made it), what it misses diffused + to the next cells; `diffuse: false` is an even grid. The rows carry their brushes and `fog.picture` draws those, so + it is a brush a cell at most (covering the union again took a fifth to a half more where discs merge). Two first + tries were worse: mixing "lit whole" cells with discs made ragged ink at 2.4 brushes a cell, and choosing by the + cell's own share alone came out 10% light where discs spilled into cells already chosen. +- **The third tone, answered without a play:** whether a map can mark tiles never seen again (black, below fog grey). + Remastered's own EUD emulator run on the dump (scm-sim-reader `remastered/probe.py`): 0x6D1260 reads 0xAF1080 and + is writable, but reads at that address give 0 and writes change only the emulator's cache — the fog tile array is + not the map's to write in Remastered. Two tones it is. +- **Probe built** (`probes/halftone.ts`, pictures from `probes/lib/halftone-gen.mts`, base map + `probes/lib/raycaster-map.mts` with `MAP_NAME`): A ramps at 4 × 5, 6 × 7, 8 × 9 diffused; B the same even; C a lit + ball in two tones and five halftones; D five pre-rendered walks of the raycaster's maze with walls greyer by distance + and side (whole, 4 diffused / even, 6 diffused / even; at most 558 brushes a frame). Played once in Classic and once + in Remastered graphics: does Remastered's blur melt the dots into grey, which cell size, and do diffused dots + shimmer when the picture moves. +- **Played 2026-10-19** (one round, F5 switching graphics, recording 11.06.27.24; minimap crops Classic 388 wide at + (338, 1040), Remastered 386 at (17, 1042) in 2560 × 1440): everything drew where built in both. **Classic:** dots, + sharp — tile to tile 9–13 apart — though a diffused ramp's averages climb steadily (108 → 164). **Remastered: the + dots melt** (tile to tile about 2) and a diffused ramp climbs 73 → 93; the ball at 6 × 7 undiffused reads as real + shading. But Remastered's minimap has a third of Classic's contrast — lit 97 against fog 71, Classic 169 against + 104 — so every grey there is dim. Undiffused ramps show steps (in Remastered not even climbing evenly); diffused + leaves a dot texture at 4 × 5 in Remastered. Walks: walls read as grey in Remastered, near against far faintly. + Open: shimmer while walking, and whether to use it in the raycaster — the player's eye. The player: the two-tone ball still + looks best; D2's dots are distracting, D4 better but not great. +- **Remastered's minimap, surveyed** (`probes/tile-survey.ts`, `probes/lib/tile-survey-map.mts`: six tilesets, 88 tiles + each lit and in fog; recording 11.37.09.25, minimap at (18, 1041), 3.03 pixels a tile). Remastered draws a tile on + the minimap as its art's average — the art's average luminance and the minimap's agree at 0.96–0.99 in every + tileset — and draws fog as **0.64 of lit, for every tile**: the contrast's ratio is fixed, only brightness can be + chosen. The brightest is **Ice snow** (tile 66, group 4, walkable): lit 130, fog 82, a gap of 48 — the board we used, + Badlands 23778, is lit 86, fog 57, a gap of 29 there (its art averages 71; 1.16.1 takes one bright pixel of it). The + best of the other tilesets: gaps 39–40. So a fog map for Remastered graphics wants snow (two thirds more contrast), + one for Classic graphics Badlands 23778 — Classic's minimap is still the sharper and wider (169 against 104). +- **Snow played in Remastered** (recording 11.54.03.26: raycaster, halftone probe, Bad Apple, built with `BOARD=snow` + / `BA_TILE=66`): lit 129, fog 82, as surveyed. The fine grate texture the earlier Remastered recording showed was + Badlands 23778's own HD art; snow is smooth. Diffused ramps climb 86 → 125 (twice Badlands' range), still smooth; + halftone dots show as soft blobs; the walk at 4 × 5 undiffused reads most like shading. Outlines and silhouettes + clean and soft-edged. +- **Not yet:** the runtime half — an image as a table a program reads while the game runs (textures, sprites, a level + painted as a picture) — waits for the lowering work on `backend-limits`. + +**Checked:** `tests/image.test.ts` (the format both ways — grey, colour, transparency, noise, one pixel — cut short, +too big, kept in the archive; every operation on a known picture; a script importing one, drawing it in the fog, a type +mistake and a build-time mistake each said on their line). **In the editor** (headless Chromium, the local build): a +PNG dropped on the Explorer, each way of keeping it previewed, imported, a script using it 0 problems, the viewer. + +### Sounds: the probe (played 2026-10-19) + +Wanted: sound effects and music from a program. Before the language: what StarCraft: Remastered does with sounds a +program plays (`probes/sound-probe.ts`; its sounds made by `probes/lib/sound-probe-map.mts`, each a tone or click of +its own so the audio says which played and when). Played, two rounds, recording 12.22.41.27; measured from its audio +(48 kHz, digital silence between sounds, so a start is read to the sample). Both rounds agree to the sample. + +- **Timing.** A sound starts on a 21.3 ms grid — 1 024 samples at 48 kHz, the mixer's buffer — so up to one buffer + after its frame (12–14 ms standard deviation); the spacing over time is exact (6 frames: 252.2 ms on average). + One every frame plays every time (60 of 60). Good enough for music at game speeds; not for sub-buffer flams. +- **The WAV list.** A sound named by no WAV slot starts on the same grid as a listed one, the first time too: no + loading delay. The list is for the editor, not for timing. +- **Again is another.** Playing a sound that is still playing starts a second copy beside it; there is no restart, + and no action stops a sound — a note's length is the file's. +- **Voices.** 32 different sounds at once, all heard to their ends: none cut, none dropped. +- **Copies add.** Eight copies in one frame are exactly eight times as loud — summed, no limiter: a library keeps its + own headroom. +- **Loudness kept.** A tone written at a quarter of another comes out at exactly a quarter: a gain made when the + script is built is the gain heard. +- **Formats.** 8- and 16-bit PCM at 11, 22, 44 (stereo) and 48 kHz, IMA ADPCM and Ogg Vorbis all play; **Ogg Opus + is silent.** Programs are Remastered-only, so Vorbis is open to them; IMA ADPCM is a quarter of 16-bit PCM and + simple to write in the editor, where no Vorbis encoder is at hand. + +So the music library can fire notes per frame (42 ms on Fastest) from pitched copies made at build, overlapping +freely within 32 voices, each note's length and loudness baked into its file. + +### Sounds: layer 2, a script's own sounds (built 2026-10-19) + +- **`.sound` files** (`compiler/sound.ts`): Import Video… on any sound file the browser decodes makes one — mono IMA + ADPCM at 11, 22 or 44 kHz, the coding the game's own sounds use, kept as decoded (`soundImport.ts`: an offline + render does the resampling and the mix to mono; a waveform, Play and Stop). Up to ten minutes. +- **`Clip`, when the script is built**: `trim`, `gain`, `fade`, `reverse`, `pitch` (a sampler's: faster and higher), + `then`, `mix`, `repeat`, `seconds`, `rate`, `peak`; `tone(pitch | "A4", seconds, { wave, volume })` (square and + saw rounded off by PolyBLEP, so nothing folds back past half the rate), `noise`, `silence`. +- **Into the map**: `playWav(clip)` puts the clip in the archive as `staredit\wav\trigscript-.wav` when the + script is built, once however often it is played (the runtime's text argument takes a clip, so a program and a + trigger both do); Apply writes them and takes out the ones no longer played (`script.ts` `withSounds`). +- **ADPCM or 16-bit.** The sound demo (`probes/sound-demo.ts`, played 2026-10-19: "very nice, though C is a bit harsh + at the end of each note"): ADPCM codes each sample as a step from the last, and a square wave's jumps came back + at 12–17 dB of fidelity, heard as harsh; a plucked string's pitched notes at 18–24 dB sounded clean, and noise at + 11–15 dB fine (the error is lost in it). So a clip goes in as 16-bit PCM when it is a tone (it repeats itself, + `periodicity` ≥ 0.5) and ADPCM would keep it under 18 dB; otherwise ADPCM, a quarter of the size. The demo: 17 + ADPCM, 6 16-bit (the square notes), 217 KB. +- **A gap for the music library:** `if (clip)` on a value known when the script is built (a rest as null in an + unrolled loop) is taken as a game condition and refused; the demo plays a short silence for a rest instead. + +### Sounds: layer 3, "trigscript/music" (built 2026-10-19) + +- **Instruments**, when the script is built: `music.sampler(clip, root)` (every note the recording played faster or + slower), `music.synth({ wave, volume, attack, decay, sustain, release })` (a tone through `Clip.envelope`, new), + `music.kit({ k: kick, s: snare, … })` (a clip a letter). `instrument.note("C6", 0.1)` is a clip on its own — a sound + effect from an instrument. Notes are remembered, so a note played twice is one clip and one file. +- **Patterns**: steps a word each — a note's name starts it, `+` joins a chord, `-` holds, `.` rests, `|` is for the + eye; a kit's step is its letters. `music.song({ bpm, steps }, ...tracks)` works it all out: a step is a whole number + of frames, so the tempo is the nearest that fits (`song.bpm`); the song is two tables — the frame each note starts on + and which clip it is — and its clips. More than 32 notes at once is refused, at the step it happens. +- **Playing**: `music.play(song)` reads the tables while the game runs: sleep to the next note's frame, then the clip + by its number — a test a clip, unrolled over the song's clips, which stay values of the build (a clip is no value a + program holds). `music.loop(song)` repeats it; `start()` puts either beside the program. `music.render(song)` mixes + the song into one clip when the script is built (`mixdown`, new): exact timing, one sound, but its size — a tonal mix + goes in as 16-bit (Ode to Joy's 19 seconds: 832 KB). +- **The language**: \`if (clip)\` on a value of the build is decided at build — null or undefined false, any other + object true, as in JavaScript — after every kind of the game's own (a condition, a read, a table, input) is tried. +- **Checked**: `tests/music.test.ts` — tempo rounding, holds, chords, a kit, the voice limit, a bad note, playback on + the right frames in the simulator, a render. **Demo** `probes/music-demo.ts` (Ode to Joy, four tracks, 33 clips, + 1.1 MB, about 285 conditions and actions a frame): lifted game clean. **Played 2026-10-19** (recording 13.07.38.31): + note by note against the same song mixed at build, onset by onset (34 of them): 7.8 ms apart on average (sd), 16 at + most, no drift over the song; equally loud second by second; a hit every 589.5 ms for the 588 written. + +### Sounds: step 1 of the quality work, the format and the master (built 2026-10-19) + +The user's direction after the music demo: make the instruments and the sound good enough for a showpiece — an +arrangement of Korobeiniki (the folk song is in the public domain; the Tetris arrangements and look are not, so the +parts around the melody are our own). Step 1 is what every later step sounds through: the file the map gets, the rate +a mix is made at, two channels, and a limiter. Steps after it: the instruments themselves (filters, detune, FM, +reverb, delay, drive), song structure, music that follows the game. + +- **Ogg Vorbis** (`compiler/vorbis.ts`): libvorbis + libogg compiled to WebAssembly by wasm-media-encoders 0.7.0 (MIT; + BSD), its `ogg.wasm` copied to `dist/vorbis.wasm` by the build with a LEGAL file; our own glue, a few calls into its + exports, instantiated synchronously (the package's JavaScript carries both its encoders inline and only + instantiates asynchronously). A clip a program plays that lasts 2 seconds or more goes in as Ogg Vorbis at + quality 5; a shorter one stays a WAV (they start on their frame — Ogg's start is not measured yet), as does an + untouched import (its own ADPCM), one made `wav()`, and any clip a `trigger()` plays (1.16.1 plays those triggers and + no Ogg). The fixed stream serial makes the file the same for the same samples — in Node and in the browser alike. +- **Encoded only for a build.** A compile names an Ogg clip by a hash of its samples; the compile worker encodes it + when the request says `sounds` (Apply, `service.build`), fetching `dist/vorbis.wasm` once, and keeps 32 encoded files + by name. A check as the user types leaves the name alone and does not send the samples back. `withSounds` refuses a + sound with no bytes that the map does not already hold. Three minutes of two channels encode in about 1.5 s. +- **Two channels**: `Clip.channels`, `pan(position)` (a balance, so 0 is the clip as it was), `mono()`; a mix, a join + or a mixdown with a two-channel part is two channels. A short two-channel clip is 16-bit WAV. Tracks take + `{ pan, volume }`, each clip a track plays made once. +- **Resampling** by a windowed sinc (Blackman, 16 each side, cutoff at the lower rate's half) in place of straight + lines, which dulled the top and folded a pitched-up note's harmonics back down (a 3 kHz sine an octave up: images + under 0.2 % now). +- **The limiter** (`Clip.limit`, `normalize`): look-ahead 5 ms (the lowest need over the next 5 ms, averaged over 5 ms, + so the gain is down smoothly before the peak and never above what it needs), release 0.1 s, the two sides together. + Ceiling −1.5 dB: Ogg Vorbis comes back up to a dB louder at its peaks (a song limited to −1 dB decoded at −0.16 dB; + at −1.5 dB, −0.45). +- **`music.render(song, { rate, limit })`**: the notes made again at 44 100 Hz (a song's own clips, for play, stay at + 22 050), through the limiter unless `limit: false`. +- **Measured** on `probes/korobeiniki-ab.ts` (32 s, seven tracks; A as before: 22 050 Hz, one channel, ADPCM WAV, + turned down to fit; B: 44 100 Hz, panned, limited, Ogg): above 11 kHz A has nothing (−56 dB), B −36 dB; B's sides + (L − R) −27 dB; B 2.1 dB louder at the same peak; A 355 KB, B 559 KB. Compile with both renders 1.1 s, encode 0.3 s. + Lifted game clean (0 ending, A at frame 98, B at 930). Headless Chromium through the real compile worker: a check + returns the name only, a build the Ogg (558 554 bytes, as Node), a second build from the cache. +- **Played 2026-10-19** (recording 13.59.06.34): A at 6.771 s, B at 41.715 s, A again at 76.659 s — 34.944 s apart + both times, the 832 frames written: the Ogg starts as the WAV does and costs the game no time. B as heard against + the file: 31.9 dB over all 32 s (the recording's AAC; the WAV A came back at 24.5), the worst 100 ms 19.5 dB — no + dropout; L − R follows the file's at 0.996; peaks back at 0.97 of full scale (the whole 1.08×, the peaks 1.00–1.05×: + something after the game rounds the top off gently) — no clipping. +- **Found on the way:** `ranges.ts` read `process.env` in the browser's worker, where there is none — every program + failed to compile in the editor. Guarded (its own commit). + +### Sounds: step 2, the instruments and the effects (built 2026-10-19) + +User after step 1's A/B: "B sounds way better, go for step 2". + +- **Effects** (`compiler/dsp.ts`, as `Clip` methods): `filter` — a trapezoidal state-variable filter (lowpass, highpass, + bandpass, notch; cutoff a number or [seconds, hertz] points swept log-linearly; resonance 0 … 1) and cookbook + biquads (lowshelf, highshelf, peak); `drive(dB)` — tanh at twice the rate; `delay` — feedback with a lowpass in the + loop, ping-pong; `reverb` — a plate after Dattorro (four diffusers, a figure-eight tank with modulated allpasses, + seven taps a side: L/R correlation 0.00); `chorus`; `compress` (soft knee, linked). Measured: the 1 kHz lowpass + −3.1 dB at its cutoff, the +6 dB shelf +5.9, plucks within 0.1 cent. +- **Sources**: `tone` takes [seconds, pitch] points (glides) and `voices`/`detune`/`width` (supersaw, two channels); + `fm()` two operators, index points, feedback; `pluck()` Karplus–Strong, tuned by a fractional delay. +- **The library**: `music.synth` gets a filter with its own envelope (octaves above the cutoff), unison, drive, glide; + `music.fmSynth`, `music.strings`; `music.presets` (supersaw, pad, bass, acid, lead, chip, keys, bell, pluck) and + `music.drums()` (k s c h o t r, 44.1 kHz) written in TrigScript itself on the public API. Tracks take `fx` (on the + whole part in `render`, on each note in `play`) and `reverb` (a send); `render` takes `reverb` (the shared room) and + `master`. A song's bar is a whole number of frames, a step need not be (sixteenths at 143 bpm are 2.5): `play` puts + a note on its nearest frame (the mixer's own grid is 21 ms). The 32-voice limit moved from `song()` to `play` (the + note tables are made when first read; a getter that refuses is a build error now — `structured.ts`'s `readField`). +- **Lazy clips.** The first arrangement took 8 s to compile, every check as the user types: a check reruns the whole + script, renders and all. A cache by recipe did not help — the song's intermediate parts are ~125M samples against a + 32M budget, so LRU thrashed and the second compile missed everything. What fixed it: a clip knows its length, + channels and recipe key at once and works its samples out only when asked (`peak`, a build's file). A check + (`CompileOptions.sounds: false`, the worker's default without `sounds`) names each clip's file by its key — with an + `ENGINE` tag so changed DSP never keeps a map's old bytes — and makes no sample. Reverb and echo tails are formulas + (a plate's: 1.24 / −ln decay s) so lengths need no samples. Clips made the same way are one object (a weak-reference + map). Hot loops are plain loops (typed-array `map()` and a closure a sample were most of the 8 s). Builds get 60 s. +- **Measured** (`probes/korobeiniki.ts`: 34 bars, nine tracks, intro / A1 / A2 / break / A3 / outro, 63 s; A = step 1's + mix): check 0.6 s cold, 0.4 warm in Node; headless Chromium worker check 1.1 s, build 3.6 s, re-check after an edit + 0.26 s, second build 0.24 s, the Ogg bytes as Node's. Stems balanced by LUFS (supersaw +6 dB, bell −6, keys −3, …); + the mix −11.8 LUFS (A −10.9), sections −22 → −12.5 → −12 → −13.5 → −11.8, peak −0.27 dB decoded; 1.25 MB Ogg. Lifted + game clean. Map 1.8 MB. +- **Found on the way:** the key hash's second half could go negative (`b ^ (b >>> 15)` is signed), putting a minus + in a file's name that `isBuiltSound` would not know — fixed, and every name of 200 checked. +- **Played 2026-10-19** (recording 16.08.43.37): A at 4.894 s, B at 39.859 s — 34.965 s apart. B as heard against the + file: the same level within 0.2 dB, second by second; no drift over 63 s (the best lag 0 samples in every 10 s); + 27 dB below 6 kHz and 8.5 dB above it (hats and air, 14% of the energy: the recording's AAC, A's sparser top came back + at 31.9 dB as before), the worst second 14 dB — no dropout; L − R follows the file's at 0.990; peaks 0.94 — no + clipping; the tail down to the recording's floor (−95 dB). User: "b sounds pretty sick yeah, maybe too strong/loud on + the beat/drums but thats stylistic complaints only" — the kit goes down in the showpiece's mix (step 5), not here. + +### Sounds: step 3, writing a song (built 2026-10-19) + +User after step 2: "go for step 3". Nothing here makes a new sound beyond three clips; it is all for writing songs. + +- **Patterns.** "@" after a step's notes or letters says how loud ("C4+E4@0.6", "kh@1.3"; 0 to 4). `transpose`, + `stretch` (eighths written as sixteenths), `repeat`, `beat` (a kit's bar from the steps each letter plays on). +- **Chords.** `chord("E", 3, 1)` → "G#3+B3+E4": 23 kinds ("m", "7", "maj7", "m7b5", "sus4", "add9", …), inversions, + "C/E" over another bass. `over(chord, template)` draws a chord's notes by number (1 the lowest, past the last round + again an octave up, ^ and _ an octave either way): an arpeggio, a bass line, a comp. `chords(progression, template, + { octave, lead })` a template a chord; `lead` turns each chord to the inversion whose notes move least from the one + before — "Am E Dm C" comes out as step 2's hand-written voicings. +- **Sections.** `part(instrument, how heard)`, `section(bars, { part: pattern }, { name, fx, volume })`, + `arrange(options, parts, ...sections)`. A pattern shorter than its section plays again until it is full (it must + divide it); a part a section does not name rests; `section.with({ lead: null, saw: tune })` is the section changed. + `song.sections` says where each is (bar, frame) — for step 4's stems and the visualizer. A section's `fx` take its + length (`{ seconds, bars, bar }`) for their points; its notes are mixed apart from the section's start, tails and + all, and through them, before the part's own fx — so `render` only: `play` refuses a song with them, in words. A + section's `volume` is a velocity, so it plays either way. +- **Swing**: every second step `swing` of a step late; a note runs to its swung end step. +- **Between sections**: `riser(seconds)` (noise through a resonant band opening, a detuned saw rising), `impact()`, + the kit's crash (`x`), `pump(beat)` (a part ducking on each beat, as under a kick), `bars(bpm, n)` — seconds of n + bars as a song plays them, for a riser's length. +- **Proof.** Step 2's arrangement written again with `arrange` (`probes/korobeiniki-form.ts`, A) gives the same Ogg to + the byte (`trigscript-120f8d218410588b.ogg`): a song of sections is a song of tracks, the same recipes. +- **B** (73 s, 40 bars): intro (filters opening) / verse / build (riser, snare roll in crescendo, half a bar of + silence) / drop (four on the floor, impact, pumping) / break / the last verse a tone up / outro (the pad closing). + The kit at 0.6 for the user's "maybe too strong/loud on the beat/drums": before the master the drums are 2 LU under + everything else together (in A they matched it). Sections before the master −21 / −13.7 / −13.3 / −12.5 / −15.3 / + −12.4 / −15.8 LUFS; through a gentler master (threshold −12) −18.5 / −12.9 / −11.4 / −11.5 / −13.3 / −11.4 / −15.7, + −12.1 in all, peak −0.57 dB. The check 0.8 s. Lifted game clean. Map 2.7 MB. + +- **Played 2026-10-19** (recording 16.56.34.39): A at 7.631 s, B at 73.488 s — 65.857 s apart, the 1,568 frames + the program waits (A's 1,496 and 72). B against the file: the same level within 0.3 dB second by second; the lag + −34 samples at every 10 s, so no drift over 71 s; 27 dB below 6 kHz, 9.5 above (the recording's AAC, as before); the + worst second 12.9 dB — no dropout; L − R follows the file's at 0.990; peaks 0.94 — no clipping; the outro's tail + followed down to −79 dB, under the recording's floor after. User: "B sounds amazing". +- **MIDI** (3e669dc). Import Video… takes a `.mid`: `compiler/notes.ts` reads formats 0 and 1 (running status, a + note struck again ending the first, the first tempo, the time signature as quarter notes a bar, names, programs, + channel 10 the drums) into a `.notes` file of the script — a note four numbers in 960ths of a beat, the MIDI file not + kept. Clicking one shows a roll, a colour a track, and plays it roughly (a sine a note). `music.midi(notes, parts, + { steps, bpm, swing, drums })` makes a song of the tracks `parts` names (by name, or place from 0): notes to the + nearest step, velocity / 100 as "@" in twentieths, overlapping notes split into voices (a note joins a chord struck + with it, of its length and velocity, else the first voice free), a kit's letters by General MIDI number. Windows' + own town.mid (11 tracks, 2,121 notes, never committed): check 0.7 s, build 3.2 s. The dialog and the viewer are + type-checked, not yet run in a browser. +- **The limiter judged by its samples let the wave between them over** — found with town.mid, decoded at 0.99. The + limited PCM's samples peaked at −2.0 dB and its true peak at −0.4: the overshoot was ours, not Vorbis's. `limit` now + takes each sample's need from its true peak (windowed sinc, 16 a side, each eighth of a sample; computed only near + samples over half the ceiling), the ceiling back at −1.5 dB: true peaks −1.5, decoded sample peaks −0.7 (town), + −0.9 (step 2's mix), −1.1 (step 3's B). 8 a side under-read a wave near 22 kHz by 0.4 dB. `ENGINE` is "sound 3": a + file's name says how it was made, and a map keeps a file whose name it has, so a change in what a method does must + change it. The map played for step 3 was built before this (edb73ee). + +### Sounds: step 4, music that follows the game (built 2026-10-19) + +User after step 3 ("B sounds amazing"): "go for step 4". + +- **What the game allows.** No sound stops; a sound starts on the mixer's 21.3 ms grid after its frame; 32 at once, + added with no limiter. So the unit is a bar: `music.stems(song, { layers, tail, quality, peak, stingers })` cuts a + song of sections into a clip a bar a layer — the notes that start in the bar with their releases, echoes and reverb + (each bar its own reverb from its own sends), so a bar overlaps what follows whatever follows it. A bar the same as + another is the same clip. A bar under a section's effects is mixed where it falls in the whole section, then cut out, + so a sweep or a pump is where it was. +- **Tails.** A bar's clip keeps `tail` (2 s) after its last note ends, faded over the last second — the bars after + cover the rest; a layer's last bar keeps all of it (the outro rings out). From the bar line instead of the last note + cut the build's four-bar riser before its top (−3.7 dB of highs): measured, fixed. 3 s → 2 s: 7.2 → 6.5 MB. +- **Level.** The game adds the clips up, so there is no master compressor or limiter over them: `together(parts)` + (sound.ts, new) turns every bar down by one gain until the song played through stays under 0.84 between samples, + worked out when a build needs it. Played through, every layer on: −16.3 LUFS, sample peak −1.9 dB, true −1.2 (B was + −12.1 through its master). Baking the master's gain curve into each bar would make it as loud, but a bar played twice + would then be two files, and a check must name files without samples — not done. +- **Size.** `Clip.ogg(quality)` (new): Ogg Vorbis at its own quality, however short. Bars at 4 (70% of 5's bytes, by + ffmpeg's encoder on 20 of them). Korobeiniki, 40 bars × 4 layers: 114 distinct clips, all Ogg, 5.4 MB (8.0 MB before + the section-effect bars stopped running to their section's end and the tails were cut). The bass bars, 1.7 s, are + the first short Oggs a map plays: the recording says whether they start on time. +- **Faithful.** Every bar summed at its time against the continuous render (no master) scaled by one gain: the + samples differ (10 dB: chorus and the plate's modulation start again each bar) but lows, mids and highs match second + by second within 0–0.5 / −0.1–1.0 / 0–1.5 dB (the highs the bars' master shelf, +1.5 dB at 9 kHz). +- **The game side.** `class Band` (made inside the program; fields the game holds: section, bar, count, at, next/now, + loop, on[8], stung, playing; `go`, `queue`, `mute`, `unmute`, `sting`, `stop`) and `conduct(stems, band)`, a task + sleeping a bar at a time: on the bar line a pending section, then each layer's clip by unrolled test (a clip is no + value a program holds — `const c = s.bars[k][b][l]` in game code is refused; written inline it folds), the stinger, + then on, wrapping a section or the song. Simulated in the tests: jump, mute, stinger, loop at the bar lines. +- **Probe** `probes/korobeiniki-band.ts` (step 3's B as stems, 4 layers; keys 1–7 sections, D B H M layers, L loop, + Space crash): build 18 s, map 5.6 MB, check 1.1 s, lifted game clean. + +- **Played 2026-10-19** (recording 17.53.58.41; the untouched pass from 3.180 s, then keys). Each bar's start + against the bars summed at exact times, by correlation of its first 0.35 s (all 40 clear, 0.53–1.00): 0, 5.3, 10.6, + 16.0 ms and round again — the mixer's 21.3 ms grid (1,024 samples at 48 kHz): a bar is 78.75 of its buffers, so each + bar lands a quarter later in one and every fourth wraps; mean 9.1, sd 7.3, never more than one buffer. The frames + themselves are steady. A bar of 32 frames (1,344 ms, 63 buffers) would land the same each time, but only at 48 kHz + — the grid is the player's device's. The level: 60 s within 0.05 dB of the file, seconds within 0.7 dB; the + untouched pass peaked at 0.824 (the file 0.831). **The bass bars — the first short Oggs — start on the grid as the + rest do** (lows: lag 5.3 / 10.7 ms, correlation 1.00), but alone in the intro the lows came 2.3–2.4 dB under the + file with the same shape: **the game plays an Ogg of one channel 2.4 dB quieter** (step 1's one-channel WAV came at + its level). Fixed: an Ogg always goes in with two channels ("stereo" in its name). **Steered, it reached 0.998 once** + (144.3 s, a riser then the drop's hit): the stinger, crash + impact, played at its own level over the drop. Fixed: + `together(…, { over })` leaves room for a clip that may play on top at any of its times and turns it down with the + rest; `stems` passes the stingers at every bar line. With that stinger the song went to −19.0 LUFS; the probe's is + now the crash alone at 0.7: −16.6 LUFS, the worst bar line with a crash 0.811. Rebuilt the map (5.6 MB). + +- **User after the second play: "it felt great - waiting for the next bar line took a bit too long imo if + sometime important happens but it wasnt bad".** A sound cannot be stopped, so a change cannot land mid-piece (the + piece's remaining beats would sound over the new section). Two answers: `conduct` now wakes every beat and plays a + stinger on the next one (≤ 0.42 s at 143); `stems({ every })` cuts every few beats (a number dividing the bar's; + its frames whole) — `Band.piece` / `beat`, `Stems.pieces` / `every` / `waits`, `Song.beats`. Korobeiniki: bars 114 + files, 5.5 MB, 13 sounds at once; half bars 190, 7.4 MB, 20 (12 left of 32), build 18 s. The half-bar map is + Maps/TrigScript/trigscript-korobeiniki-halfbar.scx beside the bar one. + +### Sounds: how to make the Korobeiniki work again (2026-10-19) + +User after the half-bar map: "half bars is fine … map size isnt a problem. we're going for polish and wow factor. +make sure we know how to reproduce this track and work". Half bars (`every: 2`) are the choice for the showpiece. The +showpiece (step 5: the falling-block game restyled, the music following it, the visualizer) waits until the language +work is done. + +**The track.** Korobeiniki is a Russian folk song in the public domain: the melody only is the folk song's; the bass, +chords, arpeggios, counter-lines, drums and form are ours. Never Hirokazu Tanaka's Game Boy bass or countermelody or +any official arrangement; no "Tetris" in a map's or video's name (a description may say "the Tetris theme"); no +official Tetris look (a 10 × 20 well, the official pieces or colours — Tetris Holding v. Xio). The StarCraft race-theme +remix is only for a video: never committed, never in the registry or on scmjs.dev, no download linked. + +| Probe (`probes/`) | What it is | Its sheet (`probes/answers/`) | +| --- | --- | --- | +| `korobeiniki-ab.ts` | step 1: the same notes before (22 kHz mono WAV) and after (44.1 kHz stereo, limiter, Ogg) | `korobeiniki-ab.txt` | +| `korobeiniki.ts` | step 2: A = step 1's mix, B = the first arrangement (presets, drums, effects; 34 bars, 63 s) | `korobeiniki.txt` | +| `korobeiniki-form.ts` | step 3: A = step 2's B as sections; B = **the track** (40 bars, 73 s: intro, verse, build, drop, break, last verse a tone up, outro) | `korobeiniki-form.txt` | +| `korobeiniki-band.ts` | step 4: the track cut in half bars, four layers, steered by keys (1–7, D B H M, L, Space) | `korobeiniki-band.txt` (`-bars.txt`: the whole-bar build) | + +**Making a map** (a scm-js checkout beside this one with its tileset data, and plugin-eudplib, as for every probe): + + npx tsx probes/lib/raycaster-map.mts fixtures/eud/sound-base.scx # the base map, once + npx tsx scripts/build-fixture.mts probes/korobeiniki-band.ts fixtures/eud/korobeiniki-band.scx fixtures/eud/sound-base.scx + SCM_SIM_DIR=../scm-sim-eud npx tsx scripts/game-check.mts probes/korobeiniki-band.ts --frames=3800 fixtures/eud/sound-base.scx + +and it goes in Remastered's maps folder, `TrigScript/`, with its sheet. Played as a multiplayer game, alone, the +game's music off, recorded. + +**The sounds as the map gets them**, to listen to or compare: `npx tsx scripts/sound-dump.mts ` (the +names alone: `--check`). The song as the game plays it piece by piece, every layer on: +`--append 'program(async () => { playWav(st.playthrough.ogg(5), 0); });'` on the band probe — the largest file. +A file's name says how it was made (`ENGINE` in sound.ts); since the true-peak limiter ("sound 3") the names differ +from the ones the recordings of steps 1–3 heard, and the bytes a little. + +**A recording checked:** `npx tsx scripts/recording-check.mts …` finds each reference after +the one before and says where it starts, how far after the last (in frames), the level against the file second by +second, the lag at every 10 s (drift), how close below and above 6 kHz, the worst second, L − R, peaks; +`--pieces=0.84` (half bars at 143) adds each piece's start against the playthrough. What the recordings gave is under +each step above; the recorder's AAC is what blurs the top (7–10 dB above 6 kHz), not the game. + +### Closures and the missing methods (built 2026-10-17) + +The language gaps a TypeScript programmer meets first, after B3–B5, closed before the libraries (`screen`, `ui`): + +```ts +function makeCounter() { let count = 0; return () => ++count; } +const hs: (() => number)[] = []; +for (let i = 0; i < n; i++) hs.push(() => i); // one i for each turn, as JavaScript has it +function counter() { let c = 0; return { inc: () => { c++; }, get: () => c }; } + +const [cmd, who, amount] = line.trim().split(" "); +if (cmd == "-give" && !isNaN(parseInt(amount))) give(parseInt(who), parseInt(amount)); +queue.push(x); const next = queue.shift() ?? -1; xs.splice(i, 1); xs.at(-1); +``` + +**Closures over locals** (`compiler/closures.ts`, IR 24). B3 refused a kept function that used a variable of the +block it was written in; the open decision above chose between "`let` only when the closure does not outlive its +frame" and reference-counted environments. Built: environments, counted. +- *What is kept where.* A variable a kept function uses is a cell of an array of its own at a row of a pool (1 023 rows + a program, a player's own in a per-player program); the block it is declared in makes a row each time it runs, its + parent the row around it. A closure is a row too: the function and the row it was made in. A function that keeps + nothing is `FN_BASE` (2²⁰) plus its id, as B3's numbers were, moved out of the pool's way; a dispatcher takes a + closure apart and hands the function its environment as a last parameter. +- *Which variables.* Found by walking the program once: the B3 watch, which refused, now records the declaration, and + the program is walked again with those kept in environments (`compiler.ts`, up to five walks; only the last one's + errors are said). Nothing is kept that no kept function uses: a function known when built (an arrow handed down, + `xs.forEach(…)`) costs what it did. +- *Lifetime.* Counted, deferred: every store into a place that may hold a reference (found to a fixed point from + what `make` gives) holds the new one and lets go of the old; a row at 0 is queued, and the program's next wait gives + back what is still at 0 there. So a closure only just made, on its way into a variable, is never let go of under it, + and one made and dropped in the same frame costs no sweep of its own. Parameters and results are not counted. +- *JavaScript's semantics, checked against it:* the function and its closures share the variable (a write either side + is seen by the other); two closures of one run share it (`{ inc, get }`); a copy of a closure is the same closure + (`a === b`); the `let` of a `for` is copied into a new environment at the end of each turn, before the update. +- *Not yet:* a function that calls itself may not declare a variable a kept function uses (its variables go on the + stack, where nothing counts them); an array or a record of the function kept by a closure (copy it into the program, + or into fields); more than 1 023 alive at once (said once, and the new one does nothing). +- *Cost, lifted game:* a variable-index read is about 70 conditions and actions, a write 15. A turn that makes an + environment and a closure, calls it and lets go of the one before: about 1 700 (2 800 before the counting was made + to read one cell where it read three). Found on the way, for every program: `xs[i] = xs[i] + n` (`xs[i] += n`) now + lowers to the game's own Add (`EUDArray.iadditem`), nothing read — 85 down to about 25. + +**Also found missing, and built:** `++n`, `n--`, `n = v`, `n += v` used as values (`return ++count`, `ids[k++] = id`, +`while (n-- > 0)`), as a call inlined where the expression is, so `&&` and `?:` run it only when JavaScript would; a +function may give a record it makes (`return { inc, get }`, `() => ({ x, y })`), inlined where it is called, as an +instance already could; `join` of an array is worked out where it is used (it was worked out before the statement it +was in, so `${xs.unshift(5)} ${xs.join()}` read the array too early). + +**Texts:** `trim` / `trimStart` / `trimEnd`, `replace` / `replaceAll` (a text to find; an empty one matches before +every character, as JavaScript's), `toUpperCase` / `toLowerCase` (A–Z only; Korean is left as it is), `lastIndexOf`, +`split(sep, limit?)` (an array of texts, taken apart with `const [a, b] = …`, indexed, looped over), `parseInt(s, +radix?)`, `Number(s)`, `Number.parseInt`. A number of a program is never NaN, so `isNaN(parseInt(s))` / +`Number.isNaN(Number(s))` is compiled as "has no number at its start", and `isNaN` of anything else is refused with +that said. Regular expressions and `replace` with a function are refused. + +**Arrays:** `shift` (a value, or `?? other` for an empty one), `unshift` (gives the new length), `splice(start, +deleteCount?, ...items)` standing on its own, `at(i)` (from the end below zero; `?? other` past either end), `lastIndexOf`, +`join` of numbers and booleans — on arrays of numbers, booleans, texts, records and units. One routine (`spliceRows`) +moves rows for all of them: the rows taken out give their blocks back, the rest move up or down a cell a column, and +the new rows are emptied before they are filled, so a text or an inner array that moved is never given back twice. +A `const` array that only these change is the program's (`hoist.ts`), not a list of the script. + +**Checked:** `tests/closures.test.ts` (20) and `tests/text-array-methods.test.ts` (20), each body also run as +JavaScript and the prints compared; `tests/function-values.test.ts`'s two refusals are now working examples. In the +lifted game and the simulator, the same lines: `probes/game-closures.ts` (6: counters, a turn's own `i`, shared +variables, closures inside closures, handlers fired frames later, 3 000 made and let go of across 30 frames with the +kept ones still there) and `probes/game-text-arrays.ts` (4). + +### Components, functions from any file, and the first libraries: ui and screen (built 2026-10-17) + +The libraries the language was missing, and what they needed of it first. + +**Language, found missing while writing them:** +- *JSX components.* ``: a function of your own as an element. Its props are a + record of the attributes (an optional one left out is `undefined`, which `??` folds; `children` is the text between + the tags, typed `Children`), and its text is written into the text it stands in — its ``s are lines there, as + React has it, not a text pasted onto the line before. A component is a function declared in the program, a `const` + arrow, a `game()` function, or (below) a function at the top of any file; its body is an expression or statements + ending with the one `return <…/>`. +- *Functions from any file.* As classes from any file already were: a function declared at the top of a file — or a + `const` arrow there — is called by a program with its own values without `game()`, planned as a game function is, + its errors said only where a program uses it so. Called with values the script knows, it still runs when the script + is built, whatever JavaScript it uses. Kept in an array or a record (`const fs = [a, b]`) it is the program's function + as a value. Destructured parameters work for all of them. +- *A function declared in a block sees the block's variables* (it saw only the program's top). +- *What a function writes makes the array or record handed to it the program's:* `add(names, t)` where `add` pushes, a + `const shop = { … }` that `choose(…, shop)` writes. And a `const` record written directly is the program's too, as a + `const` array was (it said "declare it with let"). +- *A record's text field is a made text when the record is handed to a function*, which may give it one. +- *JSX:* a colour worked out in the game by name (`color={i === sel ? "cyan" : "white"}`); `map` over an array of + texts and over a list handed in as a prop. +- *The standard library may hold `.tsx` modules* (`STDLIB_EXT`). + +**`trigscript/ui`** (`stdlib/ui.tsx`): `Meter` (a labelled bar, green / yellow / red by the share), `Panel` (a title +over its children), `MenuList` and `choose(player, count, menu)` (Up, Down, Enter; the state a record of yours that the +view shows), `DialogBox` and `say(player, box, speaker, text)` with `typewriter`, `LogLines` and `log(lines, text, +keep)`, `clock(frames)`, `thousands(n)`. One `ui.lines` a player, the components pieces of its view. + +**`trigscript/screen`** (`stdlib/screen.tsx`): `Screen(x, y, width, height, cell?)` — `clear`, `set`, `get`, `rect`, +`line` (Bresenham), `blit(sprite(rows))`, `number` (3 × 5 digits) — and `present()`, which shows it on the map, and so +on the minimap, a pixel an Overlord of the colour's owner (P3 … P8 and P1), making and taking away only what changed +since the last time; `ScreenView` shows the same grid in the text lines. Generalises the Tetris probe's `UnitScreen`. + +*Played and changed (2026-10-18, four rounds):* the board was invisible (other players' units under the fog of war: +`Screen.reveal(player)` makes Map Revealers over it) and the view showed twice (`ui.lines` now fills the eleven text +lines); then a 64 × 64 map of Space (`probes/lib/screen-map.mts`) so the board fills the minimap, the screen's owners in +the text colours' order, fixed arrays of 2 048 cells (the first frame 712k → 434k), Space rather than Enter (which opens +the chat). The minimap draws an Overlord as a dot with a gap round it; Overlords a tile apart do not fit (the game will +not place most of them), so `new Screen(…, "solid")` makes each pixel a Pylon, which the minimap draws as its two-tile +footprint: pixels side by side are one block, on buildable ground (`screen-map.mts --ground`). 320 Pylons on the grid +in the lifted game, none moved. Played (round 4): solid blocks on the minimap as expected, but on grey platform and with +the Protoss supply counter climbing; the user prefers the dots on Space, which stay the default. + +**Showpiece: Snake** (`probes/snake.tsx`, `trigscript-snake.scx`): the board on the minimap with `screen`, the score, +a length meter and the game-over menu in the text lines with `ui`, the snake two arrays that `push` and `shift`. In the +lifted game: its test passes in both; about 14k conditions and actions a frame, 400k on the first (the screen's 768 +cells pushed and the walls made — a fixed-size screen would cost less). Awaiting a play in Remastered. + +**Checked:** `tests/ui.test.ts` (components, 4 more), `tests/script-functions.test.ts` (6), `tests/ui-kit.test.ts` +(5: the kit rendered, the meter's colour in the IR, the log, `choose` and `say` with keys pressed), `tests/screen.test.ts` +(4: drawing, digits, `present` making only what changed). + +### timers, signals, random and math, and generics (built 2026-10-17) + +Four modules of pure TrigScript, no probe needed, that the next showpieces lean on — and what the language needed first. + +**Language:** +- *Generics.* A generic class's instance (`new Signal()`) and a generic function at a call (`shuffle(names)`) + are worked out with their type parameters standing for what they are there. The structured compiler's checker is + wrapped so that a type it hands back that is a type parameter comes back as that type; the instance carries its type + arguments (`typeArgs`) into its constructor and every method called on it; a call's are read from the checker's + resolved signature (a parameter declared `T` or `T[]`); a function kept as a value inside a generic method is walked + later with them; a generic function is a copy for each set of them. "A class of a program takes no type parameters" + is gone. +- *A function handed to a task keeps what it uses*: a known function handed to `start(…)`'s call is kept as a value + there, so its variables get an environment — `every(4, () => log += tag)` in a loop keeps each turn's `tag`, as a + `setInterval` callback does — and the program holds it for the task's slot (an array a `start`, a cell a slot) until a + later task takes the slot. A known function's environment is reached from where it was written, not from where its + number is made (inside a game function, which sees nothing of the program). +- *A function of the script's own that uses the game* (`random`, a read, a wait, a print — itself or through another) + is never run when the script is built, whatever its arguments (`hoist.ts#usesGame`): `range(2, 4)` drew at build. +- *A Task a function gives* (`every(…)`) is not a promise left unawaited. +- `v.rotate(a).x`: a field of the instance or record a call gives, the call made once a statement. +- A method that writes an array handed to it makes the array the program's, as a function already did; a class's text + field written in its own methods (`this.value = v`) is a made text when the class is at the top of a file. + +**`trigscript/timers`:** `every(period, fn, times?)` (handed the count), `after(delay, fn)` — tasks, so `cancel()` and +`running` — and `Cooldown` (`use()`, `ready()`, `left()`), `Countdown`, `Stopwatch`, `now()`, `SECOND` = 24. +**`trigscript/signals`:** `Signal` — `on`, `once`, `off(id)` (the place reused), `emit`, `count`, `clear`. +**`trigscript/random`:** `chance`, `range`, `dice`, `pick`, `weighted`, `shuffle` on the game's synced random; +`Random(seed)`, xorshift, the same numbers as JavaScript's. +**`trigscript/math`:** `lerp`, `inverseLerp`, `remap`, `clamp01`, `approach`, `wrap`, `sign`, `distance`, `distanceSq`, +`manhattan`, `angleTo`, `radians` / `degrees`, `PI` / `TAU`, easing (`easeIn/Out/InOutQuad`, cubic, `easeOutBack`, +`smoothstep`), `Vec` (fixed `x`, `y`: `fromAngle`, `add`, `sub`, `scale`, `dot`, `length`, `normalized`, `angle`, +`rotate`, `distanceTo`, `lerp`). + +**Checked:** `tests/generics.test.ts` (3, against JavaScript), `tests/stdlib-core.test.ts` (11; `Random` against the +same xorshift in JavaScript, easing within a hundredth), `tests/closures.test.ts` (+1, the task). In the lifted game and +the simulator, `probes/game-stdlib-core.ts` (4), about 4k conditions and actions a frame; the earlier probes and Snake +re-run, all passing. + +### What is still missing: a sweep (2026-10-18) + +Before more showpieces, about a hundred lines of the TypeScript people write were put through the compiler and the +simulator, each over values only the game has (`random(9)`), so that nothing was worked out when the script was built. +Control flow and syntax are nearly whole. What is left is mostly **what a value can be**. + +**Refused:** +- *Instances as references.* An array or a variable of a base class or an interface holding more than one class + (`const world: Entity[] = [new Bullet(), new Enemy()]`, `let x: Shape = c ? new Circle() : new Square()`); an instance + that may be none (`target: Enemy | null`, `next: Node | null`); an array of instances inside an instance + (`squad.members: Soldier[]`); a `Map` whose values are instances or records; an array of a union of records + (`Event[]` with `kind: "hit" | "heal"`). +- *Records and instances as values.* `p = p.add(q)` ("a record is assigned field by field"), which makes `trigscript/math`'s + `Vec` awkward; a `?:` that gives a record; `number | undefined`. +- *Generators and helpers.* `g.next()` by hand, a generator method, `yield*`, `[Symbol.iterator]`, a generator or an + `async function` at the top of a file called with the program's values. +- *Small:* labelled `break` / `continue`; `sort()` of texts; `${flag}`; `String.fromCharCode`, `JSON.stringify` and + `toFixed` of values of the game; `Object.keys` of a record of the program; `enum` inside `program()` (at the top of a + file it works); `using`. + +**Two faults found:** a function handed a value of the program that narrows with `typeof x === "string"` sends the +compiler into endless recursion ("Maximum call stack size exceeded"); `Date.now()` is the moment the script was built, +silently. *Both fixed:* `c ? a : b` (in a number, a text, a truth value, a colour, a record copied whole, a maybe) folds +a condition known when the script is built, as `if` did, so the side a narrowed argument does not take is never +compiled; and a variable of the program is read as its own kind whatever TypeScript narrowed its name to, which was the +loop. `Date.now()`, `new Date()`, `Math.random()` and `performance.now()` inside a program, or in a function of the +script's that a program calls, are refused with `elapsed()`, `memory.game.frame` or `random(n)` named instead; at the top +of a file they stay the build's (`tests/sweep-faults.test.ts`). + +**Already working:** `enum` at the top of a file, `switch` on texts, unions of literal types as states, spread, +`extends` and `super`, getters and setters, statics, `#private`, tuples, `sort` with a comparison, `reduce`, +`Array.from`, async generators inside a program. + +**In order of what the showpieces need:** +1. References and polymorphism (B9, below). *Built.* +2. Records and instances as values everywhere (B9, below). *Built.* +3. Generators finished, and async functions in libraries — with the cutscene library, which is written in them. *Built, + but `g.next()` by hand (below).* +4. Abstractions over what a frame costs: `unroll`; a loop spread over frames by a budget (A4 knows each line's cost); + fields packed into one cell and written with a mask; a view printed only when what it reads changed. +5. `using` for what a program holds for a while: a location (`reserveLocation`), a reveal, the camera. +6. The small ones and the two faults. + +### B9. References and values + +A showpiece with things in it — bullets, enemies, a tower and its targets — is a list of objects of several kinds, each +pointing at others. Until now an instance was its fields' variables, its class known when the script is built: cheap +(a field is a variable) and enough for one of each, but not for a list of kinds, a pointer that may be none, or a +variable that is one object now and another later. + +**References.** A class the program keeps *by reference* has its instances in one table for the program: rows of the +pool the closures already keep (`closures.ts`), counted the same way, a column for each field. +- *A reference is the row*, 1 to 1 023; none (`null`, `undefined`) is 0. `x === y` compares rows; `if (x)`, `x?.hp`, + `x ?? y` ask whether it is 0. +- *The columns* are shared between classes by field name and kind, so a field of an interface is one column whatever + class the row is, and reading it needs no question of the class. A field of a unit is three columns, a text three, an + array that grows four, as a row of an array of records already has them; the row is the same binding, so a method, a + getter, a setter and a field's text or array work on it as they do on a row of an array. +- *The class of a row* is kept in the pool's function column. A method called through a reference whose class could be + several asks it: one case for each class that gives the method its own body (one where they all share it), each the + method with `this` that class. `instanceof` is the same question. +- *Which classes are kept so* is found by walking the program, as the closures' environments are: a class goes by + reference when the program needs it to — a variable or a field that may be none, a variable given another instance, an + array or a variable of a type more than one class fits, an array of instances inside an instance, a `Map` of them — + and the program is walked again. Every other class stays as it was, its fields variables; nothing gets slower that + does not need to. +- *Lifetime* is the closures': every place that holds a reference counts it, a row nothing holds is queued, and the next + wait gives back what is still at nothing — what it held let go of too (its references, its texts, its arrays). Rows + that hold each other in a ring are never given back (no collector looks for rings); said in the guide. +- *A method of a class kept by reference* is called, not copied, from its second call on: `this` is a number, so the + method is one function for every instance — and a method that calls itself on another row (a list walked, a tree) is + recursion as any function's is. +- *Through none:* a field of none reads 0, and a write to one does nothing (JavaScript would throw). + +**Values.** A `let` record or instance of a class that is not kept by reference may be given another whole: `p = +p.add(q)`, `r = c ? a : b`, `r = { x: 1, y: 2 }` copy the fields. A copy is what JavaScript does only while nothing else +names the same fields, so a `const q = p` of such a `p` is a copy too, and a class whose instances are both given whole +and changed field by field where another name shares them goes by reference instead. `number | undefined` (and +`boolean | undefined`) is the value and a flag beside it: `=== undefined`, `??`, `!` and narrowing read them; +`xs.find(…)` gives one. + +**As built (2026-10-18).** No new IR: the object table is arrays and statements the front end writes, as the closures' +pool is (`compiler/closures.ts`, `structured.ts` *References* and *Records and instances as values*). +- *Which classes.* `refs` is carried from walk to walk as `boxed` is (`compiler.ts`, up to seven walks). A class joins + where the program would otherwise be refused: an array or a variable of a type more than one class fits or that may be + none, a field that may be none or is an array of instances, a variable given another instance (of a class something + changes in place), a function giving one instance or another, a `Map` of instances, a `Set` of them, an instance kept in + a variable pushed to an array of instances. With it come the classes that fit it. Which classes a type may be is the + checker's assignability over every class of the script's files (`instanceClasses`), so an interface fits whatever has + its members. +- *A reference is a number.* `kindOf` says "number" for a type every class of which is kept by reference, as it does for + a function (B3), so variables, arrays, parameters, results, `Map` values, `Set` keys, task arguments and fields of other + rows hold one with nothing of their own. Where it is used as an object — a field, a method, `instanceof`, `?.` — it is a + row binding (`refRow`): `rowOf` over the table, the place the number, the classes it may be beside it. +- *A call through a reference* of one class, or of several sharing the member, is that member's; of several with bodies of + their own, a call whose body is a `switch` on the row's class (`virtualCall`), a case for each body. A method called + twice is a called function whose last parameter is `this`, a number, one copy for the classes it may be; so a method + that calls itself on other rows (`left.sum()`) is recursion's. Constructors stay copies at each `new`. Methods of a class + at the top of a file may now be called functions too (they were always copies). +- *The table.* A column a field name and kind, shared between classes (`hp` of a `Grunt` and of a `Boss` is one array); + two kinds under one name are said. 1 023 rows, the closures' own: a closure, an environment and an object are rows of + one pool, each class a number in its function column (from 2²²). `new` takes a row (`make(class, 0)`) and runs the + constructor on it under one test that there was one; a field's first value is stored with nothing let go of. +- *Counting* is the closures'. Two changes for every program: only a variable that a wait may come between its + declaration and a use is counted (a loop's `const e = enemies[i]`, and every local of a called function, need nothing: + no row is given back while they are read), which also fixed a variable of an unrolled loop holding its last object for + good; and the sweep asks a row's class once, then lets go of that class's references, gives back its texts' and arrays' + blocks, and empties only the cells its constructor does not write. +- *None.* 0. A field of none reads 0; a write to one is skipped (a test before each, except on a row being made); + `x?.f`, `x?.m()` and `x ?? y` ask first. JavaScript would throw a `TypeError`. +- *Values.* `p = …` of a `let` record, or of an instance of a class whose fields only its constructors write + (`isValueClass`: no write of one of its fields outside a constructor in any file), stores a copy into its fields: + of a record written there, an instance made there, what a call gives (copied first: it may be `p` itself), another + record's fields; `c ? a : b` is an `if`. `const q = p` of a `p` given whole is a copy, and is said when a field of + either is written (the two would no longer be one record). `const r = c ? a : b` is declared empty and filled. A class + changed in place and given whole goes by reference instead. +- *A number or nothing* (`number | undefined`, `boolean | undefined`, `?:` fields): a variable, or a column, and a + boolean beside it, `(there)`. `=== undefined` and `== null` read the flag; `=== 3` reads both; `if (x)` is there and not + 0; `x ?? d` a ternary; narrowed (`if (x !== undefined)`, `x!`) it is the number, and read where the checker says it may + be nothing it is said. `find()` / `findLast()` of any list and a `Map`'s `get()` give one; a record written out may + leave an optional field out. +- *An array of a union of records* (`Event[]`): a row holds every kind's fields, a field one kind has not got empty in + its rows. Narrowed by `kind`, as TypeScript narrows, each reads its own. +- **Not yet:** rows that hold each other in a ring are never given back (no collector looks for rings); more than 1 023 + closures, environments and objects alive at once; an object made and dropped within one frame is given back only at the + next wait, so a frame that makes more than 1 023 runs out; a function that returns `number | undefined` (only `find`, + `get` and variables give one); a field of the same name and two kinds in two classes kept by reference. +- *Cost, lifted game:* a field through a reference is a cell read with a place worked out in the game, about 70; made and + given back, an object costs about 530 conditions and actions (`make`, the counting when it is let go of, the sweep) — 100 + a frame is about 53 000. A class that needs no reference costs what it did. +- **Checked:** `tests/references.test.ts` (20) and `tests/values.test.ts` (19), each body also run as JavaScript and the + prints compared; in the lifted game and the simulator, the same lines: `probes/game-references.ts` (5: one of several + classes, a list made, walked and reversed, a tree summed by a method that calls itself, instances in an instance with a + text and a `Map`, 3 000 made and given back across frames) and `probes/game-values.ts` (3). Five older tests that said + a refusal now say what the program prints. + +### Generators finished, async functions in libraries, and trigscript/cutscene (built 2026-10-18) + +The third of the sweep's list, and the library it was for. + +**Generators.** Still walked where the `for…of` is (the loop's body put where each `yield` is), now from anywhere one is +written: a generator at the top of a file, a generator method (`squad.alive()`, `this` the instance), and an instance's +`*[Symbol.iterator]()` (`for (const u of squad)`). `yield*` walks another generator there, or a list (`yield* [1, 2]`, +`yield* xs`); a `break` in the loop leaves both. A generator method of a reference that may be of several classes with +bodies of their own is refused (which one runs is the game's, and a generator is walked where it is written). +**Not yet:** `g.next()` by hand. A generator stepped by a program is a coroutine that runs a turn when it is asked, inside +the frame of whoever asks; the lowering has coroutines (a program, a `parallel` branch, a task) but none that another +calls and comes back from. That is a lowering of its own (a `resume` of a task's body, returning to its caller) and the +simulator's, kept for later; a task and `await until(…)` do what a hand-stepped generator is wanted for. + +**Async functions in libraries.** A generator or an `async function` at the top of a file is a function of the script's +own now (`isScriptFunction`), called, awaited and started with the program's values, as `trigscript/timers` had to wrap +its own in `game()` before. `sleep(frames(n))` and `sleep(seconds(n))` of a number the game holds count down a frame at +a time (the next frame at the least, as `frames(0)` waits), which a scene's timings need. + +**Found on the way:** a class from another file used only inside a function at the top of a file was never declared for +the program, so its methods were walked in the calling file and their variables not found — a fault since functions +from any file (B3's time); a parameter's default naming a constant of its file (`limit = 30 * SECOND`) was not worked out +for such functions or for methods; a unit variable the checker narrowed to `null` where it is tested was read as a +boolean. + +**`trigscript/cutscene`** (`stdlib/cutscene.ts`), everything a trigger can do, a frame at a time: +- `Camera(x, y)`: `look(x, y)` (Center View on a location the library keeps), `pan(x, y, frames, ease)` (`"linear"`, + `"in"`, `"out"`, `"inOut"`, `"back"`, from `trigscript/math`), `follow(unit, frames)`, `shake(frames, size)`. The camera + of the player the program runs as. +- `walk(unit, x, y, near?, limit?)` and `charge(…)` (a Move or an Attack order to another location the library keeps, + then waiting until the unit is within `near` pixels, gone, or `limit` frames have passed), `walkAll(units, x, y)`. +- `line(speaker, text, frames)`: the speaker's portrait talks and the words show in the middle of the screen. +- `play(player, start(scene()), key?)`: a scene started as a task, which the key (Escape) skips; it says whether it was. +- **Checked:** `tests/cutscene.test.ts` (4, simulator); `probes/game-cutscene.ts` (3) passes in the lifted game and the + simulator — a Marine really walks there, the camera's place after a glide and a shake, Escape skipping a scene. + `probes/cutscene.ts` → `Maps/TrigScript/trigscript-cutscene.scx` and its answers sheet: a scene (Raynor's line, four + Marines walking with the camera following, a glide and a shake to the Zerg base, a circle round it turned by `Vec` + given whole, back), then a list of `Effect`s of two classes (`Ping`, `Wave`) ticked every frame for twenty seconds — B9 + in a map. In the lifted game every step prints what the sheet expects; a frame costs about 5 000 at the most. Awaiting a + play in Remastered. Seen there: the centre line ("Raynor: …") is read back from the game's memory more than once + by the lifted game; whether Remastered shows it again is the sheet's question 10. + +### A second sweep, and what is left (2026-10-18) + +After B9 and the generators, about seventy-five more lines were put through the compiler and the simulator, again over +values only the game has. What B9 was for holds: a linked list built in a loop, a tree summed by recursion, an abstract +class and an interface over several classes in one array, `instanceof`, an array of instances inside an instance, +`Map`, `?.` and `??` on a reference, two-dimensional arrays, `sort` of instances with a comparison, and +`trigscript/math`'s `Vec` given back whole (`v = v.add(w).scale(2)`) all compile and run. Data is no longer what +stops a showpiece; what a frame costs is, and a handful of small refusals. + +**One fault:** a function at the top of a file that makes an instance whose field starts as `random(5)` +(`class E { hp = random(5) }`, `function mk() { return new E() }`) was worked out when the script was built, and refused +there ("random() is a coin toss … use them inside program()"): asking whether a function uses the game did not look +into a constructor or the fields' first values. + +**The small refusals** (the next slice): +- labelled `break` and `continue` (`outer: for …`, `continue outer`); +- rest parameters (`function sum(...xs: number[])`); +- a function of the script's giving back an array (one giving back a record works); +- a function in a `namespace`, and a function with overloads, taken for helpers of the build and refused when called with + the program's values; +- `find()` on an array of instances (a reference that may be none, which B9 has, rather than "take findIndex"); +- `Map` keyed by a text or a union of literal texts (`Map<"a" | "b", number>`): the keys are known when the script is + built, so each is a number; +- a record of functions keyed by states, `Record State>`, called as `table[state]()`; +- `sort()` of texts; `${flag}` and `String(flag)`; `enum` inside `program()`; +- `push` of a record that is one of a union's kinds chosen by `?:`. + +**Not worth it:** `JSON.stringify`, `Object.keys` and `for…in` over a record of the program, `WeakMap`, tagged +templates. None of the showpieces wants them, and each needs a runtime of its own. + +**As built (2026-10-18).** All of the list, and the fault; no IR change (still 24). `tests/small-gaps.test.ts` (simulator, +22) and `probes/game-small-gaps.ts` (4 tests, passing in the lifted game and the simulator, about 10 thousand a frame, +32 thousand at the most). +- *The fault:* asking whether a function uses the game now looks at what `new` runs (the fields' first values, the + constructor, the base class's) and follows a method or an accessor of the script's classes. +- *Labelled jumps* are lowered without anything new reaching a backend. A jump to the loop it is directly in is a plain + one. From deeper, it sets a flag of the label's (`(break outer)`, `(continue outer)`, made only when such a jump is + there) and breaks out of where it is. After each loop or switch it leaves, the flag is tested: in the labelled loop's + own body, the loop is broken or continued and the flag cleared; further in, it is broken out of again. Each jump goes + through the `finally` blocks of its level, and through a generator's loop as a `break` there does. A label on a block + is refused. +- *Rest parameters* were built in the inliner already; the plan of a script function refused them. +- *A function giving back an array*: the call's own array, bound to the caller's name (`instanceCall` with `list`); a + `return` of one it made, one written out, or one it was handed, every return the same one. Made again each time the + call runs, as any declaration in it is. `for (const x of evens(n))` and `evens(n).length` take it too. +- *Namespaces*: a function, a class or a const arrow in a `namespace` at the top of a file (as deep as they go) is the + file's (`atTop`). An exported const of one is registered as `Geo.mk`, since it has no name of its own once compiled. + *Overloads*: the declaration with the body is the one a call names. +- *`find()` of instances or records* gives the row at the place found and a flag beside it, as `chatted()` does (`if (t)`, + `t ? t.hp : 0`, `t?.hp ?? -1`, `t === undefined`). That fixed a fault on the way: `x === undefined` of such a record, or + of what `chatted()` found, was answered "no" when the script was built. +- *Texts of a union as keys*: a `Map` or a `Set` keyed by `"red" | "blue"` has a cell for each text, in the union's order; + a text of the program becomes its place by comparisons, as a `switch` over texts does. A `for…of` over one is unrolled, + its key a text known when built. Any text as a key is still refused. +- *A record at a text of a union* (`cost[state]`, `NAMES[team]`, `step[state]()`): each field under its test, as a value + (number, function, text, boolean); a write (`=`, compound, `++`) walks the statement once for each field. +- *`sort()` of texts*: by their characters (the game compares bytes, which is JavaScript's order for ASCII), or by a + function; stable; a row moves with the block it owns. `reverse()` too. +- *`${flag}`, `String(flag)`*: `"true"` or `"false"`. +- *`enum` inside a program*: made where the build-time parts run (a plan item of its own), so its members are values. +- *`push(c ? { … } : { … })`*: an `if` with a push on each side. + +**Then, in order:** +1. **What a frame costs, as the language's** (the fourth of the first sweep's list). Snake's first frame is 434 thousand + of the 500 thousand. + - A loop spread over frames by a budget, `for await (const e of over(enemies, { cost: 50_000 }))`: A4 knows each + line's cost, so the compiler knows where to stop and carry on the next frame; a program's start spread the same + way, which is Snake's first frame. + - Fields packed into one cell and written with a mask (`hp: bits<7>`): a field of a reference costs about 70 to read, + and a bullet hell reads many. + - A view printed only when what it reads changed: `computed()` and `effect()` over `Signal`, one rule for what the ui + kit and the fog framebuffer each do their own way now. + - A size per class (`@pool(512) class Bullet`): instances and closures share one pool of 1 024 rows (`CLOSURE_CAP`); + a size of their own gives each kind its room and its sweep a known cost. +2. **`g.next()` by hand**: a coroutine that another calls and comes back from (the lowering set aside above). Behaviour + trees, dialogue trees and Part C's computer players are written in them. +3. **`using`** for what a program holds for a while (a location, a reveal, the camera): try/`finally`, which B4 already + copies per exit, and `Disposable` in the editor's library (esnext). +4. **Texts made in the game**: `String.fromCharCode`, `toFixed`, a number with thousands. Save codes (Part C) need the + first. + +### What Remastered charges (cost step 1, 2026-10-18) + +A4 counts conditions and actions, and the dial put the budget at 500 thousand of them. But the dial's loop is the +cheapest kind of work there is, and A4's count is a number of records, not a time. Before making the language +cheaper, step 1 finds what each kind of record costs Remastered. Read in the Ghidra project (1.23.10.13515): + +- **Every Deaths and Set Deaths of an EUD map goes through the emulator**, even one in the real deaths table: the + handlers (+0x28AB30, +0x288240) take the emulator's way whenever the map is an EUD map (+0x105423C), with no fast + path. Inside (+0x2E1450, +0x2E18F0): a one-entry cache of the last region read (+0x80) and another of the last + written (+0x88), else a search of a tree (+0x2DA5A0), then the region's own read or write through its vtable. +- **The tree holds about 12,300 regions**: every unit (336 bytes), sprite, image and bullet is a region of its own. + So a read of a field of another unit than the last always misses the cache, and so does a read of a payload + variable between two reads of a unit. +- **A masked Set Deaths is a read and a write** (+0x2E7DB0); an unaligned one two of each (+0x2E7A20). A masked + Deaths condition costs only an AND. +- **A trigger costs something of its own.** The walk (+0x41B350) finds each trigger through the caches or the tree + (+0x2E1940) and reads its next pointer through the emulator (+0x2E4540), and checks the game's state (+0x33D7C0, + five instructions) after each condition and action. A4 counts none of that: an empty trigger counts 0. +- The game speed table (0x5124D8, backed by image+0xDCF050) and 0x51CE8C (image+0x1067BE8) are regions the emulator + reads and writes; whether the game paces its frames from that copy is a probe of its own, later. + +**The probe** (`probes/frame-weights.py`, hand-written eudplib, beside `probes/frame-weights.ts`): 13 mixes, each 8 +game seconds at 200 thousand and at 800 thousand records a frame, idle stretches between, 32 phases in all +(`python3 probes/frame-weights.py` prints them). It writes its phase and frame as deaths of Player 12 (units +220–222), which the profiler reads from the game's own deaths array. The mixes: the dial's loop as eudplib writes it; +payload Set Deaths 64, 8 and 1 to a trigger; empty triggers; payload Deaths conditions; Set Deaths in the deaths +table; alternating payload and deaths table (the write cache misses every time); masked Set Deaths; conditions on one +unit's fields, on 256 different units, and alternating one unit with the payload; Set Switch. + +In the lifted game every mix lands on its design count (201,741 and 806,494 a frame; empty: 781 and 3,125 turns of +256 triggers, 1,807 and 6,494 records, the loop's own). game-check: 0 accesses Remastered ends the game on, 0 +ignored, 0 never seen. One number from the count already: **eudplib's own loop is 13 records a turn; TrigScript's +lowering of the same loop is 39** — the first thing the cheaper lowering should match. + +**The profiler** (scm-sim-reader `remastered/profile.py`, Windows Python, read-only, not shipped): every 50 ms each +thread's CPU cycles (QueryThreadCycleTime) with the probe's marks, and ~1,000 stack samples a second of the busiest +threads: the instruction pointer and the stack's words that point into StarCraft.exe. A return address of the walk +among them says which of its calls the thread was under. `remastered/profile_report.py` takes the profile, the +probe's table and the lifted count, and gives cycles a record for each mix (between its two amounts, and against +idle), the trigger's own cost and an action's (from 64 to a trigger against 1), and where the samples were. Checked +on a synthetic profile first. + +**Played 2026-10-18** (single player, Fastest; Ryzen 9 7900X, TSC 4.58 GHz; 266,178 samples). The game thread +spends 5.4 ms of CPU a frame with no trigger work, the same in every idle phase from the first to the last. Every +phase held 42 ms a frame; the most any took was 20.6 ms of CPU (act1 at 800 thousand). Cost a record, from the slope +between the two amounts (against idle agrees within 3 cycles everywhere, so the cost is linear): + +| Mix | ns a record | What it says | +| --- | ---: | --- | +| switch64 | 3.2 | Set Switch: an action outside the emulator costs a third of a Set Deaths | +| cond16 | 5.4 | a payload Deaths condition, 16 to a trigger | +| empty | 9.0 a trigger | the walk alone, per trigger | +| act64 / deaths64 | 9.8 | Set Deaths, cache held; the deaths table costs the same as the payload | +| act8 | 10.0 | | +| mask64 | 12.8 | a masked write: +3 ns | +| unitSame | 13.1 | a unit field, the same unit: the unit region's read costs +8 ns over a plain one | +| loop | 14.6 | eudplib's loop, 13 records (190 ns) a turn | +| unitAlt | 16.5 | | +| alt64 | 17.1 | Set Deaths changing region every time: +7 ns, a quarter of the samples in the tree search | +| unitWalk | 17.5 | 256 different units: the tree search is a quarter of all samples | +| act1 | 18.3 | Set Deaths, 1 to a trigger | + +From act1 against act64: **a trigger costs 8.6 ns of its own** (the empty triggers say 9.0), a Set Deaths 9.7 and a +Deaths condition 4.8. So a trigger with one action costs nearly twice what its action does, and eudplib's code is +mostly small triggers. + +What the budget is, in time: the dial's 500 thousand loop records are 7.3 ms, its flicker at a million 14.6 ms, and +its slowdown between 2 and 5 million: with 5.4 ms of the game's own, 42 ms runs out at about 36 ms of trigger work +(2.5 million loop records), which fits. The flicker most likely comes from a drawn frame (16.7 ms at 60 Hz) missed +while a trigger pass holds the thread, so the smooth budget is about 7–11 ms of trigger work a frame on this +computer, and the hard one about 36 ms. A record count can't say either: 500 thousand records are 2.7 ms of +conditions or 9 ms of one-action triggers. + +Asked afterwards, the player saw no flicker in any phase, the costliest 15 ms of trigger work (act1 at 800 thousand). +So the smooth budget on that computer is at least 15 ms; the dial's flicker at a million is not explained by its time +alone (TrigScript's loop records may cost more than eudplib's; not measured). + +**As built: the weights in A4.** +- scm-sim's tap (`src/exact/cost.ts`) counts triggers. The lifted walk stores the trigger it stands at in 0x6509AC + before running it, so each record says its trigger; a record whose slot is not past the last one's starts a new + visit (a loop through one trigger). The triggers the walk passed without running anything (eudplib's jumps are + empty triggers) are found by following next pointers from the trigger before, up to 4,096, and counted only when + the chain reaches the record's trigger. Triggers before a frame's first record and after its last are not seen. + On the probe every phase's count matches its design (the empty mix 201,815 a frame against ~201,700). +- The tap takes a `weigh` (`CostWeigh`: `trigger`, `record`, `reset`) and adds its nanoseconds by frame and by group + beside the counts, with each group's costliest frame. +- TrigScript's `compiler/remasteredCost.ts`: `WEIGHTS` (the table above, as a model: trigger 8.6, Deaths condition + 4.8, Set Deaths 9.7, masked +3.0, a unit's field +7.5, a search +7.3, from one unit to the next +4.7, other actions + 3.1, other conditions counted as Deaths), `RegionMap` over `data/remastered-regions.json` (made from the dump by + scm-sim-reader `remastered/regions.py`: 6 pools, units 1,700 × 336 among them, and 729 regions), and + `RemasteredWeigh`, which keeps the two caches as the emulator does: a Deaths record's address (Current Player + resolved, masks read), a trigger found through either cache, its next pointer read through the read cache before + the next trigger. +- **Checked against the played run** (`scripts/weigh-map.mts` on the probe built with 4-frame phases): at 800 + thousand every mix is within 1.5–7.5% of what Remastered took; eudplib's loop, which no weight was fitted to, + 4% low. The model reads 0.2–0.5 ms low whenever there is work at all, an offset rather than a share. +- `gameTests.ts` sets a `RemasteredWeigh` on the host's tap where it has `weigh`; `CostLines.read` gives + `TestCost.ms` (the script's milliseconds a frame, the rest apart, the peak) and `LineCost.ms`, lines in order of + milliseconds. A host from before this change counts only, and everything shows counts as before. +- The editor: a line's hint is "2.7 ms a frame" from 0.3 ms (2% of 15 ms); the cost row says milliseconds on + average and at the most as a share of the 15 ms, with the counts in its title; the graph is in milliseconds. + `scripts/test-game.mts` prints the same. On Snake: the first frame's 440 thousand records are 6.5 ms, 1.4 ms a + frame on average. +- Not yet in the editor's game: it runs scm-sim through plugin-scm-sim, which needs a build with the new tap. + +### The cheaper lowering (2026-10-18) + +With A4 in milliseconds, each change to the lowering is weighed before and after by **the lowering bench**: +`probes/lowering-bench.ts` runs one kind of statement a line, 100 times a frame, and `scripts/bench-lowering.mts` +builds it, weighs it by line in the lifted game and compares with `probes/lowering-bench.baseline.json` (the lowering +before this slice). `probes/game-lowering.ts` checks every new path in the game and the simulator. + +Nanoseconds a turn, before → after: + +| Line | Before | After | Records a turn, after | +| --- | ---: | ---: | ---: | +| `for (let i = 0; i < n; i++)`, the loop's own | 437 | 181 | 13 | +| `x += 1` | 134 | 18 | 1 | +| `x = y + z` | 177 | 124 | 8 | +| `k = (k + 1) & 15` | 360 | 40 | 2 | +| `x = cells[k]` | 897 | 376 | 27 | +| `x = cells[3]` | 653 | 130 | 8 | +| `cells[k] = y` | 157 | 204 | 15 | +| `cells[5] = y` | 62 | 62 | 4 | +| `if (y < z)`, both signed | 318 | 318 | 22 | +| `x = y * z` | 555 | 555 | 39 | +| one `for (const u of unitsOf(P1))`, a frame | 229 237 | 229 237 | 15 943 | + +- **Adds in place.** `x = x + 1` was lowered as eudplib's `x + 1` (a copy into a temporary, the add) and a copy back: + 9 records and 4 triggers. A store whose value is the variable plus or minus something that only reads, and does not + read the variable, is now eudplib's add where the variable is (it wraps, as numbers do); `x = y + e` is a copy and + an add; `x = x & c` / `x | c` a masked write of the cell; and these nest, so `k = (k + 1) & 15` is an add and a + mask on `k` itself. +- **A loop's counter is never below zero.** The number pass already knew which variables nothing puts below zero; an + add put a variable out of it, as `wave += 1` wraps in the end. A `for` whose test is `i < e` (or `e > i`) and whose + update adds 1 to `i` now keeps `i` in, when its other stores are never below zero and nothing in the loop's body or + in a function stores into it: the update runs only after `i < e` held with that `i`, so `i + 1` is at most + 2 147 483 647. A comparison with one side never below zero is marked as reading it from 0 up, which it reads the + same: `i < n` is `n >= 0` and `n > i` in one trigger, where it was an addition of 2^31 a side. +- **ANDs in one trigger.** `&&` of tests that do nothing was `EUDAnd`, a branch an item; it is one trigger's list of + conditions, which the game ANDs. A negated one is a light bool set by one trigger and cleared by a second. + `relation()` orders two variables with `>=` and `<=` too (eudplib does `<` and `>` only): `b - a`, which the game's + Subtract stops at 0, is 0. +- **Cells of their own.** An array of up to 2 048 cells, and the rows of a per-player program and of a task's slots, + are an `EUDVArray`: a cell is a variable of 72 bytes, which a read jumps to and which writes itself out. A read at a + variable index is a jump by the index's bits; at a constant index two triggers. An `EUDArray` read takes the dword a + bit at a time either way. A write at a variable index works the cell's address out by one trigger an index bit + (`cell_write`; eudplib 0.81's `EUDVArray` has no add, and its own set is dearer). The map of variables says each + row's `stride` (72, or 4), and the readers (`gameVariables.ts`, the debugger, the names in a desync or an EUD + error) follow it. +- **A fault found on the way:** `a[i] -= v` used the game's Subtract, which stops at 0, where numbers wrap: `a[i] -= 5` + of a 3 was 0 in the game and -2 in the simulator (`probes/game-lowering.ts` E and F failed on the lowering before). + It is now the add of the negative, which wraps. + +Not done here, and why: +- **A loop over the units** scans the 1 700 slots of the unit table, about 135 ns a slot, 229 µs a frame whatever the + number of units. A condition on a unit's slot costs about 17 ns (the unit region's own read and the search), and a + trigger cannot run again without a second one to put its next pointer back, so a scan has a floor near 30 µs. The + game's lists of live units make the cost the units' own (eudplib's `f_cunitepdread_epd` follows them cheaply; the + events pass already does), but they give the units in another order than the slots: a choice for the language. +- **The showpieces moved little** (weighed over 900 frames, before and after): Snake 1.73 → 1.69 ms a frame on + average, Tetris 0.35 → 0.33, the raycaster 1.66 → 1.55, the units probe unchanged. Their costliest lines are not + the bench's: Snake's HUD (`ui.lines`, its texts built again every frame) is 1.2 of its 1.7 ms; the raycaster's are + fixed-point products and quotients and signed comparisons of values that can be below zero (`sideX < sideY`). +- `x * y` (555 ns), a signed comparison of two variables neither of which is known never below zero (318 ns), and a + write at a variable index (204 ns, 157 into an `EUDArray`) are next. + +### A view put together only when it changes (2026-10-18) + +The cheaper lowering's showpiece check left Snake at 1.7 ms a frame, 1.2 of it under its `ui.lines` line. `views.ts` +already put a view together again only after a write to what it reads, but it gave up on a view that called a +function of the script's own and put that view together every frame: a call might read the game. Snake's view calls +`thousands()` twice, and a function called twice is kept as one rather than written out where it is called (a view +calling it once was followed, which is what hid this). In the simulator Snake's HUD was shown on 60 of 60 frames. + +- **A call is followed into the function**: what the function reads of the program is what the view reads, its + parameters and its own variables aside; a function that reads the game still makes the view one that does + (`readsOf` with the program's functions, each worked out once, one that calls itself adding nothing while it is + being worked out). +- **Shown again from the text it last put together.** The once-a-second showing, before the game's lines fade, put + the view together again "in case". A followed view changes only when its flag is set, and a view that reads the + game sets its flag every frame anyway, so the showing now prints the buffer as it is; counting the lines to fill + moved into the putting together. In the lifted game the showing costs 6.9 thousand records, the putting together + and showing 24 thousand, for a one-line view of two numbers. +- **Snake: 1.69 → 0.05 ms a frame on average** (900 frames weighed in the lifted game). What is left of it is the + screen's `present()`, about 83 thousand records every move: it walks all the screen's pixels to find the changed + ones. +- `probes/game-ui.tsx` checks the view in the game and the simulator; `tests/ui.test.ts` that a view calling kept + functions is followed, and that one whose function reads the game is not. The lifted game does not tell a line + shown again unchanged from the one before it, so the probe reads the texts in order with repeats taken out. + +Found on the way: a line's cost in A4 takes in the eudplib work that runs after it with no line of its own (its +functions, its variables), so a task that runs every frame collects whatever follows it. `scripts/weigh-map.mts +--by-origin` weighs by each trigger's own origin instead, eudplib's functions among them, which is what showed that +the per-frame work was number-to-text and text functions. + +### present() and arrays filled (2026-10-18) + +What was left of Snake after the view was the screen's `present()`, which walked every pixel to find the changed ones +(about 83 thousand records a move). Drawing now lists each pixel it changes, with its place, through one private +`put()`; `present()` goes over the list and passes over a pixel drawn and drawn back, and falls back to every pixel +when more was drawn than the list holds (2 048). `new Array(n).fill(v)` on an array of cells wrote a cell at a time, +each working its address out a bit a trigger (~250 ns); `varray_fill` writes eight a trigger at addresses that move +on by themselves (~23 ns). Snake: 0.05 → 0.03 ms a frame, its first frame 7.58 → 4.61 ms. + +### Fixed numbers, shifts, products by a constant, signed comparisons (2026-10-18) + +Nanoseconds a turn on the bench, before → after: + +| Line | Before | After | +| --- | ---: | ---: | +| `fr = fa * fb` (fixed) | 5 801 | 4 698 | +| `fr = fa / fb` | 8 976 | 7 384 | +| `Math.sqrt(fa)` | 15 218 | 10 130 | +| `fa * 3` | 1 697 | 569 | +| `Math.floor(fa)` | 518 | 252 | +| `z >> 2` | 695 | 308 | +| `y << 5` | 505 | 462 | +| `y < z`, both signed | 318 | 243 | +| `fa < fb` | 272 | 207 | +| `y * 12` (y is 3) | 555 | 597 | + +- **Signed comparison of two variables**: `a < b` read from 0 up, turned over once for each side below zero, counted + into a variable whose low bit is the answer (`signed_order`); no addition of 2^31 a side. +- **Shifts by a constant** (`shift_const`): a bit tested a trigger, its place in the result added; from the top, a + group of eight bits none of which is set is jumped over; a short shift left is doublings. +- **A product by a constant**: eudplib's product goes over its first operand's bits and stops past the highest set, + so the smaller of the two goes first, the constant by its distance from 0, the result negated after. It costs a + comparison more when the variable was already the smaller (`y * 12` above), and saves most when it is large or + below zero (`fa * 3`). +- **fmul**: the 16-bit halves are a masked copy and a shift, not divisions by 65 536; the products have their + smaller side first. +- **fdiv** (`chain_fdiv`), for a divisor below 256.0: eudplib's division chain set up once and run for the whole part + and twice more for eight bits of the fraction each; the same digits as the sixteen steps it replaces, which stay for + larger divisors. +- **sqrt**: the input's bits read where they are instead of doubling it twice a step, and the fixed root kept as four + times itself so a step's trial is one comparison. +- `probes/game-math.ts` checks every one of these over values the game gives (below zero, tiny, past the fixed range, + divisors on both sides of 256, equal values) against the simulator's answers, in the lifted game. + +The raycaster: 1.55 → 1.30 ms a frame on average, its costliest frame 3.09 → 2.58 ms (1.66 and 3.31 before the +cheaper lowering). A fixed product and quotient are still several microseconds: eudplib's own product and division +are the floor under them, and the raycaster does a few of each a column. + +### A loop over units costs what the map holds (2026-10-18) + +A loop over units, and a pick, walked the 1 700 places of the unit table every time: about 135 ns a place, 229 µs a +loop whatever the map held. Two ways to make it the units' cost were tried. + +- **The players' lists**, built first: each player's list holds every unit of theirs from its making to its kill + (those in transports, buildings and training, and subunits — what the table's walk found), latest first. In the game + it ordered the units as the simulator could, but a step along a list reads a pointer, which eudplib does a bit at a + time (`f_cunitepdread_epd`, about 82 records), and the step came to about 175 records a unit: better than the table + only under about 100 units. Dropped. +- **The table, walked as the game hands its places out, stopping once every unit in use has been met** — as built. + The game takes slot 0 first, then 1699 downwards, and a freed place goes in behind the next one handed out + (scm-sim docs/specs/unit-lists.md, section 3), so the places in use lie at the top. The walk is slot 0, then 1699 + down, counting each place with a sprite against the count of records in use (0x6283F0), and stops at it. In a + 12 000-frame game of four in the lifted game, there were never more places with a sprite than records in use, and + on one frame one fewer (a record in use whose sprite had gone): then the walk goes to the end, slower and still + right. The order is the units oldest first, until a place freed is handed out again. +- **The simulator** hands places out the game's way (`World.free`), and its loops go oldest first (`World.living`): the + game's order as long as no freed place has been handed out again. When the game frees a record, some frames after + its unit is gone, is not something the simulator knows; a test that depends on the order of units made after + others died can find the game's different. (The table's walk from slot 0 up differed from the simulator even + without that.) +- **Cost**: the bench's loop 229 → 29 µs a frame; a loop over all 182 units of a map 34 µs, over 238 units 48 µs — + about 0.2 µs a unit, the old cost only with the table nearly full. The units probe's costliest frame 1.48 → 1.08 ms. +- `probes/game-unit-order.ts` checks the order, a unit given away, a unit killed in the loop (its own and the next) + and a unit made in it, in the game and the simulator. The guide, the README, the IR reference and the editor's + *scans units* note say the order and the cost. + +### What is next: arrays at their cost, cost in the language, the explorer (2026-10-18) + +After the five cost slices the showpieces are well inside a frame: in the lifted game, weighed for Remastered, Snake +costs 0.04 ms a frame (4.5 ms on its first, the screen cleared and shown whole), Tetris 0.34 ms (8.4 ms on its first, +the view put together from nothing), the raycaster 1.30 ms (2.6 at the most). What a map that is heavy *because of +its data* pays was not yet weighed, so `probes/bullets-bench.ts` was written: 200 bullets in an array that grows, +each a record of five numbers, moved, bounced and aged every frame, nothing drawn. + +| Line (a bullet) | ns | Records | +| --- | ---: | ---: | +| `b.x += b.dx` | 2 730 | 190 | +| `if (b.x >= 4000) b.dx = -3` | 1 185 | 83 | +| `if (b.life > 0) { b.life--; alive++; }` | 2 830 | 197 | + +About 12 µs a bullet a frame, 2.4 ms for 200: the bookkeeping alone, before a bullet is drawn. The bench says why +(`probes/lowering-bench.ts` gained the lines): + +| Line | ns | Records | +| --- | ---: | ---: | +| `x = cells[k]`, an array of a fixed length (cells of their own, #24) | 376 | 27 | +| `x = list[k]`, an array that grows | 1 128 | 78 | +| `list[k] = y` | 450 | 31 | +| `x = pts[j].x`, a field of an instance in an array | 340 | 24 | +| `for (const v of list) x += v`, a turn | 1 417 | 98 | + +An array that grows (`const bullets: Bullet[] = []` and `push`, the way TypeScript is written) lives on the heap, +four bytes a cell, which the game can only read a bit at a time: 32 triggers a read, and a bounds check (a comparison +of two variables, 10 triggers) before each. A record in such an array is a growing array a field, so a bullet's line +reads two or three of them. Cells of their own (an EUDVArray, 72 bytes each, a variable the game jumps to) are read +by a jump worked out a bit of the index at a time, as many triggers as the index has bits, and written the same way. + +**So the slice is, in order:** +1. **An index worked out once.** A cell's place is 72 × the index bytes past the first; a read or a write works that + product out a bit at a time, for every access. A variable used as an index whose every assignment is a constant + or a constant added (a loop's counter, `for…of`'s own) can carry the product beside it, kept by the same additions + (`i++` is `off += 72`), and every read and write at it becomes the array's place plus the product: a few triggers + whatever the length, and the same product serves every column of a record and every array the loop reads. +2. **Arrays that grow, in cells of their own.** A second heap for growing arrays of numbers (and the columns of + records), its cells EUDVArray cells; texts stay on the heap of bytes, which their copies need. With 1, a read at a + loop's index costs what a fixed array's does. Its size is a script setting beside the heap's, and each cell costs + the map 72 bytes, so the setting says it. +3. **No bounds check at a `for…of`'s own index**, when the loop's body neither shortens the array nor calls what + might: the loop has just compared the index with the length. +4. **`over(list, { per })`** in `trigscript/timers`: `for await (const b of over(bullets, { per: 50 }))`, a loop + spread over frames, `per` elements a frame; `spread(n, { per })` the same over 0 … n − 1. Written as an async + generator it compiles today; the library gives it a name, and A4's hint on a costly loop says the `per` that keeps + it under a millisecond, from what a turn cost in the run. + +**Weighed and set aside:** +- *Fields packed into one cell* (`bits`). A field of an instance in an array is 340 ns to read; taking a field + out of a packed cell is a shift right, about 300 ns for a byte, so two packed fields cost what two cells do. `u8` + and `u16` already narrow what a store keeps. Only adding to several fields at once (four counters in a cell, one + add) gains, and nothing written so far wants it. +- *A whole frame's start worked out when built*: Snake's and Tetris's first frames are the screen and the view + made from nothing, inside the budget; the view's static parts could be made when built, later. + +**After this slice, offered:** fast cells (a probe of Set Resources and Set Score for the neutral players against Set +Deaths, which Remastered sends through the emulator's region lookup); a division whose result is small worked out +as comparisons against thresholds computed when built (the raycaster's `Math.floor(HEIGHT / distance)`, 7 µs a +column, would be seven comparisons); the compiler explorer (the script beside its triggers, nanoseconds a line, from +the trigger map and A4's weights) and quick fixes from the cost hint; budgets in tests (`expect(frame).toCostUnder(ms(2))`); +the desync fuzzer, which waited for B5; per-unit behaviours over B2 and B9; then the language list above +(`g.next()`, `using`, texts made in the game) and the 4.0 release. + +**As built (2026-10-18).** Steps 1–4; no IR change (still 24). Measured with the bench and `probes/bullets-bench.ts` +before and after, in the lifted game, weighed for Remastered. + +- *Arrays that grow, in cells of their own* (`list_heap` in `python/trigscript.py`). Blocks are handed out exactly as + before — the same allocator, the same count — so a text and an array still run out at the same push in the game and + in the simulator, and the heap setting means what it did. What changed is where an array's cells are: an EUDVArray as + long as the heap, made only when some array grows, beside the bytes texts keep. A block moving on a push is copied a + cell at a time, both blocks walked by address (about 7 triggers a cell, where reading and writing at an index was 40). + Costs: 72 bytes a cell while the map is played (1.2 MB at the default 16 384 cells; the Settings line says so), about + 22 KB of the saved file, which compresses the cells away. Past 65 536 heap cells (`CELL_HEAP_MOST`; the setting goes to + a million) that would be too much memory, and arrays keep the heap's dwords as in 3.x: the probes pass both ways. +- *An index worked out once* (`Place`, `find_counters`). A number variable whose every store is a constant or itself + plus or minus a constant, named nowhere else but in reads (not a call's result, not a function's saved frame, not a + loop's own variable), and which indexes an array of cells, keeps two offsets beside it (72 bytes and 18 dwords a + step) by the same stores: `i++` is one trigger with two adds more. A read at it is a jump to the array's first cell + plus the offset (the offset's variable adds itself into its own next pointer); a write a Set Deaths whose + destination is the first cell's plus the offset. A growing array keeps where its block starts, again when it moves. + Per-player programs' variables are rows, and are left as they were. +- *No bounds check at a loop's own index* (`unchecked_loop`). For `for (…; i < xs.length; …)` at a counter, over a + growing array, whose body has nothing that changes a length, calls, or waits (`lengths_fixed_in`), and stores nothing + into `i`: a read or write of `xs[i]` is unchecked — the condition has just compared — and every other growing array + the body reads or writes at `i` is checked once on the way in, its length against `xs`'s, into a flag the access + tests instead of comparing two variables (a shorter array goes the checked way, so `b[i]` past `b`'s end still reads + 0 and a store at its length still pushes). A store at the index into the loop's own array cannot be a push there. +- *A push with room* is a write at where the next one goes, kept beside the block, and two adds; a push with none calls + one function of the array's own (the heap's grow, the handle put back, the next place worked out again). Inlined + whole at each push, a script that pushes in a loop the compiler unrolls came out 1.9 MB bigger; as a function, 0.3. +- *`over` and `spread`* in `trigscript/timers`, as async generators: the elements are the list's own, the length read + as it goes, the next frame only between elements. The cost hint does not yet say a `per`: that wants a loop's turns a + frame, and the cost tap counts by line. + +| Line | Before | After | +| --- | ---: | ---: | +| `x = list[k]`, any index | 1 128 ns | 749 | +| `list[k] = y`, any index | 450 | 639 (the place worked out a bit at a time over 14 bits, where the heap's dword was 2 triggers) | +| a turn of `for (const v of list) x += v` | 1 417 | 564 | +| a turn of `for (let i = 0; i < m; i++) list[i] = y` (not the list's length: checked) | 829 | 625 | +| a turn of `for (let i = 0; i < m; i++) x += cells[i]`, 16 cells | 566 | 462 | +| a turn of `for (let i = 0; i < m; i++) cells[i] = y`, 16 cells | 394 | 404 (four bits of index were cheaper than the place's two variables; at 2 048 cells it is eleven) | +| bullets: `b.x += b.dx` | 2 730 | 410 | +| bullets: `if (b.life > 0) { b.life--; alive++; }` | 2 830 | 530 | +| bullets: 200 a frame, the loop | 2.38 ms | 0.44 ms | +| bullets: the frame that makes them (1 000 pushes) | 2.93 ms | 1.09 ms | +| bullets: the costliest frame | 6.60 ms | 3.26 ms | + +`probes/game-arrays-cost.ts` (7 tests) checks every path in the game and the simulator: pushes past several block +sizes, other arrays read at a loop's index shorter and longer, loops that push and pop, `+=` in place, records in a +growing array, a counter counting down, an array declared again on each turn of a loop, shortened, and booleans; +`probes/game-stdlib-core.ts` gained `over` and `spread`. + +**Left:** a write at an index that is not a counter (above); a counter in a per-player program; the hint's `per`. + +### A division whose result is small, as comparisons (2026-10-18) + +`Math.min(H, Math.floor(C / x))` — the raycaster's wall height, a bar's length from a distance — divides by a +variable, which the game does a bit at a time: 7.2 µs for a fixed quotient, 7.3 for a whole one. For `x` above zero +the quotient only falls as `x` grows and the `min` caps it, so the result is the largest `h` of 0 … H with +`x ≤ T(h)`: a tree of comparisons with constants, as deep as H has bits. `compiler/thresholds.ts`, called from the +final pass of `numbers.ts` on a `min` of a constant (1 … 256) and `fdiv(C, x) >> 16` (Math.floor), `(fdiv(C, x) + c) >> 16` +(Math.round, Math.ceil) or a whole `C / x`, `C` above zero and `x` a variable. No IR change: the tree is `ternary` and +`compare`. + +- *Exact.* The thresholds are found with `fixedMath.ts` — the arithmetic the game does, which the simulator shares — + and each is checked on both sides when built. Near zero `C × 65 536 / x` passes 31 bits and the game's quotient + wraps (78 / 0.0023 is −31 683 in the game), so below the first `x` where it cannot (156 raw for 78), and at or below + zero, the division is kept, behind one signed comparison. Past it, the tree's comparisons read `x` from 0 up: one + condition each. +- *Cost* (the bench): `Math.min(78, Math.floor(78 / fa))` 7 213 → 805 ns; `Math.min(40, 1000 / y)` 7 335 → 786. The + raycaster 1.30 → 1.15 ms a frame (peak 2.58 → 2.27), its height line out of the top ten. +- *Checked:* `tests/thresholds.test.ts` holds six shapes' trees against the division at every threshold's two sides, + around zero and the wrap, and 20 000 divisors at random; `probes/game-thresholds.ts` prints each beside the division + worked out without a `min` (`q < H ? q : H`), equal in the game and the simulator; the raycaster's tests pass on its + board (they need the 128-wide map, `fixtures/eud/raycaster-base.scx`: on test-game's default 96-wide map the last ten + columns are off the map, which once looked like a fault). + +### Fast cells, the probe (2026-10-18, played the same day) + +`probes/fast-cells.py` + `.ts`: the weights probe's schedule and marks (so `profile.py` and `profile_report.py` read it +unchanged) with its own mixes — the yardsticks again (`loop` first, act64, act1, cond16, switch64), then Set Resources +(ore, gas, a number set), Set Score (custom) on Player 9–12, Accumulate and Score conditions on them, Switch +conditions, and Player 1's own custom score (action and condition). Before the schedule it sets the cells and reads +them back by condition and through memory, on the screen. Map and answer sheet in `Maps/TrigScript/trigscript-fast-cells*`; +the lifted count of a PHASE=4 build in `fixtures/eud/fast-cells-short-cost.json`, the table beside it. + +**The lifted game already says something:** 1.16.1 ignores Set Resources and Set Score for Player 9–12 (every value +reads back 0, by condition and in memory), as its Create Unit refuses those players; Player 1's and Player 8's custom +score read back right. Remastered allows Create Unit for them, so the played run decides; the fallback is the players' +own custom score, which nothing shows without a leaderboard. + +**What it would give the language** (if they are as cheap as a switch, 3.2 ns against Set Deaths' 9.7): a counter or a +flag kept in such a cell is set, added to and compared with a constant without the emulator; a copy into one is a Set +Resources whose number a variable writes (the copy itself is a Set Deaths into the action). There is no copy out +short of reading it a bit at a time, so only what is compared with constants belongs there: loop counters against a +constant bound, states, countdowns. + +**Played 2026-10-18** (single player, Fastest, the 7900X; `cells1/`; `profile_report.py` against the PHASE=4 lifted +count). No phase slowed: every one held 42 ms a frame at 800 thousand records. Nanoseconds a record: + +| Mix | ns | Yardstick | +| --- | ---: | --- | +| Set Resources, ore / gas of P9–12, add | 4.19 / 3.68 | Set Deaths (act64) 9.47 | +| Set Resources, ore set to a number | 3.69 | | +| Set Score, custom, P9–12 / Player 1 | 3.84 / 4.16 | | +| Accumulate / Score (custom), P9–12 | 3.81 / 3.74 | Deaths condition (cond16) 6.20 | +| Score, Player 1's custom | 3.69 | | +| Switch condition / Set Switch | 3.01 / 3.31 | | + +Derived again from act1 and act64: a trigger 8.27 ns (8.6 before), Set Deaths 9.34 (9.7), a Deaths condition 5.69 +(4.8). So the tables outside the emulator are about 2.5 times cheaper an action and 1.6 times a condition — and the +neutral players' cost the same as Player 1's, whose custom score certainly holds (whether Remastered keeps the +neutral players' values was the screen's line, not in the profile). + +**Set aside.** A trigger costs 8.6 ns before its records, and the lowering's statements are triggers: a counter's +`i++` kept in a custom score is 12.4 ns where it is 18.3, its comparison with a constant 3.7 where it is 4.8 — about 7 +ns of a 181 ns loop turn, 4%, for at most twelve cells (eight if Remastered too refuses Players 9–12). Not worth a +second kind of variable. What the run did give: A4's weights for actions and conditions outside the emulator, which +were Set Switch's and a guess — `otherAction` 3.8, `otherCondition` 3.6 (was 4.8, a Deaths condition's) — so a +script's `trigger()` records and the classic backend's resources, scores and switches weigh what they cost. + +### The limits, and a backend of our own (2026-10-19) + +After the cost slices, the question was whether there is more to have, and whether eudplib is where it stops. What +cannot move, whatever writes the triggers: + +- **Every Deaths and Set Deaths of an EUD map goes through the emulator** (above): a record costs 3–6 ns at the least, + a Set Deaths 9.4. The game's own 5.4 ms a frame is beyond reach. +- **A trigger decides one bit.** It runs or it does not; so taking an n-bit value out of memory into a variable takes + at least n triggers, and a 32-bit read is about 0.4 µs however it is written. +- **No code of our own runs.** The emulator lets a map at the regions it lists and no others, and the game's code is + there only as copies that ignore writes. Getting past that would be an exploit of the game, which this plan does + not do. + +Where the lowering stands against that floor (Remastered's weights, the bench): + +| Statement | Now | Floor, roughly | +| --- | ---: | ---: | +| `x += 1` | 18 ns | 15 | +| a loop's turn | 181 ns | eudplib's 190: the same | +| `x = cells[k]` | 376 ns | 200 | +| `list[k] = y`, any index | 639 ns | 200 | +| `fmul` | 4.7 µs | 0.5–1 | +| `fdiv` / `sqrt_fixed` | 7.4 / 10.1 µs | about 1 | + +The simple statements are at the floor; what is far from it is everything that takes a number apart a bit at a time — +products, quotients, shifts, a read of memory, an index worked out from a variable — and everything that pays a trigger +for each action because the code is made of small triggers. eudplib is a library of macros written when 1.16.1's +measure was the number of records: each operation is emitted alone, with no view of the next, no idea how wide a value +is, and a jump as an empty trigger. Remastered charges a trigger 8.3 ns of its own, as much as the action in it. + +**The order:** + +1. **Two probes, cheap, that could change the rest.** + - *Where Remastered decides a map is an EUD map* (the flag at +0x105423C), read in Ghidra, and the weights probe's + yardsticks built as a map without EUD: if a map that is not EUD runs its triggers much cheaper, the classic backend + (scripts that never touch memory) gains from staying classic, and a script could be told so. + - *What Bring and Command cost* a unit in the location, in an EUD map: the game's own loop over units, one + condition. If it is cheap per unit, a library can ask "is any bullet within this box" natively instead of in + arithmetic. +2. **Value ranges.** The compiler works out the range of every number variable and expression it can (constants, + loop counters, masks, `u8`/`u16`, comparisons that guard a branch, `Math.min`/`max`, `%` by a constant, what a + field of the game holds), and the lowering takes only the bits the range has: a read, a product, a quotient, a shift, + an index worked out from a variable. Nothing in the language changes; the bench says what it gave. +3. **A backend of our own, part 1.** Below eudplib's macros, not beside them: a trigger-level IR (triggers, conditions, + actions, labels, values written into other records) that the lowering emits into and that is then scheduled and laid + out before eudplib is handed the records: + - *packing* independent actions of straight-line code into one trigger (up to 64 share one walk); + - *laying code out* so the common path falls through rather than jumping through an empty trigger, and conditions + sit in the trigger whose actions they guard; + - *choosing* between lowerings of one statement by Remastered's weights, the same model A4 shows. + Every change is held against the lifted game and the simulator, as the lowering's slices were: the same tests both + ways, the bench before and after. The later parts — values kept in the actions that use them, a calling convention + of our own, profile-guided layout from A4's runs, and eudplib's loader and layout replaced — wait for what part 1 + measures. + +Not on the list: anything that runs outside the trigger walk, and threads (the game has one for triggers). + +**The two probes, built and played (2026-10-19).** Read in Ghidra first (1.23.10.13515): +- *A map is an EUD map when it loads with one.* +0x358C0A goes over every trigger of the map (0x960 bytes apart) and + +0x2E17E0 sets bit 1 of +0x105423C for a Deaths condition, bit 2 for a Set Deaths action, whose player is above 26 or + is 12, or whose unit is above 232. Nothing sets it later. Without it the walk (+0x41B350) follows the game's own list + of triggers, and Set Deaths (+0x288240) writes the deaths table straight — refusing a player above 8 or a unit above + 227 — so a map that is not EUD cannot reach past the deaths table at all. There is no mixing the two in one map. +- *Bring and Command never touch the emulator.* Both read a table of counts, a cell for each player and unit type (0x540 + bytes): Command copies the game's own counts into it (+0x28AF60); Bring counts the units in its location (+0x28BBD0). + The table is kept while the queries stay of one kind and, for Bring, of one location number: it is thrown away at the + start of every trigger pass (+0x41C007) and by the actions that kill or remove units (+0x28A960). **Writing a location's + sides does not throw it away**: a Bring of a location moved that way since the last Bring of it reads the old counts + (played: `probes/location-stale.py`, below). A library that moves a location to ask about a place must ask something + else in between. +- `probes/classic-weights.mts` (no eudplib: the editor's own code writes 5,017 triggers) — blocks turned on by phase: + 16 conditions a trigger with the last failing, the same with 62 Set Deaths after, 62 Set Switch; marks as Player 8's + deaths (a map that is not EUD writes none of Players 9–12), `remastered/profile.py --marks-player=8`. A pass comes + every other frame (hyper triggers), as the lifted game shows. +- `probes/native-queries.py` (eudplib, the weights probe's schedule): Bring of one location (one scan a frame, the rest + lookups), Command, Bring alternating two locations with 0, 16 and about 64 marines in them (a scan each time), + Bring alternating with Command, and a location's four sides written and Bring of it, two in turn. 160 marines in + boxes around Home; the lifted game finds 16, 16, 67 and 71 (the two big boxes take a few of each other's). + +**Played (2026-10-19), Remastered 1.23.10.13515, the same computer as the weights.** +- *classic-weights* (`remastered/classic_report.py`, and the samples under the walk counted by phase): the game thread's + own work moves by about 1 ms a pass from one idle phase to the next, as much as the probe's biggest block, so the + fit is loose. The samples under the walk put a Set Deaths at about 5–7 ns (an EUD map's: 8.4 in the same session) and + a Deaths condition at 7–10 ns, no cheaper than an EUD map's 5.4. A map that is not EUD saves perhaps a third on its + actions and nothing on its conditions, and gives up every loop and every address: **nothing to win there.** Settled. +- *native-queries* (`profile_report.py … --short=4`), ns each: + + | Query | ns | + | --- | ---: | + | Bring of one location, asked again | 4.6 | + | Command, asked again | 4.3 | + | Deaths condition (the same run) | 5.4 | + | Bring, a new location, nothing in it | 191 | + | Bring, a new location, 16 marines | 1,582 | + | Bring, a new location, about 64 marines | 2,450 | + | Bring and Command in turn, one location (half of them a scan) | 808 | + + A query asked again is cheaper than a Deaths condition; a new location is a scan, 0.2–2.5 µs by what is in it, and + turning from Bring to Command (or back) throws the table away too, as Ghidra said. For a library: ask one location + many times in a row, all of its Commands together and all of its Brings together, and never alternate. The moving + pair (four sides written, then Bring, two locations in turn) is a scan each time, 1.6 µs a Bring, as expected. + Remastered's own counts match the lifted game's exactly (Home at 128 2512; same 16, empty 0 0, 16s 16 16, 64s 67 71, + moving 16 16), so each row measured the boxes it says. (The moving pair is placed before the marines exist, so + this line does not test staleness; the next probe does.) +- *location-stale* (`probes/location-stale.py`, no profiler: nine lines on the screen), Remastered the same as the + lifted 1.16.1 game, line for line. In one pass, after a Bring of location L: + - L's sides written (A to B, and B to A): the next Bring of L reads **the old counts**. + - A unit made in L by Create Unit: **the old counts** too (the next frame sees it). + - The Move Location action: fresh (not read in Ghidra; likely its search for the unit to centre on). + - A Bring of another location, a Command, or a Remove Unit action in between: fresh. So does the next frame. + For the backend: a library that writes a location's sides (every `at(x, y)` query) and then asks Bring must put a + query of another location, or a Command, in between when L was asked earlier in the same pass and the answer must be + new. The cheapest is a Command (about 30 ns, from the Bring-and-Command row; the next Bring of L is a scan either way). The same after + making units in a location that is asked again. + **TrigScript had this bug** (`probes/game-location-stale.ts`: `countUnits(P1, marine, spot)` after `centerLocation(spot, + …)` printed `4 4 4 4,4,4,4,` in the lifted game where `4 0 4 4,0,4,0,` is right; the simulator, which keeps no such + table, had it right, so the two disagreed). Fixed in the lowering: `centerLocation()`, `unit.locate()`, `unit.give()` + and the actions Create Unit (with Properties), Move Unit and Give Units are followed by a Command (`forget_counts`). + +### What Remastered offers past eudplib + +Read in Ghidra (1.23.10.13515, 2026-10-19): the condition and action tables (+0xDAFB50, +0xDAF970), every handler, +the walk (+0x41B350) and the emulator's entry points. What eudplib does not use, by what it would give the backend: +1. **Registers the game keeps natively.** Set Deaths (+0x285410 → set +0x288240, add +0x2868F0, subtract +0x288E30) + and the Deaths condition (+0x28A2C0 → +0x28AB30) take three unit columns apart *before* the EUD emulator: 230, 231 + and 232 (Men, Buildings, Factories) of Players 1–8 are tables in the game state (+0x2D84, +0x2ED4, +0x2FF4), + written and read with no region found, no virtual call — 24 dwords. 229 (Any unit) reads Men plus Buildings: a + sum in one condition; it is never written. Set Switch / Switch (+0x285600 / +0x28A1A0: bit operations on +0xEB50, + 256 bits) and Set Resources / Accumulate (+0x2859F0 / +0x289D50: ore and gas of Players 1–8, an add wrapping; + "ore and gas" reads their sum) are native too. Everything else in an EUD map, the deaths table included, goes + through the emulator (+0x2E18F0, +0x2E1450). For the emitter: a home (a dword conditions read as memory) can be + one of these — a loop counter's add and its test both native. Not a place an action reads its number from (those + are fields of our own triggers). Caveats: a player's Men, Buildings and Factories count their units' deaths — use + a player that owns none (Player 8 in the probe); resources show in that player's own top bar; switches are the + map's. The weights probe put Set Switch at 3.2 ns against Set Deaths' 9.7: the primitives probe weighs the rest. +2. **The walk passes a trigger marked done without looking at it.** A trigger whose flags (2376 bytes in) have bit 3 + set is skipped before its conditions (the flag a trigger without Preserve gets after it ran); bit 0 (conditions + already held) makes it run its actions without testing them. A branch the emitter knows to be dead, or a block + switched off and on by writing one dword, costs the walk alone. +3. **A mask costs nothing.** A masked Deaths / Set Deaths ("SC" at bytes 18–19 of the condition, 30–31 of the + action, the mask where the location is) is the emulator's same path and an AND (+0x2E1890 sets it up per action). + eudplib uses it already; the backend can test or set any bits of any dword in one record. +4. **A player group is a loop in one record.** All Players (17) runs the handler for each player in the game among + 1–8 and then always for 9–12 (+0x28A680); a force (18–21) for its members; Allies, Foes, Neutral Players (22–25) + do nothing here. One Set Deaths writes each of them (consecutive dwords, 4 bytes apart); one Deaths condition + compares their *sum*. Which players are in the game is the game's, so only 9–12 are certain: a write of four + dwords in one action, a sum of four in one condition. Current Player (13) is the game's own variable + (+0x10B0D34), eudplib's address register. +5. **What is not there.** No action copies a dword from one address to another, or writes more than one value: every + way a value moves is eudplib's (a cell routed) or the emitter's (written where it is used). The emulator's + subtract stops at 0 (+0x2E7A20), the native adds wrap. An unaligned write is two reads and two writes; an EPD's + never is. The walk finds every trigger's region by address (+0x2E1940, a cache, then a tree): a program whose + triggers sit in one region (the payload) stays in the cache. + +`probes/primitives.py` (eudplib, the weights probe's schedule; `python3 probes/primitives.py --json` for the table) +weighs each against the EUD record it would replace — native Set Deaths, Set Switch, Set Resources, a masked Set +Deaths, All Players' Set Deaths; native Deaths, Any unit, Switch, Accumulate, a masked Deaths; the walk over empty +triggers and over done ones — and says on its first second what they do (the lifted game: `Native: men 1 wrap 1 sub +1 any 1 ore 1 all 3 sum 2 mask 1 done 0` — All Players reached Players 1 and 2, the two in the game). The lifted +game's counts: `primitives-short.scx` (PHASE 4) with `exact-eud.ts --cost`; played with +`python profile.py StarCraft.exe prims1`, read with `profile_report.py --short=4` (the walk's +two mixes by their triggers a frame, which the cost's `triggers` has). To be played. + +**Played (2026-10-19), Remastered 1.23.10.13515, `profile.py … prims1`.** The `Native:` line is the lifted game's +(`men 1 wrap 1 sub 1 any 1 ore 1 all 3 sum 2 mask 1 done 0`). Nanoseconds a record (`profile_report.py --short=4`): + +| Record | ns | Against | +| --- | ---: | --- | +| Set Deaths, EUD (eudplib's) | 7.8 | | +| Set Deaths, Men / Buildings / Factories | 16.0 | twice the EUD one: the native path checks the game state | +| Set Switch | 4.4 | | +| **Set Resources** | **1.2** | a sixth of an EUD Set Deaths | +| Set Deaths, masked | 11.0 | a mask costs a write about 40% | +| Set Deaths of All Players | 35.4 | about 6 a dword written | +| Deaths, EUD | 5.2 | | +| Deaths, Men / Buildings / Factories | 3.6 | | +| Switch | 3.5 | | +| **Accumulate** | **0.85** | a sixth of an EUD Deaths | +| Deaths, masked | 4.8 | a mask costs a condition nothing | + +The walk past a trigger whose "done" flag is set is about 43% of the walk past an empty one. So the native registers +worth having are **resources**, not the death columns the reading pointed to: a counter kept as a player's ore or gas +costs a sixth of an EUD cell to add to and to test. Only players not in the game can lend theirs (the top bar shows a +player's own), and adds wrap where the game's subtract stops at 0 — the emitter's places add negations anyway. +(`profile.py` stops itself now on this probe's magic too: it knew only the weights' before, and was stopped by hand.) + +### Value ranges and the backend, as built + +**As built: value ranges (2026-10-19).** `compiler/ranges.ts`, run after `numbers.ts`; IR 25 (`range` on a number). +- The range of every number variable is the union of what is stored into it, and 0 (a cell before its first store); + facts from tests (`if (i < n)`, a loop's own test) hold until the variable is stored into, and are dropped at a wait, + a call, a statement holding a call, the top of a loop for what it stores. A loop's counter whose only stores are its + declaration and the update keeps its test's fact through waits and calls in the body (not in a task, whose slots + share the code, nor a per-player program's shared variable). A variable something stores into behind the program's + back — a task's handle, the error in flight, a loop's `was` — is any number. Ranges that still grow after eight + passes go to their end on that side, then narrowing passes bring them back (`k = (k + 1) & 15` creeps one a pass). +- Arrays take a range only when nothing else reaches their cells: tables known when built, `u8` / `u16`, fixed arrays + that are no window, row, text row or heap array. +- In the lowering (`python/trigscript.py`): a product by a constant of a side known 0 … top is a trigger a bit of top + (`mul_bits`, where it beats eudplib's by the weights); a quotient or remainder by a constant (`div_bits`) or by a + variable (`div_var_bits`: eudplib's chain, as many steps as the dividend has bits, started as soon as the next + doubling would pass the dividend's top) when both sides are never below zero, and no zero check when the divisor's + range says so; a shift moves only the bits a value has, and needs no group jumps under eight of them; eudplib's + product takes the side with fewer bits first; `min` / `max` / `abs` one side of which always wins are that side. +- The pass also reads comparisons whose sides are never below zero as unsigned, and turns one the ranges settle into a + constant when its sides do nothing but give a value, and wherever it is met. +- Checked: `tests/ranges.test.ts` (the shapes, and every game probe simulated with and without ranges: the same lines + and faults — it found a task handle the pass had kept as 0), `probes/game-ranges.ts` 5/5 in the lifted game and the + simulator, every game probe as before (game-debug's one failure is meant), vitest 1123. + +| Line (values known 0 … 1023 and 0 … 63, not their values) | Before | After | +| --- | ---: | ---: | +| `sv * 12` | 721 ns | 243 | +| `sv * tv` | 1 080 | 700 | +| `sv / 3` | 1 094 | 302 | +| `sv % 10` | 1 067 | 275 | +| `1000 / (tv + 1)` | 7 026 | 1 751 | +| `sv / (tv + 1)` | 7 011 | 1 692 | +| `sv >> 2` | 327 | 217 | + +The showpieces hardly move (raycaster 1.15 → 1.13 ms, snake's first frame 4.41 → 4.33, Tetris and the bullets the same): +their time is not in products and quotients of bounded values. Where it is, `scripts/trigger-shapes.mts` says: + +| A frame of | ms | the walk alone | routing | data actions | failed bit tests | +| --- | ---: | ---: | ---: | ---: | ---: | +| bullets (200, moved and aged) | 0.46 | 30% | **51%** (next pointers 0.10, a cell's destination 0.07, its modifier 0.06) | 16% | 2% | +| raycaster | 1.37 | 36% | **35%** | 21% | 18% | + +*Routing* is eudplib's way of reading a variable: a variable is a trigger of one action whose amount is its value, and a +read sets that action's destination and modifier and the trigger's next pointer — three Set Deaths — before the walk +goes through it. `x = y + z` is four triggers, 124 ns, of which the reading is two setups of three actions each. That is +the part a backend of our own takes first (below). + +**As built: the backend, part 1 — routing decided once the program is whole (2026-10-19).** In `python/trigscript.py` +(*Static routing*), between eudplib's macros and its allocator; `staticRouting` "0" turns it off (`STATIC_ROUTING=0` in +build-fixture), `routingReport` says why each read inside a loop keeps its routing (`ROUTING_REPORT=1`). +- Every copy `SeqCompute` makes of a variable this file made (and everything eudplib does through it — `<<`, `+=`, a call's + arguments; every eudplib module that took `SeqCompute` by name is pointed at the new one) is lowered as a *site*: what + came before jumps to a Forward; behind it the setup trigger (the constant stores before it, then the routing of each + cell, chained) and a bare way in (the constants alone). Everything else that asks a cell for a routing field — its + vtable, its destination, its mask, its modifier — is recorded with where it was asked, and so is every request for + its value's address that is not a condition's (a write). The array helpers that routed a value by hand (`cell_write`, + `varray_write`) go through `SeqCompute` too. +- Code is lowered in *regions*: the loops of the IR, and code that runs apart — a coroutine (a program's body, a task, a + branch), a function's body, a scope apart. A loop records whether it waits, the functions it calls, and whether it + calls one whose body cannot be followed (a function that calls itself). +- At the end of `afterTriggerExec` each group goes in bare when every cell in it is one of: read at that one site and + touched nowhere else — its routing written into eudplib's variable buffer as the map is built (`EUDVarBuffer.WritePayload` + patched: next pointer at 72·i, destination at 340 + 72·i, modifier byte at 351 + 72·i); the only read inside some loop + that nothing else in it (nor a function it calls, nor — when it waits — another coroutine) routes — its routing set + once in triggers in front of the loop; read at several sites of a loop that never writes it — each site reads a copy + of its own, routed for good, which the loop's way in fills. Otherwise the group keeps its setup, as eudplib had it. +- Checked: every game probe in the lifted game and the simulator (game-debug's one failure is meant), vitest. + +| | Before | Routed for good | + loops (copies, hoisting) | +| --- | ---: | ---: | ---: | +| bench, one turn of every inner line | 53.1 µs | 51.3 | 49.6 | +| bench, the loops over arrays and units | 64.8 µs | 61.4 | 54.8 | +| a loop's own turn (`for (…; i < n; i++)`) | 181 ns | 108 | 108 | +| an empty loop's turn | 189 ns | 189 | 112 | +| raycaster, a frame | 1.13 ms | 1.08 | 1.06 | +| bullets, a frame | 0.46 ms | 0.44 | 0.44 | + +The showpieces move little: what they spend is in two places routing cannot reach from outside. The bullets' fields are +read through `varray_read`, which works the place out at every access (part 2, *record loops*, below); the raycaster's +time is fixed-point arithmetic inside eudplib-made functions (`fdiv` 0.41 ms of its 1.06, eudplib's product 0.16, `fmul` +0.15), whose own variables eudplib shares between calls (part 3, *arithmetic of our own*). + +**As built: the backend, part 2 — record loops (2026-10-19).** A `for` loop whose update is `i = i ± c` at a counter with a +place, and whose body is as `lengths_fixed_in` wants it (no wait, no call, no length changed, nothing stored into `i`), +reads and writes its arrays at `i` through registers of their own (`Cursors`): a read is a trigger whose own next +pointer is the cell's address, so it jumps straight into the cell (which writes itself out through eudplib's getter); +a write is an action whose own destination is the cell's value. Each register is set once on the way in (the array's +first cell plus the counter's place, out of the way of the loop's code, as static routing's are) and moved on by the +counter's step at the update — the counter, its place and every register in one trigger. `varray_read` and +`varray_write` worked the place out at every access: two variables routed into a jump or an action each time. +- Fixed arrays go through registers too, checked against their length unless the ranges prove the index inside. +- A loop over a growing array whose body reads other growing arrays at its counter tested a flag at every access (were + they at least as long as its own when it began?). It is now *unswitched*: the flags are tested once on the way in, and + a copy of the loop that knows them all true runs — the usual case, a record's fields grow together — or the copy that + tests them. Each copy is a region of static routing of its own. +- A read that always happens fills its temporary whole, so it no longer starts from 0. +- `probes/game-cursors.ts`: records moved and aged, other lists longer and shorter at the counter, fixed arrays with the + bound proved and not, steps of 2 and −1, `continue` / `break`, a loop inside reading at the outer counter — 4/4 in the + lifted game and the simulator. + +| bullets bench (200 records, moved, bounced, aged) | Before this work | Part 1 | Part 2 | +| --- | ---: | ---: | ---: | +| a frame | 0.46 ms | 0.44 | 0.20 | +| `b.x += b.dx`, a bullet | 410 ns | ~400 | 107 | + +**What is next for the backend.** Parts 1 and 2 took routing away where a read's routing can be decided ahead. What is +left, in the order the measurements put it: +1. *A loop's test turned round.* eudplib's branch spends two triggers and two actions on the way its conditions hold + and one trigger on the other; a loop's test holds on every turn but the last. Testing the opposite and leaving when + it holds makes the way on one trigger whose condition fails (about 28 ns a turn on every loop with a comparison). + *Tried 2026-10-19 and set aside:* the list loop gained (4.05 → 3.63 µs) but the empty loop (1.79 → 2.17), the array + loop (4.70 → 5.08) and the bench's own turn (108 → 128 ns) lost — the opposite comparison reads its two sides another + way (`floored_zero` where `<` used eudplib's own), which static routing then serves less well. It needs the + comparison lowered so that both directions read their sides alike first. +2. *Arithmetic of our own* (part 3). **Built — below.** +3. *Values written where they are used* — the end of the road routing starts on. A variable is one action whose amount + is its value, and every use of it elsewhere is a copy made by routing it there. In a block of straight-line code the + uses are known when the map is built, so each value can live in the amount fields of the actions that use it: the + producer's trigger has an action for each of them and writes them all when the walk passes it. `x = y + z` with x + used three times is y's trigger setting three fields and z's adding to them — no setup, no routing, a trigger a + producer. Copies for a loop's reads (part 1b) are the first case of it. Straight-line expressions first, then + variables across a loop's turns, then the bit-serial arithmetic of 2 written that way. + +**As built: the backend, part 3 — arithmetic of our own (2026-10-19).** Measured first (`scripts/trace-frame.mts`, frame +112 of the raycaster, each eudplib cell counted with the line that ran it): `fdiv` was 0.60 ms of the frame's 2.10, and +81% of it was copies — the doubled divisor written into two or six fields at each step of eudplib's division chain, +each a routed run of a variable (about 46 ns) — against 16% for the comparisons that do the dividing. A copy cannot be +made much cheaper (a value is in one action, so each place it goes is one run of that action), so the work is fewer of +them: +- *Cells of our own.* A trigger of one or more actions each adding its own amount to itself: one run doubles every + number in it, nothing routes it, and conditions and actions elsewhere read and change the amounts where they are + (`COND_VALUE`, `ACT_VALUE`, … in `python/trigscript.py`). A quotient, a remainder and the dividend's bits still to + come are three actions of one trigger. +- *The divisor stays where it is.* Long division as on paper — the remainder doubled a step, the divisor taken away + when it fits — so the divisor is copied into the one comparison and the one subtraction of a loop of four triggers + (cells, the dividend's next bit, the test, the end), not into every step. `fdiv_vars`: 47 steps, the dividend's + leading zeros run first by the cells alone (a step with nothing in it each). `fmul_vars`: the 16-bit halves, + `ah × b + al × bh + al × bl / 65 536`, one loop of 16 steps reading bit 31 and bit 15 of a doubled `a`. +- *One side known when the map is built.* `c / x` (`make_const_fdiv`): where the divisor's top bit is (a search of + comparisons) says how far down the known dividend the first step that can take the divisor away is, and what the + remainder is there — a constant per leaf — so only the steps that make quotient bits run; the quotient carries a bit + above it that reaches the top on the last step (the loop's end test). `x / c` and `x * c` (`fixed_by_const`): no + copies at all — the doubled divisor is constants in the steps, and a product is each bit of `|x|` adding `c` times + its place, split at the point so the rounding is the product's. +- Exact: `probes/game-fdiv.ts` (written by `scripts/gen-fixed-probe.mjs`) holds 960 results — `c / x`, `x / c`, `x * c` + for six constants each and `x * y`, `x / y` over 32 × 12 values, at the edges (1, 2^31 − 1, −2^31, 0) — against the + compiler's own arithmetic as literals: the same bits in the lifted game and the simulator. It found one: −2^31 / −2^31 + through fdiv's own way, which reads both as −2^31. + +| Lowering bench, ns a turn | Before | After | +| --- | ---: | ---: | +| `fa * fb` | 4520 | 3027 | +| `fa / fb` | 7174 | 5116 | +| `fa * 0.66` | 4520 | 818 | +| `fa / 2.5` | 7174 | 1380 | +| `1 / fa` | 7174 | 1821 | +| `Math.floor(78 / fa)` | ~7400 | 2262 | + +The raycaster: 1.06 → **0.77 ms** a frame on average, its costliest frame 2.10 → 1.51 (division 0.60 → 0.16, products +0.40 → 0.23). What is left in it: the fog library's drawing (0.56 ms, now the most), then the products of two variables +(`(mapX + 1 - x) * deltaX` — ranges cannot see that the left side is 0 … 1, which would halve it). Not done yet: +`Math.sqrt` (10.1 µs, the same treatment would apply), integer `*` and `/` of two variables (eudplib's still). + +**Where a raycaster frame goes now** (`scripts/trigger-shapes.mts`, frames 10–120, 0.78 ms): the walk itself 39%; routing +still 0.19 ms (24%: next pointers 0.083, a cell's destination 0.052, its modifier 0.053); triggers whose conditions +failed 0.19 ms (25%). Routing is what values written where they are used takes away; the failed triggers and the walk +are the shape of eudplib's branches and jumps. Both are the emitter's. + +### The fog library, faster + +Measured first (`scripts/trace-frame.mts --wide`, frame 112 of the raycaster, every eudplib cell counted with the line +that ran it): the fog library was about 0.56 ms of the frame's 1.51, and a brush — 292 of them that frame — about 1.2 µs, +of which the unit made and taken away was 0.05. The rest was arithmetic around it: +- *the location's four sides*, each a temporary made and copied (`x - 16`, then `+ 32`, …): about 0.75 µs; +- *a product by 32* of a coordinate the game holds, `(top + r) * 32`, a test a bit (27 of them): about 0.4 µs — worked + out again for every brush of a bar, for the column's x as much as for the row; +- *a `max(…, 0)`* the front end puts around every coordinate of a `centerLocation`: a signed comparison of two values + and a branch, about 0.17 µs a coordinate; +- per column, `heights[i] >> 1` (a shift of 31 tests) worked out twice, and a pass over every column that a light + outline — all of whose depths are 0 — has nothing to do in. + +What changed, with nothing drawn differently: +- *Python* (`center_location`): a location of a size known when the script is built has its four sides written in one + `SeqCompute`, the centre routed into the two sides that take it and the constants added where they land. + `clamp_const`: a `min` / `max` against a constant is a copy and one or two triggers whose conditions compare with + constants (signed and unsigned, at the edges: `probes/game-clamps.ts`). `forget_counts` (the Command after a + location moves or units are made) only in a map whose programs ask Bring at all — the fog library asks none. +- *The library* (`stdlib/fog.ts`): brushes placed in pixels (`discPx`, `columnBarPx`), their products by 32 worked out + once and stepped by adds — across a bar, across the columns, from a picture's or a video frame's corner; each + column's half height clamped before the shift (the same number, the shift then has the clamp's few bits) and kept, + with its pixels, for the passes after; the pass over the groups skipped when every depth is the least; the edge pass + carrying the face and height before it rather than reading them again; a video word's row as `(w & 0xff00) >> 3` + rather than `((w >> 8) & 255) * 32`. +- *Checked:* the fog board in the lifted game, hashed every frame for 70 frames of the raycaster (walking, turning, both + inks), the same as the branch's base build on its own map; every game probe; the raycaster's two tests. + +| | Before | After | +| --- | ---: | ---: | +| Raycaster, a frame on average | 0.77 ms | **0.60 ms** | +| Raycaster, its costliest frame (112) | 1.51 ms | 1.16 ms | +| The fog library in that frame | ~0.56 ms | ~0.27 ms | +| A brush of a bar | ~1.2 µs | ~0.25 µs | +| Bad Apple, a frame while it plays | 0.360 ms | 0.305 ms | + +What is left in a brush is the four sides' routed copies (about 55 ns each): the emitter's, when a location's sides can +be places of the coordinates. Tried and set aside: shifts left by 5 as doublings (`shift_const`; about 66 ns a doubling +against 14 a bit tested, but the raycaster's coordinates have few bits set, and it came out 0.01 ms slower). + +Two things found on the way, neither the library's: `compiler/ranges.ts` spread a list's values into `Math.min(…)`, which +overflows the stack for a video's table (Bad Apple did not build on this branch; now a loop, kept once a list); and the +raycaster's test of its first picture, "failing in the lifted game" with columns 32–41 undrawn, was being run on the +default map, 96 tiles wide — the board is 128, and those columns are off it. On its own map +(`fixtures/eud/raycaster-base.scx`, now said in the probe's header) both of its tests pass, before these changes and after. + + +### The emitter: a backend of our own, one statement kind at a time + +Parts 1–3 changed what eudplib makes from inside it. From here the lowering itself moves: a TypeScript emitter that +lowers the IR to triggers and decides where every value lives, beside `python/trigscript.py` until it has taken all of +it, then the layout and the loader, then the input plugins — and then no Python at all. + +**E1 — the graph, and the splice.** `compiler/backend/`: +- *The graph*: triggers (≤ 16 conditions, ≤ 64 actions, a label, where the walk goes next) whose conditions compare a + dword with a number (a mask optional) and whose actions write one (set, add, subtract, a mask optional), plus the + game's own records passed through. A dword is a game address, a field of one of the graph's triggers (a condition's + or an action's number, an action's destination or modifier, a next pointer), or a variable of the Python side's, + named by id. Labels resolve when the Python side makes the triggers. +- *The splice*: a statement the emitter takes becomes `{ kind: "graph", graph, original }` (IR 26). The Python side + makes `graph`'s triggers where the statement was (a small materializer: labels to `Forward`s, variables to their + cells' addresses, through `Lowering.var`); the simulator runs `original`. So every game probe holds the emitter + against the IR's meaning — the lifted game running the graph, the simulator the statements it replaced. + +**Values written where they are used** — what the emitter is for. A number has *places*: every amount field that +reads it (the number an action adds, sets or compares with), and a home that conditions read as memory. A write to the +number writes all of its places — a constant one action each (`i += 1` with i read in four places is one trigger of +five actions, where eudplib adds to i's cell and then routes the cell to each of the four, about 46 ns each). `x = y + z` +with x in three places is six actions — y set into each place, z added — and so y's places include those three +actions' amounts, which y's own writes keep. A comparison with a constant reads any place as memory; `x < y` reads x as +memory against y placed (less one) in the condition. In a straight-line block or a loop every place is known when the +map is built; where it is not — an element read at a variable index, a function's result, a per-player variable — a +value comes from a cell, routed once into its places, as now. + +The order, each measured on the bench and the showpieces, each held by every game probe: +1. E1's graph, the materializer, the splice, the differential check (the simulator against the lifted game) wired in. +2. Numbers whose every write the emitter makes (a variable written only by taken statements, temporaries always): + `declare`, `assign`, `if`, `while`, `for`, `break`, `continue` of numbers and their comparisons. Branches laid out + as the emitter likes — the fall-through on the likely way, a failed condition costing 13.4 ns rather than a jump. +3. The rest of the numbers: arrays and records (part 2's registers), function calls (parameters written into the + body's places; results through a cell), the arithmetic of part 3 as graph code. +4. The layout and the loader of our own (the payload, its relocation, the start), proved on the lifted game and the + allowlist; then units, text and the input plugins; then the Python side goes, and Pyodide with it. + +**As built: E1 and the start of E2 (2026-10-19).** `compiler/backend/graph.ts` (the triggers), `compiler/backend/emit.ts` +(the emitter), `Lowering.graph` in `python/trigscript.py` (the materializer), IR 26 (`graph`, `original`), on with +`CompileOptions.emitter` / `EMITTER=1` (`EMITTER_REPORT=1` says which loops it took and why it left the others). +- *Taken:* a `while`, `for` or `do` whose every statement is a number declared, set or added to from constants and + variables (`+`, `-`, negation), `if` / `else` on comparisons (`and`, `or`, `!`, a number tested), `break`, + `continue`, loops inside — of variables that are plain cells (not a per-player program's, not a task's, no `u8` / + `u16`). A loop the front end unrolled (a known count) never reaches it; neither does anything with an element, + a call, a product. +- *Places, signed:* a place holds the variable or its negation, plus a constant: the game's subtract stops at 0, so a + variable is taken away by adding its negation, and every write is sets and adds. Comparisons read a home holding the + variable plus 2^31 when signed (an unsigned comparison then), `x < y` of two variables reads y less one with a + second condition keeping y off 0; a `u32` against a signed number is two comparisons (`plain`). +- *Copied variables:* a variable set from itself other than by adding (`x = y - x`), and the variables of a loop of + reads (`x` from `y`, `y` from `x`: Fibonacci, a swap) would need places without end; their writes go to their + cells alone and a copy (`Copy`, a routed read per place, as eudplib's) follows each write. +- *Layout:* a branch sets its own next pointer and the trigger it goes to puts it back; a loop's test is the way out, + which fails on every turn but the last; every fall-through is said, so an empty trigger is dropped. +- *Checked:* `tests/emitter.test.ts` — 4000 random loops (nested, `break` / `continue` under random conditions, + signed, unsigned and mixed comparisons at the edges, variables set from themselves and from each other) run as + written and as their graphs by a small interpreter of the triggers: every variable the same. `probes/game-emitter.ts` + in the lifted game against the simulator (which runs `original`). +- *Scaled places:* a place holds any constant times the variable plus a constant, so a product by a constant and a + shift left by one are as linear as a sum: `g = g * 3 + 1` with g in four places is four sets of its multiples. +- *Tested once:* the part of a loop's test that reads nothing the loop writes is tested on the way in (a `u32` + counter against a signed bound: the bound's sign). +- *Measured:* the bench's `for (let i = 0; i < m; i++) { }` of 16 turns 1793 → **659 ns** (874 before the test was + split): a turn is one failed test and the counter's add, about 32 ns. Every game probe passes with the emitter on. +- *Islands:* a statement the emitter does not lower stays in a taken loop, lowered by the Python side where it is + (`Trigger.island`): every variable is in its cell (a cell is always one of its places), and what the island may + write — its own stores, its calls' results and parameters, what the functions it calls write — is copied back into + the graph's places after it. Not an island: anything that leaves it (a `break` of a loop outside it, `return`), a + wait, a `try`; an array read at a `for` loop's counter (the Python side's registers, part 2, are better there); a + store of a variable some array is indexed by (the Python side keeps such a counter's offset beside it, `Place`, + and steps it at each store it lowers). With islands the raycaster's column loop and four of Snake's loops are + taken; every game probe still passes. The raycaster is no faster (0.78 ms against 0.77): its loop is mostly islands + (the products, the quotients, the array stores), and the copies after them cost what the routing did. Islands are + what lets the emitter take a loop at all; what it gains comes as more of a loop's statements become its own (E3). +- *Not the emitter's:* the raycaster's game test of its first picture (columns 32–41 not drawn by frame 6) fails at + the branch's base too (45ac0d7) — the heights and faces it draws are the simulator's, number for number. + +**As built: E3, more of a loop the emitter's own (2026-10-19, branch emitter-e3).** Measured first: with islands the +raycaster's column loop was taken but no faster (0.78 ms a frame against 0.77), its loop almost all islands — the +inner loop's `cellAt(mapX, mapY)` (a call of a function that reads a cell of the maze), `Math.floor`, the products and +quotients, the `?:` and abs of the steps, the stores. Each step below measured on the raycaster (average frame / +frame 112) and kept only once it paid. +- *Calls inlined.* A call of a function that is one `return` of a number — called by name, or (called once) carrying + its body — is that expression with the parameters replaced by the arguments, where every argument is a sum the + emitter lowers (`inlined`). The loop is rewritten before it is looked at; `original` stays as written. +- *Cells of lists known when the map is built, read where they are.* `CELLS[mapY * 16 + mapX]`: the index into a + variable of the emitter's own (`own`, a dword of data, no cell on the Python side), the target set to 0, and a + trigger a bit the list's values have between them — the index inside the list (read from 0 up, so below zero is + past the end and reads 0) and that bit of the cell, the condition's *player field* (the EPD it reads) a place of the + index times the cells' spacing plus the first cell's EPD (`Num`, `{ epdOf }`): 18 for the Python side's EUDVArray, 1 + for a list of PACK_FROM values or more, which goes packed (the Python side checks the spacing, `Graph.arrays`). The + index condition comes first: the game stops at a trigger's first condition that fails, so a cell past the end is + never read (the lifted game's memory check on a probe reading past both ends: nothing outside the map's own memory + and what is known allowed). Only for lists whose values have 6 bits or fewer between them (a maze's 0–2: two + triggers); a list with a number below 0 has 32, and is left to the Python side. A cell inside a sum is read into a + variable of the emitter's own first. **The raycaster: 0.77 → 0.70 ms, 1.51 → 1.36** — the inner loop is the + emitter's whole (91 µs of frame 112 where the call, its glue and eudplib's jumps were about 240). +- *Fixed-point products and quotients called from the graph.* `fmul` / `fdiv` of sums the emitter lowers call part + 3's functions (`fmul_vars`, `fdiv_vars`; with a constant above zero `fmul_by`, `fdiv_by` (c / x), `fdiv_by_const`): + the arguments written into the function's parameter cells by the trigger before the call (each a place of what it + reads), the call (`Trigger.call`) setting where the function goes back to and where its result goes — the target's + cell, as eudplib's call writes a return straight into a dword — and the target copied after. A constant below 0 is + left to the Python side (its quotient negated after is not the same bits at −2^31). Alone it was slower (0.72 / + 1.41): `mapX` was still an island (`Math.floor`), and copying it into the places that hold it times 65 536 (the + fixed argument) went through eudplib's product by a constant (`_mulf`, 48 µs a frame). A copy times a power of two + is a shift now (0.71 / 1.39), and then: +- *Shifts right by a constant.* `x >> k` / `x >>> k` of a sum: the sum into a variable of the emitter's own (or a + variable alone, read in its cell), and a trigger a bit that comes down adding its new place's worth to every place + of the target — so a place holding the result times 65 536 costs nothing more; for `>>` the sign bit adds its + weight below 0. From the top eight bits a group, a group with none set jumped over. `Math.floor` of a fixed is this. + **0.67 / 1.31.** +- *`?:`, `Math.abs`, `Math.min` / `max` rewritten* (`Rewriter`): `x = c ? a : b` is `if (c) x = a; else x = b`; + `x = abs(e)` tests e's sign (abs of a product or quotient: the result's, in the target); min / max a comparison + (unsigned where the IR says); such a value inside a sum or a comparison's side computed first into a variable of + the emitter's own (`@t…`); a loop's test that needs one, computed at the top of each turn. A statement whose + rewriting the emitter would not take whole stays as it was (an island cannot read the emitter's own variables). + Alone it was slower again (0.71 / 1.38): `deltaX` — a call's result, then its abs — was copied into its six places + twice a column, where the Python side routed it once a read. +- *Routed variables.* A variable whose writes each end in a copy (a call's result, one an island writes, one copied + for a loop of reads) is instead read where it is used, when that is cheaper — its reads against its writes times its + places, each weighed by how deep in the graph's loops it is (a loop inside counted four turns of the one outside): + the dwords a trigger is about to read refreshed from the cell just before it. A write's reads are refreshed before + its first action (`c = b - c` reads the c from before), and a write of the variable makes later reads in the same + run of actions stale. A trigger with conditions that reads one is entered through its refreshes (`…in`), while the + trigger keeps its label for the branch that sets its own next pointer. **0.67 / 1.31** — the rewritten statements + cost what the islands did, and are the emitter's for what comes next. +- *Lines:* each trigger of a graph says the line of the statement it comes from (the map of triggers, the cost of a + line), where all said the loop's line; writes of several statements in one trigger say the first's. +- *Found on the way:* the IR's serialization (`serializeIr`) passed a `graph` statement through as it was, so an + island with text in it (a `print`) would have reached the Python side with the compile's local text ids — it maps + `original` and every island now. `fixed_fns()` returned early whenever its table had anything in it, so a graph that + made `fixed_vars()` first left `fdiv_vars` without the `fdiv` it falls back to: a build that failed (the bench, with + the emitter on). + +| With the emitter (`EMITTER=1`) against without | Without | With | +| --- | ---: | ---: | +| Raycaster, a frame on average | 0.77 ms | **0.67 ms** | +| Raycaster, its costliest frame (112) | 1.51 ms | **1.31 ms** | +| Bullets, a frame | 0.20 ms | 0.20 ms | +| Snake, a frame / its costliest (frame 2) | 0.07 / 4.17 ms | 0.07 / 4.00 ms | +| Tetris, a frame / its costliest | 0.34 / 8.11 ms | 0.34 / 8.09 ms | +| Lowering bench, all lines | 97.2 µs | 94.1 µs | +| `for (let i = 0; i < m; i++) { }`, 16 turns | 1793 ns | 659 ns | + +With the emitter on, the bench takes its own loop, so its lines' costs are the graph's: lines whose writes share a +trigger with the line before them are counted there ("did not run"), and only the total compares. Checked: +`tests/emitter.test.ts`, 4000 random loops now with lists short and long (in and past their ends), calls of a +function that is a cell, fixed products and quotients, shifts right, `?:` / abs / min / max — run as written and as +their graphs, every variable the same; `probes/game-emitter.ts` A–I in the lifted game and the simulator; every game +probe with the emitter on and off. **Left off by default:** everything measured is as fast or faster and every probe +passes, but the lifted game is 1.16.1 with Remastered's EUD rules modelled — the emitter's reads through a +condition's player field and its calls into part 3's functions should be played in Remastered first (the emitter +probe as a map), and the cost-by-line report gives a shared trigger's cost to its first line. + +**Played in Remastered and switched on (2026-10-19).** `probes/game-emitter.ts`, built with the emitter (all 11 of its +loops taken), played in Remastered 1.23.10.13515: its nine lines (A–I) are the lifted game's, number for number — the +cells of a known list read through a condition's player field and the fixed products and quotients called straight +from the graph included. The emitter is now on by default; `emitter: false` / `EMITTER=0` turns it off. The lowering +bench built with it off at first (a trigger of the emitter's may hold several lines' work, and the count gave it to +the first); since the next paragraph's per-action lines it builds with it on. With both of today's branches the +raycaster is 0.50 ms a frame (0.60 with the emitter off; 1.13 when this section began). + +**The emitter owns more of the loop (2026-10-19, branch emitter-e4).** What was added, each checked by the fuzz test +(4000 random loops, every kind below in them, the interpreter modelling each exactly), by `probes/game-emitter.ts` (a +tenth section, J) and `probes/game-fdiv.ts` (two more groups) in the lifted game and the simulator, and by every +`probes/game-*.ts` with the emitter on and off: + +- *Stores.* `a[i] = v` into an array the program writes (EUDVArray, 72-byte cells, at most 2048) is one trigger: a + guard on the index, and a Set Deaths whose destination is a place of the index (× 18 dwords plus the array) and whose + number a place of the value. A counter that indexes an array has its byte and dword offsets (the ones the Python side + keeps beside it) as places too, so a loop with `heights[col] = …` no longer leaves the counter to the Python side. +- *Values computed once.* A shift by a constant, a fixed product or quotient, or a list's cell, that reads nothing the + loop writes, is computed ahead of the loop (352 of the fuzz's loops; the raycaster's per-column constants). +- *Masks.* A comparison of `x & K` against a constant is a masked condition; an ordered signed one with the sign bit in + K is split in two by the sign. +- *The reciprocal natively.* `c / x` with a constant c (part 3's `fdiv_by`) is the emitter's own: the shifts picked + per top bit of |x| from a tree of jumps, the loop's cells routed straight into the result's place; x's zero and + sign handled; a quotient past 31 bits goes on to the Python side's function as before. +- *Integer `/` and `%` of two variables* on the Python side: a 31-step loop of the program's own cells (`udivmod_vars`, + both quotient and remainder sent out in one run), eudplib's `f_div` only for a dividend at 2^31 or more. Signs as + JavaScript's: the quotient towards zero, the remainder the dividend's sign. +- *`switch`, loops the front end unrolled, booleans.* A switch is tests of its value and its bodies one after the + other (a break ends it, a continue is the loop's); a boolean is a cell of 1 or 0. +- *Reads by a jump into the cell.* A cell of an array the program writes, or of a list too wide to read a bit at a + time (wider than 6 bits, fewer than 256 cells), at an index the loop computes: eudplib's getter pointed at a scratch + cell and back, then a trigger whose next pointer is a place of the index (× 72 plus the array's address). The jump + lands on the cell's trigger, which writes its value to the scratch cell and returns. This is eudplib's own way of + reading an EUDVArray, driven from the graph instead of through a call. +- *Shifts and copies by value ranges.* A shift of a number value ranges know is never below zero tests only the bits + it can have; a variable a statement left to the Python side stores is copied into the emitter's places with its + multiples worked out over only its bits. +- *Each action's line.* A trigger of the emitter's carries the line of each of its actions (the trigger map's fifth + column); the cost by line splits a trigger's time by its actions. So the bench builds as a build does, emitter on. + +Numbers, the lifted game's weights. The raycaster on its base map: 0.50 → 0.47 ms a frame (0.60 with the emitter off); +its frame 112, 963 → 899 µs. Line 97's floors 43.6 → 14.4 µs; the faces store 25.4 → 1.0 µs. What is left of that +frame is the fixed products (`fmul_vars`, about 230 µs) and the reciprocals (about 160 µs). Bullets 0.20, snake 0.07, +tetris 0.33 ms: unchanged, their time is in statements the emitter leaves (a sleep or a return in the loop, unit loops) +or in eudplib's library code. The bench (`probes/lowering-bench.ts`, its baseline saved again with the emitter on): +all lines 93 387 ns off, 90 559 ns on; by line, with the emitter on: an array store 172 → 23 ns, a product by a +constant 492 → 18, a shift left 421 → 10, a record field written 136 → 23, an empty loop 1 793 → 659, the reciprocal +1 821 → 1 513. An integer quotient of two variables, 7 189 → 3 402 ns either way (the Python side's new loop). + +Set aside: + +- *`Math.sqrt` natively.* Each step compares two variables, which a condition cannot do; done as the reciprocal is, + about twice as fast at best, and no showpiece has it in a hot loop. +- *The fixed product natively.* Built (the bits of `fmul_vars` as the emitter's triggers, its cell routed into the + result), exact on 751 fuzz loops and on game-fdiv's 384 pairs. The raycaster went 0.47 → 0.51 ms: making the + magnitudes and halves as the emitter's own variables costs more than the call it replaces. Taken out. +- *Mask lines.* `k = (k + 1) & 15` is still 301 ns with the emitter on against 40 off: the mask is the Python + side's, and its result is copied back into the emitter's places. + +Nothing here needs a new trick in Remastered: a store is a Set Deaths whose destination another action wrote (what +every routed read already is), and a read by a jump is eudplib's EUDVArray getter. Still, game-emitter with its J is +built into the maps folder as `trigscript-emitter-2.scx`, its answer sheet beside it. **Played (2026-10-19):** its ten +lines (A–J) in Remastered are the lifted game's, number for number — the stores and the reads by a jump included. + +### Our own layout and start-up + +The second step past eudplib: a map made with no eudplib and no Python, for a program the emitter lowers whole. + +**What eudplib does to start a map** (read in `~/.cache/eudplib-src`, `maprw/injector/`): +- *Where the payload is.* Remastered keeps the string section at 0x191943C8, so eudplib appends its payload to the + strings (`STRx`) and relocates every pointer in it when the map is built (`payload_init.py`: `payload_offset` is + 0x191943C8 plus the strings' length). Nothing is relocated in the game. (Its in-game relocator, + `payload_reloc.py`, is the other mode, not the default.) +- *Stage 1, in TRIG.* A few of the game's own triggers per player: the current player (0x6509B0) set to the EPD of + the player's list's last node (0x51A284 + 12 × player), its 30 address bits read into a scratch death count, the + current player made the EPD of that node's next pointer, and that pointer set to the payload. A Set Deaths through + 0x6509B0 is out of range, so the map is an EUD map when it loads. The map's own triggers are turned off for a while. +- *Stage 3, in the payload* (`inj_finalizer.py`): the next pointer put back, the map's locations put back (eudplib + scrambles MRGN against copying), the map's triggers' flags flipped back on and inline code dispatched, then every + player's list re-linked to a runner of its own — first node in the payload, last node ending at the list's own end + `~(0x51A280 + 12 × player + 4)` — and the main loop run once a game tick (0x57F23C changed). eudTurbo sets the game's + wait between passes (0x6509A0) to 0 every pass, so triggers run every frame. + +**The minimum, as built** (`compiler/backend/layout.ts`, `scripts/build-own.mts`): stage 1 for the program's owner +only (63 triggers, run once each); then the payload, at a fixed address after the strings: +- `@wake` heads the owner's list on every pass: "the game's tick is at least W → go to R". W and R are fields of its + own condition and action. First W is 0 and R the start. +- `@init`, once: the owner's list made the payload alone (first `@wake`, last `@stop`, as eudplib's runner is linked), + the current player set to the owner. +- the program's graph (`emitBody`: the whole body the emitter's — a print of a known text is the game's Display Text; + a loop the front end unrolled is laid out a turn at a time; a variable's copy, which the Python side routes + elsewhere, is a trigger a bit adding into its places). +- `sleep(frames(n))`: W set to the game's tick (0x57F23C, read a bit at a time) plus n, R to the statement after, and + the pass ends; there `@wake`'s next pointer is put back. The program's end sets W to a tick never reached. +- `@stop`: 0x6509A0 set to 0 (eudTurbo's write), then the list's end. +No relocation, no scrambling, no flags to flip (the map has no triggers of its own), no inline code. + +**Results** (`probes/own-loader.ts`: loops of numbers checking themselves, sleeps, a loop across frames): +- The lifted game prints its eight lines at frames 2, 2, 6, 6, 10, 10, 15, 15 — the frames the same script built + through eudplib prints them at. Every access is one Remastered allows (`scripts/game-check.mts`: 72 distinct, 63 of + them the map's own memory, none ending the game, none never seen). +- Built in **0.7 s** (compile 0.45 s, the emitter 0.01 s, the map 0.3 s) against 5.3 s through eudplib (in Node, its + Python native; the editor's build also loads Pyodide). +- In the game: the start costs 0.004 ms against eudplib's 1.07 ms (its start-up, frame 2: 82 068 records); a frame + while the program runs 1.3 µs against 5.1; a frame once it has ended 2 records against 170 (eudplib's own work + every frame, about 4 µs). +- **Played in Remastered (2026-10-19):** `trigscript-own-loader.scx` printed all eight lines, every one "ok" — the + first TrigScript map Remastered has run with no eudplib in it. Quitting was clean: the recording ends on the Single + Player menu, no error — the end of a game, when the owner's list still holds nodes outside the game's heap, is fine. +- Not yet (as of 2026-10-19; every one of these has since landed, the map's own triggers as step 13): anything the + emitter leaves to the Python side (texts with values, units, arrays it does not read, calls, tasks, per-player + programs, `trigger()` records of the map's own), and the map's own triggers alongside (eudplib's stage 3 keeps them; + ours drops them). The probe map is `trigscript-own-loader.scx` in the Maps folder: played in + Remastered, it says whether the start-up holds there — and whether the end of a game does, the one moment where a + list holding nodes outside the game's heap could matter (eudplib's lists hold them too). + +### Past eudplib: the machine the measurements describe (2026-10-19) + +After the own loader played, the question was what comes next to leave eudplib entirely, and what more there is to +have once the backend is ours. eudplib takes the trigger walk for a slow processor with memory and gives it macros, +each lowered without seeing the next. What the probes measured is another machine, and a backend written for it does +better than a port of eudplib's: + +- *Code is data.* Every condition's number, every action's number and destination, every next pointer is a dword + another action can write. The emitter's places are this. +- *A next pointer is a jump that costs nothing,* and since the own loader the walk is a list the program owns. +- *A trigger is a bundle:* up to 16 conditions that must all hold, the first that fails ending the test (a mask free), + then up to 64 actions. +- *The emulator remembers one region:* an access in another region than the last costs about 7 ns more (the weights). +- *Some records never reach the emulator:* resources (Set 1.2 ns, Accumulate 0.85), switches (4.4 / 3.5), Bring and + Command asked again (4.6 / 4.3). +- *The machine has an exact copy:* the lifted game, and Remastered's emulator under Unicorn (`remastered/probe.py`), + so a trick is checked before anyone plays it. + +None of what follows runs code of our own or reaches past what the emulator lists: the rule of "The limits" holds. + +**Ideas, by what they would give.** Each wants a bench or a probe before it is built; a number called a guess is one. + +*Speed:* +1. **Branches as places.** A boolean that changes seldom makes every `if` on it a next pointer: `else + b · (then − + else)`, a place with a scale, which the emitter keeps already. Cases laid out a fixed stride apart make + `switch (state)` the place `first + stride · state`. A state machine's dispatch, an `if (hardMode)` in a loop, cost + no condition; today a switch is a test a case. The rule that weighs a variable's places against its reads decides. +2. **The walk as the scheduler.** A task asleep, or a handler with nothing to do, is not in the list: it costs nothing, + where today each branch's wait is tested every frame. A timer wheel (list heads, one stepped each frame) links a + sleeper back on the frame it wakes; a signal links its handlers in when it fires. `@wake` is the first of these. It + is what would make a coroutine for each of 1,700 units affordable. +3. **The start worked out when built.** Run the program in the lifted game until it first reads what the build cannot + know (input, a unit, the tick), and put its own memory as it is then into the map, the walk starting there. Only the + payload's cells, nothing of the game's. Most of Tetris's 8 ms and Snake's 4 ms first frames should go; a table a + program builds is free. (Set aside on 2026-10-18 as one map's matter; with the fixed layout it is general.) +4. **Tables instead of bits.** A read by a jump reaches a cell in a few triggers, and value ranges know domains: a pure + function of a small range becomes a table built with the map (the cost model against the map's size); a number as + text a table of 1,000 three-digit dwords written into the text (eudplib's `f_dbstr_print` divides by ten up to ten + times, each a bit at a time); a product of bounded numbers two reads, by quarter squares (ab = ⌊(a+b)²/4⌋ − + ⌊(a−b)²/4⌋). The raycaster's frame 112 is about 230 µs of fixed products and 160 of reciprocals out of 899. A + guess: ten times faster or more on bounded operands. +5. **A superoptimizer, offline.** The machine is small and known: search for the cheapest records under Remastered's + weights for shifts, var < var, abs / min / max, signs, the fixed product and quotient, sqrt, the digits — short + ones enumerated, longer ones by stochastic search — each checked exhaustively at 8 bits and at 32 by the graph + interpreter and z3 (`z3-solver`, wasm). What it finds becomes rules of the emitter. +6. **Scheduling.** The hot path falling through; loops pipelined (a turn's first actions in the trigger of the turn + before's last); accesses grouped by region; Bring and Command grouped by location and kind (4.3 ns asked again, + 0.2–2.5 µs a scan); counts from runs in the lifted game as the profile. +7. **Effects read from both games.** For every condition and action handler of 1.16.1 and Remastered: what it reads, + writes and throws away (Bring's table), and whether it goes through the emulator — a table the compiler loads. The + stale Bring was found by a probe; reading the handlers would have found it. Scheduling (6) may move an action past + another only when their effects do not meet. +8. **Storage by cost, as registers are given out.** Hot values into the cheapest storage the map allows: resources of + a player who can never be in the game (the map's slots say), switches, the payload's cells, the game's memory. + Probe too: Highest / Lowest Score of the custom score, an argmax over players in one condition. +9. **Bitboards.** A grid of booleans as rows of 32 bits: whether a falling piece fits is four masked conditions, the + shift for its column chosen by a jump. Not the `bits` packing set aside on 2026-10-18 (taking numbers out of a + cell): testing bits, where the mask is free. + +*Language:* +10. **Signals made of places.** A signal's readers are its places (texts, conditions, branch pointers), its effects + linked into the walk when it changes (2): a view costs nothing on a frame where nothing changed, by how it is made + rather than by comparing. +11. **Units with behaviours, and entities.** `u.behave(async function* () { … })`, a coroutine a unit, keyed by its + slot, in the walk only while awake; `world.query(Pos, Vel)` over columns of cells with counter offsets (what took + the bullets from 2.38 to 0.44 ms). +12. **Precision and units as types.** `Fixed<8>`, `Num<0, 4095>` given to value ranges, choosing table arithmetic (4); + pixels, tiles and frames told apart. +13. **Input of our own.** MSQC carries a value of about 20 bits through selection commands (a typed line at most + three). Read the command dispatch of both games for the command that carries the most bits to every machine and + does nothing else; typed channels on it (`channel<{ x: u16, y: u16 }>()`), changes only, a budget a turn; the chat + read from the local buffer instead of chatEvent. Held first on the lifted game's play with two seated (21,394 of + 21,394 frames on a real replay). The last of the euddraft plugins. + +*Tools:* +14. **A real game's replay, debugged.** A replay of a game played in Remastered, run in the lifted game with the map of + triggers: A5's scrubbing, the cost by line and EUD errors at their lines on what really happened, several players + included, and the counts (6) from real play. A spike first: does an EUD map's replay from Remastered run true in + the lifted game? +15. **Edits while it runs.** Nothing is relocated: rebuild, compare the payloads, write the triggers that changed into + the lifted game between frames, carry the variables over by name (A2's variable map). +16. **The compiler explorer.** The script beside its graph, nanoseconds by line and by action, a variable's places + shown, `EMITTER_REPORT`'s reasons as quick fixes. +17. **Random programs.** Whole programs made at random (as Csmith does C), run in the simulator and in the lifted game + built both ways, the game's state compared every frame (A3), a failure cut down to the smallest. The emitter's + 4,000 random loops, for everything: what makes deleting the Python side safe. +18. **A map's triggers as TrigScript.** Any UMS map's triggers opened as a script (Preserve as a loop, a switch's + patterns as a state machine) that builds back. Classic triggers only. + +**The way off eudplib.** Each step held the same way — the script built both ways says the same at the same frames in +the lifted game, and the probe plays the same in Remastered — and each better than eudplib's way, not a copy of it. +The list had nine steps until the audit of 2026-10-19 ("The way off eudplib, audited", after the arrays section) +found what they left out; where a step moved, its old number is given. + +1. Calls ✓ (#35), and recursion ✓ (#37). +2. Texts with numbers in them ✓ (#38). Texts as values moved to 8. +3. Arrays of a fixed length ✓ (#39). Arrays that grow moved to 9. +4. **Numbers** ✓ (all 59 of the ledger's rows). Part 1: products, quotients and remainders — "Products and quotients + on our own backend"; part 2: bits and shifts by a value — "Bits and shifts on our own backend"; part 3: fixed + products, quotients and printing — "Numbers with a fraction on our own backend"; part 4: random numbers — "Random + numbers on our own backend"; part 5: `sqrt`, `sin`, `cos`, `atan2`, `u8` and `u16` — "Square roots, sines and + small numbers on our own backend". + A product, a quotient and a remainder of two values and by a constant, signed and `u32`; `&` `|` `^` + as values; a shift by a value; `trunc`. Fixed point: `fmul`, `fdiv`, `c / x` (the emitter's reciprocal exists), + `sqrt`, `sin`, `cos`, `atan2` (`tan` and `hypot` come down to these). `u8` and `u16` variables (saturating + `bits`). Random numbers: `random()`, `randomInt`, seeded as `f_randomize` seeds them (the Randomize switch), each + draw a product (`f_dwrand`). A fixed number printed. eudplib's `f_mul`, `f_div`, the bit functions and `f_dwrand`, + each beaten. +5. **The game's conditions and actions** (new). Part 1 ✓: every condition and action with its fields known when the map + is built, laid out as the game's record — "The game's conditions and actions on our own backend"; `memory()` and + `setMemory()` at an address known when built came with it (from 6). Part 2 ✓: fields from values and texts made + while the game runs — "Fields from values on our own backend". Part 3 ✓: the game's numbers read as values — + "The game read as a value on our own backend". Part 4 ✓: locations moved, prints to other players, `rose` and + `once` — "Locations, players and latches on our own backend". Prints at the centre of the screen ✓ (same section). Wait is + refused on purpose: step 5 is done but for it. A trigger record of any condition and any action in the graph and the + layout — they encoded three before: the Memory condition, Set Deaths and Display Text. Fields filled from values + (`action.variables`), action texts (transmissions, objectives, leaderboards). Every condition in a test and as a + value (ore, deaths, score, kills, Bring, Command, the countdown; the Python side's bit search), the players' facts + (race, slot, human, left) and supply; `rose` and `once`. A print to a group — each player in turn, as the Python + side does — and at the centre. Sounds: Play WAV with the clips in the archive, the music library. Locations: + `centerLocation`, the reserved locations, the scratch location an order goes through. +6. **The game's memory** ✓ (all 50 of the ledger's rows: "The game's tables on our own backend"). `stats()` tables read and written (the speed, colour and name cells included), + `memory.vision`, `memory.game`, `memory.map`, `memory()` and `setMemory()`. What the fog library, the raycaster and + Bad Apple stand on. +7. **Programs, owners and players** ✓ (the ledger's rows but a program owned by Player 12, refused on purpose: + "Programs, owners and players on our own backend"). + Several `program()`s in a map — a top-level `on.*` is a program of its own, and `trigscript/fog` adds one. Owners + checked against the map's slots and forces, as the Python side's `owner_slots`. Per-player programs. The walk linked + into every player's list with a once-a-tick guard, as eudplib's runner is, so an empty slot or a player who leaves + does not stop the other players' programs (a program whose owner leaves stops, as the Python side's does) — both + played, the leaver in a game of two. +8. **Texts as values** (was half of 2). Part 1 ✓: texts known when built, chosen while the game runs — "Texts known + when built on our own backend". Part 2 ✓: texts made while the game runs — "Texts made while the game runs on our + own backend". Part 3 ✓: JSX (`buildText`), a player's name and colour in a print, texts in arrays and records — + "JSX, names and arrays of texts on our own backend". + Text variables and arrays; compared, searched, measured, repeated, split and + joined; templates and JSX made while the game runs; `name()` and `color()` in a print; `ui.lines`. +9. **Arrays that grow, and what stands on the heap** (was half of 3) ✓ — "Arrays that grow, and what stands on the + heap, on our own backend". `push`, `pop`, a length set, `through`, `Map` + and `Set`, a task's arrays, an array in a function that calls itself (the front end makes every one of those + growing today, a fixed length too); closures over locals and reference classes (`held`, the pool of 1,024 cells); + signals, which stand on both. (A task's arrays are step 11's, with the tasks.) +10. **Units** (was 5) ✓ — "Units on our own backend". Every unit value, unit loops, fields, flags, writes and verbs, + picks, `select()` and the game command it sends (the events' rows are step 11's, `underMouse`'s step 12's); the + queries scheduled (ideas 6, 7) still to come. +11. **Tasks, `async` and errors** (was 6; errors new). Part 1 ✓: `try`, `catch`, `finally`, `throw`, `uncaught`, a + program's `errors`, the error classes — "Errors on our own backend". Part 2 ✓: `start`, `cancel`, `running`, + `Promise.all`/`race`/`any`, `every`, `after`, a task's error awaited — "Tasks on our own backend". Part 3 ✓: the + `on.*` handlers and the pass that finds what they are handed — "Unit events on our own backend". +12. **Input** (was 7; idea 13). Keys, clicks, the mouse, typed lines, sync channels, the `local.*` reads and the user's + player id. The test harness types and presses through chatEvent's and MSQC's addresses (`GameSim`): it moves with + them, and the map stops needing what MSQC takes (a location, the Valkyrie's entry, Player 12's units). +13. **The map's own triggers** (was 8) ✓ — "The map's own triggers on our own backend" (all 13 of the ledger's rows). + The map's TRIG section and the script's `trigger()` records laid out as the game's own nodes in the payload, a copy + a player, each player's run in turn before the programs, as eudplib's build runs them; `hyperTriggers()` records + left out, the map's triggers running every frame as it is. +14. **The build in the editor** ✓ (new; "The build in the editor (2026-10-23)" below). A build step of the plugin's own for Save, Test Map and export; the CHK and the + archive written in the plugin (mopaq bundled, or an addition to the plugin API); eudplib's fixes to the map + (MTXM's null tiles, a desync by its source's account; UNIT flags; UNIx/UPGx/TECx defaults); the script's settings + (stack depth, heap) passed; a refusal shown as a Problem while the script is typed, naming what it refuses; the + changeover (below); Magenta and TrigScript in one map; the payload's size (triggers stacked as eudplib stacks + its objects: done early, below; what is left is the routing, a trigger a place). +15. **The tools on maps of our own** ✓ (new; "The tools on maps of our own (2026-10-23)" below). The trigger map and the variable map from the layout, in the rows scm-sim + reads — A1 to A5, Find an EUD Error, cost by line, the desync detector and `sim.program()` name lines with them; + `build-fixture` moved onto the own build (the `trigger()` records, Home/Away/Pixel, PEOPLE, the settings); + the allowlist's evidence traced again; the lowering bench's baseline anew; a built map tested end to end in CI. +16. **The gate before the Python goes** ✓ ("The gate before the Python goes (2026-10-23)" below). With it goes the second build every step is held against. Before: + random whole programs (idea 17) built our way and run in the lifted game against the simulator; every probe and + showpiece built our way and played again in Remastered; the simulator's model (`docs/ir.md`: the Python lowering's + today) moved to the own backend. +17. **Deleted** ✓ ("The Python goes (2026-10-25)" below): `python/trigscript.py` (7,113 lines), its embedding + (`embed-python.mts`, whose stdlib half stays as `embed-stdlib.mts`), the IR's hand-off to Python (`serializeIr`, + `packTable`, `IR_VERSION`, the `graph` statement and the emitter's hybrid mode), the `requires` of plugin-eudplib, + and the copy in four repositories that says a map is built by eudplib (this one, scm-js, plugin-eudplib; the + registry's description follows the next release). + +### Calls on our own backend (2026-10-19) + +A map of our own refuses every call the front end did not reduce to one `return` (`emitBody`). What a call is in a +graph that holds the whole program: + +- **Arguments into the callee's places.** A parameter is a variable of the function; its places are the dwords of the + function's triggers that read it. A call writes each argument into every place of its parameter, as any write does — + in one trigger for all of them, where eudplib's call sets each argument's cell and the body routes it again. +- **Back by a next pointer.** A function ends at a trigger of its own whose next pointer the call sets to the trigger + after it, in the same trigger as the arguments, before it goes to the function's first. (eudplib's EUDFunc goes + back the same way.) +- **The result through its places.** `return e` writes the function's result variable; the call copies it into its + own (the IR's `Call.result`), whose places are where it is read. So the result variable's places are the actions of + every call's copy: a return writes them all, and each call's copy reads its own. The more calls of a function, the + more actions at its return — the rule that weighs places against reads applies as to any variable. +- **Calls in expressions** made statements first (the rewriter): `x = a + f(b)` is `f(b)` into its copy, then + `x = a + copy`; a value to the left of a call that the function may write is first taken into a variable of the + emitter's own, so the order is JavaScript's. A call in a later part of `&&` / `||` is not taken (it may not run). +- **Inlined calls** (a function called once, or one the front end cannot call): the body where the call is, `return` + writing the call's result and going to its end. +- **Not yet:** a function that calls itself (its variables and where it goes back to, kept on a stack around the call: + the eudplib side's `Saves`, for a later part), and calls inside the eudplib build's loops (still islands there). + +Checked by the emitter's random programs with functions (a test of their own beside the random loops), and by +`probes/own-calls.ts`, built through eudplib and as a map of our own: the same lines at the same frames in the lifted +game, then played in Remastered. + +**As built (2026-10-19, branch `calls`).** `compiler/backend/emit.ts`: `Scope.call` / `function` / `returned` (what +is taken), `Emitter.call` / `ret` / `functions` (the layout), the rewriter's calls (`Rewriter.call`, `before`, `each`, +and `&&` / `||` with a call in a later part as `if`s on a variable of the emitter's own); `emitBody` rewrites each +function's body once, the first time a call reaches it (`bodyOf`), and a function's number kept as a value is a plain +number there (`plainFns`: the dispatcher's call lays the function out). Calls in the eudplib build's loops are islands +as before. +- *The first measure was worse than eudplib's:* the frame of two hundred calls (`probes/own-calls.ts`, H) cost + 0.63 ms as a map of our own against 0.17 through eudplib. Places multiplied across calls: `pick`'s result had a place + in every call's copy (14), each of those actions read the parameters (so `x` had 30 places), and the counter passed + as `x` had as many again — a call wrote about 145 records. And a copy in a map of our own was a trigger a bit. +- *A called function's result in its cell alone* (`Emitter.results`): each call reads it from there, so a return + writes one or two actions and a parameter has the places of the body only. +- *Copies routed in a map of our own* (`layout.ts`, `expandCopies`): a variable that is copied keeps its value in the + number of the one action of a trigger of its own (Set Deaths, add), 72 bytes apart and overlapped as eudplib's + EUDVariable is; a copy into a place sets the place to its constant, points that action at it and goes through, about + 50 ns a place against half a microsecond a copy. A multiple other than 1 is still filled a bit at a time. +- *A loop of reads broken once:* calls make one whenever what a function gives back reaches its own parameter + (`pick(a, pick(b, c))`, `i = next(i)`); the eudplib build's loops copy every variable of such a loop, a map of our own + copies the one whose writes are fewest (weighed by the loops around them) and looks at the rest again. Copies in + `own-calls`: 21 → 2. +- *Numbers* (lifted game, Remastered's weights): the frame of two hundred calls 0.63 → 0.29 → **0.11 ms** (eudplib + 0.17); `own-loader`'s payload 636 → 484 KB (its copies routed). +- *Found on the way, both builds:* a loop's `a && b` test was split by `writtenIn`, which saw assignments and + declarations only, so a part that a boolean set from a test or a call in the loop changed was tested once on the way + in (`while (i < 10 && go) { i++; go = i < 5; }` ran ten turns); the split now uses everything the loop may write. + *The eudplib build:* `y = a + bump()` read `a` after `bump()` changed it — the Python side read a variable where the + arithmetic ran — so a value left of a call is copied first (`Lowering.nums`: binary operators, comparisons, min / + max, fixed functions). `probes/own-calls.ts` C was WRONG through eudplib before. +- *Checked:* `tests/emitter.test.ts` — 3000 random programs with functions (calls as statements, in sums and + comparisons, in a later part of `&&` / `||`, as arguments of calls, in loops' tests; inlined calls; returns from + inside ifs and loops) run as written and as their graphs, every variable the same; about 430 of them laid out and run + from the payload's bytes as the game walks triggers (`runOwn`: the routed cells and their overlap included); the + loop split, which fails without the fix. `probes/own-calls.ts`: its nine lines the same at the same frames built + both ways, every one ok; game-check: 327 accesses, none ending the game. Every game probe with the emitter on. +- *Not yet:* a function that calls itself (a stack of our own); calls inside the eudplib build's loops; the eudplib + build's loops breaking a loop of reads once (to be measured there: its copies are routed reads already); a call in + a `do` loop's test. +- **Played in Remastered (2026-10-19):** `trigscript-own-calls.scx` printed all ten lines, every one "ok", in the + lifted game's groups (3, then 5, 7 and 10 lines about four frames apart: frames 2, 6, 10 and 14), no error, no + stutter seen. The first map of ours with routed copies: Remastered runs the overlapped cell triggers as the lifted + game does. + +### Recursion on our own backend (2026-10-19) + +The meaning is the Python side's (slice 8): a call that may come back into the function it is in (`Call.saves`, which +the front end makes a statement of its own) keeps that function's variables and where it goes back to around the +call, and takes them back after; past the depth the script's settings allow (1024 by default, counted in calls over +every function) the program says so in red — `TrigScript: stack overflow in f, line n - 1024 calls deep. The program +has stopped.` — and stops for good. What a map of our own does with it: + +- **A stack of cell triggers.** The cells are the routed copies' (72 bytes apart, overlapped, one action that adds + its number where its destination says and goes where its next pointer says). Keeping a frame is one trigger, a Set + Deaths a variable, its destination a place of the stack's `sp` and its number a place of the variable; taking a + cell back is a trigger that sets a scratch cell to 0, points the stack cell at it and back, and goes there (its own + next pointer a place of `sp`), and the scratch cell then goes into every place of the variable. eudplib's build + reads a cell back a bit at a time (`f_dwread_epd`, about 35 triggers). +- **Where a function goes back to**, for one that calls itself, is a variable whose places are the next pointer of + the trigger it ends at and each store that keeps it; every call of the function sets them all to its own label (a + `label` item, expanded once every place is known). The frame of a call keeps the calling function's, as the Python + side's does — with two functions that call each other they differ. +- **A stack for each function.** One for them all gave `sp` a place at every call of every function that calls + itself — 107 in the probe — and each call wrote them all twice; a stack of its own keeps a function's `sp` to its + own calls. The depth that runs out is still one count for them all. +- **Only what is read again.** `saves` lists every variable of the function, on purpose; a map of our own keeps no + more of it than some way on from the call reads before writing (`liveAfterCalls`, worked backwards over the + function's statements: loops until nothing more comes in, `break`, `continue`, a `return` from its value and where + it goes — an inlined call's goes on after the call). A call that may come back takes back what it keeps, its own + parameters among them, so it writes only its result as far as the function is concerned. `fib` keeps two cells a + call instead of three. + +| `probes/own-recursion.ts`, frame 10 (`fib(18)`: 8361 calls; `down(1000)`) | ms | +| --- | ---: | +| One stack | 32.05 | +| A stack for each function | 17.78 | +| Only what is read again | 13.06 | +| Where it goes back to written by each call, not copied before each store | **11.64** | +| Through eudplib | 26.06 | + +Going past the depth (frame 14, 1024 calls): 0.25 ms against eudplib's 0.51. Payload 1.87 MB (the stacks: 72 bytes a +cell, the depth times each function's largest frame). + +Checked: the random programs with functions now with functions that call themselves (a depth given that goes down +each call, one or two calls of itself a statement each with the front end's `saves`, inside loops and ifs): 1500 in +the suite (644 with a stack) as written, as graphs and a fifth of them from the laid-out bytes; 14,000 once (`EMITTER_SEEDS`, +in pieces side by side; a program the evaluator runs past a hundred thousand statements is set aside — functions +calling themselves from inside each other's loops multiply into hundreds of millions of calls, which the interpreters of +the test would take minutes over; the test fails past a few in a hundred). They found +two mistakes of the liveness before it was right (an inlined call's `return` taken as leaving the function; a call +that may come back taken as writing its parameters). `probes/own-recursion.ts` (fib, two functions calling each +other, a loop around the call, parameters handed on crossed, a thousand deep, fib(18), past the depth): its lines the +same at the same frames built both ways, every one ok, the overflow line in red and nothing after it. + +- **Played in Remastered (2026-10-19):** `trigscript-own-recursion.scx` printed all nine lines word for word, A–F + "ok", in the lifted game's groups (3, 5, 7, 9 lines; game frames 2, 6, 10, 14 over 333 ms), the overflow line red + and nothing after it; no stutter seen on the frame of about 9,400 calls. Remastered runs the stacks of cell triggers + as the lifted game does. + +Not yet: an array declared in a function that calls itself (its handle on the stack), units and texts kept on it. + +### Texts with numbers in them, on our own backend (2026-10-19) + +Step 2 of the way off eudplib. eudplib's build makes a number into text by dividing it by ten for each digit, a bit +at a time, and writes the text a byte at a time through a cursor (a read of the dword, then a write). The proposal +was a table of 1,000 three-digit cells; working it out, the table only saves the last step — a number still has to be +cut into groups of three, a division by 1,000 — and the harder part is where the digits go: a number's width changes +from print to print, so everything after it moves a byte, and the game writes whole dwords. What was built instead: + +- **A template for each print**, laid out when the map is built: its text, and for each number a field of 12 bytes + on a dword's start, the room before the number filled with 0x0D — a carriage return, which StarCraft draws as + nothing; eudplib pads its strings with it, and its own prints carry it around every number (the lifted game's text + lines through eudplib: `A: \r0\r\r\r, \r\r7…`). Nothing in a text ever moves. A byte from a value (a colour from JSX) + is one byte of the template, 0x0D for a 0. +- **One routine writes a number into a field**, shared by every print: the field set to 0x0D; the number's size (a + signed one's sign apart); how many digits, by comparisons with powers of ten (four at most); then for each digit + from the first a trigger each for 8, 4, 2 and 1 times its power of ten, which takes that much from the size and + adds it to the digit's byte when the size holds it. The '0's and the minus sign go in with the trigger the + comparisons land on. About four triggers a digit. +- **A print** hands each number to the routine, copies its three dwords into the field (routed, as any copy), sets + its bytes, and shows the template with Display Text. The template is a string of the map's: a placeholder three + bytes longer, the template written in it from its first dword's start and the string's offset moved there + (`scripts/build-own.mts`) — among the map's own strings, since 1.16.1's table names only the first 64 KB of them. +- **The values in JavaScript's order**, worked out before a later value's call (the rewriter). + +| `probes/own-print.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| One text, one number (frame 6) | 0.001 ms | 0.013 ms | +| A hundred texts of two numbers (frame 13) | **0.133 ms** | 2.411 ms | + +Checked: the random programs print now (words, one of them Korean, numbers signed and `u32`, bytes from values): about +9,600 texts in the suite compared byte for byte with what JavaScript makes of them (less the 0x0D), as graphs and from +the laid-out bytes, and in the programs with functions that call themselves. `probes/own-print.ts`: its twenty lines +the same at the same frames built both ways, once the 0x0D are taken out of both; game-check: 178 accesses, none ending +the game. + +- **Played in Remastered (2026-10-19):** `trigscript-own-print.scx` printed every line word for word as the lifted + game — the Korean too — with nothing drawn where the 0x0D are, in Remastered's graphics and, after F5, in Classic's. + +Not yet: a fixed number printed (`3.25`), a player's name or colour, a text of the program's; `position: "center"`. + +### Arrays on our own backend (2026-10-19) + +Step 3 of the way off eudplib, its first half: arrays of a fixed length — of numbers and of booleans, windows on them +(a grid's rows), lists known when the map is built — in a map of our own. Growing arrays, the heap, are the second. + +- **A region of cell triggers for each array**, laid out after the triggers as the stacks are: eudplib's EUDVArray's + cells, 72 bytes apart and overlapped, each cell's number at +348. A window is its array from an offset. +- **A store is one action** — Set Deaths, its destination a place of the index (the first cell's EPD plus 18 a cell), + its number a place of the value — under a condition that the index is inside the array, read from 0 up. +- **A read into a variable is a jump into the cell**: one trigger points the cell's action at the variable and its + next pointer back, and goes to it — the three a place of the index — and the cell adds its number in. Straight into + the variable when it has no other place; otherwise into a scratch cell copied into its places. +- **A read in a test is a condition**: the cell against a constant, read through the condition's player field, which + is a place of the index — `if (!seen[n])` is one trigger. A signed comparison is the ranges of bits it holds for: one + or two conditions, or two triggers either side of the sign. The index is tested inside first, in the same trigger or + one before it, so a cell past the end is never read (the game stops at the first condition that fails); outside, a + cell reads 0, as on the Python side. +- **What the value ranges know** (`compiler/ranges.ts`, as the Python side takes it): an index known to be inside is + not tested, and a store then shares its trigger with the writes after it — `while (m < 2000) { big[m] = true; m += + n; }` is two triggers a turn: the loop's test, and one with the store, the add and the index's places. A read moved + ahead of what decides whether it runs (a later part of `&&` or `||`, a value worked out once ahead of a loop) is + tested still, since a range known from those parts does not hold there. +- **The index's own places**: the store's destination, the jump's fields and the condition's player field are places + of the index variable itself, kept by each write of it. A variable whose places would pass a trigger's worth (64) + is copied by the plan instead, and read where it is used; the fields then read a variable of the emitter's own + written just before. That is for the few that need it: along a run of variables each set from the one before + (values that functions give back, in the random programs with functions that call themselves), places multiplied + into the thousands — copies into 4,600 places at a call's way back, the test of those programs taking 100 s. + +Fixed on the way: `==` and `!=` compare the 32 bits as they are, signed or not — `x == 0` is one condition on the +variable's own cell, where it was two on a copy with the sign moved; a trigger that does nothing is dropped; a cell +read before a call that stores into its array reads what was there, as JavaScript does (`a[i] + f()` read `f`'s +store before); a part of a loop's test that reads a cell is tested every turn (it was tested once on the way in when +nothing else in it changed, and the loop ran on past it). + +| `probes/own-arrays.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| Stores and reads at an index, a grid's rows (frame 2) | 0.010 ms | 1.085 ms | +| Past the ends; a sieve of booleans to 200 (frame 6) | 0.055 ms | 0.200 ms | +| A function reading an array; a sieve to 2000 (frame 14) | **0.581 ms** | 2.541 ms | +| Reads in order; cells in tests (frame 18) | 0.004 ms | 0.015 ms | + +Frame 14 came to 1.665 ms first: every index went through a copy of its own, and a cell in a test was read into a +variable — four triggers a turn of the sieve's inner loop, eleven of the outer. 0.770 ms with the ranges, the index's +own places and the read straight in; 0.581 ms with the test as a condition: two triggers a turn of the inner loop, +three of the outer. + +Checked: random programs with arrays of numbers and of booleans, stored at and read in values and tests, in loops, +functions and functions that call themselves — as written, as graphs and from the laid-out bytes, every other one with +the value ranges a run of it shows (about 4,500 indexes); `probes/own-arrays.ts`: its twelve lines the same at the +same frames built both ways, every one ok; game-check: 4,731 accesses, none ending the game; the calls, recursion, +print and own-loader probes still all ok, recursion's frame 11.53 ms (11.64 before). The suite: 1231 pass. + +- **Played in Remastered (2026-10-19):** `trigscript-own-arrays.scx` printed all twelve lines word for word as the + lifted game, in its groups four game frames apart, every one ok; the group of the two sieves came 150 ms after the + one before (the others 150 to 183 ms), no stutter seen. + +Not yet: arrays that grow (`push`, `pop`, a length set: the heap), arrays of texts, `u8` and `u16` arrays, a task's +arrays; numbers (step 4) — the probe's first store was `board[i] = i * i + 1`, refused. + +### The way off eudplib, audited (2026-10-19) + +Step 4 was found by accident: the arrays probe's `i * i + 1` was refused, and the probe was changed to fit. So the +list was checked against everything the Python side does, from four sides: + +- **The probes.** Every program of the 93 probes given to the own emitter, then each of its statements and expressions + alone, every refusal counted by what refused it. +- **One feature at a time.** About 230 scripts of a feature each, compiled and given to `emitBody`. +- **The code.** The IR, kind by kind, against `emit.ts` and `python/trigscript.py`; eudplib's own machinery (its + injector, euddraft's driver and plugins, `fixmapdata`); the euddraft plugins the input uses. +- **Everything that reads a build.** The editor's build, the tools of Part A, the scripts, the tests, CI, scm-sim, and + the other repositories that mention eudplib. + +**What a map of our own takes today.** Numbers added, subtracted, multiplied and shifted by a constant, compared, +`min`, `max`, `abs`; booleans; arrays of a fixed length; calls and recursion; a print of known text and whole numbers to +the current player; sleeps; loops; and what the front end turns into those — classes, records, generators, labels, +sorting, callbacks, function values, `await` of a function, `until`. Twelve of the 93 probes build: the five own-backend +probes and seven measurement probes. No showpiece does. + +**What the nine steps left out** — steps 5, 6, 7 and 14 to 16 now, and parts of 4, 8, 9 and 11: + +- *The game itself.* The graph and the layout can hold three kinds of record. Every other action is refused — Create + Unit (5,992 statements across the probes), Remove Unit, Set Resources, Play WAV (312), Center View, Talking Portrait, + Transmission, Minimap Ping, the countdown, leaderboards, objectives — and every read of the game: ore, deaths, Bring, + kills, a player's slot, race or leaving. Random numbers too. No step named any of it; units (old 5) covered only + unit values. +- *The game's memory.* `stats()` tables, `memory.*`, `local.*`: what the fog library, the raycaster and Bad Apple + write. +- *Locations.* `centerLocation`, the reserved ones, the scratch location an order goes through. +- *Fixed point past `+` and `-`.* `fmul` (23 statements), `fdiv` (81), `sqrt`, `sin`, `cos`: old step 4 named only + whole numbers. +- *Errors.* `try`, `throw`, `uncaught`. +- *Programs and players.* 27 of the 93 probes have more than one program, and `trigscript/fog` adds one to any script + that imports it; `build-own` takes one. The start-up links the owner's list only (`layout.ts`): an empty owner slot + never starts the program, and an owner who leaves may stop it. eudplib links a runner into every player's list. +- *The build and the tools.* No build step in the editor (`build-own` is a script that imports the editor's code from + a checkout, and the plugin API cannot write the archive); no sounds in the archive; none of eudplib's fixes to the + map; Magenta and TrigScript cannot share a map (two payloads at one address, two links of the lists); A1 to A5, + Find an EUD Error and the test harness read the variable and trigger maps only the Python build writes; no built map + is tested in CI; and once the Python is deleted, nothing is left to build the second way and compare against. + (Step 14 made the build step, on the scenario's bytes alone — the editor writes the archive — with eudplib's fixes, + the sounds from the apply, and a map with Magenta's triggers sent through eudplib; the tools and CI are step 15.) + +**Wrong without a word — made refusals with this audit.** `print(…, { to })` was shown to the program's owner +whoever it was for (`Scope` never read `to`; the Python side sets each player of the group current in turn): a text +for anyone else is refused now (`tests/emitter.test.ts`). `trigger()` records were dropped by `build-own`, which +clears the map's triggers: refused now. + +**Why the arithmetic stayed hidden.** Most refusals read `a assign it does not lower (no reason given), line N`: +`Scope.test()` drops the reasons `bool()` gives, `lowers()` and `withCells()` give none, and `why ||=` keeps an +earlier reason that is not the one. `a * b`, `a / 4`, `random(6)` and `minerals(P1)` were all refused in the same +words. Step 14 asks for a refusal that names what it refuses; the ledger (below) asks for it to be right. Since the +ledger, a refusal names the first part of the statement that the emitter never takes (`unlowered` in `emit.ts`) and +its line — "a product of two values, line 51", "the action Set Resources, line 10", "a write of unit.sight, line 550", +"a unit's hp" — and falls back on the checks' own reason, now kept statement by statement. + +**Smaller things**, to fix inside the step that meets them: +- A boolean `?:` is refused, with constants on both sides too (`Rewriter.cond` has no case for it). +- A call in a `do` loop's test is refused. +- `emitBody` does not try a crowded branch again with copies, as `emitLoop` does. +- "An array read at the loop's counter", a rule for eudplib's loops, refuses code of our own too. +- `build-own` passes no stack depth, so the refusal that says to lower it cannot be followed. +- Arrays and stacks past 65,536 cells. + +**Which showpiece waits on which step.** Tetris: 4 (random), 5 (Remove Unit), 11, 12. Snake: the same and 8 (JSX +made in the game). The raycaster: 4 (fixed point, `sin`, `cos`), 5 (`centerLocation`), 6, 7 (the fog's program), 12. +Bad Apple: 5, 6, 7, as the fog library. The bullets: 5 (a death count read), 9. The cutscene: 5, 8, 9, 10, 11, 12. + +**Decisions it asks for:** +- *The changeover.* Build our own way wherever the emitter takes the whole script, and through eudplib elsewhere, + saying which in Output — from step 14 on; or switch nothing until step 13. The first gives map makers the speed early + and needs a script's refusals shown while it is typed, so that a map does not change builds without anyone seeing. +- *Protection.* eudplib scrambles the locations, shuffles its payload and adds an ISOM section of negative length; + ours does none of it. Keep none of it, or some? +- *Magenta.* It keeps plugin-eudplib alive. Until it builds on the own layout, a map with both could have TrigScript's + program built into Magenta's eudplib build, or refuse the pair. + +**The ledger, so the list stays whole.** Built before step 4: see the next section. + +### The ledger (2026-10-19) + +`ledger/` holds a script for each feature of the language and the library — 849 of them, in folders by area — whose +first line says which step of the way off eudplib makes it a map of our own's, and whether a map of our own takes it +today, in the words of its refusal: + + // ledger: step 4, refused: a product of two values + // ledger: step 1, takes + +`tests/ledger.test.ts` compiles each one as the editor does and gives it to `emitOwn` (`compiler/backend/own.ts`, the +checks `build-own` makes, now shared); it fails when a first line no longer says what happens, and when a kind of the IR +(129, less six that never survive the front end), a name a script has from the library, a member of `on`, `ui`, +`memory`, `local`, a unit, a set of units or a task, or an export of a module of the standard library (340 names in +all) is in no script. When a step lands, `npx tsx scripts/ledger.mts --write` writes the first lines anew and the diff +is the step's account; `npx tsx scripts/ledger.mts` prints the table, `--check ` looks at a few, `--missing` +lists what no script reaches. A step is done when its scripts say so. + +| Step | Taken today | +| --- | ---: | +| 0 The start | 158 of 158 | +| 1 Calls and recursion | 33 of 33 | +| 2 Texts with numbers | 6 of 6 | +| 3 Arrays of a fixed length | 54 of 54 | +| 4 Numbers | 59 of 59 | +| 5 The game's conditions and actions | 197 of 198 | +| 6 The game's memory | 50 of 50 | +| 7 Programs, owners and players | 27 of 28 | +| 8 Texts as values | 48 of 48 | +| 9 Arrays that grow, the heap | 27 of 27 | +| 10 Units | 99 of 99 | +| 11 Tasks, `async` and errors | 50 of 50 | +| 12 Input | 0 of 26 | +| 13 The map's own triggers | 0 of 13 | + +(Step 5 has 195 rows since two of step 6's — `memory()` and `setMemory()` at an address known when built — came with +its part 1.) Every step called done is: a `?:` of two booleans (step 0), the last, is taken since step 5's part 1 — the +side its test picks worked out under an `if` (`Rewriter.cond`). (A cell read at a shifted index, `xs[a >> 1]`, the other +one, is taken since step 4's part 4.) Every script a map of our own takes was also run +from its laid-out bytes against the simulator — the numbers, arrays, texts and language scripts, and about forty edge +cases besides (signs of `>>`, `u32` printed and compared, wrap-around, reads past the ends, negative indexes): all +printed the same. The README's reference to the language predates TrigScript 4 (it says `try` and `async` are missing): +the ledger is the list it can be written again from at 4.0. + +**Found while writing it**, in the front end — both builds — unless said: + +*Fixed with the ledger:* +- A field or flag read straight off a pick was worked out when the script was built, as `undefined`: + `` print(`${first(…)?.hp}`) `` printed "undefined", `if (first(…)?.cloaked)` was nothing at all, and + `n = first(…)?.hp ?? 5` was `n = 5`. Now a read of the game's, as `u.hp` is (`tests/units.test.ts`); a `??` after + it is a compile error, as after `u?.hp` (below). +- The action `order(…)` did not compile inside a program: `on.order`'s name was taken for it, and its arguments were + never worked out (`tests/events.test.ts`). +- `music.loop(song)` never compiled — the loop showed no wait of its own (`tests/music.test.ts`). +- A map of our own refused a print to the owner named by its slot (`{ to: P1 }` in P1's program), and took a program + owned by P9 to P12, whose triggers the game does not run — refused now (to be confirmed in the game). + +*Fixed after it (both builds):* +- A function that keeps a state — one changing a variable declared outside it, a closure's, or a field of `this` + outside a constructor, itself or through another function of the script's — was worked out when the script was + built, once where its call was written: `` const next = counter(); while (…) print(`${next()}`) `` printed + 1, 1, 1 for 1, 2, 3 (`hoist.ts` `keepsState`). Now a call of it is the program's: a closure made in the program runs + in the game (`ledger/language/closure-keeps-a-count.ts`), and one made when the script was built, a method of an + instance made then, or a function writing a `let` or a record of the script's is refused, as the write written in + the program is (`tests/script-functions.test.ts`). A cache filled by a method (`films.set(…)`) is no assignment, and + a function handed to `forEach` writing its own function's variables is that function's business: both are still + worked out when built. Three calls written one after the other used to come out right; they are refused now too. + Not caught: a state changed only through a method (`log.push(…)`, `counts.set(…)` on something of the script's) — a + cache does the same, and the two cannot be told apart from the code. +- `order(…, orders.Move)` made an Order action of modifier 6 (orders.dat's number), where the action knows move, + patrol and attack (0, 1, 2). orders.Move, orders.Patrol and orders.AttackMove are now those three, and any other of + `orders.*` a type error — `orders.Die` and `orders.Stop`, 0 and 1, were taken for move and patrol + (`tests/events.test.ts`). A raw number is a second signature, the action's own. + +*Open:* +- Step 15's leftovers (2026-10-23). *Two bench lines slower* than the Python side's: REF_FIELD_WRITE 43 → 51 ns, + DIV_CONST_R 261 → 293. *The allowlist* is not made anew from own-build traces (`scripts/allowlist.mts`); the + probes' traces say nothing outside it is touched, which is what the list is for. *`game-check`'s line checks* + hold only a line that says in brackets what it expects ("A: 7 30 (expect 7 30)", the lifted game's rule): the own + probes say "ok" instead, so "0 as expected" — their answer sheets are what holds them. + *A probe through eudplib* (`EUDPLIB=1`) still needs the plugin-eudplib checkout and the Python: step 16's gate is + the last use of it. +- Step 14's leftovers (2026-10-23). *Magenta.* A map with Magenta's member goes through eudplib with TrigScript's + programs in its build. Decided 2026-10-23: Magenta's build rows go on our backend too — a slice of its own after + step 17 (*Magenta on our backend*, under *Open decisions*). *Protection.* Decided 2026-10-23: none. The own build + scrambles nothing (eudplib's MRGN, shuffle and fake ISOM), and will not. *The fixes' lines.* + "227 rows of UNIx at the defaults with settings left in: zeroed" is true of nearly every map StarEdit wrote (it + fills the defaults in); the line could be quieter. *A refusal found at build only.* The apply's compile answers for + the map as it was; a map whose players change after the apply (a slot made a computer, say) is built with the + manifest's answer until the next apply — the step then throws and Save goes on without the programs, saying so. + *The step's purpose.* `document.export` from a plugin builds too (the host runs the steps); nothing says which plugin + asked. *Sounds.* A clip the apply wrote as a member is in the archive; a build after `withSounds` refused one is not + reached (the apply throws first). +- Taken by the bug pass of 2026-10-23 (*The Open list's bug pass*, below): `new P(4).x`; `disabled()` and `preserve()` + in a program; the false errors (`n++` as an index, a comparison as a boolean argument, a `switch` on a known text, an + arrow's `this` in a method, `first(…)?.order(…)`, `u?.hp ?? 0`, `sleep(…).then(() => 7)`); `return` at the top of a + program and the cutscene example; `~`, `**`, `Math.pow`, `Math.sign`, `Math.log2`, `fill(v, start, end)`; a + condition kept in a `const` with `&&`; a build-time throw inside a `try`; `rows[i].pop() ?? -1`; the emitter's + fold of a masked scratch cell into a constant; `memory()` of the deaths table's cells 12 to 26. +- `displayText(text, false)` with `name()` or `color()` in the text is a print, and a print is always displayed: said + as a remark since 2026-10-23 (a flag on the print IR would be the way to keep the `false`, in both backends). +- A `const HALF = 7 / 2` worked out when built is 3.5, a fixed number; the same `7 / 2` in the game is 3. (JavaScript's + arithmetic when the script is built, the game's in the game: as documented, not a bug.) +- `command()` straight after `killUnit()` or `removeUnit()` still counts the units (the game counts them until the next + frame) where the simulator does not; and two Transmissions, the second while the first one's time runs, hold the + second until the program reaches it again (both builds; found writing `probes/own-fields.ts`, 2026-10-20). +- A condition kept in a `const` is asked where the `const` is used: `const there = bring(…); centerLocation(…); if + (there)` asks Bring after the move (both builds; found writing `probes/own-locations.ts`). +- **Units on a map of our own (step 10), what is left:** a walk is about 2× eudplib's, not more: each condition that + reads the record is a field the step moves on (10 ns a place), and a pick's result is one more (the emitter has no + copy out of a variable's cell once that is not a place of it — to add). A loop over a player's units could walk that + player's list where the order cannot show (a count, a sum, a write to each), at about 0.7 µs a unit against 0.1 µs a + place of the table: worth it when the player holds few of the map's units, which is known only while the game runs. + `nearest` is 0.30 ms to eudplib's 0.33 for 115 units in one cluster, where the box rejects none: each unit's x and y + are read a bit at a time (32 triggers); reading only their high bytes first, to bound the distance, would halve it. + The map is bigger (2.86 MB to 1.82): reads of fields outside loops could be the run time's functions, as the heap's + are, and a loop's body is written up to three times (place 0, two places a turn). `give()` leaves the game's counts + as they were (both builds: eudplib's `cgive`), so `command()` after it reads the old owner's; the game's own Give + Units does not. +- **Errors on a map of our own (step 11, part 1), what is left:** a call in a loop of a function that may throw is + about eudplib's (W1, 0.18 ms to 0.21 for 300): the error costs it one action, but the call itself does not keep step + 1's lead (1.5×) — the argument is copied into each place of the parameter (here three: its cell, the home a signed + comparison reads, the action that adds 1; about 170 ns), and the result is copied out (75 ns). Value ranges carried + through calls would let a parameter never below zero be compared without a home; the result could be read where it + is. A throw is 3.2× eudplib's (W2), and most of what is left is its message, made where it is thrown (7.3 µs of 8.7 + when the message has a number in it): the throw could keep where it was thrown and its values, and the message be + made only where it is read — a `catch` that reads `e.message`, an uncaught error said — about 1.3 µs a throw, 20× + eudplib's, for the many catches that read nothing. In code only an error goes through, a signed comparison still + reads its variable's home (the variable plus 2³¹), a place every write keeps. The map is bigger (1.52 MB to 1.05). +- **Tasks on a map of our own (step 11, part 2), what is left:** a start that can run while its tasks still run lays + the task's body out once a slot (`max`, 16 unless said): the fastest at its turn, and small since a task's `catch` + is laid out once (a 16-slot start's body is 9 triggers in the probe), but a large body grows the map sixteen times; + one body with the slot's variables swapped in and out at each turn, as the Python side does, would be the way for a + large one. Such a task's arrays, and a start inside it, are refused for now (they would be a slot's own). A called + function that waits is refused in a program with tasks (it is laid out once, outside every coroutine). A task asleep + is a condition that fails each frame (13 ns), one with nothing in it a trigger passed by as done: a timer wheel + (idea 2) would make a sleeper cost nothing until its frame. "did not start" is said in red by the game, as the Python + side's build says it, where the simulator notes a fault (both builds). +- **Unit events on a map of our own (step 11, part 3), what is left:** the watch is gated since 2026-10-23 ("The + watch's gate", below) when neither orders nor types are watched; with `on.order` or `on.morphed` every place is still + walked every frame (48–60 ns a place: 9.3 µs at 128 places, about 90 at the full table), and no cheap sign of an + order or a type changed is known. The gate's holes, each a unit whose change the game's counts do not show (reported + at the next frame the gate opens, or lost if its place is handed out again before that): a Zerg Egg, Cocoon or Lurker + Egg dying while another unit's death is in progress (the counts leave the three out, and the dying list's head moves + only when it was empty); a unit removed while hidden in a transport or a bunker (not counted). A frame on which a + place is freed costs the watch and three reads (about 15 µs at 80 places): the place behind the head moves, and + nothing else says so. The gate's numbers hold in Remastered (played 2026-10-23: the probe's events came on the frame). + The handler reads a changed place's values a bit at a time, about 4 µs for a unit made: through the current player, + pointed at the record once, instead of a copy of the record's EPD for every read. The 1,700 places are two triggers + each (1.84 MB against eudplib's 1.27 for the probe without `on.order`): a stub of fewer actions packs tighter. +- **Input on a map of our own (step 12), what is left:** a carrier the map kills — a "Kill Unit for + Player 12" of a map's own triggers would — is not made again (MSQC's QCDebug respawns; ours should watch one + carrier a human a frame). Typed lines lean on the text ring's byte (0x640B58) moving with every line shown; if + Remastered's emulator holds it still, the scan of the thirteen lines must run every frame instead (the mark after the + colon keeps it right either way) — the probe settles it. Two lines typed in one frame keep the later one. The carriers stand at (64, 64): a `Bring` of the map's own triggers over a location at the + corner counts them for the human. The passes' cost a frame is not weighed. A sync() value is at most 2^20 − 2 on + any map of 64 by 64 or more, as before; on a smaller map the point carries fewer bits (18 on 32 by 32), and the + value is cut to that. The emitter's fold of a sum of bit-slices of one read with a negative constant (above, "Found + on the way") was the lone-place fold of a masked scratch cell, fixed 2026-10-23 (*The Open list's bug pass*). +- **The map's own triggers on a map of our own (step 13), what is left:** the order is eudplib's — every player's + triggers, then the programs — and no other can be said yet (a program before the triggers would run from the first + player's list, before their entry's chain: a word in `program()`'s options). A trigger for a player group the game + gives no list (Players 9 to 12, Foes, Allies, Neutral, Non-Allied Victory Players) is in no list, as in the game, + said as a build warning since 2026-10-23 (a notice in the editor and a line of the log). The map's own triggers' + Comments are kept as the game keeps them (a Comment action in the payload: 32 bytes each). A map maker's own hyper + triggers in the map's TRIG section are left out as the script's are, found by their shape (preserved, 32 or more + `Wait(0)`s) — said in the build's line, and as a build warning since 2026-10-23. The chain's nodes are + stacked as the graph's triggers are, 2,408 bytes a node only when full. Open in eudplib's build, found by the probe: + it runs a trigger whose Disabled flag is set (the game does not), and with `hyperTriggers()` in the map it goes + through the triggers every second frame (the game's own doing under eudTurbo, measured: a list with hyper triggers in + it is held to every second frame). +- A `deaths(p, u)` read whose cell is 12 to 26 dwords into the deaths table (Player 2's Ghosts, Player 1's Vultures) + cannot be asked by its EPD — the game reads the player group of that number instead (13 the current player, 17 all + players, 18 to 21 the forces) — so a map of our own asks the game's own Deaths condition of that player and unit, the + mask in its record (fixed in step 13, found by its probe: the read gave the current player's Marines). `memory()` and + `setMemory()` of those fifteen dwords name the cell as the deaths of player e mod 12 of unit e div 12 since + 2026-10-23 — the front end's doing, so both builds and the simulator have it. +- A variable set from a constant less itself (`j = 1 - j`) is copied a bit at a time — 32 triggers, about 0.5 µs, where + eudplib's is two actions: the game's subtract stops at 0, so the emitter copies a variable read times −1 (found + weighing the errors probe, 2026-10-22). +- A map of our own: JSX inside a program was "made while the game runs" even when it held nothing of the program's — + taken since step 8's part 3; + (`Math.tan` and `Math.hypot`, refused as what they become, are taken since step 4's part 5; `a >> 0`, refused where + `a >> 1` to `a >> 31` were taken, is the number itself since its part 2.) + +### Products and quotients on our own backend (2026-10-20) + +Step 4, part 1: `a * b` of two values, `a / b` and `a % b` of two values and by a constant, signed (towards zero, the +remainder with the dividend's sign) and `u32`, by 0 giving 0 as the simulator has it (`compiler/backend/emit.ts`, +`Emitter.product`, `divideByConst`, `divide`). Each is written where it is used, not called; the value ranges decide +how many bits it goes over. + +- **A product** tests the side the ranges leave fewer bits a bit at a time, where it is (a masked condition, free in + Remastered), and adds the other side for each bit set. When the tested side has 8 bits or fewer and the other side's + multiples can be fields of their own — a variable the plan does not copy, or a scratch value with few bits — each bit + is one trigger adding the other side times the bit (13 ns, 23 set). Otherwise Horner's way: the product so far + doubles in one action whose number is its own destination, in a trigger that also moves its own next pointer on a + trigger each time it runs, so it walks a run of bit triggers laid out one after another with nothing pointing it; + the other side is added from one shared trigger (about 70 ns a bit) or a field per bit (46). A side below zero is its + magnitude, the product negated as it comes out of the doubling trigger (its action pointed at a scratch cell and made + a subtraction from `MAX`). eudplib's product routes its operand through a call twice a bit, about 95 ns. +- **A quotient or a remainder by a constant** is eudplib's chain by a constant, inlined: a trigger for each bit the + quotient can have, as many as the ranges leave the dividend, groups of eight that cannot take anything jumped over; + one chain for a dividend below zero and one for the rest, each gathering the answer with its sign in one scratch cell. +- **A quotient or a remainder of two values** is long division with the remainder (and the quotient) doubling in such a + trigger: a step is the dividend's bit added, then one trigger shared by every step that takes the divisor away when it + fits — the divisor set into its two fields once, whatever the number of steps. A dividend below zero is read as itself + less 1, whose clear bits are its magnitude's set ones (a run of steps of its own). About 80 ns a step, as many as the + dividend has bits; eudplib's division routes its divisor into two fields of each of 32 steps first, about 6.5 µs. +- **What the plan copies is routed, not kept.** A variable the plan copies into its places after each write (one set + from itself times something, or from a product, a quotient or a cell jumped into: `Scope.heavy`), or a scratch value + such a routine fills, would have every field holding a multiple of it filled a bit at a time at each write. So such + an operand is routed into the routine's fields where the routine starts (56 ns a field, `setTo`, `routeFed`), and a + multiple of it is never asked for. + +Fixed on the way: a fill folded into a target with no other place (`apart` when the target is an operand, its scratch +cell's conditions and routes rewritten with its actions); a copy into fields holding multiples goes over only the bits +the value's range allows (`Copy.top`, now in a map of our own's layout too). + +| `probes/own-numbers.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| 200 products of numbers the ranges cannot bound (frame 4) | **0.677 ms** | 0.943 ms | +| 200 quotients and remainders by constants (frame 6) | 0.526 ms | 0.730 ms | +| 200 quotients of two values (frame 8) | 0.825 ms | 1.175 ms | +| 200 remainders of two values (frame 10) | 0.727 ms | 1.148 ms | +| 200 products and quotients of small numbers (frame 12) | **0.514 ms** | 0.764 ms | + +(After part 2's fill into the one field that reads a value; before it, 0.700, 0.572, 0.848, 0.750 and 0.514.) + +The first versions were slower than eudplib's: 2.08 ms for the products, which had every operand's multiples as +places — a variable set from itself had them filled a bit at a time on every write (6 µs a turn). The table is after +the routed operands and Horner's way. Each frame also carries the probe's own `x = x * 1103515245 + 12345`, an +existing path that copies `x` a bit at a time into the multiples its uses keep: most of what is left in frames 4–10. + +Checked: random programs with products, quotients and remainders of values and constants, signed and `u32`, with and +without the ranges a run shows (of the sides and the answers, about 9,600 sides), at every edge (`−2^31 / −1`, by 0, +divisors past 2^31) — as written, as graphs and from the laid-out bytes; the ledger's step 4 scripts against the +simulator; `probes/own-numbers.ts` built both ways, its ten lines the same at the same frames, every one ok. + +Next for these (before they are as far past eudplib as arrays are): a divisor whose range is small (`b` in 1 … 13) jumps +into a chain by each constant (about 240 ns where the doubling takes 900, at a chain's size each); a variable set from +itself times a constant (the LCG above) worked out by a product into a scratch value instead of copied; a shared routine +when a program has many divisions (each written out is up to 75 triggers, 180 KB of the payload). + +### Bits and shifts on our own backend (2026-10-20) + +Step 4, part 2: `&` `|` `^` as values, with a constant and of two values, and shifts by a value (`Emitter.bits`, +`shiftLeft`, `shiftRight`). + +- **Bits are masked writes.** The game works a masked Set Deaths on the bits under its mask alone — of the dword and of + the number both, so an addition's carry past the mask is lost (scm-sim's `maskedWrite`; the tests' interpreters + applied the mask after the addition and now do as the game does). So the left side is copied into a scratch cell + and: `& k` sets k's clear bits to 0, `| k` its set bits to 1, `^ k` adds k to the even bits and to the odd bits apart + (each sum's bit is the two bits' exclusive or) — two actions. Of two values the same: the right side is the number of + `^`'s two additions, or the **mask** of `&`'s and `|`'s one write — an action's mask is a field like any number + (`{ act, part: "mask" }`, the record's first dword), set from the right side as the routine starts, and for `&` its + bits flipped by two masked additions of all ones. This is eudplib's own way for two variables (its `&` is this write, + its `^` these additions), without the calls; a trigger a bit, built first, was 2.6× slower than eudplib. +- **A shift left by a value** is the number in a doubling trigger walked n times: runs of 16, 8, 4, 2 and 1 empty + triggers, one for each bit of n, the doubling trigger going back into its run after each (about 37 ns a doubling). + eudplib's is a loop of routed additions of the number to itself. +- **A shift right by a value** is Horner's way over the bits that stay: the answer doubling in a trigger that also + counts, a trigger a bit from the top adding it when set, and one shared test ending the walk when the count reaches + 31 — 32 − n bits, about 69 ns each. `>>` of a number below zero works on its bits flipped and flips the answer as it + comes out. eudplib's right shift by a value is 2^n by 32 comparisons and a whole division. +- **By 32 or more**, as TrigScript has it (the README's operators row; JavaScript shifts by the remainder): `<<` and + `>>>` leave nothing, `>>` the sign — tested unless the ranges know the count below 32. The emitter's existing `x << k` + by a constant took the count's remainder, and a count of 0 (`a >> 0`, refused before) is the number itself. +- **A value filled into the one field that reads it.** A product's, a quotient's or a mask's answer read by one write + only (`sum += x & 0xff`) is worked in that write's number where it is, not copied there (56 ns): a variable of the + emitter's own nothing reads at its cell has its scratch cell folded into its one other place. + +| `probes/own-bits.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| 200 each of `& 0xff`, `\| 0x100`, `^ 0x5555` (frame 4) | **0.063 ms** | 0.146 ms | +| 200 each of `&`, `\|`, `^` of two values (frame 6) | **0.086 ms** | 0.217 ms | +| 200 each of `1 << n`, `x >> n`, `x >>> n` (frame 8) | **0.834 ms** | 2.399 ms | + +Checked: random programs with `&` `|` `^` and shifts by values and constants (counts of 32 and more, below zero), at +the edges, with and without the ranges a run shows — as written, as graphs and from the laid-out bytes (2,995 of 3,000 +taken: a shift by a constant of what the emitter does not take yet is refused); the ledger's scripts against the +simulator; `probes/own-bits.ts` built both ways, its lines the same at the same frames, every one ok. + +### Numbers with a fraction on our own backend (2026-10-20) + +Step 4, part 3: `fixed` (16.16) products and quotients, by a constant and of two values, a constant over a value, and +a fixed number printed (`Emitter.fixedByConst`, `fixedOverConst`, `fixedProduct`, `divide` with a fixed or constant +dividend, `fixedParts`). The answers are the simulator's (`compiler/fixedMath.ts`): towards zero, wrapping at 32 bits, +a quotient by 0 is 0. + +- **By a constant** (`x * 1.5`): the magnitude's bits each add their share of the constant, split in two — the part + above the point straight into the answer, the part below into a low cell whose bits 16 and up are added after — on + two chains, one for each sign. eudplib's is its whole `f_mul` of two values and a shift. +- **Over a constant** (`x / 1.5`): the Python backend's `make_fdiv_by_const` written as triggers: the whole part a + chain of comparisons, then sixteen rounds that shift the remainder in place. +- **Of two values** (`x * y`): a loop of sixteen steps in a doubling trigger — each step tests the top bit of one side, + adds the other side's magnitude into the high part and its shifted half into the low, and the doubling trigger + doubles both and counts. 64-bit work in 32-bit cells, the low half's top bits added into the answer at the end. +- **A quotient of two values** (`x / y`) and **a constant over a value** (`3 / x`) are the division of part 1 with + sixteen more steps after the dividend's bits (the point); a constant dividend's bits are known when the map is + built, so its steps are entered by the divisor's top bits four at a time, with what the remainder holds there + preloaded. +- **A fixed number printed**: the sign, the whole part by the digits routine of step 2, and the point and four places + below it, rounded half up as `fixedText` rounds them (10 000 carried into the whole part), the zeros at the end not + shown. A trigger a bit for the places, and a trigger each for 8, 4, 2 and 1 times a place's power of ten. eudplib's + build divides by 65 536, by 1 000, by 100 and by 10. + +| `probes/own-fixed.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| 200 of `x * 1.5` (frame 4) | **0.179 ms** | 0.252 ms | +| 200 of `x / 1.5` (frame 6) | **0.290 ms** | 0.375 ms | +| 200 of `3 / x` (frame 8) | **0.348 ms** | 0.428 ms | +| 200 of `x * y` (frame 10) | **0.524 ms** | 0.677 ms | +| 200 of `x / y` (frame 12) | **0.693 ms** | 1.093 ms | +| nine fixed numbers printed (frame 12, past the quotients) | **0.015 ms** | 0.174 ms | + +`sqrt`, `sin`, `cos` and `atan2` came in part 5. + +### Random numbers on our own backend (2026-10-20) + +Step 4, part 4: `random(n)`, `random()` and the `trigscript/random` library over them (`Emitter.randomNumber`). + +- **The game's own random bits.** Set Switch to a random state is one draw of the game's generator — bit 7 of its + 15-bit number, the same on every machine (scm-sim, round seventeen). The routine draws into switch 256 and tests it + with a masked condition where the game keeps the switches, a trigger each, and puts the switch back as it was. +- **Drawn, not divided.** random(n) draws the bits of n − 1 from its top bit down and draws again when they make n or + more — fewer than two tries on average, never for a power of two — so every number below n is exactly as likely. A + constant n's bits are known when the map is built; a value's top bit is found by a search of comparisons, and the + draws entered there. random() is one bit, and a bound of 0 or 1 gives 0. +- eudplib seeds a generator of its own from a switch once (`f_randomize`) and then works each draw out with two + products (`f_dwrand`) and a division by n (`f_div`), whose remainder leans a little towards the small numbers. +- An index that is more than a sum — a random number, a product, a shift — is now worked out into a variable first, so + `pick(list)` and `xs[a >> 1]` (step 3's last refused row) are taken. + +| `probes/own-random.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| bounds: 82 draws of `random(n)` (frame 2) | **0.045 ms** | 1.365 ms | +| 200 of `faces[random(6)] += 1` (frame 4) | **0.423 ms** | 1.226 ms | +| 200 of `random(j % 10 + 1)` (frame 6) | **0.257 ms** | 1.836 ms | +| 200 of `random()` (frame 8) | **0.063 ms** | 0.252 ms | + +Checked: random programs with fixed products, quotients and fixed numbers printed, as graphs and from the laid-out +bytes; random numbers below constant and variable bounds drawn 400 times a value, each within a fifth of its share, +both ways; the ledger's fixed scripts against the simulator line for line, its random ones 200 times each way (the same +shares, within chance); `probes/own-fixed.ts` and `probes/own-random.ts` built both ways, every line ok. + +### Square roots, sines and small numbers on our own backend (2026-10-20) + +Step 4, part 5, its last: `Math.sqrt` of a fixed number and of a whole one, `Math.sin`, `Math.cos` and `Math.atan2` +(and what comes down to them: `tan`, `hypot`, `atan`, `trigscript/math`'s `distance`, `angleTo` and `Vec.length`), and +`u8` and `u16` variables and arrays. Step 4 is done: the ledger's 59 rows are all taken. + +- **A square root without a variable amount** (`Emitter.squareRoot`). The Python side's `root4` compares the remainder + with four times the root so far and takes it away — an amount that changes every step, which a trigger can only + hold in a field something copies there. Here the root is kept at the place its bits end up (after s of K steps, + r × 2^(K − s)), so it only ever gains a constant, and the remainder doubles instead, in a doubling trigger. Each + step: the next two bits of the number added to the remainder at half its scale (two triggers testing them where the + number is); one trigger shared by every step comparing the remainder with Q = ρ + 2^(K − s − 2) — its condition's + number — and taking Q away — its action's; that trigger moves its own next pointer one trigger on when it took Q, into + a pair laid out for each step, which adds the step's constants to the two copies of Q (and the bit to the root) and + moves it on to the next step's pair. The remainder at half scale stays below 2^32 even for a whole number's root of + 31 bits. Steps whose bits are all clear are jumped over four at a time. About 120 ns a step while the number's bits + come in, 100 after; eudplib's is about 280. +- **Sines and arc tangents as statements** (`Rewriter.sine`, `arcTangent`, `between`): the product by the scale, the + cell's place and the way to the next by masks and a shift, the table's two cells read as any array's cells are, their + difference times the fraction — a small product, the ranges say so — shifted down. The arc tangent's quotient of the + smaller side over the larger is `divide`'s new `ratio`: 17 steps, not the 47 of a fixed quotient, since it is at most + 1. Past a table's end a read is 0, which is the arc tangents' rule and the sine table's first cell (sin 0). +- **`u8` and `u16`** are a cell each, as the Python side's are: every store into one stopped at its top, read from 0 up + (`Rewriter.top`) — the front end already stops it at 0 — unless the value ranges keep it below. +- **Found on the way:** a whole number's square root below zero — read from 0 up, so the largest fixed number — had the + value range 0, so `Math.sqrt(w) == …` was worked out the wrong way when the map was built, in both builds + (`compiler/ranges.ts`; a test in `tests/fixed.test.ts`); and a shift whose count comes to a constant (`0 >>> (c - c)`) + was refused (`linear` folds it now). + +| `probes/own-math.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| 200 of `Math.sqrt(x)`, x fixed (frame 4) | **0.389 ms** | 1.348 ms | +| 200 of `Math.sqrt(n)`, n whole (frame 6) | **0.653 ms** | 3.672 ms | +| 200 of `Math.sin(x)` (frame 8) | **1.085 ms** | 2.261 ms | +| 200 of `Math.cos(x)` (frame 10) | **1.085 ms** | 2.261 ms | +| 200 of `Math.atan2(y, x)` (frame 12) | **1.343 ms** | 2.300 ms | + +What is left to gain: a sine is still most of its time in copies — the cell's place into the five fields the two reads +test (0.9 µs), and two copies of 32 bits (0.9 µs each), since a cell read into a variable of the emitter's own cannot +be read negated — a table of the differences, or a read that gives a cell and the next at once, would take them away. +And each square root, sine and arc tangent is written where it is used (a square root about a hundred triggers, 240 +KB): `probes/own-math.ts` is 9 MB. A routine shared by every use, as the digits are, is the next thing for code size. + +Checked: random programs with square roots (fixed and whole), sines, cosines and arc tangents, `u8` and `u16` +variables and arrays, as graphs and from the laid-out bytes (3,000 of 3,000 taken); the ledger's scripts against the +simulator; `probes/own-math.ts` built both ways, every line ok — its edges: roots of 0, below zero, the largest, a +whole number from 2^30 up; angles on the axes and of (0, 0); sines at a turn. + +### The game's conditions and actions on our own backend (2026-10-20) + +Step 5, part 1: every condition and every action of the game's whose fields are known when the map is built — Bring, +Command, Accumulate, Switch, the clocks, Score, Opponents, Always and Never and the rest; Create Unit, Kill and Remove, +Give, Set Resources, Set Switch, the leaderboards, Transmission, Play WAV, Center View, Victory and the rest — taken by a +map of our own and laid out as the game's own record (`Cond.game`, `Act.record` in `compiler/backend/graph.ts`; the +layout writes the record as it is, the action's text and sound file put among the map's strings by `build-own`, and the +script's clips into the archive). `memory()` and `setMemory()` at an address known when built are a Deaths and a Set +Deaths whose player is the address's EPD: they came with it, from step 6. Wait is refused (it would hold the owner's +whole trigger list; `sleep` is the program's wait). Ledger: step 5 118 of 195 taken; the 77 left are fields from values +(34), the game read as a value (18), `centerLocation` (6), texts made or shown to others (7), the players' facts and +supply (7), Wait, `rose`, `once`. + +- **Turned around in one condition** (`gameConds`): `!` of at least n is at most n − 1 (at least 0: never), at most n + is at least n + 1, a switch set is cleared; Always and Never are known. Exactly, the Most and the Least have no + opposite in one condition: the trigger that asks them sends the walk past the goto. eudplib's `EUDNot` is a trigger + setting a flag and one testing it. +- **`&&` in one trigger.** A test whose every part fits in one trigger — up to 16 conditions — is one trigger that skips + past the goto, where it was a branch a part (`Emitter.jump`); `||` turned around the same. This is general, as is the + next: with the two, the numbers probes are 2–8% faster. +- **An `if` in one trigger** (`Emitter.guards`, `Proto.guarded`): no `else`, a body of actions of the game's and writes + that read nothing but their own target (`n++`, `n = 3`), conditions that fit in one trigger — that trigger is the + `if`: its conditions, then its actions, run when they hold. No branch and no put-back. The copies of a variable it + writes, when the plan copies it, come after it whether the conditions held or not — they write back what the places + hold when they did not. +- **Bring counted again.** Bring keeps the counts it found for a location until a query of another kind is asked: + after an action that makes or moves units (Create Unit, with properties, Move Unit, Give Units), a program that asks + Bring gets a trigger asking Command, as the Python side's `forget_counts`. + +| `probes/own-game.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| The start and every check once (frame 2) | **0.003 ms** | 1.074 ms | +| 200 turns: Set Resources, `if` of 3 conditions → `n++`, Set Deaths (frame 4) | **0.020 ms** | 0.028 ms | +| 200 turns: `if (!deaths(…, "==", 5)) n++`, Set Deaths (frame 6) | **0.021 ms** | 0.038 ms | +| A frame with nothing to do | **0** | 0.004 ms | + +Frame 4 was 0.029 ms (a tie) before `&&` in one trigger and the `if` in one trigger; the 200 turns are four triggers +each now — the loop's test, Set Resources, the guarded `if`, Set Deaths with `j++`. What is left there is the loop's +own test at its head, which every loop pays: a loop tested at its foot would save a trigger a turn. + +Checked: `tests/ownGame.test.ts` (the records byte for byte, the conditions turned around, the `if` in one trigger, +Bring counted again); the emitter's random programs; every earlier `probes/own-*.ts` built and played again, every +line ok; `probes/own-game.ts` built both ways, the same lines at the same frames. + +### Fields from values on our own backend (2026-10-20) + +Step 5, part 2: an action of the game's whose fields come from values — a unit type, a count, an amount, a time, a +player or a location — and one whose text is made while the game runs (a Transmission, the objectives, a +leaderboard's label). Ledger: step 5 154 of 195; the 41 left are the game read as a value (19), `centerLocation` (6), +texts shown to other players than the owner (4) or at the centre (2), the players' facts and supply (7), Wait, +`rose`, `once`. + +- **A field from a value is a place of its variable** (`Emitter.action`; `actPlaces` takes every part of an action + now): the variable's writes keep it, so the action reads the field where it is and costs nothing more when it runs. + eudplib copies the variable into the field before every run. The unit type, the action's kind and its count share a + dword: the place's constant is what is not the value (the kind times 2^16, the count times 2^24). A value more than + one variable times a constant plus a constant goes into a variable of the emitter's own first (`Rewriter.action`); + the front end's `max(n, 0)` around an amount is dropped where the value ranges already know it (`settled`). +- **A unit count** is that many units, 0 or below none: the action done 255 at a time while more are left, then once + with the rest, each half only where the count's range reaches it. A type and a count both from values are one + variable of the emitter's own, the type plus the count times 2^24. A unit type is stopped at 228, as the Python + side's is. 258 Scourges in one place: ours makes about 253 (two Create Units, the game spreading each one's units), + eudplib's 50 to 100 (Create Unit of one, 258 times). +- **A text made while the game runs** fills a template of its own, as a print's does (`Emitter.template`, split out of + `print`), and the action's text field names the template's string (`Act.textOf`): one for each place in the script, + where the Python side keeps one string for each kind of field, so two Transmissions written apart share it. +- **The game's counts thrown away.** The game keeps one table of counts for Command and for Bring while the queries + stay of one kind, throwing it away at the start of a pass, at a query of the other kind and in the actions that kill + or remove units — not after Create Unit, Move Unit or Give Units. So `command()` after making units read the old + count, in both builds (the Python side's `forget_counts` covers Bring alone). Now, in a program that asks Command, + Bring or the Most/Least queries, such an action is followed in its own trigger by Kill Unit of Player 12's units of + type 228, which no unit is (played in the lifted game: both count again after it, and Player 12's minerals stay). + Part 1's Command trigger after it is gone. +- **A Transmission** holds the rest of its trigger until its time is over and marks itself started, so the game + steps over it the next time the trigger is reached: in a loop, every other Transmission never showed (eudplib's + too). Ours is the first action of a trigger of its own, and the trigger after it puts its flags and the trigger's + current action back (`Emitter.transmission`, the fields `flags` and `current`): each one shows. A Transmission while + another one's time runs is still held, as the game holds it — to undo that needs the players' wait timers, which no + probe has found yet. +- **Found on the way** (`probes/own-fields.ts`): the game counts a unit killed or removed until the next frame — a + `command()` straight after `killUnit()` still sees it, in both builds and the simulator does not; and the eleven text + lines of a frame: a line printed past them is lost to `testGame`. + +| `probes/own-fields.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| The start and every check (frame 2) | **0.004 ms** | 1.074 ms | +| 200 Set Deaths adding a counter (frame 4) | **0.014 ms** | 0.021 ms | +| 258 Scourges made and removed (frame 6) | **0.001 ms** | 0.059 ms | +| A frame with nothing to do | **0** | 0.004 ms | + +The 200 adds are two triggers a turn: the loop's test, and the Set Deaths whose amount is a place of the counter, which +`j++` keeps. Lines C and D (a count of 3, 0 and −2; a type and a count, then a kill) and H (258 made) are WRONG in +eudplib's build: the old counts, and the Create Units of one. + +Checked: `tests/ownGame.test.ts` (the places and what shares their dword, the count's halves by its range, the +Transmission's puts-back, the template); `probes/own-fields.ts` and `probes/own-game.ts` built both ways; a loop of +four Transmissions in the lifted game (ours shows each, eudplib's every other). + +### The game read as a value on our own backend (2026-10-20) + +Step 5, part 3: what a condition can be asked, read as a number — ore, gas, deaths, kills, a sum over a group, +units counted (Command, Bring), a score, the clocks, the opponents — and the players' race, slot and supply +(`Emitter.readGame`, `Rewriter.fact`). Ledger: step 5 179 of 195; the 16 left are `centerLocation` (6), texts shown to +other players than the owner (4) or at the centre (2), `hasLeft` (it needs the map's slots, which a map of our own +does not know until step 7), Wait, `rose`, `once`. + +- **Where the game keeps it** — one player's deaths, kills, ore or gas, and `memory()` at an address known when built + (a Deaths whose player is past the groups; with a mask, its bits alone) — the dword is read where it is, a masked + condition a bit, entered at the highest group of eight bits it has: three tests (below 2^8, 2^16, 2^24) send the + walk past the bits that are 0, so ore below 65 536 is read in 16. eudplib's `f_dwread_epd` reads 32. +- **Otherwise the condition is asked**, a bit at a time from the top: the condition's amount is a place of what is + found so far plus the bit, so each bit is one trigger — its condition holding is the bit — whose action adds the + bit to the places of the bits after it. 12 bits for a count of units, 4 for the opponents, 32 for the rest, as the + Python side's. +- **A player's race and slot** are a byte of the player table: its eight bits of the dword, each added moved down into + place. A computer's slot reads 5 in a Use Map Settings game, 1 in melee: both 1, as the Python side has it. + **Supply** is its race's table (the player's own race read first when none is given), halved as the top bar shows + it, half a supply in use counting as one. + +| `probes/own-reads.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| The start and every read once (frame 2) | **0.025 ms** | 1.163 ms | +| 200 reads of ore below 2^16 (frame 4) | **0.129 ms** | 0.219 ms | +| 200 counts of units (frame 6) | **0.102 ms** | 0.418 ms | + +- **A read compared with a value is the condition itself** (`Rewriter.asked`, `Emitter.condsOf`): `minerals(P1) >= + cost` is Accumulate at least `cost`, the condition's amount a place of `cost` (`cond.amount`), and no read at all. + `>` and `<` are at least one more, at most one less; turned around, one less or one more again, where the value's + range keeps the amount inside 0 to 2^32 − 1 (else the trigger sends the walk past). The value never below zero, as + the ranges know it: a count is never below zero, a value may be. + +| | Own | eudplib | +| --- | ---: | ---: | +| 200 of `minerals(P1) >= j * 10` (frame 8) | **0.012 ms** | 0.248 ms | + +Every line the same in both builds, supply included (4 of 10, at most 200; Zerg 2). What is left to gain: each read +is written where it is used — a 32-bit search about 32 triggers, `probes/own-reads.ts` 3.5 MB. + +Checked: `tests/ownGame.test.ts` (the table read and its entries, the count asked in 12 bits, the race's byte and the +supply's table); `probes/own-reads.ts` built both ways, every line ok. + +### Locations, players and latches on our own backend (2026-10-20) + +Step 5, part 4. Ledger: step 5 195 of 198 — the two prints at the centre of the screen, and Wait, refused on purpose +(in a program it holds only its own trigger and takes the player's one wait from the Transmissions after it: a +program waits with `sleep()`). `hasLeft` and a print to a force moved to step 7: both need the map's slots. + +- **`centerLocation`** (`Rewriter.centerLocation`): the four sides written where the game keeps them (MRGN), each a Set + Deaths whose amount is a place of the side — the point less half the size, the far sides the near ones plus the + size — after which the counts are thrown away (part 2's Kill Unit). Without a size the four sides are read first + (part 3's reads) and the size kept. Actions whose fields are places now share a trigger while nothing but actions is + in it, so the four are one. 200 moves: **0.036 ms**, eudplib's 0.061. +- **A print to another player** (P1–P8) or to All Players: Display Text with each player of the group the current + player in turn, then the owner again — it shows on the computer of the player who is current, and on nobody's when + nobody plays that slot, so All Players needs none of the map's settings (the Python side reads them). A force does. + TrigScript's own warnings print to everyone: two scripts of step 9 (the pool of instances, `?.` through one) were + refused for that alone, and are taken now — the same as the simulator. +- **`rose()` and `once()`**: a latch for each place in the script, a variable of the emitter's own (0 when the map + starts): the value is the test and not the latch; then a rise's latch is the test, a once's the latch or the test. + +- **A print at the centre of the screen**, as eudplib's `f_eprintln` (`Emitter.centre`): for each player it is for, + with that player the current player, the game's "Cannot create more units" raised — Create Unit for the current + player with the game's first free unit (0x628438) read, taken away and put back from a place of what was read — + which shows the game's line of errors (the thirteenth text line, 0x640B60 + 218 × 12) at the centre; and, on that + player's computer alone (the player at this computer, 0x512684), the text written into that line: the template is the + line itself (`Graph.templates`' `at`), every dword of it written, then its numbers into their fields. The lifted game + has the line as eudplib's does; Remastered is `probes/own-step5.ts`. + +Found on the way (`probes/own-locations.ts`): a condition kept in a `const` (`const there = bring(…)`) is asked where +the `const` is used, not where it is written — in both builds; a boolean set by an `if` is the value at the time. And +eudplib's build misses a Command count after a Create Unit there again (part 2). + +For Remastered: `probes/own-step5.ts` (with an answer sheet) — the centre print, three Transmissions in a loop, a print +to every player, counts straight after Create Unit, a location moved and units made in it; all as they should be in the +lifted game. Played in Remastered on 2026-10-20 (recording 09.33.15): every line right, the centre print centred with +no "Cannot create more units" text or sound, three Transmissions of three with the portrait, no EUD error. + +Checked: `probes/own-locations.ts` built both ways, every line ok; prints to P1, P2 and All Players in the lifted game +(P2's not shown on Player 1's screen, as eudplib's); `rose`/`once` rising twice against the simulator. + +### The game's tables on our own backend (2026-10-20) + +Step 6: `stats()` tables read and written — unit types, weapons, upgrades, techs, players — and `memory.game`, +`memory.map`, `memory.vision`, the `local.*` reads (`Rewriter.tableRead`, `tableWrite`, `cellWrite`, `speed`). Every cell +is at an address known when the map is built (`base + key + index × stride`, a player's row the owner's for the +current player), so step 5's reads and actions are all it takes. Ledger: step 6 50 of 50, the five `local.*` reads of +step 12 among them (the front end keeps a value only one computer knows from the game's state, both builds alike). + +- **A read** is its bits of the dword it is in, read where they are and moved down (part 3's reads, the group entry now + on a masked field too: a word below 256 is read in 8 bits); a scaled field divided by its scale; a bit 0 or 1. +- **A write** is a Set Deaths into that dword, masked for a byte, a word or a bit (the mask in the record's location, + 'SC' in its last), its amount a place of the value — times the field's scale unless written so, stopped at its top. A + constant `true` or `false` is the bit itself. A player's colour is its byte and the minimap's. +- **A name** known when built is its text's number among the map's strings (`Act.amountText`, laid out as the string + is placed). A name made while the game runs is refused. +- **A top speed** is the unit type's flingy's, as the Python side writes it: the flingy read where the game keeps it, + and the writes into its tables naming their dwords by a place of it — a dword's EPD the table's plus the flingy, plus + half or a quarter of it for a word or a byte, whose place in its dword a test chooses. A constant speed's acceleration + and halting distance are worked out when built (rounded as Python rounds, a half to the even one). + +| `probes/own-memory.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| The start and every check (frame 2) | **0.025 ms** | 1.167 ms | +| 200 reads of a word of the unit table (frame 4) | **0.092 ms** | 0.140 ms | +| 200 writes of it from a value (frame 6) | **0.040 ms** | 0.188 ms | + +Checked: `tests/ownGame.test.ts` (a byte of a player's row masked, a flag's bit, a scaled read); `probes/own-memory.ts` +built both ways, every line ok — speeds included, the flingy's four fields the same as eudplib's; a unit name's string +read back from the game ("Space Marine"); `local.*` the same in both builds. + +### Programs, owners and players on our own backend (2026-10-20) + +Step 7: every program of a script, each as every player it runs for (`compiler/backend/own.ts`, `layout.ts`). Ledger: +step 7 27 of 28 — the one left, a program owned by Player 12, refused on purpose as the Python side refuses it. + +- **A run** is a program as one player runs it: one for a program owned by one player, as the first of its owners; + one for each of its owners the map gives a human or a computer for a per-player program (a list of owners, All + Players, a force — the Python side's `owner_slots`, `group_slots`). Each run's graph is the program's lowered with + that player as the owner (the current player while it runs), its labels, scratch cells and data its own, its + variables and arrays its own but for the `shared` ones, which are the program's (`renamed`). No player number is + looked up while the game runs: a per-player program is copied for each player, where the Python side keeps a row a + player and indexes it. Faster, and larger: the payload holds a copy for each player. +- **The runs** come one after another every frame, in the order of the programs and then of the slots: each run's + `@wake` has, besides the tick it sleeps until, its player's list not empty (the list's first node below 2³¹; a player + who has left, or a slot nobody took, has the list's end there) — the Python side's `f_playerexist` before a run. + A program whose owner leaves stops, as the Python side's does; one whose owner was never there never starts. +- **The entry.** The game walks each player's list once a frame, P1's first, and empties a leaver's list by following + its next pointers — measured in the lifted game: each node it reaches has its two pointers zeroed and is freed. So the + start-up is now in every player's triggers (each player's first and last of them their own, the 61 between + everybody's), and links the list's last node to that player's entry; the entry makes the list itself alone and sends + the walk to its gate, which lets it into the runs only when every player before this one has an empty list. `@run` + puts every entry's and gate's next pointer back at its list's end. The list a leaver's game empties is their entry + alone: nothing any other player's walk needs is reachable from it. (eudplib's runner is linked the same way, with a + check of the game's tick against a copy as its guard; the empty lists before are a guard that needs no copy.) +- **A print to a force or All Players** goes to the map's human and computer slots of it, each the current player in + turn. **`hasLeft(p)`** is one condition, the list's first node exactly the list's end — 0 for a slot the map gives + nobody. + +| `probes/own-programs.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| The start: five runs of four programs (frame 2) | **0.008 ms** | 1.077 ms | +| A frame where every run sleeps (frames 3–14) | **under 0.001 ms** | 0.007 ms | +| `probes/fog-lib.ts`: the fog library's program beside the script's, a frame | **0.003 ms** | 0.007 ms | + +Played in the lifted game: `probes/own-programs.ts` built both ways, the same lines at the same frames, every line ok +(Player 2 a computer: computers have a list, and their runs ran); `probes/fog-lib.ts` (`fixtures/eud/fog-base.scx`), +the map's tile flags the same in both builds every frame; `probes/own-leaver.ts` in a game of two people with either +leaving at frame 40 — each player's deaths the same in both builds at every frame: the leaver's runs stop, the other's +go on, `hasLeft(P1)` true from the next frame; at the leave the game touched the leaver's entry alone. The ledger's +step 7 scripts against the simulator: the same (those of the fog library and `CurrentPlayer`'s minerals stop at +actions the checker does not model, and were played instead). + +Found on the way: a program whose first statement jumps — `let done = false` is a jump past the side that sets it +true — lost the jump in a map of our own: the graph's first trigger, empty but for its next pointer, was dropped as +empty triggers are, and the walk came in at the true side (`if (!done)` false at once). Fixed (`Emitter.graph`: the +first trigger is kept when it goes elsewhere than the one after it; `tests/emitter.test.ts`, +`ledger/language/boolean-declared-first.ts`); eudplib's build was right. + +*Open:* `let total = shared(0)` in a per-player program is set to 0 again by each run's declaration, so a count the +runs add to in the same frame ends at 1, not the number of runs — in both builds and the simulator alike. Whether a +`shared` declaration should set its value once, at the first run, is a question of the language. + +### Texts known when built on our own backend (2026-10-20) + +Step 8, part 1 (`compiler/backend/texts.ts`, a pass over each program's IR before the emitter). A text that is, whatever +happens in the game, one of a few the script wrote — a `?:` of texts, a list of them read at a value, a variable only +ever given such texts (`VarDecl.text` "id", now a number), a slice or a character of one at a value — is a number in a +map of our own: its place in the script's pool of texts. The pool goes into the map's strings one after another from the +next free one (`build-own`: `setString` appends), sorted as the simulator compares texts. Nothing of such a text is ever +copied, where eudplib's build copies its bytes into a buffer every time: + +- **Printed alone**: Display Text of the string its number names — the action's text field from the value (step 5's + fields from values), one action. Likewise an action's text (a transmission's). +- **In a printed text**: printed once for each text it can be under a test of which, when there are four ways or + fewer; otherwise a `pick` part (new in `TextPart`, made only by this pass): a field as long as its longest text, and + for each text — each run of places with the same text, for a table's — a test and the text's dwords set whole. +- **Compared**: as numbers, `===` and `<` alike (the pool is in the texts' order). +- **What a function of one gives** — its length, `includes` / `startsWith` / `endsWith`, `indexOf`, a character's + number, a slice or a character: worked out when built for each text it can be (a test of which, for each), or, for + a text known when built at a variable (`s[i]`, `s.slice(i, i + 4)`), a table of what it gives at each value of the + variable — as far as it changes, within the variable's range — read at the variable held between its ends. A + scratch variable the front end declared just before (`(index)` = `i + 4`) is seen through to the one it reads. + +| `probes/own-texts.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| A–C: chosen texts printed, compared, measured, searched, sliced (frame 2) | **0.012 ms** | 1.309 ms | +| D–F: a switch on a text variable, many choices in a print, a transmission (frame 3) | **0.005 ms** | 0.145 ms | + +Checked: `probes/own-texts.ts` built both ways, the same lines at the same frames, every line ok; the 18 rows the part +takes against the simulator, and edge cases besides (order, an index past either end, a negative start, the empty text, +characters of three bytes, a variable given another's texts): the same; `tests/ownGame.test.ts`. + +### Texts made while the game runs on our own backend (2026-10-20) + +Step 8, part 2 (`compiler/backend/texts.ts`, the same pass; the emitter's new reads and writes at an address that is a +value). A made text variable has a block of its own — a string of the map's, 1,280 bytes, so that a text printed alone +is Display Text of its block as it is, never copied — and its length in bytes and in characters beside it. Every block +is laid out once, its address set at the program's start. + +- **Memory at a value's address** (the emitter, for this): a read of the dword at an EPD that is a value — a trigger a + bit, each condition's player field a place of it, its bits moved down or up as they are read (`readAt`); a dword + there compared with a constant in one condition (`cond.epd`); a write there is a Set Deaths whose player field is + the value (step 5's fields from values). A variable read in many places and written often is kept in one cell and + read where it is used (`VarDecl.routed`): a write of the cursor was one action a place, hundreds of them. +- **Made at one cursor** — a dword, a byte, the bytes and characters written — by the run time's functions, laid out + once for the program and called: another text's dwords copied whole (at a dword's start; at any other byte each + dword read once as two masked reads that put its bytes where they go in the two dwords it falls in: 32 triggers a + dword either way), a number's digits, one byte, the ending 0 (the rest of its dword 0s, so a text compares dword by + dword); a known text copied from a block that holds it. What a text needs made first — a call's result, a text read + from — is made before it is begun, so one text is made at a time. +- **Calls**: a made text parameter is made into its block before the call; a `return` of a text makes it into the + result's block (a call whose text cannot be made is refused whole — it gave an empty text once: "Time " for the + clock). A function that calls itself keeps no made text (refused). +- **Read**: the length is its variable; a character's number is its byte when it has a byte a character, else a walk; + `indexOf`, `includes`, `startsWith`, `endsWith` and the order of two texts by the run time's functions; a made text + the same as a known one is a condition a dword. In a `while`'s test, the loop asks it at the top of its body. +- **Made**: templates, `padStart` / `padEnd`, `repeat`, `slice` (of one byte a character: from the byte; else walked), + `for … of` over its characters; the library's `trim`, case, `replace`, `parseInt`, `join`, `clock` and `thousands`, + which stand on these. + +| `probes/own-made-texts.ts` (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| A–B: a number made a text, compared, padded, repeated, a function's, the clock, thousands (frame 2) | **0.275 ms** | 1.391 ms | +| C: trimmed, in capitals, replaced, read back, searched (frame 3) | **1.291 ms** | 1.694 ms | +| D: walked, sliced, made a character more each time, joined (frame 4) | **0.540 ms** | 0.840 ms | + +Size: 3,364 triggers and a 15 MB payload (390 KB as a file), against eudplib's 1.1 MB map: every trigger took its +2,408 bytes. Stacked (next section), 1.3 MB. + +Checked: `probes/own-made-texts.ts` built both ways, the same lines at the same frames, every line ok; the 33 rows the +step takes against the simulator, and every row of the ledger that takes (422 the same; the others actions the walker +does not run, prints to a group counted once a player, random draws); `tests/emitter.test.ts` (memory at a value's +address; texts made, copied at every byte, read and ordered). + +Found on the way: part 1's table of a known text at a variable chose its window around 0, so `"ABC…Z".slice(c - 97, +c - 96)` (the library's `toUpperCase`) read past it for every letter and gave "" — fixed: the window is where each part +of the numbers that moves as a line of the variable lands inside the text. A list written in a call's arguments +holding a value of the program, `[n, 2, 8].join(", ")`, is "A call's arguments must be known when the script is built" +in both builds — a gap of the front end's (closed 2026-10-22: *Arrays written where they are used*, below). + +*Open:* a text cut at 1,023 bytes counts its characters as its bytes (the Python side counts them again); the message +the Python side shows once when a text is cut is not shown. + +### JSX, names and arrays of texts on our own backend (2026-10-20) + +Step 8, part 3 (`compiler/backend/texts.ts`), on part 2's run time. Step 8 is done: 46 of 46. + +- **JSX:** a buffer (`buildText`, read as `built`) is a made text of its own, a block like a variable's. Put together + from its start, or on from its end (the cursor at its bytes and characters); a component's buffer is copied into + its parent's; a buffer printed alone is shown as it is. +- **A player's name and colour** in a print or a text: the name from the game's table of players (36 bytes a player + at 0x57EEE0, the name 11 in, as eudplib's `PName` reads it). For a player known when built, its length is a + condition a byte (where its first 0 is, no byte read), the first byte put alone and the rest copied a dword at a + time; the current player's, a byte at a time. The colour: the first shade of the player's colour (8 bytes a player at + 0x581D76) and eudplib's table of the code nearest each colour of the palette (`_pcolor`). +- **Arrays of texts and texts in records:** the front end's three arrays of numbers (where each cell's text is, the + block it owns, its length in characters) kept, each cell's text a block of its own laid out with the variables', + its EPD in the first array once the array is declared, its length in bytes in the second. A store makes the text + into the cell's block (through the scratch block when it reads the array); past the end, nothing. Up to 64 cells, + of a fixed length (one that grows is step 9's). + +**Through the current player** (part 2's run time made faster on the way, to beat eudplib's on every frame): a +condition's or an action's player field of 13 is the dword the game's current player (0x6509B0) is the EPD of, so one +write of the current player points every read and write after it there, and moving it on is an addition — eudplib's +`cp` reads and writes. A read at EPD 13 (`peek(k(13))`) and a Set Deaths for player 13 are that, with no change to +the emitter. Each function gives the current player back to the program's own (read once at its start) before it ends. + +- **Copy:** each byte of a dword the cursor may be at has its own two pointers; the current player pointed at the text's + dword, its bits read straight into a variable, pointed at the cursor's, written (two masked writes when the cursor is + inside a dword). Each read had put the address into its 32 conditions' player fields, a routing each. +- **Known texts up to 256 bytes** (a block's room past its most): written, not copied — a function of the run time's + for each text, one trigger for each byte of a dword the cursor may be at, the current player moved on a dword at each + write, as eudplib writes a known text. Copied from a block, a known text was read as any made one. +- **The cursor read in few places.** A variable read in an action's field is pinned: copied into each such place at + every write. Put, end and the known texts write through the current player, pointed at the cursor in one place each, + so a write of the cursor costs 11 routings, not 19; a known text's function sets how far the cursor goes in a + variable of its own and adds it once. Two traps on the way, each blowing the payload up several times: reading + `CE − SE` (a variable read at −1 is filled a bit at a time, at every write of either), and one variable shared by + every known text's function (copied into every one of them at each write). +- **Only the run time's variables a program reads are declared:** a program with no made text pays nothing at its + start (own-leaver's start 0.005 → 0.003 ms). + +| Lifted game, Remastered's weights | Part 2 | Now | eudplib | +| --- | ---: | ---: | ---: | +| `own-made-texts` frame 2 (made, padded, a function's, clock, thousands) | 0.275 ms | **0.151 ms** | 1.391 ms | +| `own-made-texts` frame 3 (trim, capitals, replace, read back, searched) | 1.291 ms | **0.714 ms** | 1.694 ms | +| `own-made-texts` frame 4 (walked, sliced, typed, joined) | 0.540 ms | **0.418 ms** | 0.840 ms | +| `own-jsx-texts` frame 2 (JSX with a number, a colour, a meter) | — | **0.069 ms** | 1.388 ms | +| `own-jsx-texts` frame 3 (two names and two colours) | — | **0.031 ms** | 0.130 ms | +| `own-jsx-texts` frame 4 (an array of texts, joined; records) | — | **0.190 ms** | 0.310 ms | + +Size: the known texts' functions cost room — `own-made-texts` 1.45 MB of payload (stacked, part 2's was 1.33 MB); +copying every known text instead, 1.16 MB and frames of 0.204, 0.811 and 0.430 ms (`own-jsx-texts` 0.158, 0.074, +0.239), still under eudplib's everywhere. + +Checked: `probes/own-jsx-texts.tsx` built both ways, the same lines and colour codes (eudplib pads with `\r`, which +draws nothing); the 13 rows against the simulator (8 the same, 5 the same but the colour codes the simulator's log +leaves out), every other row that takes as before (430 the same); the 20 `probes/own-*.ts` in the lifted game with the +free dwords filled with ones, the same lines; `tests/emitter.test.ts` (JSX, a name and a colour from the game's memory, +arrays of texts and records), its walker taking a player field of 13 as the current player. + +*Played (2026-10-22):* `own-step8-known`, `-made`, `-made-filled` (the free dwords filled with ones) and `-jsx` in +Remastered, every line as written. A computer's name came out grey in one game: `probes/own-colours-check.ts`, built +both ways and played, showed Remastered dealing the players' colours afresh each game on the probe map (VER 206, COLR +0 1 2 3 4 8 6 7, no CRGB) — that game the computer was white, whose code draws almost as plain text — and both builds +printing every name in the colour dealt. + +### Arrays written where they are used, and names and colours looked into (2026-10-22) + +Two gaps of the front end, the same in both builds, closed: + +- **An array written out where it is used** with a value of the program in it — `[n, 2, 8].join(", ")`, + `[1, n].length`, `[n, 4].includes(15)`, `for (const x of [n, 1])` — was "must be known when the script is built". It + is now an array of the program made where it is written, as `const xs = [n, 2, 8]` makes one (`isProgramLiteral`, + `listHere` in `structured.ts`); so is a list a call makes looked into where it is made (`xs.map(f).join()`, + `xs.slice(1).includes(7)`). `Math.min` / `Math.max` spread an array of fixed length (`Math.max(...xs)`); one that + grows is refused, since an empty one is -Infinity, with the `reduce` that says what an empty one gives. +- **A text holding `name()` or `color()`, looked into** — `` `${color(P1)}`.length ``, `.charCodeAt(0)`, `=== "…"` — was + worked out when the script was built, on the mark that stands for them there (U+E000, a letter, the player, U+E001): + length 4, character 57344. The hoister now leaves anything that reads into such a text to the game (`readsMark` in + `hoist.ts`), and the build-time evaluation does not look into one either; a text only carrying the mark along (a + template, a `+`) is still put together where it is used. The simulator keeps a colour code as the character the game + has (the slot's colour when nothing changes it: 8 red, 14 blue, …; `playerColor` to say otherwise), and a JSX + colour likewise, so its lengths and its log agree with the game's. + +Checked: `tests/text-array-methods.test.ts` (each against JavaScript, the colours against the game's codes), +`tests/emitter.test.ts` (both from our own map's bytes, a name and a colour from the game's memory), two rows of the +ledger (`arrays/written-where-used`, `texts/player-colour-as-a-value`); `probes/own-made-texts.ts` line D joins +`[n, 2, 8]` itself again. + +### Arrays that grow, and what stands on the heap, on our own backend (2026-10-22) + +Step 9 (`compiler/backend/heap.ts`, a pass of its own before the emitter; the emitter's reads, stores and places made +cheaper on the way). Step 9 is done: 27 of 27, with a row added for an array in a function that calls itself. + +- **One heap for the map.** Every growing array's cells are a block of `@heap`, an array of a fixed length the emitter + already lays out — a region of cell triggers as long as the script's heap setting (16,384 cells, 1.2 MB) — and its + list of blocks given back, `@heap.free`, a head a size class. Blocks are handed out as the simulator counts them: a + power of two of cells, four at the least, from the class's list (threaded through each block's first cell) or new + ground from cell 1 up; so an array runs out at the same push in both, and the game says so once, as the Python side + does. The heap is one for every program and every player's run of one: where new ground starts and whether running + out was said are cells no program declares (`VarDecl.global`), read where they are used; the layout lays a region + every run names out once. +- **An array is five numbers** of the program's: where its block starts, its length, its room, its block's class, and + where its next cell goes (the block's start plus the length), which a push stores at and a pop reads at. A read is + the heap's cell at the block's start plus the index, or its first cell — which nothing writes — past the length, so + the test chooses where the read goes and the value goes straight to where it is used. A push with room is two + triggers: the test, and the store with the length and the end moved on; one with none calls the array's own `more`, + which calls its `grow`, which calls the run time's. A store inside the length is the cell; at it, a push. Declared + again, an array gives its block back. A `u8[]` stops at its ends as a cell of the simulator's does. +- **Where its block starts is written in one place** (the array's `grow`) and read wherever a cell is, so it keeps a + place in every field that reads it however many (`VarDecl.everywhere`); what `grow` gives back is kept in one cell + each (`routed`), so that one write of it is one action, not one for every place every array keeps. Outside every + loop, a read, a store and a push are calls of the array's own functions, so that the block's start has places only + where it is hot. +- **Loops over an array.** `for (…; i < xs.length; …)` — `for…of` is that — whose body neither changes the length nor + writes `i`, calls nothing and sleeps nowhere tests nothing at `xs[i]`; and when the step adds a constant, each cell + at `i` is kept beside the counter (the block's start plus `i`, moved by the same additions), as eudplib keeps an + offset beside a counter. Other arrays read at `i` — a record's fields — are weighed against `xs`'s length once on + the way in: none shorter, the loop where they are as sure as `xs`; one shorter, the loop with theirs tested. Each + version has a counter of its own, given back after, so that its steps write only its own places; a body that reads + its counter only to index is a loop of the cell's place against where the array ends, with no counter at all. (So + where the next cell goes is 0 for an array with no block: never above where its first cell is.) +- **Rows that grow** (`through`): the row's numbers taken from the outer arrays' cells at its index before what is done + to it and put back after, the outer arrays themselves growing or not. **An array in a function that calls itself**: + its numbers kept on the stack with the function's other variables around a call that may come back in, the array + empty for the call, the block the inner call left given back after it. +- **Arrays of texts that grow** (`texts.ts`): each cell's text a block of a pool of 64 the size of a made text's, taken + the first time a text is stored there and given back when the cell's text is let go of — popped, cut off, declared + again; the pool is the payload's bytes, not a string of the map's (`data` on a template: the game's own table of + strings names only its first 64 KB in 1.16.1). A cell's text in one side of a `?:` is read in that side. +- **Closures** (`held`): a variable a closure keeps is declared once, 0, as the program starts, and its declares are + assignments — what a backend needs of it; the rest of closures is the front end's arrays and statements. Signals + stand on both and are taken with them. + +**The emitter, on the way** (every program, not only those with arrays that grow; the eudplib build's IR the same, +byte for byte, for all 112 probes): + +- A variable written in a loop and read only outside it — a sum a print reads after the loop — is kept in its cell + alone (routed), when its writes cost more across its places than refreshing them where they are read: not the + program's emitter-made cells, nor what a called function names (its body is laid out outside the loops it is called + from). `total += xs[i]` in a loop then reads the cell straight into the addition. +- A store's number holds a variable plus a constant itself (`xs[i] = xs[i] + 1` is no variable of its own for the + sum), and a cell read goes straight into a field that adds a constant (its scratch cell starts at the constant). A + store's value that is a sum of cells is stored as it is, and a cell at an index that is more than a sum read where it + is. A variable of the emitter's own whose cell nothing reads is no place to fill: a read of a cell into a sum goes + straight into the store's number. +- A copy of a variable that is never below zero and has a top (`VarDecl.top`) works out only the bits it can have. +- A trigger with no conditions that the walk only reaches by falling from one before it, with none either, is that + one's actions after its own. +- A store of the cell itself plus a constant or a variable — `xs[i] += 1`, `hp[i] -= damage` — is that added to the + cell where it is: one action, the cell not read. +- A variable a loop's body declares that nothing outside the loop reads — `for (const v of xs)`'s `v` — is folded as + one of the emitter's own is (`VarDecl.scoped`): what is read into it goes straight to the one field that reads it, and + its cell is left stale (step 15's map of variables is to say so). +- `emitBody` builds again when it is crowded: a write with more places than a trigger holds is copied after its reads, + and an `if` in one trigger whose writes turned out not to fit is laid out as a branch (it was refused). + +| `probes/own-growing-loops.ts`, 500 cells (lifted game, Remastered's weights) | Own | eudplib | +| --- | ---: | ---: | +| 500 pushes (frame 2) | **0.287 ms** | 1.504 ms | +| A: `for (i < xs.length) total += xs[i]` (frame 6) | **0.070 ms** | 0.115 ms | +| B: `for (const v of xs) total += v` (frame 10) | **0.079 ms** | 0.125 ms | +| C: `xs[i] = xs[i] + 1` at the loop's index (frame 14) | **0.033 ms** | 0.081 ms | +| 200 records pushed (frame 18) | **0.425 ms** | 0.789 ms | +| D: 200 records moved and aged, `for (const q of bs)` (frame 22) | **0.089 ms** | 0.126 ms | +| E: filtered in place, cut to its length (frame 26) | **0.618 ms** | 1.040 ms | + +| `probes/own-growing.ts` | Own | eudplib | +| --- | ---: | ---: | +| A–B: pushes past several blocks, pops (frame 2) | **0.072 ms** | 1.189 ms | +| E–F: 500 arrays declared again, booleans, `u8` (frame 10) | **15.3 ms** | 30.2 ms | +| G–H: a `Map`, a `Set`, `filter`, `splice`, sorted (frame 14) | **0.532 ms** | 0.749 ms | +| I: a thousand pushes (frame 18) | **0.417 ms** | 0.795 ms | +| J: a thousand reads at an index that jumps about (frame 22) | **0.822 ms** | 0.969 ms | +| K: a thousand stores there (frame 26) | **0.719 ms** | 0.861 ms | +| L: a loop over a thousand, and pops to empty (frame 30) | **0.299 ms** | 1.010 ms | +| M: out of memory (frame 34) | **10.85 ms** | 30.85 ms | + +| `probes/own-heap-values.ts` | Own | eudplib | +| --- | ---: | ---: | +| A: rows that grow (frame 2) | **0.469 ms** | 1.653 ms | +| B–C: arrays of texts that grow, given back, `split` (frame 6) | **10.0 ms** | 14.9 ms | +| D–E: closures; an array in a function that calls itself (frame 10) | **12.7 ms** | 14.9 ms | +| F: a signal (frame 14) | **0.021 ms** | 0.030 ms | +| G: a second program's array on the same heap (frame 22) | **0.219 ms** | 0.473 ms | + +J and K are mostly `k = (k + 373) % 1000`: the index written from a remainder into the fields that hold multiples of +it, a bit at a time — the emitter's, not the array's (next: a remainder by a constant worked straight into its +target's places). + +Arrays of a fixed length gained as much from the emitter's changes: `xs[i] = xs[i] + 1` over 500 cells 0.169 → 0.028 ms +(eudplib 0.213), a loop over three arrays of 200 as records 0.221 → 0.083 ms (eudplib 0.353). The other 22 own probes +print the same lines at the same frames, as fast or faster than before (`own-recursion`'s costliest frame 11.53 → +11.36 ms, `own-math`'s 1.343 → 1.329; `own-random` 124 → 67 KB). + +Size: the heap is 1.2 MB of the payload wherever an array grows (eudplib's is the same). Beyond it, what the library +writes out where it is used — a `Map`'s, a `Set`'s, `splice`'s and `join`'s loops — copies variables bit by bit into +the fields that hold multiples of them: `own-growing` 3.41 MB against eudplib's 2.67, `own-heap-values` 7.5 against 3.8 +(`own-growing-loops` 1.77 against 1.82). + +Checked: the 27 rows against the simulator from their laid-out bytes, and every other row a map of our own takes (475 +the same; the rest the game's own conditions and actions, which the walker does not run, and random draws); +`tests/emitter.test.ts` (pushes, pops, a length set, stores past the end, blocks given back, booleans and `u8`, a `Map` +and a `Set`, rows that grow, arrays of texts, closures, recursion, loops at a counter over arrays and records and a +shorter array, three programs running the one heap out where the simulator does); the three probes built both ways in +the lifted game, the same lines at the same frames; the 22 other own probes before and after; the IR the eudplib build +is made from, the same for all 112 probes as before. + +- **Played in Remastered (2026-10-22):** `own-step9-growing`, `-loops` and `-heap` (built with `MAP_NAME`, so the lobby + names each) printed every line word for word as the lifted game, in their groups four game frames apart; the + out-of-memory line once, before M; the second program's line at game frame 22; no hitch seen on the 15 ms frames. + +Found on the way, in both builds: a constant out of a `u8` / `u16` array's range — `bytes.push(300)`, `xs[i] = -9`, +`[300]` — was stored as its top (255 for −4, the simulator's `kept`), where the guide says below zero is 0 and a +variable's was already an error: now an error, "bytes is an array of u8 and each cell holds 0 … 255, not 300" +(`checkWidths` in `eud.ts`). And a `?:` with a cell of an array of texts in a side was refused in a map of our own. + +*Open:* `rows[i].pop() ?? -1` is "Expected a number" in both builds (`ys.pop() ?? 0` of an array alone is taken); the +pool of blocks a run's arrays of texts take is 64 for each run, and runs out where the simulator, which counts texts +on the heap, does not. (The size, the remainders at J and K, and `q.x += q.dx`: the next section.) + +### The emitter after step 9 (2026-10-22) + +What step 9 left the emitter's, all for maps of our own only (the eudplib build's IR the same for every probe): + +- **A remainder by a constant worked in the target itself** (`divideByConst`): a dividend never below zero and less + than four times the divisor — a counter kept inside a ring, `k = (k + 13) % 50` — is written into every place of the + target, then each step taken from every place where the target's cell holds that much: no scratch cell, and no copy + of one into the fields that hold multiples of it, a bit at a time. `k = (k + 373) % 1000` is two triggers. +- **Two cells summed into a store:** `q.x += q.dx` — the cell plus another — is the other cell read straight into an + addition to the cell. +- **A copy laid out once** (`expandCopies`, `layout.ts`): the same copy after several writes of a variable — the same + places from the same cell — is one run of routings and bits after the graph, entered from each write with the place + it goes back to set first: two triggers more a copy, every copy past the first a trigger instead of its expansion. + (A refresh before a read is a copy of the places that read alone: one at each read, as before.) +- **An `if` whose test is a constant** — the library's `if (false)` around the branch of `splice` a call never takes — + lays out the side it takes, alone. +- **Cells moved along an array** (`heap.ts`, `moved`): the library's loops that move cells by a gap — `splice`, + `shift`, `unshift`, the three arrays of an array of texts at once — are the run time's `move`, walking the heap by + where the cells are, one function for the map, when every cell they touch is inside each array (asked once on the + way in); otherwise the loop as written, its reads and stores the arrays' own functions (a read past an end 0, a store + at the length a push). + +| Lifted game, Remastered's weights | Step 9 | Now | eudplib | +| --- | ---: | ---: | ---: | +| `own-growing` J: a thousand reads at `(k + 373) % 1000` | 0.822 ms | **0.352 ms** | 0.969 ms | +| `own-growing` K: a thousand stores there | 0.719 ms | **0.258 ms** | 0.861 ms | +| `own-growing-loops` D: 200 records moved and aged | 0.089 ms | **0.064 ms** | 0.126 ms | +| `own-heap-values`, the map | 7.47 MB | **5.91 MB** | 3.81 MB | +| `own-growing`, the map | 3.41 MB | **3.32 MB** | 2.67 MB | +| `own-made-texts`, the payload | 1.41 MB | **1.02 MB** | (the map 1.1 MB) | +| `own-jsx-texts`, the payload | 795 KB | **564 KB** | | + +Every other frame of the three step 9 probes as before or faster; the 22 earlier own probes the same lines, as fast or +faster. Checked: `tests/emitter.test.ts` (a counter kept by a remainder, read and stored at; moves along arrays of +numbers, booleans and texts for `shift`, `unshift` and `splice`, both ways); every ledger script a map of our own takes, +against the simulator; the probes in the lifted game. + +What is left of the size: the regions are cell triggers, 72 bytes a cell — the heap's 1.2 MB as eudplib's, the +closures' seven arrays of 1,024 cells (0.5 MB), and a function that calls itself's stack, as deep as the setting +(1,024 calls of `build`'s seven cells: 0.5 MB, where eudplib's stack is four bytes a cell, read a bit at a time). Of +the triggers, the copies a routed variable's reads refresh are a bit at a time into every field that holds a multiple +of it, one at each read: a cell for each multiple kept beside the variable would make them routings (to weigh: each +write a few actions more). + +### Units on our own backend (2026-10-22) + +Step 10 (`compiler/backend/units.ts`, a pass of its own before the heap's, as `texts.ts` and `heap.ts` are). Step 10 is +done: 99 of 99. A unit an event gives (`on.created` and the rest) is step 11's row, as the tasks that listen are; a pick +near the mouse (`underMouse`) is step 12's, with the input. + +- **A unit is two numbers** of the program's: the EPD of its record in the unit table (0: none) — what a condition's + player field and a Set Deaths' destination take as they are — and the uniqueness byte its place had when the unit was + taken. A unit kept in a variable, an array or a call's parameter or result is both; one there by construction — a + loop's turn, a pick in the statement that made it — is its EPD alone, and its byte is read only if it is kept. + Before a kept unit is used, one trigger of four conditions asks that it is there still: the EPD, a sprite, an order + other than Die, the same uniqueness byte (the Python side's `when_alive`). +- **A unit's numbers are read through the current player**, as eudplib reads: the pointer written once at the dword + the number is in, the read's conditions on player 13, the pointer given back to the program's own player. A read in + place would make each of its conditions — 24 for hit points — a place of the unit, which every write of the unit + moves. Hit points and shields as the game shows them (a started point counts), energy a byte of its word, the most + hit points and shields from units.dat at the unit's type. A flag is one condition where it is. A write is one masked + Set Deaths, stopped at the field's top as the simulator stops it; hit points of 0 kill. +- **Verbs** as the Python side's: kill and remove are the order Die (remove with the user-action flag eudplib sets); + damage and heal in the game's own 256ths, at 0 dead, up to the type's most; an order through a location made a box of + four pixels around the unit for the one action and put back after; locate a location centred on the unit; give + eudplib's `cgive` — the owner's byte, the unit out of its player's list and into the new one's after its first + unit, the pointers read as EPDs (the two low bits left out). `select()`: on that player's computer, the selection's + twelve slots and the panel's flag, and a Select command of the units' tags written a byte at a time into the game's + queue of commands at its length, when it has room. +- **A loop or a pick walks the unit table** oldest first, as the Python side's `scan`: place 0, then 1,699 down. It + walks down to the lowest place the game has ever handed out, a cell for the map (`UNIT_END`, a global as the heap's + are): the game hands the places out from a list whose never-used places are always its end, in order (scm-sim's + `docs/specs/unit-lists.md`, section 3), so that place only moves down, and each walk moves it past the places below + it that are no longer all 0 (a sprite, a position or a uniqueness byte) — one trigger in a frame where nothing new + was made. Nothing is counted on the way: eudplib's walk counts the units it meets against the game's count of + places in use, a trigger and a read of the record at every place. A turn is the loop's test, the filter asked in one + trigger — a sprite, the owner, the type, inside the location, with the order asked only of what it lets through — + and the step down, laid out where the test is gone back to. Two places a turn when the body is small and no + `continue` of its own reaches the loop: the test and the step are then paid once for two. Place 0 is a turn of its + own ahead, read at its constant address (its `break` ends the walk, its `continue` goes on to the loop). +- **A pick:** the first unit stops the walk; the nearest asks each unit, through the current player, whether it is + inside the box |dx|, |dy| < the nearest so far — one trigger — and measures only one inside (the box's sides and the + centre kept in every place they are read, `everywhere`; what a measure writes kept in one cell each, `routed`, so + that a unit's x is not copied into every place it reaches); one at random counts, draws and walks again. + +| Lifted game, Remastered's weights (`probes/own-units.ts`, 193 units) | Ours | eudplib | +| --- | ---: | ---: | +| W1: every unit counted | **0.021 ms** | 0.040 ms | +| W2: a player's Marines among them counted | **0.024 ms** | 0.046 ms | +| W3: a pick that finds none, the whole table walked | **0.024 ms** | 0.044 ms | +| W4: every Zergling's hit points added up | **0.089 ms** | 0.293 ms | +| W5: the nearest Zergling (115, all in one cluster) | **0.298 ms** | 0.333 ms | +| C–D: a pick's fields read, a location on a unit, a loop in it | **0.085 ms** | 0.217 ms | +| E–F: writes, damage, heal, two units compared | **0.035 ms** | 0.075 ms | +| K–L: a function finding the weakest unit, eight random picks | **0.224 ms** | 0.497 ms | +| the map | 2.86 MB | 1.82 MB | + +What a walk costs now, place by place: about 85 ns (every unit) to 105 ns (a filter of two) against eudplib's 190 to +230 — the filter's trigger (the record's first read in a trigger 19.6 ns, each more 12.3) and the step (each condition +that reads the record is a field the step moves on, 10 ns). What is left is in the ledger's *Open* list. + +Checked: `tests/emitter.test.ts` ("a map of our own, units": loops with every filter, a `break` and a `continue`, fields +and flags, `first`, `nearest` and none, writes, damage and heal by points and by a share, kill, arrays and kept units — +the payload walked from its bytes over a unit table laid out as the game's, against the simulator); the probe both +ways in the lifted game, every line the same and "ok". + +Played in Remastered (2026-10-22, recording 18.56.36): all 19 lines word for word and in order, W's counts the lifted +game's (193 units, 3 Marines, 115 Zerglings), T about 4.2 s after the first group; the Marine ordered away walks off, the +one given to Player 2 is shot by Player 1's own, two Marines are in the panel after `select()`, the walked Marine ends +at the right of the row; no hitch. + +### Errors on our own backend (2026-10-22) + +Step 11, part 1 (`compiler/backend/errors.ts`, a pass before the texts'; the emitter's `try`, `throw` and `handler`). +The ledger's rows of errors are taken, 12 of 12 (six added for what they did not reach: an error up through a function +that calls itself, a stack that runs out caught, `finally` on a `return`, a class's own field and `name`, `e.stack`, an +uncaught error thrown in a called function). The two rows of a task's error awaited wait for the tasks (part 2). + +- **What the front end does already** (B4): before a `throw` it puts the error in flight — its class's code into + `Program.errors.thrown`, its message into `message` — copies `finally` before every way out, and makes a program's + body a `try` whose `catch` is `uncaught`. `errors.ts` writes out what the emitter would need texts for, before the + texts are lowered: the line each error is thrown at into a variable of the program's (one thrown again keeps it), + `uncaught` as the print the Python side makes ("TrigScript: uncaught RangeError: …, line N." in red, and the code + back to 0), and for a program with a function that calls itself, a function that sets the RangeError of a stack + that ran out. +- **A throw is a jump**: to the `catch` of the nearest `try` around it. In a called function with no `try` around it, + out of the function: one that never calls itself goes out through a trigger of its own for errors, whose next + pointer each call points at where an error goes from that call, in the trigger that sets where it goes back to — so + nothing is looked at on the way back, where eudplib looks at the error in flight after every call of a function + that may throw. When every call names the same place, that trigger is given it when the map is built, and the calls + set nothing. A function that calls itself goes out from its end, as a `return` does: each call takes its frame back + and then looks at the error in flight (a frame keeps what a `catch` after the call reads, too). A `try` costs + nothing while nothing is thrown. +- **A stack that runs out throws a RangeError** ("Maximum call stack size exceeded", at the line of the call), as + JavaScript, the simulator and the Python side's build do; a map of our own stopped the program there before, and + still does in a program that neither throws nor catches. +- **What only an error goes through is cold**: statements that end in a `throw`, and a `catch`. What they read of a + variable is refreshed from its cell just before it is read, as a routed variable's is, and is never a place of it + that every write keeps. Without it, a message made from a parameter cost every call of the function an action and a + copy (about 75 ns), and each throw wrote the error's code and its message's lengths into every place a `catch` read + them from (about 90 actions a throw in the probe). The payload went from 1.88 MB to 1.21; a throw from 11.4 µs to 8.7. + A loop is never cold, its test and its body: it may turn many times on an error's way too (`while (alive()) { … }` + and a `throw` after it, the whole body of a program, laid out its reads refreshed at every turn before). + +| Lifted game, Remastered's weights (`probes/own-errors.ts`) | Ours | eudplib | +| --- | ---: | ---: | +| A–B: a throw caught, classes told apart, messages made while the game runs | **0.211 ms** | 1.492 ms | +| C–D: thrown again, `finally`, out of called functions | **0.080 ms** | 0.125 ms | +| E–F: up through a function that calls itself; a stack run out at 1,024 calls, caught | **0.611 ms** | 2.412 ms | +| W1: 300 calls of a function that may throw, none thrown | **0.183 ms** | 0.208 ms | +| W2: 100 errors thrown in a called function, each caught (a number in the message) | **0.876 ms** | 2.846 ms | +| W3: an error up through 40 frames, four times | **0.096 ms** | 0.364 ms | +| W4: 200 turns through a `try` with a `finally`, one in two left by `continue` | **0.029 ms** | 0.058 ms | +| the uncaught error said | **0.023 ms** | 0.057 ms | +| the map | 1.52 MB | 1.05 MB | + +W1 is where the call is, not the error: the error costs one action a call. What is left is in the ledger's *Open* list. + +Checked: `tests/emitter.test.ts` ("a map of our own, errors": try and catch, the classes and a field, a rethrow and +`finally` on every way out, out of called functions and a function's own `try`, uncaught, through recursion and its +frames, the stack run out — and every one of the ledger's scripts of errors — the payload walked from its bytes against +the simulator); the 30 scripts of `tests/errors.test.ts` a map of our own takes, the same way (once, not kept as a +test); the probe both ways in the lifted game, every line the same at the same frame and "ok". + +Played in Remastered (2026-10-22, recording 20.07.15): all 10 lines word for word and in order, the uncaught error in +red and nothing after it; the groups 2, 2, 4, 18 and 2 game frames apart, as in the lifted game — the frame a stack runs +out 1,024 calls deep and the frame of 100 throws among them; no hitch. + +### Tasks on our own backend (2026-10-22) + +Step 11, part 2 (`compiler/backend/emit.ts`: `planTasks`, the coroutines, `start`, `cancel`, `parallel`, `tasks()`, the +tasks' turn). The ledger's rows of tasks and of `Promise.all`, `race` and `any` are taken, 23 of them — 35 of step 11's +50; the 15 left are the `on.*` events, part 3. + +- **A coroutine** is the program, a task's slot or a branch of a `Promise.all` or `race`: a trigger that wakes it — + "the map's count of frames has reached W: go to R" — whose W and R its sleep writes. The program's is its run's + `@wake`; a slot's and a branch's are triggers of their own, passed by as done while nothing runs in them (the walk + skips a trigger whose done flag is set before its conditions, at about 40% of an empty trigger's cost). +- **The map's count of frames** (`@now`) is one more each pass (`@run`). A sleep copies it, plus its frames, into its + wake: one copy, where the program's sleep read the game's tick a bit at a time (32 triggers, about 0.5 µs; now about + 70 ns, for every program). +- **The tasks' turn** follows the program's turn, and the program's wake when it does not hold: each start's slots in + the order of the starts' numbers, as the simulator orders them; when a task can start one, round again while one + was started, for it to have its first turn in this frame too. A task outlives its program. +- **A start** that can run only once — in no loop, nowhere twice, in no task of more than one slot — has one slot, + always free when it runs: its arguments go straight into the task's parameters. One that can run while its tasks do + has its `max` of slots, each with its own copy of the task's body and variables (the variables the simulator swaps): + it finds the first free slot, says once in red that none was when all run, and the task's number is + `slot | task << 8 | generation << 16`, as the Python side's. `running` is a number one slot's number is; `cancel` + switches that slot off where it waits. +- **`Promise.all` and `race`**: each branch a coroutine with a trigger of its own, all due as the statement is + reached and run in order; then the coroutine around sleeps a frame at a time and runs those still running, until + all have ended — or one has, a race, which goes on from that branch's end with the others switched off. +- **A task's error**: the front end's `catch` around every task's body (the error noted in the slot for an `await` of + it, or said) reads the task's number and nothing of the task's, and is laid out once, a function each task calls + with its number; an uncaught error is said through one function too. A probe with two starts of sixteen slots was + 19.8 MB before, 1.37 MB after (eudplib's 2.05). An array of texts longer than 64 cells — the errors' messages, a + cell a slot — takes a cell's block from the pool the first time it holds a text, as an array that grows does. +- **Found on the way:** a loop's test was split into what nothing in the loop writes, tested once on the way in — but + while the loop sleeps a task may write it (`await t` is `while (t.running) sleep(1)`): a loop that sleeps tests its + whole condition each turn. A slot's number is worked out from its generation into places, which only a write sets: + the slot's variables are declared as the program starts. + +| Lifted game, Remastered's weights (`probes/own-tasks.ts`), µs a frame | Ours | eudplib | +| --- | ---: | ---: | +| W1: sixteen tasks asleep, none waking | **0.7** | 40.2 | +| W2: sixteen tasks awake every frame, a little each | **3.9** | 90.4 | +| sixteen tasks started in one frame | **10.0** | 141.5 | +| W3: the program asleep a frame at a time (the sixteen asleep beside it) | **0.9** | 40.4 | +| W4: four branches of a `Promise.all`, each asleep a frame at a time | **1.0** | 41.0 | +| A: a task awaited, `running`, a task cancelled | **1.0** | 7.8 | +| F: a task's error caught where it is awaited | **20.7** | 55.2 | +| the first frame | **23.1** | 1,084.1 | +| the map | **1.37 MB** | 2.05 MB | + +A task's turn costs about 0.2 µs (W2, less the frame around it) against eudplib's 3.1: its variables are its slot's +own, no swapping; a slot asleep 13 ns a frame, a free one less. What is left is in the ledger's *Open* list. + +Checked: `tests/emitter.test.ts` ("a map of our own, tasks": every row of `ledger/tasks/`, `music.loop`, `ui.lines`, a +program for every player with tasks, and cancelling, slots and their numbers, a task started from a task earlier and +later in the turn, branches inside a task and a race inside a branch, errors in tasks — the payload walked from its +bytes frame after frame against the simulator, every text at its frame); the probe both ways in the lifted game, every +line the same at the same frame and "ok". + +Played in Remastered (2026-10-22, recording 20.57.34): all 9 lines word for word and in order, "did not start" in red; +each group at the lifted game's frame (2, 14, 22, 29, 40, 43, 47, 75), the frames where 32 tasks start among them; no +hitch. + +### Unit events on our own backend (2026-10-22) + +Step 11, part 3 (`compiler/backend/units.ts`: the events pass's handler and the loops over what it found; the emitter's +`watch`; the pass before the run's `@wake` in the layout). The ledger's 15 rows of `on.*` are taken: step 11 is done, +50 of 50. + +- **The map's events pass**, once for the map before every program's turn (laid out by the first program with events, + for what all of them loop over), is a watch of the game's unit places: a trigger for each, whose conditions read the + place's record where it is against what the trigger saw there last, its own conditions' numbers — the uniqueness + byte, and as the events need them whether the unit is alive or its order, and its type. A place as it was is that + one trigger: its conditions hold, and its two actions put the trigger before it back and go on to the next. One that + changed goes to its stub (the record's EPD, where the trigger's numbers are, where to go on) and to the handler, laid + out once: a unit of another uniqueness byte that is alive came on, the one seen alive and alive no more died, one + that took up another order or turned into another type did; and what it sees now goes back into the trigger's + numbers. +- **A place whose unit is dead or gone is watched by its uniqueness byte alone:** the handler writes 0 into the kind of + the trigger's second condition, and the game reads no further (48 ns a place against 60 for one alive). Every unit + dies through the order Die, its sprite still there, and the game hands a place's first unit a uniqueness byte above + 0, so the sprite is not watched. +- **The places are walked newest first**, as the simulator hands units on: from the place the game will hand out next + (the boundary) up to place 1 699, then place 0. The game hands places out in that order and never-used ones last, so + the boundary only moves down: when the handler sees it handed out, the walk starts at the next one from then on, and + goes on from there in the same frame. +- **A listener** is a task that loops over what the pass found, as the front end writes it; a handler that waits starts + a task for each unit it is handed. What was on the map before the first pass is not handed on: a listener sleeps a + frame before it first looks (eudplib's build is the same). +- **Built first, the eudplib build's way:** walking the game's lists by their pointers. A record cost about 3.2 µs a + frame against eudplib's 1.9 (the pointer read a bit at a time and copied into the places that read it): 46 units + dying cost 146 µs a frame against 89, and `on.order` 221 against 164. It was replaced before the merge. +- **Found on the way:** an action whose number is a place is found by its index in its trigger, counted over the + trigger's actions — a write before it in the same trigger moved it. A unit's place in a task's call (`start`) was + rewritten as an expression, not as statements. An empty trigger whose next pointer is written must be kept. + +| Lifted game, Remastered's weights, µs a frame (`probes/own-events.ts`; without `on.order` beside it) | Ours | eudplib | +| --- | ---: | ---: | +| nothing coming or going, `on.order` among the events | **7.2** | 164 | +| forty-six units dying, `on.order` among the events | **9.0** | 224 | +| forty units made / killed in a frame, `on.order` among the events | **227 / 228** | 506 / 528 | +| without `on.order`: forty-six units dying | **8.7** | 89 | +| without `on.order`: forty units made / killed in a frame | **188 / 180** | 248 / 392 | +| without `on.order`: nothing coming or going, about 80 places handed out | 6.7 | **5.1** | +| without `on.order`: the same, 128 places handed out | 9.3 | **5.2** | +| the first frame (the listeners started) | **249** | 1,272 | +| the map, `on.order` among the events / without | 1.96 / 1.84 MB | **1.78 / 1.27 MB** | + +eudplib's build without `on.order` looks at its lists' heads alone when nothing comes on and nothing is dying: on such +a frame it is ahead, by 60 ns for each place the game has handed out (Open, below). Every other frame is ours. + +Checked: `tests/emitter.test.ts` ("a map of our own, unit events": a unit table laid out as the game keeps it, changed +between frames — a unit handed out, an order taken up, a type changed, a unit dying in a fight, its place handed to +another — the payload walked from its bytes frame after frame, each line at its frame; a handler that waits, a task +each); `probes/own-events.ts` and the same without `on.order`, both ways in the lifted game, every line the same at +the same frame and "ok". + +Played in Remastered (2026-10-22, recording 21.59.29, the lists' build): all 7 lines word for word and in order, each at +the lifted game's frame (2, 4, 6, 12, 15, 29); the Marines made and killed as the script says; no hitch. +Played again as merged, the watch (2026-10-22, recording 22.58.21, `own-step11-events-watch.scx`): the same 7 lines in +order at the same frames, to the video frame; the Marines made and killed as before; no hitch. (The lines would be the +same if Remastered read past a condition of kind 0, only slower: what a dead unit's place costs there is not measured +yet.) + +### Input on our own backend (2026-10-23) + +Step 12 (`compiler/backend/input.ts`, the emitter, the layout, `scripts/build-own.mts`): the keys, clicks, the mouse, +typed lines and `sync()` carried between the players' computers by the map itself, in place of chatEvent and MSQC. + +**What the command dispatch says (the lifted game, 2026-10-23).** What a map can carry from one computer to all of +them is a game command: the game sends each player's commands to every computer in turns and runs them in step, so +whatever a command leaves in the game's memory is the same everywhere. Each command of the dispatch (0x05–0x5C, +`ledger-tables.md` §7) was queued in the lifted game and the memory diffed a frame later, for the trace it leaves and +what else it does: + +- A queued command runs two frames later (one turn of latency alone); several queued in one frame run in that one + turn, in order, before the frame's triggers — so a Select, an order and a Select putting the selection back all land + together, and nothing of the player's can come between them. +- **Select** (0x09, up to 12 unit tags) writes the player's command selection (`0x6284E8 + 48 × player`, every + computer's, readable in Remastered). Of units the player does not own only the first is kept, and none of them beside + own units: a carrier has to be the sender's. +- **Hotkey** (0x13, set group g) copies that selection into the player's group (`0x57FE60 + 480 × player + 48 × g`, + 12 tags of 16 bits, readable and writable in Remastered). A carrier that clobbers a control group was weighed and + put aside: it would have to be saved and put back around every send. +- **Right-click** (0x14) on a point does nothing to a unit whose dat entry has no right-click action — which is what + MSQC's carrier has; MSQC's "right-click" is a **targeted Move** (0x15, order 6): the handler writes the point into + each selected unit's order target (`+0x58`, read and write allowed in Remastered) that same frame, and the movement + code copies it into the waypoint (`+0x10`, MSQC's cell) the frame after. The point is clamped to the map: x in + `[1, W − 2]`, y in `[1, H − 34]` (W and H the map in pixels); a point outside the map is dropped. Two Moves to one + unit in one turn leave only the last. +- **Minimap ping** (0x58) reaches only computers allied to the sender; **vision** (0x0D) and **alliance** (0x0E) are + 4 and 8 bits of players who are not there, and a turn keeps only the last; **chat** (0x5C) is 78 bytes and a line on + everyone's screen. Nothing carries bits to every computer and does nothing else: the carrier is a unit, as MSQC's. + +**The channel.** For each human player, a ring of hidden units of theirs — the carriers — and a cursor at each end. +A send is a Select of the next carrier and a Move to a point; the point is the value (`x − 1 + (W − 2) × (y − 1)`: +`B = floor(log2((W − 2)(H − 34)))` bits, 21 on a 64×64 map, 23 on 128×128, 25 on 256×256; MSQC's `val` is 22 on +128×128). A value arrives in the carrier's `+0x58`, 0 when nothing has (a sent x is never 0). Every computer, before +the programs' turns, reads each human's ring from its read cursor, takes what arrived in order, writes 0 back and +moves the cursor on; the sender keeps its write cursor and never has more in flight than the ring holds (its own +computer reads its ring like any other, so it sees what has landed). A turn that brings several frames' sends keeps +every one — MSQC's one carrier per value kept the last, which cost S2 its key presses and the Python side a bank of +held keys and a grace. Two rings: **points** (the mouse, raw, one send a frame when it moved) and **messages**, +each a header and then raw values the header counts: a **state** (one packet: a bit a key the programs read, then the +three mouse buttons; sent when it changed, paid out one a frame on arrival, so a key is held for as many frames as it +was down, and a press is the frame its bit came on), a **sync** (the slot, then the value), a **line** (the pattern, then +its captures). After a frame's sends the player's own selection (`0x6284B8`, this computer's) goes out as a Select, as +MSQC's `RestoreSelUnits` does, so their orders go on reaching their units. + +**The carriers** are made at the first frame, on every computer alike: the chosen type's dat entry patched as MSQC's +(the Command Center's flingy so it cannot move, 1×1, no weapon, no sight, no right-click), the units created in +transit — hidden, and selectable — through a unit-properties slot the build writes, given to Player 12 so they are in +no human's list (no supply, score, Command or Bring of theirs), and their owner byte put back to the player's so a +Select and an order of that player reach them. Their records' EPDs and tags are kept in cells of the map's. The type +is the Valkyrie unless the map uses it, in which case the build takes the first type the map's units, triggers and +script never name. A carrier the map kills is made again. + +**What the map gives up**, against MSQC's location, Valkyrie entry and Player 12 units: one unit-properties slot and +one unit type's dat entry (chosen so the map does not use it), the scratch location the own backend already keeps for +orders, and `ring × humans` hidden units of Player 12's (6 a human for messages, 6 more where the mouse is read) at +the map's top left corner. No location for the mouse: `mouse(p)` is a value. + +**Reads on the sender's computer**, as MSQC's and chatEvent's: the key bytes (`0x596A18`) unless a line is being +typed (`0x68C144`), the buttons (`0x6CDDC0`), the mouse on the screen (`0x6CDDC4`) and the screen on the map +(`0x62848C`); a typed line from the text lines (`0x640B60`, the ring's position at `0x640B58`), the line beginning with +this computer's player's name, matched against the programs' patterns there and sent as its number and values. The +test harness (`GameSim`) already types through the game's chat command and writes keys and the mouse where the map +reads them: nothing of it moves. The simulator keeps its model (a value arrives the frame after); a built map's trip +is two frames in the lifted game where MSQC's was five. + +**The passes.** The receive pass joins the events pass before the programs' turns; the send pass is a block of its own +after the last run's end (`Graph.after`, the layout's `@after`), so a `sync()` sent in a frame goes out that frame. +`syncSend` puts its value in a pending cell the send pass takes; `input` reads are reads of the map's cells; `select()` +and `local.selected` were step 12's too and are taken with it. `underMouse(p)` is the pick nearest to the point. + +**As built, part 1 (2026-10-23, branch `input`).** `compiler/backend/input.ts` (the plan, the reads rewritten, the +receive and send passes, the carriers' first frame), the emitter's and the layout's `after` block (`Graph.after`, +`AFTER_END`), `units.ts` (`local.selected`; `underMouse()` as the pick nearest the kept mouse; every walk of the +table passing the carrier type over), `own.ts` (`OwnMap` from the build: the humans, the map in pixels, the slot, the +type), `scripts/build-own.mts` (the slot written in transit through the editor's own code; the type the Valkyrie +unless the map's units or triggers name it). Not yet in part 1: typed lines; a carrier the map kills is not made again. + +- **Tests** (`tests/emitter.test.ts`, "a map of our own, input"): the harness plays the game's side — Give Units puts + a human's carriers at the head of Player 12's list, the key bytes and the mouse are written where the map reads them, + and what the send pass queued is read back as the game would run it two frames on. Keys go out the frame they + change and arrive two frames on; a key held 3 frames is held 3 frames; the mouse goes out when it moves, on its own + ring; a click is the frame the button came down; `sync()` resolves two frames on, two values together; a program + for every player reads each player's own keys. +- **The lifted game** (`probes/own-input.ts`, keys, the mouse and clicks written at set frames by a runner): every + line as the script says — `sync 0 1048574 12345 67890 ok` (the smallest and the largest value a point carries, exact), + a press 2 frames after the key, "held 5 frames" for a key down 5 frames, the click at the pixel, a right-click on a + Marine found and one on bare ground not, two 1-frame taps two presses. The same script through eudplib and MSQC: the + sync 6 frames later than ours, "held 7 frames" for the 5 (the bank's grace), the click at 320,2864 for 300,2600 and + "moved 2 times" for 4 (MSQC's mouse through its location), the two taps one press (the grace again). +- **Sizes and costs:** 12 carriers a human with the mouse read, 6 without; the probe's payload 2.14 MB (eudplib's + 0.8: the own build's size is step 14's business). The passes are not weighed yet. +- **Found on the way:** the emitter folds a sum whose terms are bit-slices of one read and a negative constant wrongly + (`(v & 0xffff) − 1 + ((v >>> 16) − 1) × 1024` came out as `v_low − 65536 + …`: the constant's high half went into + the sliced copy); the decode is written as separate steps, and the fault is in the Open list below. + +**As built, part 2 — typed lines (2026-10-23, branch `input`).** A line is read where it was typed. The game shows every +line in its thirteen text lines (0x640B60, 218 bytes each) as `name:\x07 text`, and moves the byte at 0x640B58 on +with each (the next slot: 10 at the start of a game and wrapping at 11 in the lifted game — the modulus is not leaned +on, only the change). The send pass, on this computer's player's turn, reads that byte; when it has moved, it looks +through the thirteen lines for one beginning with the player's name and `:\x07 ` (the prefix read once at the first +frame from the player table, 0x57EEEB + 36p, into cells), copies the text after it into cells, swaps the two bytes +after the colon (chatEvent's mark: `name: \x07text` shows the same and is not found again), and matches the text +against the patterns in the order the programs read them: written text byte for byte, `{n}` as digits kept to what a +point carries, `{k:ore|gas}` by a hash of the word to the next space and `{u:unit}` by a hash of the rest of the line +(`h = h × 31 + byte`, capitals as small letters, as the Python side's) found among the unit names' hashes in rising +order by halves (a table of 455 names in cells with values, from the compile's `unitNames`). The first pattern that +fits the whole line is kept as a pending line, and goes as a *chat* message of the ring: a header (kind 2, the +pattern's number) and then its values, all or none (the ring needs room for every packet, and the queue too — the +packet's room check now covers the whole message, which the sync's needed as well). On arrival a pattern's flag is up +for a frame, and its values are in cells of its own, so `chatted()` is a cell read and `on.chat` the same test in a +loop. One matched line a frame is sent (a person types one at a time); the three addresses are read-allowed in +Remastered's emulator (0x640B58 "read back"), and the line's bytes write-allowed, so the mark is kept there too. + +- **Tests:** "a typed line is matched where it was typed and arrives as its pattern and values, two frames on": the + harness shows lines as the game does (the name "oracle", the ring's byte 10 wrapping at 11) — `-help`, `-give 5000 + gas`, `-spawn Terran Marine`, `-give 99999999 ORE` (1,048,575), `-spawn zerg zergling` (37), `-ping 7` through + `on.chat`; `hello there`, `-give x ore` and `-spawn nothing of the kind` match nothing; fourteen more lines after the + ring went round each arrive once. +- **The lifted game** (the probe's runner typing lines through the game's own chat command): both builds print the + same eight lines on the same frames — ours and eudplib's chatEvent+MSQC alike, two frames after the line shows. + The probe's payload 3.08 MB with the four patterns and the name table (2.14 without). +- The ledger: step 12's 5 chat rows and `on.chat` taken; **26 of 26**. + +**Played in Remastered (2026-10-23, recording 10.29.27, single player).** Every line of the sheet as expected: the +sync corners, presses two frames on, clicks at the pixel (the corner click at 87,65), `underMouse()` a Marine and +the Command Center, `local.selected`, two quick taps two presses, the Marines moving on right-clicks with the +selection kept, no stray unit, no error — and every typed line: `-help`, `-give 42 gas`, `-spawn Terran Marine`, +`-ping 7` through `on.chat`, `hello there` nothing, `-give 99999999 ORE` as 1,048,575, `-spawn zerg zergling` as 37, +and thirteen more `-help` each answered at once as the text ring went round, the typed line showing unchanged after +the mark. So the text ring's byte moves in Remastered too. **One fault:** a hold of F7 of about a second gave "F7 +pressed 2, held 17 frames before" during the hold — Remastered's key table drops a held key for one frame when the +key begins to repeat (MSQC's grace hid this). Fixed: the send pass keeps a bit up through one frame it reads down +when it was up the two frames before (`@in.raw1`, `@in.raw2`), so a release goes one frame late and a one-frame gap +is passed over; a tap of one frame is still one frame. The probe's "held 5 frames" reads 6 now; a hold of three +seconds is the re-test. A chat line stays on screen longer than a printed line in Remastered (older `-help` lines +stood alone while their answers had faded). **Re-test played (recording 10.44.13):** a hold of F7 one press and +"held 56 frames", two quick taps two presses, `-help` answered. Step 12 played out. + + +### The map's own triggers on our own backend (2026-10-23) + +Step 13 (`compiler/backend/own.ts`: `OwnTrigger`, the records with the players whose lists hold a copy; +`compiler/backend/layout.ts`: the chains after each player's entry, the gate turned around; `scripts/build-own.mts`; +the start-up's current player). The ledger's 13 rows of `trigger()`, its options and `hyperTriggers()` are taken: step +13 is done, 13 of 13. + +- **The map's own triggers are the game's own nodes in the payload.** The map's TRIG section (as the editor holds it + before the script's records go in) and then the script's `trigger()` records, in order, each copied once for every + player whose list the game would copy it into — the slots named, All Players, a force, among the players the map + gives a human or a computer (a trigger for a player not in the game runs for nobody, as in the game). A copy is laid + out as a node of the game's — its conditions and actions byte for byte as the TRIG section would hold them, its + texts and sound files put among the map's strings, its execution flags as written — stacked as the graph's triggers + are, and chained: the player's entry sends the walk to the first copy, each goes on to the next, the last to the + player's gate. The game runs them as it runs a TRIG section's: a trigger that is not preserved is switched off once it + has run, a Wait holds it in the player's wait slot and the walk goes on, a disabled trigger, condition or action is + passed over. The TRIG section holds the start-up alone, as before. +- **In eudplib's order.** The gate is turned around: it lets the walk into the programs when every player *after* this + one has an empty list, so the programs run from the last player's list still there — after every player's own + triggers, as eudplib's build runs them (its RunTrigTrigger at the top of each frame, the programs after). Each + player's copies run in their list's walk, so the current player is theirs; a copy of Player 2's that reads what + Player 1's set in the same frame sees it. +- **Hyper triggers are left out.** A map of our own goes through its triggers every frame as it is (`@stop` sets the + game's wait between passes to 0), and under that pass the game holds a list with hyper triggers in it to every second + frame (measured in the lifted game with the probe's triggers as a plain classic map: passes on the odd frames, `Wait` + counting 84 ms a pass; eudplib's build with `hyperTriggers()` in the map is held so too). The build's line says how + many it left out. +- **Found on the way.** (1) The start-up left the current player at the EPD it wrote through, and the first frame's + chain ran with it: the `Wait` took a wait slot that was nobody's and ended at the next pass. The start-up's last + trigger now puts the current player back to the player whose list it is. (2) A `deaths(P2, units.TerranGhost)` + read gave the current player's Marines: a deaths cell 12 to 26 dwords in is a player group's number as a player + field (Open, above); such a cell is now asked as the game's own Deaths condition of that player and unit. (3) + eudplib's build runs a trigger whose Disabled flag is set; the game does not (the classic map, the lifted game): + ours follows the game. + +| Lifted game, Remastered's weights (`probes/own-triggers.ts`: eight triggers of the map's own beside a program) | Ours | eudplib | +| --- | ---: | ---: | +| a quiet frame (the triggers walked, the program asleep) | **0.4 µs, 10 triggers** | 3.3 µs, 111 triggers | +| the first frame | **6 µs** | 1,082 µs | +| the map | 60 KB | **54 KB** | + +Checked: `tests/ownGame.test.ts` ("a map of our own, the map's own triggers": the players a record is copied for; +each copy's bytes against `encodeTriggers` of the record, its flags, the chain's next pointers, the gate's conditions); +`tests/emitter.test.ts` (the same, walked from each player's entry frame after frame as the game walks the lists — a +trigger not preserved switched off after its run, Player 2's copy of an All Players trigger seeing the switch Player +1's set the same frame, the program after every player's triggers; a player not in the game left out); the ledger; +`probes/own-triggers.ts` both ways in the lifted game: every line "ok" and at the same frame as eudplib's build without +`hyperTriggers()` — but E, where eudplib runs the disabled trigger — and as the same triggers in a plain classic map. +Played in Remastered (2026-10-23, recording 12.37.25, `own-step13-triggers.scx`): all ten lines in order, each "ok"; +the first two groups on screen as the view fades in, D 10 game frames after them, G one after D, A2 31 after D — the +lifted game's spacing to the frame; a clean quit. Remastered runs the payload's nodes as its TRIG section's: a Wait in +the player's slot, a trigger switched off after its run, a disabled condition passed over, a disabled trigger not run. + +### The watch's gate (2026-10-23) + +Step 11's one loss to eudplib closed (`compiler/backend/units.ts`: the handler marks what it found; `compiler/backend/emit.ts`, +`watch`: the gate and its numbers read again). When a program's events are `created`, `died` and `killed` alone — no +orders, no types, which change with nothing else of the game's to show for it — the watch of the unit places is passed +by on a frame when nothing can have come on or gone. + +- **What the game shows, read first** (`~/trigscript-audit-2026-10-19/tools/step13/gate-research.mts`: a quiet map + in the lifted game, units made, killed and removed through the engine's own actions, the game's data diffed frame by + frame). A unit handed a place pops the head of the free list (0x628438); a place freed goes in *behind* the head, so + the head does not move for it, and a pop after it can bring the head back — but not the place behind the head as + well. A unit that starts to die or is removed is taken off its owner's count at once — 0x581DE4 + 4p units (men and + every other unit but an Egg, a Cocoon and a Lurker Egg; incomplete ones too), 0x581F34 + 4p buildings (0x488CF6 in + 1.16.1, the score screen's "Units" and "Buildings"), 12 players each, Player 12's included — where the deaths table + moves only for a death in a fight (not Kill Unit), the dying list's head (0x62842C) only from empty, the unit + finder's count (0x66FF74) is not Remastered's to read, and the free count (0x6283F0) moves a frame late. +- **The gate**: four triggers before the walk, each a group of the numbers compared with what the watch last read — + the head and the place behind it (two conditions, the second's player field the EPD of the head's next pointer); the + units of players 1 to 6; of 7 to 12; the buildings — each holding going on to the next, the last to past the watch; + one failing says which group and goes into the watch. About 0.25 µs when quiet. +- **Read again** after the walk, as far as the frame's changes reach: the head and the place behind it when a unit came + on (`@ev.madeN`); the counts of each player the handler marked as having a unit come on or gone (`@ev.own`, a bit + a player); when the gate opened and the handler found nothing (a place freed, a unit given to another player, a + morph: `@ev.touch` 0), the group that failed alone. A number wrong only costs the next frames the watch — the gate + holds only when every number is right — so the handler need not mirror the game's books; it marks owners and the + reads do the rest. The handler watches whether a unit is alive whenever gated, listed or not. + +| Lifted game, Remastered's weights, µs a frame (`probes/own-events.ts` without `on.order`, 80 places handed out) | Ours | eudplib | Ours before | +| --- | ---: | ---: | ---: | +| nothing coming or going (the whole frame, the listeners' tasks included) | **1.9** | 5.1 | 6.7 | +| of it the gate | **0.25** | | | +| a dead unit's place freed (the place behind the head moved: the watch and three reads) | 15 | 5.1 | 6.7 | +| the first frame (the listeners started, the gate's 27 numbers read) | 389 | 1,272 | 331 | + +Every line of the probe at the same frame as before, both ways. Checked: `tests/emitter.test.ts` ("a map of our +own, unit events": a unit put in a place with neither the head nor a count moved is not seen until one moves; the head +and Player 1's count move as the game moves them and the unit is handed on). Played in Remastered (2026-10-23, +recording 12.42.11, `own-step11-events-gated.scx`): all six lines "ok", each group at the lifted game's frame spacing +(B two frames after A, C six after B, W fourteen after Weighed) — Remastered moves the free list's head and the counts +as 1.16.1 does, and the gate opens on the frame a unit comes on or dies. + +### Triggers stacked (2026-10-20) + +Brought forward from step 14 (`compiler/backend/layout.ts`). The game reads no more of a trigger's 2,408 bytes than +its next pointer, its conditions and actions, its flags and its current action — and, of the condition and the action +after the last, two dwords each: the game asks whether one is switched off (its flags) before whether it is the last +(its kind), in both loops (read in the lifted game's code; eudplib holds the whole condition and action after). The rest +is room for other triggers, as eudplib's allocator stacks its objects (`stack_objects`): each trigger goes at the first +place, from a little before where the last went (4,096 dwords), where none of the dwords it holds is another's; the +variables' and data's dwords go in the gaps; a list or a template, after everything. + +- **Rows.** A doubling trigger walks a run of triggers by adding 2,408 to its next pointer, so those runs are laid out + one after another (`Graph.rows`, the emitter names each; `checkGraph` refuses a walker pointed at a trigger in none, + where the same trigger does not also move its addition aside to take its numbers out). +- **Cell triggers** (routed variables, arrays, stacks) held only their kinds' dwords; they now hold the flags' too. A + cell's first condition's flags were the dword of the next action's kind of the cell five before (0), and its next + action's flags the current action's dword of the cell 28 before (players' bytes, 0) — right by luck, and the data + put in the gaps would have landed there. +- **Checked:** every dword nobody holds filled with ones (`LayoutInput.free`, `OWN_FREE` for `build-own`): the 20 + `probes/own-*.ts` in the lifted game print what they printed unstacked, at the same frames and costs; the emitter's + tests run every payload so, the walker asking a condition's and an action's flags first as the game does (with the + cells as they were, five of those tests fail). Remastered: `own-step8-made-filled.scx`, the made-texts probe filled so. + +| Probe | Payload before | Stacked | File before | Stacked | +| --- | ---: | ---: | ---: | ---: | +| `own-made-texts` | 15.2 MB | **1.33 MB** | 391 KB | **188 KB** | +| `own-math` | 9.1 MB | **1.15 MB** | 259 KB | **154 KB** | +| `own-fixed` | 5.0 MB | **389 KB** | 147 KB | **87 KB** | +| `own-texts` | 2.3 MB | **229 KB** | 97 KB | **69 KB** | +| `own-recursion` | 1.9 MB | **915 KB** | 78 KB | **64 KB** | +| `own-game` | 479 KB | **28 KB** | 52 KB | **46 KB** | + +eudplib's build of `own-made-texts`: 894 KB of strings in a 1.1 MB scenario, 121 KB as a file (ours: 1.38 MB of +strings, 1.65 MB scenario). What is left: about half the triggers are routing, a trigger for each place a variable is +copied into (3,219 places in that probe) — fewer places is the next saving. + +### The build in the editor (2026-10-23) + +Step 14: the map of our own is what Save, Test Map and an export write, from the editor, with no eudplib and no +Python — the plugin's own build step (`service.ts`, `buildOwn`; `compiler/backend/build.ts`, run in the compile +worker), registered with the host's `document.buildSteps.add` beside the `before` step that applies the script. + +- **On the scenario's bytes alone.** The host hands a step the map as Save would write it and takes a bare scenario + back, laying the file out itself with the user's save options and the map's members as they were — so the plugin + writes no archive (mopaq stays a dev dependency) and reads the CHK out of the step's input with the host's + `sections.chkOf` (plugin-api 1.61.0). `chk.ts` reads and writes what the build touches: the chunks (a negative + length ends the reading, what follows is kept), the string table (STR read on as STRx — a Remastered file keeps its + strings there — two strings alike sharing their bytes as the editor writes them), DIM, OWNR and FORC for who plays, + UNIT and the triggers for the unit types a carrier must not be, UPRP and UPUS for the slot marked in transit. What + `build-own.mts` did through the editor's code it now does through `buildOwnMap`, the same function (the probe script + keeps the probe's preparation: a base each, the players, the name); the step-13 probe built both ways prints the + same ten lines at the same frames in the lifted game, and its sections differ only by the fixes below. +- **The map's own triggers** come out of the input's TRIG: the hand triggers and the script's block as the apply + ordered them (`compiled.triggers` is empty from the editor; a probe under Node still passes its records, which no + list holds yet). Hyper triggers among them are left out, as step 13 has it. +- **eudplib's fixes** (`fixes.ts`, from `eudplib/core/mapdata/fixmapdata.py`): UNIT flags made to agree with the + figures, UNIx/UPGx/TECx rows at the defaults zeroed, VER 206, a null tile made tile 1 (said as a warning, with where) + — so a map built either way is the same map. Each says a line in Output when it changed anything. +- **The settings** reach the emitter: `emitOwn` takes `{ heapCells, stackDepth }` (`growingArrays`' cells, `emitBody`'s + depth), and the step passes the script's. A stack past 65,536 cells is refused as before, now with a setting to + follow. +- **A refusal while the script is typed.** `compileScript` takes `own` — who plays, the map's size — and answers + `ownRefusal`: `emitOwn` run on a copy of the IR (its passes rewrite what they read), the first part it never takes + with its line, and the file when it is not the entry file (`refusedAt` in `emit.ts`: ", line 12 of ai/waves.ts"; + the ledger's rows are one file each and read as before). The service passes it with every compile, so the editor + shows it as a warning on the line in Problems ("build" in the source column) and Monaco, and the apply writes it + into the manifest (`ownRefusal`). +- **The changeover**, as the audit proposed: TrigScript builds wherever the emitter takes the whole script, eudplib + elsewhere. Both are decided off the manifest, so a save asks for free: the own step applies when the script has + programs, the manifest holds no refusal and Magenta's member is not in the map; the eudplib contribution applies + when the script has programs and the step does not. The status bar reads **built by TrigScript**, or eudplib's + state with why underneath; Output gets the build's lines through the same events the library's builds send + (`onBuild`, contributors `["trigscript"]`), so the editor's handler needed no second path. +- **The worker.** `buildInBackground` sends `{ build: OwnBuildInput }` to the compile worker, which answers with + `buildOwnMap`'s outcome; a build is not superseded by a compile typed meanwhile, and supersedes none. A worker + bundle without `buildOwnMap` (an older release's `dist/compiler.js`) says to update the plugin. +- **Seen in the editor** (headless, the plugin served from the worktree with the dist override): a program typed, + Apply, Ctrl+S — the status bar **built by TrigScript**, Output "Built by TrigScript itself: 1 program laid out into + the map, no eudplib" and the fixes' lines (71 placed units' flags, 227 UNIx rows, VER 206 from 205, STR to STRx, + "93 triggers of the programs, payload 23594 bytes at 0x191954a8, 3 triggers of the map's own (6 copies), start-up 65 + triggers"), "Built 290 KB" and the file downloaded. Before the dist override the page ran the committed bundle and + Save opened eudplib's install dialog: the loader takes a served plugin's `build` (dist/plugin.js), and the worker + the release's compiler from the CDN unless `monacoDist` points it at the served `dist/`. +- **Tests:** `tests/own-build.test.ts` (a synthetic scenario in, the sections out: STRx with the pool and the payload, + the start-up, the fixes, the map's triggers and hyper triggers, the input's slot, the script's records, the refusals + with file and line, the settings' effect), `tests/service.test.ts` (the step applies and the contribution does not + and the other way round, the manifest's refusal, Magenta's member, the events, no second compile), the suite 1,362 + green, the ledger unchanged. + +### The tools on maps of our own (2026-10-23) + +Step 15: everything that read a built map through the Python build's log reads a map of our own the same way, and +every probe is built our way by default. + +- **The two maps from the layout** (`compiler/backend/maps.ts`, `buildOwnMap`'s `maps`): the trigger map — every + trigger of the payload by rising offset from the string section's end, its conditions and actions counted off the + bytes as the game counts them, the statement it was made for as "file:line:column", each action's own where a + trigger of the emitter's holds several lines' work, nothing for a cell trigger, a route, a wake or the gate — and the + variable map, a row a run (a per-player program's runs each with their owner, the run's renaming undone), each + variable's cell out of the layout's `vars`: a unit its EPD and uniqueness byte (`epd`, the reader makes the address + and the word), a made text its block's EPD and length, a text of the table its number in the pool (`textBase`, the + reader adds it), an array that grows its handle's cells and the heap's region as `cells` (72 bytes a cell), a fixed + array its region's value field, a list known when built its data. Said in the Python's own lines + (`TRIGSCRIPT-MAP`/`-ROWS`/`-VARS`/`-PROGRAM`), so `readTriggerMap`, `readVariableMap`, `programVariables`, the cost + by line, the desync detector, Find an EUD Error and the debugger are unchanged; the editor's build logs them under + `withVariableMap`. +- **`build-fixture` on the own build.** The default now: the prepared map (players, a base each, Home/Away/Pixel, the + script's records in TRIG, MAP_NAME, KEEP_TRIGGERS, STACK_DEPTH/HEAP_CELLS, OWN_FREE) through `buildOwnMap`, the maps + beside it with TRIGGER_MAP/VARIABLE_MAP; `EUDPLIB=1` builds through plugin-eudplib as before (the second build a step + is held against). The compile is without the emitter's graphs for the own path (they are the Python side's). So + `test-game`, `game-check`, `debug-game`, `lifted-weigh` and `bench-lowering` run on maps of our own with nothing + changed in them; `build-own.mts` is gone. +- **Held in the lifted game:** `probes/game-program.ts` (sim.program(): numbers, booleans, texts of the table, a + record, arrays fixed and growing, a unit held, a per-player program by player) 3 of 3 in the game, after two faults + the first run found — a text of the table read as its pool number (the reader needed `textBase`), a unit's + uniqueness byte kept as the byte where eudplib keeps the word (`epd` shifts it); `probes/game-debug.ts` through + `debug-game` ("every check held": the record against pictures of frames, variables agreeing); `game-check` on + `own-triggers` and `game-emitter` with lines named by the map and the allowlist: 0 ending the game, 0 never seen — + the evidence holds for our builds without the list being made anew. +- **The lowering bench's baseline anew** (`probes/lowering-bench.baseline.json`): all lines 42,936 ns against the + Python side's 90,559 (−53 %); UNITS −56 %, LIST_WRITE −88 %, LIST_LOOP_WRITE −78 %, ARRAY_LOOP_WRITE −72 %, + DIV_VAR_THRESHOLDS −70 %, LIST_READ −79 %. Slower: REF_FIELD_WRITE +20 % (43 → 51 ns), DIV_CONST_R +12 % + (261 → 293): for the Open list. +- **End to end in CI** (`tests/ownWalk.ts`): the built file's STRx payload walked as the game walks the lists — + each player's entry every frame, the current player theirs, Deaths and Set Deaths masked and not, Always, Never, + Switch, Display Text read from the file's own string table — against the IR simulator's lines frame by frame, and the + variables read back through the map with `programVariables` over the walked memory (a number, a growing array, a + fixed array, a text of the table reassigned, a per-player program's by player). No lifted game needed: CI runs it. +- **Tests:** `tests/own-build.test.ts` +2 (the maps' rows and the lines read back; the walk), `tests/service.test.ts` + (the maps logged under `withVariableMap`), `tests/game-variables.test.ts` as before. + +### The Open list's bug pass (2026-10-23) + +Between step 15 and step 16, the Open list's bugs (above, *The ledger* → *Open*), taken on branch `open-bugs`. Each +has a row in `tests/open-list.test.ts` (the bodies run in JavaScript too, and compared), and a ledger script where it +is a thing a script can say. + +- **The emitter's fold.** `(v & 0xffff) − 1025` came out as v's low half under −1025's high half (0xFFFF0003 for + v = 3): the plan's *lone place* fold puts a scratch cell straight into the one dword that takes it, and when that + dword starts from a constant it turns every `set 0` act of the scratch cell into a set of the constant — the `&`'s + own `set 0 under ~mask` among them, which then wrote the constant's bits under the mask. The fold is not made when a + masked write works the scratch cell (`&`, `|`, `^` by a constant); the copy with the constant added is right. The + test (`tests/emitter.test.ts`, "a sum of bit-slices of one read") has eight shapes of it. +- **`return` at the top of a program** ends it: the front end emits a `return` with no function around it, the emitter + goes to the run's END (`Emitter.ret` with no frame), the Python side jumps to a label after the body (its top + coroutine's `end`), and the simulator already ended the run. A value is refused: a program gives nothing back. +- **The deaths table's cells 12 to 26.** `memory()` and `setMemory()` of a dword 12 to 26 dwords into the table name it + as the deaths of player e mod 12 of unit e div 12 — the same cell — in the front end (`runtime.ts`, `deathsAt`), so + both builds and the simulator have it; the game would have read the player group of that number. +- **Said now.** `disabled(…)` in a program is refused (a condition or an action); `preserve()` is a remark; + `displayText(…, false)` with `name()` or `color()` in it is a remark (a print is always displayed). The build warns, + as a notice in the editor and a line of the log, of hyper triggers of the map's own left out and of triggers for a + player group the game runs no list for (`OwnBuild.warnings`, the service's `warning` event). +- **False errors.** `n++` as an index in a loop (`checkSleeps`'s `assigned` now follows calls inside expressions; the + index is worked out before the value, as JavaScript has it); a comparison as a boolean argument; a `switch` on a text + known when built (matched then); an arrow's `this` inside a method (the hoister leaves a function value with `this` + in it to the program); `first(…)?.order(…)` (`libraryName` no longer takes a member of one of the library's + interfaces — the unit's `order()` — for the library's `order()` action); `u?.hp ?? 0`; `sleep(…).then(() => 7)` as + a value; `new P(4).x`; `const both = command(…) && bring(…)` (kept as the conditions, asked where tested); + `rows[i].pop() ?? -1`; a build-time throw inside a `try` (the program's error, which the catch takes). +- **Against JavaScript.** `~a` (a ^ −1), `a ** n` and `Math.pow(a, n)` by a power known when built (products), + `Math.sign` (two signed comparisons), `Math.log2` of a whole number (rounded down: five comparisons with powers of + two, by halving; 0 for 0), `fill(v, start, end)` (`sliceEnd`'s bounds). +- **The library.** `first()` and `randomUnit()` take a set of units — `first(unitsAt(locations.Base, { owner: P1 }))`. + The cutscene module's header example compiles as written (a test reads it): the walk the camera follows is + `start(walk(…))`, a task, since a promise kept in a const and awaited later is not the language's. + +Still open from the list: the step 10 to 15 leftovers (sizes and speeds), `j = 1 - j` copied a bit at a time, the +texts' 1,023-byte count, `shared(0)` set again by each run, `let total = shared(0)`, and P9 to P12 owners confirmed in +the game. + +### The gate before the Python goes (2026-10-23) + +Step 16, on branch `gate`. The idea 17 of Part D built: whole programs made at random, run every way there is, and +what they found, fixed. Then every probe built both ways and played in the lifted game, the last time the two builds +are held against each other; and the simulator's model named the backend's. + +**Random whole programs** (`tests/randomScript.ts`). A script is drawn as a small tree — the program's variables, its +functions, statements and expressions of the language's core: numbers with every operator, booleans, texts known when +built and made in the game, arrays fixed and growing, a record, `if`, `for`, `while`, `do`, `switch`, `break` and +`continue`, calls and a function that calls itself, `try`, `throw` and `finally`, the deaths table and the switches, +`sleep` — and rendered twice from the one tree: as TrigScript, and as JavaScript whose own arithmetic (32 bits, +`Math.imul`, a quotient cut towards zero, a shift by 32 or more leaving nothing) says what the lines must be. Every +loop carries a guard, every index stays inside its array, a `throw` stands inside a `try`, and a program prints a line +at the end of each of its frames with its seed in it. Three programs to a script, so one compile holds three. + +- **Four runs, the lines compared frame by frame.** JavaScript (`runJs`: each program a generator, `sleep` a yield, + the programs resumed in order on the frame their sleep ends); the simulator; the map of our own built on the tests' + synthetic scenario and walked from its bytes (`tests/ownWalk.ts`); and, with the game, the lifted game on the own + build and on the eudplib build. `tests/random-scripts.test.ts` runs the first three over 60 seeds in CI + (`RANDOM_SCRIPTS=n` for more, `RANDOM_SEED=n` for one, its source printed), about a second a seed; + `scripts/random-scripts.mts` runs any range, adds the game (`--game`, `--eudplib`, a few programs to a map since the + lifted game is 1.16.1 and its string table names 64 KB), and cuts a failing seed down to the smallest script that + still fails the same way (`--reduce=SEED`: whole programs first, then a statement at a time out of every body, a + compound statement replaced by its body, functions and declarations nothing names). +- **What the generator had to learn** about writing TypeScript that means what it says, each a false failure before it + was a rule: `a < b … > (` reads as a generic call to TypeScript's parser whatever `a` is, so no `<` is ever written + (the sides change places); two constants compared have "no overlap", and two constants in arithmetic are worked out + when the script is built, so a side is always a variable; `x == 20` narrows `x` to 20 and a later `x != -77` has no + overlap, so a constant compared for equality is `z + 20` with `z` a number the compiler cannot see (the deaths of a + unit nothing sets); a text variable against a literal narrows it too, so texts are compared variable to variable or + as a template of the variable; a divisor the compiler can see is 0 is a compile error, so divisors are `z` or made + odd; `deaths()` is a u32; a `finally` cannot `break` out of its try; a function that calls itself only with less; + `−(−2 147 483 648)` is a u32 literal. None of these is a bug of the language. + +**Found and fixed, in the front end** — what the compiler worked out when the script was built, against what the +game computes (each the smallest script in `tests/gate.test.ts`): +- A condition of the game in a `?:`, a loop's bound or a text ternary was taken as *true*: `switchIs(…)` is a record + when the script is built, an object, true to JavaScript. The evaluator (`structured.ts`) now treats a condition as a + value of the game's — nothing is worked out over it — and the hoister (`hoist.ts`) never hoists a `?:` whole, so its + sides are hoisted on their own and the condition is tested where it stands. (`if (cond)` was right all along.) +- `7 / 2` was 3.5 where the plan (B6) says 3: a quotient of two whole numbers known when built was JavaScript's. + `wholeQuotient` / `wholeArithmetic` (`hoist.ts`, by the `fixed` and `u32` brands of the sides' types) now decide, in + the evaluator and in every hoisted thunk (a helper emitted by the TypeScript factory around `+ − * /`, the shifts, + unary minus and `Math.abs`): whole numbers add, subtract and multiply in 32 bits — `i + 2147483647` for i = 1 is + −2 147 483 648, `65536 * 65536` is 0 — divide towards zero, shift by 32 or more to nothing, and `−x` and `Math.abs(x)` + wrap; a `u32` from 0 up; a `fixed`, or a side with a fraction, as a fraction. Before, `n = Math.abs(i + 2147483647)` + in an unrolled loop was a compile error ("not 2147483648") and `xs[i & 3] = Math.min(7, i << 1000000)` stored i. +- `xs[ys.pop() & 1] = ys.length ^ 255`: the index is worked out before the value, as JavaScript has it, when it holds a + pop or the value calls (the call may change what the index reads); before, only an index with a call in it was. + +**Found and fixed, in the map of our own:** +- A `do … while` whose test needs a value computed first — `(n1 + n2) >= (n2 | n0)`, a call, two made texts compared — + was refused ("a do it does not lower"): the rewriter left such a test alone, since what it needs would have to run at + a `continue` too. A `do` now carries `before` (IR): statements run before its test at every turn's end, where a + `continue` lands; the emitter lays them out at the loop's continue label, and the texts pass puts a `do`'s and a + `for`'s text work there (a `for`'s at the top of each turn, as a `while`'s). +- A cell at a masked index compared with a constant — `if (xs[n & 3] <= 26)`, in a `?:`, as a boolean argument — was + refused ("a if it does not lower", "the value given to b"): `cellCompare` left the index under its mask, and a + condition reads a cell at a variable plus a constant only. The index is a sum now. Nearly every index a random + program writes is masked, which is why this was 49 of 100 seeds. +- A call among a print's parts clobbered the text: `print(\`s ${f(5)} ${t}\`)` with an `f` that makes a text showed + the text alone. The parts were written at the program's one cursor from the first, and the call — hoisted later, by + the emitter's rewriter — ran its own text through the same cursor. The texts pass now reads every part up to the last + call ahead of the text (`ahead`): a number into a variable of its own, a text of the map's into a number of its own + (its texts the variable's), a made text into a block of its own — in the parts' order, as JavaScript reads them, so + `${t} ${f()} ${t}` with an `f` that changes `t` prints the old and the new. A text ternary's test is asked once, + before the text is begun, and what the side it picks needs done first is done under it. + +- A number of the game's compared with a constant below zero — `if (i32(deaths(P1, u)) >= -10)` — was asked of the + game as the condition's own "at least 4 294 967 286": false. `asked` (the rewriter) now reads such a constant as the + signed number it is and asks the numbers it holds for as bits, one or two runs of "at least" and "at most", as + `cellCompare` reads a cell. (A linear sum's constant arrives from 0 up; its sign is in its top bit.) +- A text ternary whose sides came to one text — `f() ? t : t` — lost its test, and the call in it: a choice of one text + was written as that text. Its test is asked now when it does something. +- A program's tasks died with its body: at its end the run's wake was set to never. The simulator and the Python side + let a task run on ("a task that runs on after the program ends", `probes/game-tasks.ts`); the own build's wake now + comes every frame and goes straight to the tasks' turn when the program started any — 4 of 4 in the lifted game. +- `sync()` inside an `if` or a loop was refused: the input pass lowered a `syncSend` only where it stood at the top of + a list of statements, and left one inside a statement's body to the emitter, which does not take it. The pass walks + every list of statements now. `probes/local-select.tsx` and `local-ui.tsx` build our way (to play: the pack below). + +- **Tetris and Snake did not build our way.** Nobody had asked: the showpieces were played on their eudplib builds + and the ledger holds the language a row at a time. Tetris's `if (cy >= 0 && board[cy * W + cx] !== 0) return false` + was refused by the masked-index fix above — `cellCompare` sent every index through `sum`, and a condition reads a + cell at one variable plus a constant, which `cy * W + cx` is not: an index is computed first now when it is masked or + of more than one term, else read where it is. Snake (and `probes/game-ui.tsx`) call the ui library's `thousands()` + twice in one line: a called function makes its text into *its own* result's block, one for the function, while each + call site had a result of its own that nothing wrote (an inlined call's body writes the call's) — "a assign it does + not lower" on the function's own statements. A call reads the function's block now, and the statement-level hoist + copies what a call gave into a block of its own before a later call writes it again, so `${f(a)} and ${f(b)}` is + right. Both showpieces build our way; the pack below plays them. + +**What the lifted game taught about reading it** (scm-sim's `readText`): the game's text is a ring of thirteen +lines read once a frame, so of a frame that printed more only the last thirteen are seen, and a slot whose text is as +it was when last read, and that the pointer did not pass this frame, is not seen again; a template printed with values +carries `\r` where its padding was (Remastered draws nothing for it, as the walker drops it), and a dot in a pattern +does not match `\r`. `random-scripts.mts --game` runs the simulator's lines through the same ring before comparing, +and `gate-sweep.mts` compares the two builds' lines, both read the same way. And 1.16.1 names the first 64 KB of +strings: a few programs to a map. + +**Left as it is, in the Open list:** a function that calls itself with a `finally` in it is refused ("the variable +(kept (error message)) of f, which calls itself"): the error kept across the `finally` is a text, which the stack of a +map of our own does not keep; a function whose every value is known when the script is built runs as JavaScript, with +JavaScript's arithmetic (`(f8 result) is a number … not 2147483648` where the game would wrap); the lifted game takes +about six programs to a map. + +**The numbers.** The first run of 60 seeds failed 58; after the generator's rules, 93 of 100; after the `do` and the +masked-index fixes and the condition's, 21; after the print's, 14 — then 13, every one the regression of the print fix, +and 0. Seeds 1–100 all agree, 3,826 lines; 201–300 all agree, 4,511 lines; 301–400 (a sample never fixed against): all agree, 5,585 lines (two of its seeds found the last two bugs, the do loop's dropped text work and the shift count's parse). In the lifted game, both +builds: 30 seeds (90 programs, three to a map), 573 lines of each build read through the game's ring, every one among the simulator's lines in its order, and the own build's lines the same as eudplib's, line for line. + +**Every probe, both builds** (`scripts/gate-sweep.mts`): each script under `probes/` built our way and through eudplib +by the command its header gives, each played for 300 frames by the lifted game with nobody at it, the lines compared +frame by frame; a `game-*.ts` probe runs its tests in the game on the own build instead. The table (the scratchpad's `sweep2.md` +and `sweep3.md` of 2026-10-23, 119 scripts) comes to: + +| What | Scripts | +| --- | --- | +| Built both ways, played, the same lines | 78 — every `own-*` probe, the korobeiniki four, sound-demo, music-demo (its sound made first), local-reads/select/ui, classes, map, numbers, arrays, functions, recursion, reads, units, v3, inside, callbacks, spike, strings-spike, remastered-dump, tile-survey, ui-spike, ui-spike-2, frame-cost, the fog and measurement probes that print nothing | +| Built both ways, the same lines as far as the shorter run goes | local-ui, own-input (eudplib's run a line or two further in the frames) | +| Built both ways, lines differ by timing, ours earlier | cutscene ("B: 4 got there" at frame 204 against 221), own-step5 (a transmission's wait), own-triggers (B at frame 3 against 6: eudplib runs a trigger it should not) | +| Built both ways, lines differ because the game is random | tetris, snake (the board drawn from random pieces: played by hand, the pack) | +| Built both ways, eudplib's lines wrong | own-fields ("C: a unit count WRONG" from eudplib, ok from ours), key-held (eudplib prints garbage where ours prints "taps 0 let go 0") | +| Refused by the own build | leftovers (a function that calls itself and gives back a text), strings (a unit's name written from a made text), game-closures and game-references (the length of the texts' address array of records kept in an array that grows) | +| Not built here | bad-apple, halftone, sound-probe (their video, image and base map are not on this disk) | +| `game-*.ts` tests in the game on the own build, all passed | arrays-cost 7, async 3, clamps 2, cursors 4, emitter 10, errors 7, fdiv 7, fixed 6, function-values, generators 3, location-stale 2, math 4, program 3, ranges 5, short-circuit 1, small-gaps, stdlib-core 5, tasks 4 (after the fix above), thresholds 2, ui 1, unit-order 3, values 3, unit-pixels 1 | +| `game-*.ts` tests failing in the game on the own build | events, events-2, events-input, key-held, key-tap, local, local-players, players, tests, raycaster, cutscene, lowering — the harness's events and input on a map of our own (the Open list), to be looked at one by one in step 17; text-arrays past the lifted game's 64 KB of strings | + +**The simulator's model.** `compiler/simulateIr.ts` and `docs/ir.md` named `python/trigscript.py` as the model the +interpreter follows — the heap's blocks, a made text's bytes, the stack's frames, a held key's grace, a number's 32 +bits, an uncaught error said in red. Each names the backend's file now (`heap.ts`, `texts.ts`, `emit.ts`, `input.ts`, +`errors.ts`); the Python is named once, as what laid the same programs out until step 17. The README's account of the +build still says a program the own build does not take goes through eudplib, which is true until step 17 deletes it. + +**Played, and what the first play found (2026-10-24).** The pack's Tetris, played in Remastered: every piece fell +where it appeared, no key moved or turned it, and the game ended on a stack in one column. Written into the lifted +game's key table the same way (the step 12 runner), the own build of `probes/own-input.ts` printed nothing after its +first line either — and the same probe built at the step 13 merge answered every key. The build in the editor (step 14) +took the unit-properties record over from `build-own.mts`, and `markInTransit` in `compiler/backend/chk.ts` wrote the +carriers' "in transit" state flag at byte 16 of the twenty-byte record, where the game reads it at byte 14 (the +valid-flags half was right). Every carrier since then was made in the open, an ordinary unit, and no point sent to one +came back: nothing of the players' input reached a program on any map built our way since step 14 — the step 12 and +13 plays in Remastered were of `build-own.mts` maps, step 14's played probe pressed no key, the suite's test of the +record asserted byte 16 too, and nothing in CI presses a key in the game. Byte 14 now, the test with it (and byte 16 +clear). The step 12 probe prints every line again (sync, presses, held, clicks, the mouse, typed lines); Tetris drops +on Space and slides on A and D in the lifted game; and `game-key-tap` and `game-events-input` pass their tests in the +game on the own build — so the harness does reach the own build's carriers, and the row of failing `game-*.ts` tests +in the table above is largely this one fault (`game-key-held` reads 11 frames held where the simulator says 12: the +late release the send pass adds for Remastered's dropped frame, counted differently by the two; for step 17). The ten +pack maps that read input were rebuilt (own-input, input, events, cutscene, tetris, key-held, local-select, snake, +raycaster, local-ui); the other four are as they were. + +**The second play found the second fault (2026-10-24).** The input probe, rebuilt and played: every key, click and +the mouse answered, and a typed `-help` did not. In the lifted game the same: the probe's typed lines (its third +program) and its per-player F7 (its fourth) heard nothing, while its keys (its second) did — and each of them alone, +or first, heard everything. The runs' naming: a run after the first gets every array and variable of its own, +`id#n`, but for what is the map's, and the input's arrays were not on that list (five of its globals were; the events' +pass keeps everything of its `@ev.` by prefix). So the second program that read input read `@in.pressed#1` and +`@in.chatf#1`, cells of its own that no pass wrote: on any map of our own, only the first program that read the +players' input ever heard it, since step 12 — the step 12 probe is one program, and so is every `game-*.ts` input +test. Every `@in.` id is the map's now, as `@ev.`; `tests/emitter.test.ts` holds a second program's reads to the +first one's cells. The input probe's lines, keys, typed lines and the per-player F7 all come in the lifted game, and +the pack's ten input maps were rebuilt once more. Of the `game-*.ts` tests failing in the game on 2026-10-23, `game-local`, `game-tests` and `game-cutscene` pass now with `key-tap` and `events-input`; still failing: `game-events` (one of four: a listener a player), `game-events-2` (on.killed and on.order counts: the action events, an A2 leftover), `game-lowering` (the C line), `game-key-held` (11 for 12), `game-local-players` (the harness itself) — step 17's list. (An hour of the day went to a `name(P1)` that printed nothing: the +lifted game's player is called "oracle", and the runner's filter dropped every line with that word in it.) + +**The events map found two more (2026-10-24).** Played in Remastered: every "died" line read `died 0 at 0, 0`, +Firebats included, and the three "back" lines a handler prints two seconds after a death never came, though the +Marines it makes did. The lifted game said the same. *A died unit's fields:* a handler's unit is read through the +guard every unit read has — its place holds a sprite, an order other than Die, the uniqueness byte kept — and a +unit handed to a died handler has the order Die by definition, so every field of it read as 0 (the type filter, which +reads the record directly, was right: a Marine handler never fired for a Firebat). A read of a field now asks only +that the place holds the record (`present`: a sprite and the byte), as the simulator reads a died unit as it died; +`tests/emitter.test.ts` has a Firebat die at a place and the line say so. *A task's print while the program sleeps:* +the walk runs from the last player's list, and the game sets the current player to that player; the wake sets it to +the run's owner only when the program runs, and the tasks' turn comes while it sleeps too — from the wake's failing +side, and straight from a sleep's own pointer — so a text a task showed then went to the computer. A run with tasks +now has a node before its wake, `@cur`, that sets the current player to its owner every frame (one node a frame; a +run without tasks has none), and the test holds it there. Putting the set into the emitter's own node for the turn +lost the lines of the first sleep instead, for a reason not found; the node before the wake takes every path. With +both: the events map prints its made, died, back and E lines in the lifted game as the sheet asks; `game-tasks` 4 of +4 and `game-cutscene` 3 of 3 still; `game-events` and `game-events-2` fail as before, on the action counts the +harness has only from an eudplib build. The whole pack was rebuilt (every map with tasks or unit reads is touched). + +**The pack, played out (2026-10-25).** Twelve of the fourteen play as their sheets ask on the build of our own, in +Remastered: tetris, snake, own-input, input, key-held, local-select, local-ui, events (every step, F included), +cutscene (the scene and the references), raycaster (Classic; the way out in 27 s), fog-lib (the disc and the heart +held), korobeiniki (A fit 1.000, B 0.995 against the files, no drift) and music-demo (the mixed song fit 0.999; the +note-by-note song follows it within 30 ms). ui-spike-2 was in the pack by mistake: its content was a hand-written +eudplib plugin, and built our way it says one line. One observation from the music demo for the sound library, not +the build: the note-by-note song is 2.5 dB quieter than the mixed one in every band, because a note's clip is a +mono WAV and Remastered plays a mono sound 2.4 dB quieter (the render is a stereo Ogg since ead7eab; the 2026-10-19 +play, before that, had them equal) — short clips should go in with two channels too. Four faults the plays found +are fixed above; the gate's part 2 is done. + +*Open:* +- Step 16's leftovers (2026-10-23). *A function that calls itself with a `finally`* in it, refused (a text on the + stack); the generator leaves the shape out until then. *A function that calls itself and gives back a text* — the + same stack — refused: `probes/leftovers.ts`'s `path()` ("a function that gives back a text (path)") builds through + eudplib until it is taken, and step 17 must take it or change the probe. *The test harness on maps of our own:* + `probes/events.ts` counts "Display Text Message —" action events, which `sim.events` has only from an eudplib build + (0 of 3, 0 of 2 in the game, the lines themselves shown), and `probes/game-tests.ts`'s typed line and key (`sim.type`, + `sim.press`) were thought to reach only the Python side's chatEvent and MSQC — they reach the own build's carriers too, + once the record was right (2026-10-24, above); the action-event counts stay an A2 leftover for step 17 or the + Magenta slice. `probes/game-text-arrays.ts` runs its strings past the + lifted game's 64 KB. *The hoister's narrowing:* `let b = false; if (b) { f(b || x) }` is `never` to TypeScript (the + variable narrowed to false, then nothing), and the error says "must be known when the script is built": right, badly + worded. *The lifted game's eudplib build* prints the same lines as ours, so `--eudplib` is kept only until step 17. + *Texts in records kept in an array that grows:* `probes/game-closures.ts` and `game-references.ts` are refused ("a + assign it does not lower": the length of the texts' address array, `(kept) addr`, `(objects) name.addr`) — a B9 shape + the texts pass does not make; through eudplib until taken. *The `game-*.ts` probes that fail in the lifted game on + the own build* (the table above) are to be gone through one by one at the start of step 17: most drove the players' + input, and the unit-properties record was the fault (2026-10-24); what is left after it is to be seen. *A text made inside a function that calls itself:* `t = \`${t}${f(d - 2)}\`` in `f` reads `t` after the inner call + in the simulator and in the map (the same in both), before it in JavaScript ("zzz000" against "zzz0000"); a template + with a call at the top of the program reads its parts in order. The generator leaves texts out of a function that + calls itself until this is taken. *Build-time functions* run as JavaScript: a function whose + arguments and reads are all known when the script is built is called then, and its body's arithmetic does not wrap + (`i + 2147483647` inside it is 2 147 483 648, then an error naming the result). *The lifted game's strings:* 1.16.1 + names the first 64 KB, so `--game` puts three seeds (nine programs) to a map and says so when a map is past it. + *`EUDPLIB=1`* went with the Python in step 17, as did `--eudplib` of `random-scripts.mts` and `gate-sweep.mts`. *A mono clip plays 2.4 dB quieter* than a stereo one in Remastered, and a note of the music library + goes in as a mono WAV while the rendered song is a stereo Ogg: the short clips should go in with two channels too + (the music demo's play, 2026-10-25). + +### The Python goes (2026-10-25) + +Step 17, branch `python-goes` off trigscript-4 (d25e975). What went: `python/trigscript.py` (7,113 lines) and +`compiler/generated/trigscriptPy.ts`; `scripts/embed-python.mts` (now `embed-stdlib.mts`, the standard library's half); +`compiler/eud.ts`'s `serializeIr`, `packTable` and `PACK_FROM` (the file is `check.ts`, `checkProgram` alone); +`IR_VERSION` and `Program.version`, and the `graph` statement (IR 26) with the emitter's whole hybrid mode — `emitLoop`, +`emitGraphs`, islands, copies on the way in as anything but a refusal, the fixed-point `call` triggers, `Scope.native` +(every path is the native one now; `island()` is `refuse()`, which notes why and goes on so that `emitBody` can name the +first part never taken); `vendor/eudplib.ts` and the service's `library()`, `watchLibrary()`, `collect()` and the +contribution to the library's build; the editor's library status item and its five states; the manifest's `ownRefusal`; +the MSQC and chatEvent plugin sections of `compiler/input.ts` (`inputPlugins`, `buildPlugins`, `INPUT_NAMES`, the +location the plan reserved and the "needs one free location among the map's first 63" error, which the own build never +needed — the plan says `mouse` now, and no slot); the `requires` of plugin-eudplib in `plugin.json`; +`tests/python.test.ts`, `tests/eud-build.test.ts`, `scripts/drive-eud.mjs`; `EUDPLIB=1` of `build-fixture.mts` and +`--eudplib` of `random-scripts.mts` and `gate-sweep.mts` (the sweep is every probe built and played, one column). 67 +files, 9,896 lines out, 587 in. + +**What the build does now.** The step applies to every script with programs (`hasPrograms`). One the emitter does not +take is a Problems warning on the line while typing ("The programs will not be built: TrigScript does not take this yet +(…). The map is saved without them.") and a failed step on save with the same reason, whose notice saves the rest +(*Save without it*). A map with Magenta's member is refused with the reason (`whyNotBuilt`): eudplib's build rebuilds +the strings section from its parsed strings, which would drop the payload behind them, and ours would drop eudplib's +the same way — neither order works until Magenta builds on our layout (the next slice, *Open decisions*). The debugger, +the cost view, the EUD-error lookup and the allowlist say "the build's own triggers" where they said "eudplib's own +work" (`STEP_BUILD`); a trigger map's origin for a trigger no line made is `""`. + +**Found on the way: the editor had been building loops through eudplib since step 14.** `compile.ts` never passed +`emitter: false` — only the tests and `build-fixture.mts` did — so `emitGraphs` ran on every compile in the editor and +the own build was handed loops already turned into `graph` statements, which `emitBody` refused ("a graph in a statement +it cannot lower, line 3"). Every script with a plain numeric loop (`while (n < 12) n += 5`) had gone through eudplib +with a warning, in the editor alone; every map played since step 14 was built by `build-fixture.mts`, and so by the own +build, which is why nobody saw it. Gone with the hybrid: there is one lowering, and the editor's compile and the tests' +are the same compile. + +**Also found.** *Lists read a bit at a time* were laid out 18 dwords apart (the EUDVArray's stride, for the Python +side's benefit): a dword a value now (`readTable`'s `stride = 1`), and the condition's player field a plain place of the +index. *A printed number's field* was 12 bytes whatever the number; a line of 24 one-digit numbers (`game-lowering`'s +C line, `${k < v ? 1 : 0}` × 24) was 290 bytes of template and the game's 218-byte text line cut it — the lifted game +showed "C: 110001 001101 01011". The field is now sized by the value ranges (`fieldWords`: 4 bytes up to four +characters, 8 up to eight, else 12), the routine's last dwords copied into it; the C line is about 100 bytes, and +the probe's eight tests pass in the game. *A copy that sent +no block* made scm-sim's `differences()` throw "Cannot read properties of undefined (reading 'buffer')" in a two-copy +test (`game-local-players`, "the harness itself" in the gate's table): guarded in scm-sim-eud (8852b7c), both tests +pass in the game. + +**The `game-*.ts` tests in the lifted game, gone through** (the gate's list; `test-game.mts`, SCM_SIM_DIR=scm-sim-eud): +`game-tests`, `game-key-tap`, `game-events-input`, `game-local-players` (with the guard), `game-lowering` (with the +field sizes) pass. Left, each with what was learned: + +- `game-events`, "a listener for each player: each sees its own" — the per-player program `each` (`on.created({ owner: + CurrentPlayer, type: Overlord })`, `owner: [P1, P2]`) counts 0 in the game for P1 where the simulator counts 2; the + other three tests of the file pass (the Overlords are made: the `events` program counts them). And a per-player + program whose handler writes a variable the body then reads — `on.created(…, () => { mine++ })` followed by + `print(\`mine ${mine}\`)` or `setDeaths(CurrentPlayer, …, mine)` — is *refused*, "a action it does not lower, line + 0": the refusal is in the events pass (`passStmt`, whose statements have no line), and the same body with one owner + is taken. The probe's `each` program has no such read, so it built; the two are one fault to look into first in the + events/per-player follow-up. The repro is five lines: `program(async () => { let mine = 0; on.created({ owner: + CurrentPlayer, type: units.ZergOverlord }, () => { mine++; }); await sleep(frames(2)); print(\`mine ${mine}\`); }, + { owner: [P1, P2] })` — refused; with `owner: P1`, taken. +- `game-events-2`, on.killed (1 expected, 0) and on.order (6 expected, 0) in the game, the simulator right in both. + Remastered's play of the pack's events map (2026-10-25, 12/14) exercised on.killed and on.order on an own build and + was right, so what fails is the lifted 1.16.1 game or the harness's `sim.kill(a, P2)` (it writes +0x90 and calls + COUNT_DEATH) and its orders, not the build. To be settled with a trace of the watch's reads in the lifted game. +- `game-key-held`, 12 expected (ten frames held and two more), 11 in the game. The build keeps a bit up for one frame + it reads down when the two frames before were up (Remastered drops a held key for a frame as it begins to repeat), so + a release goes one frame late; the simulator's `HELD_GRACE` is two frames, from the Python's day. The lifted game + loses the second message of every hold, which is not Remastered's behaviour either. The key-held play's sheet + (2026-10-24) has the tap and hold rows filled ("solid", one tap each) and the let-go rows blank, so Remastered has + not said which number is right; a play that counts the frames after letting go decides it, then the simulator or the + build follows. + +**Open (added to the lists):** the per-player listener fault above; `sim.kill` / orders in the lifted game; the held +key's late frames; the Open list's refusals that still stand (a recursive text function, `unit.name` from a made text, +texts in records in growing arrays, a text made inside a recursive function) — each a compiler slice of its own, none +taken here; a key-pressing lifted-game test in CI (CI has no lifted game: the own-build tests walk the built map in +JavaScript, `tests/ownWalk.ts`, which carries no units, so the carriers cannot be modelled there — the game harness is +the only check of input, run by hand). The perf slice after this step (unit walks, lazy error messages) stands as +assessed on 2026-10-23. + +### Magenta on our backend (2026-10-25): the design, and part A, the service + +Decided 2026-10-25 (the user): *TrigScript is the library.* Magenta no longer builds anything itself; it writes its +rows as TrigScript source and contributes them to TrigScript's build, the way it contributed euddraft sections to +eudplib's. Branch `magenta-library` off trigscript-4 (6a4e53d). + +**The service.** `trigscript.build` (`contract.d.ts` at the repository root, version 1; `plugin.ts` provides it, the +service is `ScriptService.buildService`): `contribute({ id, label, applies, collect })` — `collect` gives +`{ files, entries }`, TrigScript source by path under a folder named after the plugin (`magenta/main.ts`, so that no +file of a contribution can shadow the map's) and which of them run as entries; `onBuild` (the same events the editor +hears); `check(files, entries)` — the source compiled against the open map without building: TypeScript's and the +compiler's problems, a `trigger()` where only programs go, and what the own build refuses, each at its file and line, +for the contributor's own preflight. The build step applies when the map's script has programs *or* a contribution +applies. `buildOwn` collects what applies, compiles the map's script and the contributions' files together with +`CompileOptions.entries` (new: files that run as entries after the script's, in order, so their programs come after +the script's — `runModules`'s `then`, before the test files), and lays every program out in one build. A contribution +holds programs only — a `trigger()` in one fails the build, since nothing writes it into the trigger list — and may +import its own files and the standard library. A failure in a contribution is said in its name ("Magenta: …") and +the `failed` event's `from` is its id. The map's own script still guards the build with its tests when the settings +say so (its compile runs first, cached as before; the combined compile is a second, a few hundred milliseconds). +The Magenta refusal (step 17) stays for a Magenta that has *not* contributed — the one that builds through eudplib, +whose build would rewrite the strings section — and says "update Magenta"; a Magenta that contributes as "magenta" +lifts it. Tests: `service.test.ts` "what other plugins contribute to the build" (five), `script-structured.test.ts` +"entries another plugin contributes" (two). README: "Building with TrigScript" under *For other plugins*. + +**What Magenta renders (part B, in plugin-magenta).** Its spec (`composePlugins`' `MagentaSpec`: hooks, scans, chat, +msqc, the map-wide options) becomes one TrigScript file, `magenta/main.ts`, from a pure `renderScript(spec, …)`: a +program for P1 that loops every frame — for each hook in list order, `if (deaths(flag) == 1) { … }` and the flag +cleared after the last hook of that flag; the chat and input cells set from `chatted()`, `keyPressed()`, `clicked()`, +`mouse()` and cleared at the next turn; the scans' cells written at the end of each turn. The verbs map onto the +language as it is: text → `print` with `${deaths(cell)}`, `name(P)`, `color(P)` and the `` control bytes as the +string's own; maths → `setDeaths(…, deaths(a) * deaths(b))`, `/`, `%`, `random(n)`; foreach → `for (const u of +allUnits({ owner, type, at }))` with `u.hp = …`, `u.kill()`, `u.remove()`, `u.invincible`, `u.give(P)`, +`u.locate(L)`, `u.order(…, L)`, the timers, `u.cooldown`, `u.resources`, `u.damage()` / `u.heal()`; count → +`allUnits(f).reduce`; read → `allUnits(f).find(() => true)?.field ?? 0`; pick → a loop keeping the best, or +`randomUnit(f)`, or `underMouse(P, { within })` for the nearest to a mouse; scan → `allUnits(f).some(u => u.hp < +v)` into the cell; setloc → `centerLocation(L, x + w / 2, y + h / 2, w, h)` with the size Magenta knows from the map +when the row keeps it; chat → `chatted(P, "-heal")` and `"-set {n}"`; keys and clicks → the language's own carriers +(no MSQC: no location, player or unit type taken; Magenta's preflight checks of those go); the camera → the cutscene +library's `Camera` following the location; a looped sound → the sound library. Players are `P1`…`P12` or a cast, +unit types and locations casts of their numbers, so the rendering needs no names. + +**What the language lacks for a full rendering (part C, small additions here).** A location's bounds as values +(`left`, `top`, `right`, `bottom` — for *Move by dx, dy*, *keeping its size* when the location was moved in play, +and a mouse-over against a location that moves); unit writes for the remaining build time, the rank, the +hallucination and speed-upgrade flags and a defensive matrix, and a cooldown that sets the air and spell cooldowns +with the ground one; unit reads for `hasTarget`, `inTransport` and the current speed; a typed line's text after the +command (`{rest:text}`, for Magenta's `^…$` patterns). *noAirCollision* and *unlimiter* are code patches of +euddraft's plugins with no counterpart here: Magenta's Build dialog drops them, saying so. + +**What changes for a Magenta map.** Rows run one frame later than through eudplib's hooks: the program runs after the +map's triggers (as the hooks did), but a scan or an input set at the end of a frame is what the *next* frame's triggers +see, and a scan reads the units before the game's tick between the frames. With a build row in the map the triggers +run every frame, whether or not *Run triggers every frame* is ticked (a program's map does). A map that holds both a +TrigScript script and Magenta's rows is built in one pass, in one layout, which is what the step 17 refusal was for. + +**Parts.** A, this (built 2026-10-25). B, Magenta (built 2026-10-25, branch `trigscript-library` of plugin-magenta): +`src/model/trigscript.ts` renders the rows (`renderScript`, pure: the list of what it cannot take yet and what it took +in part), `src/ui/contribute.ts` contributes them (`applies` when the map has a row, a chat command, an input or an +option; `collect` refuses with the first unrenderable row's reason, which the save's notice shows), the Build dialog +is a preflight — the rows against the map, the unrenderable ones at their triggers, and the rendered file compiled by +TrigScript's `check` there and then — with the options kept and said to be not built yet; `requires: +plugin-trigscript`; the Python, the embedding, the eudplib contract, `composePlugins` and the build-freshness record +deleted; `tests/trigscript.test.ts` holds each kind's rendering and, with this repository beside it, compiles the +rendered file with the compiler and asks the own build (one program, taken). Magenta's `requires` resolves to +TrigScript's latest release, which has no service until 4.0 is tagged: the Magenta PR waits for that. C, the language +additions above, as B needs them (the camera, the bgm, *move by*, the mouse over a moving location, the verbs and +reads listed). D, Magenta's probe maps 6, 7, 10 and 11 (`scripts/make-probe-maps.mts`, which still builds through +eudplib from hand-written specs) redone on this build and played in Remastered, with answer sheets. Then plugin-eudplib +is required by nothing we ship. + +**Part C, the language (built 2026-10-25, branch `magenta-language`).** `bounds(location)` is a record of four cells of +the game's location table (`memory.ts` `locationField`: 0x58DC60 + 20·(n−1) + {0, 4, 8, 12}, dwords), read and written +as `memory.*` cells are (`tableRead` / `tableWrite`, so `b.left += 32` is one write); `hoist.ts` knows `bounds(…).left`, +`b.left` with `const b = bounds(…)` and `const { left } = bounds(…)` as game cells, so the record is the script's and +every edge the program's; the IR simulator keeps the edges in its locations, which `centerLocation()` moves too. Units: +`buildTime` (+0xAC, a word), `rank` (+0x8E), `matrix` (+0x114; a write puts 250 points of matrix at +0x112 for frames +≥ 1 and 0 for 0), `speed` (+0x38, read only), the flags `speedUpgrade` (0x10000000, written), `hallucinated` +(0x40000000, now written too), `hasTarget` (+0x5C ≠ 0) and `inTransport` (0x40); `cooldown` wrote the three cooldowns +at once already, so Magenta's "ground only" note goes. Ledger: 14 rows (step 6 for the bounds, step 10 for the units), +all taken; `tests/emitter.test.ts` walks the own payload beside the simulator for every new field and for the bounds. +Left out, on purpose: `{rest:text}` chat capture (Magenta's `^…$` patterns stay unrendered), the status writes cloak / +noclip / cooldownUpgrade (a cloak write showed nothing; the others are not in the renderer's list), the camera and the +bgm (the renderer's: `centerView` each frame, and the sound library). + +**Part C merged (#67), B merged (plugin-magenta#1), D built (2026-10-25, plugin-magenta PR `probe-maps-ts`).** The +renderer takes every row: hallucinate, the speed upgrade, a matrix, build time and rank writes, the has-target / +in-transport / speed / build-time reads; a slide by an offset, and every moved location, mouse-over and nearest pick +read `bounds()` in play; the camera is a gliding program of each human's own (`clamp((target − cam) / inertia, ±max)`, +`reserveLocation` + `centerView`), the music a per-human `playWav(path, ms); sleep(frames(len · 24))` loop. Probe maps +6, 7, 11 and a new 12 (the two options) are built by `scripts/make-probe-maps.mts --build` through this repository's +compiler and `buildOwnMap` (TRIGSCRIPT_DIR), each with an answer sheet, and ran 1,500 frames in the lifted game with +no access Remastered ends a game on; map 10 is gone (terrain rewrite and tint failed in Remastered and left Magenta's +model). Plays in Remastered decide: the matrix write (250 points at +0x112, timer at +0x114), the speed-upgrade and +hallucination flags, rank (+0x8E), build time (+0xAC), the in-transport and has-target reads, the camera's glide and +the loop's seams. Cost to look at: a map with any input carries ~6,800 triggers a frame (map 7), the input machinery's +— the perf slice's business. Open in C: `{rest:text}` chat capture; cloak / noclip / cooldownUpgrade writes. After the +plays: the 4.0 tag, Magenta's release on it, eudplib out of scm-js's defaults/registry/docs, the repository archived. + +## Part C — the game, pushed + +Each is a library over Part B, offered only after its probe. "Known" means a played probe +already says so. + +| Idea | What it needs | Known | +| --- | --- | --- | +| Terrain as a framebuffer | tile buffer writes | **No.** Magenta 10: Remastered does not draw them. Dropped. (My first answer on 2026-10-15 proposed it; that was wrong.) | +| Fog of war as a framebuffer | the vision countdown `0x51CE98`, sight writes, Map Revealers made and removed in one pass | **Yes**: S4, Bad Apple played in Remastered (2026-10-17) | +| A raycaster in the fog | B6, `fog.columns` | in the lifted game and the simulator (S5, 2026-10-17); Remastered is the probe | +| Units moved freely every frame (bullet hell, orbits) | location + Move Unit, B6 for the paths | position writes end the game (Magenta 6); moving by action is ordinary, so the probe is how many a frame before it lags (A4's dial) | +| Panels of text: objectives, leaderboard label, unit names as live UI | made texts in those fields | **Yes**, the strings spike (2026-09-19): re-read every draw, written only on the target's machine | +| Custom command buttons | button set data in memory | unknown: probe | +| Custom spells | B2's `on.order`, Magenta's damage/heal verb | partly: the verb is verified, the order watch is not | +| Save codes | strings, a checksum, `chatted()` | needs no new address; a probe for the round trip | +| Unit stats changed while the game runs | the type tables | Magenta-verified fields only | +| Computer players written in TrigScript | B1 tasks, unit orders through the scratch-box trick | the trick is verified (Magenta); a behaviour library is new | +| Camera work for one player | `centerView`, B5 | ordinary actions; B5 makes them safe | + +## Showpieces + +Added 2026-10-16, after Part A was built. Part A is tooling: it makes TrigScript right, and +a map maker sees little of it. What a map maker sees is what a map can do. So Part B is +steered by three demonstration maps, each a map someone would want to play, each built +from the slices it needs, in the order those slices come. A slice that no showpiece needs +waits behind one that does. Each showpiece is also a test: it runs in the lifted game +(A2) and in several copies (A3), its cost per frame is under the budget (A4), and it is +played in Remastered before it is shown to anyone. + +### S1. Act out a cutscene + +The map maker plays the scene in the editor's game: moves the units, sends them into the +fight, places the camera. TrigScript writes the script that plays it back. + +```ts +// Written by Record Scene, 0:42 of play, 3 players' units, 61 orders. +export async function ambush() { + centerView(locations.Ridge); + order(group.raiders, "move", locations.Ford); + await sleep(frames(96)); + order(group.raiders, "attack", locations.Camp); + … +} +``` + +- **The recording** is scm-sim's: the person plays the map in the Replay view's game, and + every command they give is a replay command with its frame. A cutscene is a replay of + the person's own play. +- **Writing it back:** a trigger cannot give every command a player can. What it can give + is the game's Order (move, patrol, attack) to a location, through the scratch-location + trick Magenta verified, plus Create Unit, Center View and the transmission. A recorded + command becomes the nearest of those; the rest (a spell, a build) are said at their + line and left out. Units are named by groups the recorder makes (`group.raiders`), which + are units found by type, owner and place when the scene starts. +- **Checking it:** the written script runs in the lifted game and the units' places are + compared with the recording, frame by frame. The script will not match exactly (an order + through a trigger lands on a trigger pass, a player's command on its turn), so the + comparison has a tolerance, and the view shows where the two part. +- **Needs:** B1 (the script is an `async` function), B2 for "when the raiders arrive", and + scm-sim's view taking commands from the editor (it plays the person's own copy: rendering, + so the StarEdit rule applies). +- **Probe:** none of its own for the playback (only ordinary actions). Its probe is the + scene played in Remastered beside the recording. + +### S2. A live interface in the game's text + +The objectives panel, the leaderboard's label and a unit type's name are re-read every +time the game draws them, and a made text can be written into them for one player (the +strings spike, 2026-09-19). That is a screen. S2 gives it a TypeScript way to be drawn: +JSX. + +```tsx +ui.objectives(P1, () => ( + + Wave {wave} of 10 + + {heroes.map((h) => {h.name}: {h.kills} kills)} + +)); +``` + +- **The compiler** reads the script as TSX. An element is a text built from its children; + `` is characters in colour codes; `color` is the game's colour codes. Nothing of + React runs in the game: the tree is lowered to a template (IR `template`), known parts at + build time and the rest as values. +- **Redrawn when it changes:** the function is a reactive view. The compiler knows which + variables it reads, and the lowering rewrites the text only on a frame when one of them + changed, so a panel that is not changing costs a comparison a frame. +- **Needs:** 3.9's texts (built), B3 for `map` with a callback over an array that grows, + and the made-text field writes (built). No new address. +- **Probe:** one, for how many characters and colour codes a panel holds before the game + cuts it, and whether a rewrite every frame flickers. +- **Where it draws, first** (2026-10-16, started on S2 with a Tetris map as its target): + the objectives panel is read again on every draw, but it is a dialog the player opens, + and in a single-player game opening it may pause the game. A board the player watches + while playing may belong in the text lines instead, which Remastered's emulator lets a + map write (`0x640B60`, 13 lines of 218 bytes; the line pointer at `0x640B58` is kept + beside the game). So the probe asks where before it asks how much: + `probes/ui-spike.py` (hand-written eudplib, built beside `probes/ui-spike.ts`), steps + A–I: how many text lines stay up, which cell characters line up (■ □ █ ▀ ▄ ░ ▓ ㅁ, a + full-width space and more), which colour codes colour a cell, how many cells fit on a + line, whether a line break in one Display Text makes several lines, a board redrawn every + frame and every fourth frame, twenty rows in ten lines with half blocks, the lines' + memory written with no action, a 22-row board in the open objectives with a clock in the + leaderboard's label, and how much of that label shows. Run in the lifted game first: no + address Remastered ends the game on, about 2.4k actions a frame on average and 82k at + most (eudplib's start at frame 2). Copied to + `Maps/TrigScript/trigscript-s2-ui-spike.scx`. + + **Played 2026-10-16** (A–G single player, H again hosted as multiplayer; Remastered at + 2560×1440, the bars measured in the screenshots): + - **The board goes in the text lines.** 11 stay up (a header and ten rows). One Display + Text with line breaks makes as many lines (E), so a board is one action. Redrawn every + frame it neither flickers nor tears, and every fourth frame looks the same (F1, F2). + Sixty cells still fit on one line (D). + - **The lines are drawn from their memory, live** (G): text written into `0x640B60` with + no action appeared and counted. But each line still fades on its own timer; a write + changes the words, not how long the line stays. A Display Text restarts a line's timer. + - **The objectives panel is out for anything live** (H): it pauses a single-player game, + and in multiplayer the open panel is a picture taken when it opened (it said second 128 + at 137 and 146). It shows nine lines; past them the line breaks are lost and the rest + runs together on one line. It is fine for a static text. + - **The leaderboard's label** shows about 23 characters, then "..." (I). Live, as the + strings spike found: a score or a level, not a board. + - **Cells:** ■ □ ● are one width; ㅁ, the full-width space, O and ▒ are another (wider). + █ ▀ ▄ ░ ▓ ▌ ▐ Ⅰ are not in the font and come out as narrow boxes, so half blocks are + dead. Colour codes take no width. + - **Colours:** 02 03 04 07 08 0E 0F 10 11 15 18 19 1B 1C 1D 1E 1F each draw a colour of + their own (16 is 04's white; 01 takes the line's colour). **Grey sticks:** after 05, + every later code on the line is ignored and the rest stays grey, so empty cells must not + be grey. 06 was only seen after 05; a second round tests each code on a line of its own. + + **Second round played 2026-10-16** (`probes/ui-spike-2.*`): **only 05 sticks**; every + other code lets later codes through, and 06 is red (as 08). A board made in the game and + shown by one Display Text a frame draws right: orange piece, coloured stack. Empty cells: + 15 (brown) nearly vanishes over dirt, 18 (dark green) and 0E (blue) read on black, **1E + (lavender) reads on black and over terrain and is no piece's colour**, so it is the + default for an empty cell. How long a line lasts was not caught (under six seconds, from + G), so a view that has not changed is still shown again once a second. + + So S2's first surface is `ui.lines(player, () => <…/>)`: the text lines, one Display + Text a frame when the view's inputs changed (and again before the lines fade), ten rows + under a header. The objectives and the label stay as plain made texts. + +**As built (2026-10-16, IR 17).** JSX in a `.tsx` file (the entry may be `main.tsx`): the +compiler options say `jsx: react` with the factory `__jsx`, so TypeScript type-checks every +element against `JSX.IntrinsicElements` and a JSX expression is a `string`. The elements are +`` (a line of its own; a line break goes before each one but the first thing +written), `` (its colour comes back after it, lazily: the parent's next piece says +it again), `` (■ in its colour; 0 or none is □ in lavender — a board's cell) and +``. A colour is one of `textColors`' names (the codes the spike +measured) or a number of the program's. `{…}` is the program's value; `cond && …`, `?:` and +`.map((x, i) => …)` are its if and its loop — unrolled over a list the script has, so a board +drawn over `[...Array(10).keys()]` reads its cells at constant indices. Nothing in a text can +wait. + +- **Lowering:** a JSX text is not a template. It has a buffer of its own (`buildText` writes + parts at its end, `reset` empties it; `built` reads it), so a 100-cell board is written in + place, never copied whole per piece. Parts gain `char`, a byte from a number. JSX is never + hoisted (`hoist.ts#hasJsx`); outside a program `__jsx` makes the same text at build time + (`compiler/jsx.ts`). +- **`ui.lines(player, () => …)`** is a task (`start`, one slot) that loops: built and printed + when a flag says the view is stale or 24 frames after it was shown (the lines fade in under + six seconds). `views.ts` declares the flag first in the program, finds what the view reads + and sets the flag after every statement anywhere in the program that writes it. A view that + reads the game is built every frame. It gives back the Task (`board.cancel()`). +- **Measured in the lifted game:** a static 10×10 board costs ~360 actions a frame when nothing + changed, ~63k on a frame it is built and printed. Nothing Remastered refuses. +- **Found on the way:** the freeze check called `while (move(0, 1, 0)) {}` endless: a call in a + loop's condition that writes what is outside it now counts as changing it. And, played + (the user held D and the piece "moved in place"): **the Python lowering did not + short-circuit `&&` / `||`** — every item's code ran before any was tested, so + `keyPressed(P1, "W") && !move(0, 0, 1)` turned the piece every frame in the game and never + in the simulator. Fixed (an item after the first that calls, pops or draws a random number is + worked out inside the test of those before it); `probes/game-short-circuit.ts` fails on the + old lowering in the lifted game and passes now. With it fixed, Tetris costs ~645 actions on + a frame nothing moves, ~77k on one the board changes, 7.5k a frame on average. +- **Not yet:** components (``: call a function in braces instead), `Array.from`, the + per-cell cost (~600 actions: the colour byte and the glyph are copied in; a cell could be one + dword), the editor's workspace knowing `.tsx`, guide and README (wait for 4.0). + +**Held keys, played 2026-10-16** (`probes/key-held.tsx`, Remastered): a key held down reaches +the game on every frame it is down — the strip stays solid — and a tap is two or three frames +of it, counted as one press by `keyPressed` (MSQC's KeyDown since `b09f04d`). So `keyHeld()` +(MSQC's KeyPress, sent each frame) works today, for every player, through the same path as a +press: S3's movement needs no `Local` and no `sync()`. Magenta probe 7's "one event a press, +no key-up" was its own setup; R0's `GetKeyState` reading agrees with what was played. + +**Pixels made of units, played 2026-10-16** (`probes/unit-pixels.ts`, steps A–D; E not yet +reported). A board can be drawn with units on the map and the minimap: +- **Colours:** nine Overlords, P1 … P8 and P12, show nine colours on the minimap once the board + is in sight (your own units are green there). Player 2 is a computer and its AI moved its + Overlord a cell, so a board's owners are P1 and the empty slots P3 … P8 (P12 too: Remastered + made it, the lifted game did not). +- **Vision:** Map Revealers of Player 1's lift the fog over the board. The lifted game never + makes a Map Revealer — a gap in scm-sim, not the game. +- **The minimap merges neighbours:** units 64 pixels apart and of one owner join into one block, + so a piece reads as a solid shape. +- **Overlords** sit on their cells on any ground. Science Vessels are wider than a cell and + zigzag; Pylons, Flags and small items miss cells where the ground will not take them, and + **Remastered prints "WARNING: Unit unplaceable! (Protoss Pylon) (1024,2304)" in the text lines + for every Create Unit that fails** — so a unit board must never ask for a cell it cannot have, + or it scribbles over a text board beside it. + +### S3. A hero moved with the keys + +W A S D move a hero, the mouse aims, a key casts. The map plays as an action game. + +```ts +on.key("W", (p) => heroOf(p).order("move", ahead(heroOf(p), 64))); +const aim = await sync(localMouse()); // Local → Point, the same on every machine +``` + +- **Keys held:** Magenta 7 found that Remastered sends one event a press and no key-up, so + "held" does not reach other machines today. R0 found that the keyboard region of + Remastered's emulator calls `GetKeyState`: reading it gives the key's state on the local + machine, now. That is a `Local` (B5), and `sync()` makes it shared a few frames + later. +- **Movement** is a location centred ahead of the hero and Order to it (positions are never + written; Magenta 6 and R0). B6 gives the directions and distances. +- **Checking it:** A3 runs the map in two copies with keys held on one, and the copies must + stay the same. That is the whole point of B5's type, shown working. +- **Needs:** B2 (`on.key`), B5 (`Local`, `sync`), B6 (directions). +- **Probe:** the first, before B5 is built: hold a key and read the keyboard region every + frame in Remastered; then the same in a two-person game, to see each machine reads only + its own. + +### S4. Bad Apple + +Added 2026-10-17. The shadow-play music video drawn by the game while the song plays, as the +renderer showpiece before a raycaster. It asks the questions a renderer has to answer — +where the game can draw, how fine, how much a frame costs — on a video that is mostly the +same from one frame to the next. A raycaster is the opposite (every column changes when the +player turns), so whatever S4 builds has to be cheap for a whole new frame too, and S4's +scene cuts are where that is measured. + +**Where it draws.** Three surfaces, best first: + +| Surface | Resolution | Known | +| --- | --- | --- | +| Fog of war, on the minimap | 128 × 96 on a 128 × 96 map, one tile a minimap pixel | works in the lifted game (below); Remastered's drawing of it is the probe | +| Units as pixels, on the minimap | ~40 × 30 filled (the 1,700-unit limit) | S2's unit-pixel probe; grey levels if Remastered's minimap takes custom colours (unknown) | +| Glyphs in the text lines | 11 rows × ~60 cells | S2, played; which glyphs exist is read from Remastered's font (below) | + +#### Fog of war as a framebuffer + +Found 2026-10-17 in the 1.16.1 decompile, OpenBW and the lifted game: + +- **How the game keeps fog.** Each tile has a byte of "visible" bits and a byte of + "explored" bits, a bit a player. A unit reveals at once when it is made or moves + (`refresh_unit_vision`), and revealing only ever adds: nothing turns a tile dark again + except a wipe. The wipe is in the frame step (`0x4D94B0`): a countdown at **`0x51CE98`** + goes 100 → 0, and at 0 every tile's visible bits are cleared (`0x480880`) and every unit + reveals again. Optical Flare sets the countdown to 1, so the game itself already + asks for a wipe on the next frame. +- **Remastered's emulator allows `0x51CE98` both ways** (`data/remastered.json`, from R0), + and units.dat's sight range (`0x663238`) both ways too; `stats(t).sight` is already in + the language, played by Magenta. +- **The order of a frame** (`0x4D9670`): the frame step (countdown, wipe, units reveal) + runs before the triggers, and the screen is drawn after both. + +So a frame of video is painted like this, in one trigger pass: + +1. for each brush: set the revealer's sight, centre a location on the brush, Create Unit a + Map Revealer there (it reveals at once), Remove Unit it; +2. write 1 to the countdown. + +The revealed tiles stay visible through the draw with no unit left on the map. The next +frame's step wipes them before the triggers paint the next picture, so nothing ghosts. +Tiles revealed once stay *explored*, so the picture is three-toned: never seen (black), +fog (dark) and visible (bright). The video opens on black, which is the never-seen board. + +**Run in the lifted game** (`fog-spike`, scratch harness, 2026-10-17): the countdown is +seen at 99 … 0 every 100 frames untouched; written to 1, the next frame wipes. With two +brushes a frame (sight 2 and 4, the sight byte changed between the two Create Units) and +the positions alternating, every frame shows exactly that frame's two brushes visible and +the last frame's as fog. No ghost, no unit left alive. + +**The brushes are the game's sight masks.** A Map Revealer is a flyer (units.dat flag +`0x4`), and air sight takes a path of its own (`reveal_sight_at`, in the air branch): it +leaves out the 3 × 3 core, so **sight 0 reveals nothing**, and every mask is a little +lopsided (one row more above the centre than below). Measured, in tiles: sight 1 = 3 × 4 +(12), 2 = 28, 3 = 52, … 11 = 480. A ground unit's sight 0 is a plain 3 × 3, but ground +sight is cut by higher ground, and a revealer made ground (its flyer flag cleared before it +is made; `0x664080` is allowed) is untried. + +**What a frame costs, worked out on test pictures** (128 × 96, greedy cover of the visible +pixels with masks that never touch a dark one): + +| Picture | Brushes | Missed | +| --- | --- | --- | +| A dancer, black on white | 140 | 0.3% | +| An apple, black on white | 83 | 0.5% | +| "Bad Apple" in type | 143 | 0.5% | +| The dancer, white on black | 88 | 10% | + +With five sight values (1, 2, 4, 8, 11) instead of all eleven it takes about 5% more +brushes. Thin *dark* detail is kept (fog fills the gaps between brushes: fingers 3 pixels +wide came out), thin *bright* detail under three tiles is lost. Bad Apple is mostly dark +figures on white, the case this surface draws best. For the white-on-dark scenes the +encoder may cover a few dark pixels next to a thin bright line, so it is drawn a pixel +too thick rather than not at all. + +Every frame is drawn whole, so there is no diff to keep and a scene cut costs what any +frame costs. At ~150 brushes and some ten actions each (location, create, remove, a sight +write when the size changes), a frame is a few thousand actions against A4's budget of +500k. The data is ~3 bytes a brush (x, y, size): at most ~2.4 MB for 3:39 at 23.8 frames +a second before using what frames share. How big a map Remastered loads is not known. + +**Read in Remastered's own code** (the Ghidra project `scr12310-full` and the R0 emulator, +2026-10-17; offsets from the image base): +- **The countdown write reaches the game.** The emulator's region for `0x51CE98` is backed + by a real variable at `+0x1067C0C` (it held 20 in the dump), and Remastered's frame step + (`+0x31C610`) is 1.16.1's line for line: 100 → 0, the flag, the wipe (`+0x3606C0`, the + same OR of 0xFF into every tile). Optical Flare's routine still sets it to 1. The sight + table is backed at `+0xE5A860` (Marine 7, Ghost 9, Vulture 8, Goliath 8). +- **The minimap draws fog a tile at a time, at once** (`+0x361980`): each tile's visible + and explored bits against the local player's masks pick one of the remap tables at + `+0xAD0200` (never seen, fog, visible), and the terrain's minimap colours go through it + into the picture. No blur, no fading: the minimap shows the bits as they are when it is + drawn. +- **Fog is in the multiplayer checksum** (`+0x3A0FF0` hashes one row of the tile array a + frame). Painting is the same on every machine, so this is safe; a fog write made on one + machine only would desync. +- Not found: the code that builds the main view's fog. The shaders have no fog shader; + `mask.glsl` (black, alpha from a texture's red) and `blur.glsl` (a five-tap Gaussian) are + the likely pair, which would give the soft edges, but whether it fades over time is the + probe's to see. + +**Open, for a probe in Remastered:** +- how the main view draws fog that changes every frame: at once or faded (fading would blur + motion), and how soft the edges are; +- how often the minimap is redrawn, and how the three tones look on snow (Ice) against dark + ground; +- whether a created Map Revealer lands on the location's centre (Remastered makes them; + 1.16.1 refuses: `lifted-create-unit-findings`), and whether a sight write between two + Create Units in one pass holds as in the lifted game; +- how many brushes a frame before the game stutters (the unit-pixel probe made 80 a frame + with no slowdown); +- a revealer made as a ground unit, for the 3 × 3 brush. + +**The probe, built 2026-10-17** (`probes/fog-probe.ts` + `probes/fog-probe.py`, made by +`probes/lib/fog-gen.mts` on a 128 × 96 snow map from `probes/lib/fog-base-map.mts`; copied +to `Maps/TrigScript/trigscript-s4-fog-probe.scx` with an answers sheet). Steps A–H: a brush +jumping with the countdown written (A) and not (B), the eleven sizes (C), a brush sliding a +tile a frame (D), four pictures (E), a 72-frame animation (F), the dancer painted 1, 2, 4 +and 8 times a frame (G), ground revealers beside air ones (H). + +Run first in the lifted game with Overlords for the revealers (1.16.1 keeps no Map Revealer +a trigger makes), the tile array read every frame against the encoder's own picture: +**0 tiles differ** in every picture, every animation frame and at 8 paints a frame. A brush +centred on the last two rows or columns is moved inward to fit the map, so the encoder keeps +centres two tiles from the edge (the dancer then misses 58 tiles instead of 27). Ground at +sight 0 is a 3 × 3 (9 tiles), at sight 1 21 tiles. + +Cost (A4's tap, conditions + actions a frame): a picture of 84–143 brushes 25k–42k; the +animation 41k on average, 48k at most; 8 paints a frame (~1,100 brushes) 322k, still under +the 500k budget. About 300 a brush read from the table, most of it `f_dwbreak` unpacking +x, y and size from one dword: a table of pixel positions laid out ready to copy is the first +thing to make cheaper. `game-check`: 0 accesses Remastered ends the game on, 0 ignored, 0 +never seen. + +**Played 2026-10-17** (Remastered, hosted as multiplayer alone, recorded at 2560 × 1440; +frames taken out of the recording and set beside the encoder's pictures): +- **The countdown write works.** A: the brush's old place drops to fog the frame it jumps; + B, with the write left out, both places stay lit until the game's own wipe. +- **A sight write reaches the game a frame late.** Every brush of a pass came out at the + size written last in the pass before: the sizes row (C) was eleven blobs of sight 11, and + the dancer (E) lost her arms and legs to brushes of the largest size. A and D, one size a + frame, were right. So the size can't change within a pass: round 2 gives each size a unit + type of its own (eleven flyers, none with a sight upgrade), each type's sight written to + the same value every frame. +- **The minimap is blurred** in Remastered graphics: soft round blobs, not tiles. The + routine read in the code (`+0x361980`, 2 × 2 pixels a tile) is likely the Classic path; + round 2 is played in both. +- **On the screen** fog over snow is a soft, only slightly darker grey; the strong contrast + is never-seen black against lit snow, which can be had only once per tile. +- **"Cannot create more units."** at 8 paints a frame (~1,100 made and removed): a removed + unit keeps its slot until the next frame. The lifted game agrees (round 2: from 959 a + frame, not at 822). A picture needs 84–160. +- **No slowdown** at any step: the step texts stay on the same offset from game time. + +**Round 2** (`probes/fog-probe-2.*`, `probes/lib/fog-gen-2.mts`, copied to +`Maps/TrigScript/trigscript-s4-fog-probe-2.scx` with an answers sheet): A again, the sizes +as eleven types (C), the pictures (E), the animation (F), ground Map Revealers at sight 0 +in steps of their own (H), and 3 to 10 paints a frame for the unit cap (I). In the lifted +game: 0 tiles differ in C, E and F; the dancer costs 32k a frame (41k with the sight byte +written per brush); `game-check` clean. + +**Round 2 played 2026-10-17**, once in each graphics mode (both recorded, minimaps set side +by side frame for frame): +- **One unit type per size works.** The sizes row grows left to right in both modes, and the + pictures keep their thin parts: the dancer has her hair, both arms, the fingers and both + legs; "Bad Apple" reads as type. +- **Classic graphics draws the minimap a tile a pixel, sharp** (the `+0x361980` routine); + Remastered graphics blurs the same picture by a few tiles. Classic is the one that makes + the video. A map cannot choose the mode, so the map says so when it starts. +- **Ground revealers work in Remastered:** the Map Revealer with its flyer flag cleared, at + sight 0, drew 3 × 3 squares beside the air brushes' 3 × 4. (The flag write took effect + within the pass, unlike the sight write.) A twelfth, finer brush. +- **"Cannot create more units."** from 7 paints a frame (959 made and removed), not at 6 + (822) — Remastered and the lifted game agree to the step. A picture needs 84–160. +- **Never-seen black is a third tone that runs out.** The first picture over a board that + earlier steps had partly lit came out a patchwork: black where nothing had been lit yet, + fog grey where something had. A video has to light the whole board once first (or use + the black on purpose, once), so every figure is the same fog grey. +- **The camera's box is drawn on the minimap**, over the picture. The video's map needs room + off the board to park the camera: a 128 × 128 map with the board in the top 96 rows keeps + the minimap at a tile a pixel and puts the box below the picture. +- On the main view, fog over snow is a slightly darker grey in both modes, soft-edged in + Remastered. The main view is not where the picture is. + +So the renderer is settled: twelve brushes (a ground 3 × 3 and eleven flyer sizes, each its +own unit type, sights set every frame), made and removed in one pass, the countdown written +after it, the board lit once before the first picture, watched in Classic graphics. + +**The video, built 2026-10-17** (the user: fetch one, put the song in the map). The PV +(YouTube FtutLA63Cp8, 480 × 360, 219.1 s) taken to 5,216 frames at the game's own rate +(`fps=1000/42`, area-averaged to 128 × 96, threshold at half); `probes/lib/bad-apple-encode.mts` +(a binary heap over every placement of twelve brushes — the ground 3 × 3 and the eleven air +shapes — then a 3 × 3 on every light tile still dark, so a thin light line is drawn a tile +thick rather than lost): 166 s on 20 processes, **117 brushes a frame on average, 386 at +most**, 0.74% of tiles off from the threshold picture, a table of 2.45 MB (1.34 MB gzipped; +only 19% of brushes carry over from the frame before as they are). `bad-apple-map.mts` +(128 × 128 snow, the song in the archive), `bad-apple-gen.mts` (the eudplib: the board lit once +at frame 2, ten seconds to switch to Classic, the song and the video from frame 240). The +table goes in as a data file (`EXTRA_FILE` in `build-fixture.mts`): a plugin's source may hold +512 KB, data files 4 MB in all — a limit a built-in video import has to plan around. + +In the lifted game: **all 5,216 frames on the board exactly as encoded**, nothing lit below +it; 27.7k actions a frame on average, 90k at most; `game-check` clean (618,861 accesses, none +Remastered refuses). Maps: OGG song 4.4 MB, an 8-bit WAV fallback 6.3 MB. Not known until +played: whether Remastered loads a map this size, plays OGG from a map, and keeps the song in +time with the frames. + +**Played 2026-10-17: it works.** The OGG map loads, the song plays, and the video runs on +the Classic minimap from start to end (the user's recording, 2560 × 1440). Measured by +correlating the board's brightness in the recording with the source frames, and the +recording's sound with the source song, over the whole run and in 25-second windows: +the picture follows the source with a correlation of 0.998; **the picture leads the sound +by 23 ms**, the same in every window to within one recorded frame — no drift over 3½ +minutes, no stutter. Left as it is (half a game frame, below notice). + +The weak point is contrast: fog over snow against lit snow is about 100 against 140 on the +minimap. The minimap's fog colours come from a remap table (1.16.1 `0x59C2C0`, four 256-byte +tables by visible / explored state, read by `0x4A3A40`; Remastered `+0xAD0200`), but the table +lies outside every region Remastered's emulator maps (a read ends the game). So contrast comes +from the terrain: the tile whose lit and fogged minimap colours lie furthest apart. + +**The terrain survey (2026-10-17).** For each tileset a map whose tiles are every distinct +megatile once (1,346–4,668 of them), loaded in the lifted game; each tile's minimap colour +read from the buffer at `0x59C190`, its colour in each state through the tables at +`0x59C2C0` (state = visible × 2 + explored), and the tileset's palette. Our snow: lit 156, +fog 96 (the recording agreed). The best: **Badlands tile 23778** (and the same white in +Jungle and Twilight) — **lit 255, fog 104, never seen 4**, 2.5 times the difference. A board +can be one tile repeated: the game draws the tile ids the map holds. The whitest tiles are +not ground a unit stands on, so that board is drawn with the air brushes only +(`BA_AIR_ONLY=1`): 119 brushes a frame, 1.17% of tiles off instead of 0.74% (thin lines a +little thicker). Fully walkable tiles top out at a difference of 95. Built as +`trigscript-s4-bad-apple-white.scx`; in the lifted game all 5,216 frames exact, `game-check` +clean. Not yet played. + +#### Glyphs in the text lines + +Read from the install through kascade (2026-10-17). The text lines' shapes come from +`font/bl.ttf` ("BlizzardGlobal", TrueType, 512 units an em), the only font in the install +that fits every played fact (■ □ ● one width, ㅁ U+3000 O ▒ another, █ ▀ ▄ ░ ▓ ▌ ▐ Ⅰ +missing). The game's font sets (`+0x3484A0`, `+0x3466E0`) chain a Latin font first, then +bl.ttf; a Korean, Japanese or Chinese client puts Kostar, UDTypos or arud first instead. + +| Advance | Glyphs | +| --- | --- | +| 427 | ■ □ ▣ ▤ ▥ ▦ ▧ ▨ ▩ ▼ ○ ● ◐ ◑ | +| 358 | ▲ △ ◆ ◈ | +| 393 | ▶ ▷ ◀ ◁ | +| 512 | ▽ ◇ ◎ ▒, box drawing U+2500–254B, arrows, U+3000, full-width forms, kana, CJK, Hangul jamo, 〓 ☆ | +| 461 | Hangul syllables | + +- **No Braille** and no half or quarter blocks: ▒ is the only one of U+2580–259F. +- **A grey ramp at one width** (427, ink measured in the outlines): ○ 11%, □ 13%, ◐/◑ 18%, + ▤ 19.5%, ▣ 20.5%, ▧ 22%, ▦ 24%, ● 25%, ▩ 27%, ■ 33%. Four levels: □ ▤ ▩ ■. ◐ ◑ split a cell + left and right; nothing splits it top and bottom. +- The 427 shapes are small squares with a gap between them; ▒ and the box-drawing glyphs + fill their whole 512 cell and join edge to edge. +- Six 427 cells (2,562) line up with five 512 cells (2,560). +- **A Korean client breaks the board:** Kostar draws the shapes and Hangul all at one width + (900 of 1,000), so ■ and ㅁ come out the same width there. +- Not checked: which size the text lines use (font10 is the likely one), and how the + widths round to pixels in the game. A probe line of ■▣■▣ over □□□□ answers both. + +So the text lines can carry a four-level picture, but only 11 rows of it: the fog is the +picture, and the text lines are for lyrics and a small second copy. + + +#### Everything else that makes it look good + +- **The sound.** 23.8 frames a second on Fastest against the video's 30: frames are chosen + by time. Open: whether Remastered plays a 3:39 sound from a map (OGG or WAV) and how + large; whether the frames keep time against it (under 500k actions they should); chunks + restarted every few seconds as the fallback. +- **Every surface at once:** the video in fog on the minimap; the main view centred on the + board, where the same fog is drawn with Remastered's soft edges; the lyrics in the + transmission line, coloured word by word with S2's JSX; the time in the leaderboard + label. +- **The encoding is most of the look:** frames blended before thresholding at 23.8 fps, + the error spent on outlines (the silhouette's edge is what people recognise), brushes + kept from one frame to the next where they still fit. +- **The music and the video are not ours.** The song is Alstroemeria Records', the video + is Anira's. Fan remakes are a long tradition, but whether a map with the song in it goes + on scmjs.dev or the registry is the user's decision; the video alone with the song + played beside it is the safe form. + +**Needs from the language:** a video imported at build time (`import video from +"./bad-apple.webm"` → a table of brushes, B8's machinery), typed memory for the countdown +(B7: `memory.vision.countdown`, allowed both ways by R0), and the lifted game's test of the +whole video read back from the tile array and held against the source frames (A2). + +### S5. A raycaster in the fog + +Added 2026-10-17, with B6. A 3D maze on the minimap, walked with W A S D: `probes/raycaster.ts`, the textbook DDA +raycaster (Lode Vandevenne's, a ray a column stepped from grid line to grid line) written as any TypeScript would +have it, fixed numbers for its fractions. 42 columns 3 tiles wide over 78 rows; under them the maze from above, three +tiles a cell, the player blinking on it; a way out that flickers; `I` switches between light walls on fog and dark +walls between a lit ceiling and floor. + +**How it draws: `fog.columns(player, x, y, layout, heights, shaded?)`.** A brush every 3 × 4 tiles took 860 brushes a +frame in that maze, 1 008 at worst: past the game's limit of about 950 unit creations a frame. The columns are drawn +instead as rectangles whose covers the encoder works out when the script is built: a band across every column as deep +as the shallowest, a band a group of six as deep as that group's, and a bar a column for the rest, each half above +and below the middle (light ink) or below the top and above the bottom (dark ink). A cover a rectangle depth is a +table; a single column's bar is the smallest brush stacked, worked out rather than read. Shaded columns (the other +face of the walls, so corners show) are drawn hollow, lit a brush deep along their top and bottom edges: two brushes a +column. Lines across them at their quarters looked best but cost two dozen a column. + +**Measured in the lifted game** (`scripts/fog-frames.mts`, which plays a map headless with keys held and writes the +fog as pictures): about 280 brushes a frame, 440 at worst over a walk through the level; 220k conditions and actions +a frame on average, 252k at most — the rays about 150k of it (four fixed multiplies and three divides a column, after +the ray's direction is stepped across the camera plane instead of computed a column at a time), the drawing the rest. +The tests hold the game's fog against a floating-point raycaster column by column (within a row) and pass in the game +and in the simulator, which now draws the fog of units a program set the sight of. + +**Played (2026-10-17), three rounds.** Round 1: it works in Remastered. Its minimap changed 12 times a second, as +Bad Apple's recording does: Remastered redraws the fog on the minimap every other frame — so `fog.show(2)` keeps a +picture two frames and the raycaster draws every other one, half the work for the same picture. Round 2: panelled +faces (lit bands at the quarters) "look weird": combs. Six looks mocked up on the same views (solid, hollow, +panelled, outline, seams, floor lines); the user chose **outline** — every wall as its edges, top and bottom and a +column lit whole where one face meets another — now the default of `fog.columns` with its `faces` argument. Round 3 +(outline, about 110k a frame): "it looks great", rendered cleanly throughout, and the way out was found in 22 s. + +**Not yet:** Textures, sprites +(a monster drawn as a scaled picture in front of the walls), a floor with distance, more than one level. + +### Smaller things that show well + +Each is small and makes the editor feel like it knows the game. They go in where a slice +passes near them. + +| Idea | Built from | Where | +| --- | --- | --- | +| EUD error code to line: Remastered's "EUD ERROR: 0x…" is the refused address inverted (R0); the editor reads the code and points at the line that touches that address | the trigger map and trace (A1), R0 | built 2026-10-17, below | +| Typed chat patterns: `on.chat("-give {n}", (p, { n }) => …)` with `n: number` worked out by template literal types | B2 | built: `chatted()`'s patterns, which `on.chat` took over in B2 | +| A line's values over time beside it, as a small graph, from the debugger's recording | A5 | after A5's open items | +| Changes kept while the game runs: edit the script, rebuild, carry the variables over by name into a snapshot and go on | A2's variable map, scm-sim snapshots | after B1 (a task's place has to carry over too) | +| Fuzzing: random inputs on every seat at the lifted game's speed, looking for desyncs, games that end and failed expectations, then the smallest input that still fails | A2, A3 | after B5 | +| A memory field's hover says what Remastered does with it ("read: fine, from the dump; write: ends the game, probe 6") | R0, Ghidra structs (the analysis has to be run again) | B7 | + +#### EUD error code to line (built 2026-10-17) + +**Find an EUD Error…** (the palette and the More menu) asks for the code Remastered showed and answers with the +lines. In the editor it goes: + +1. **The code is read.** "EUD ERROR: 0xFFA632E3", "0xFFA632E3" or "FFA632E3"; the address is the code's bits inverted + (R0, the dump probe's step D: 0xFFA632E3 is 0x59CD1C). +2. **The address is named.** `compiler/eudError.ts` knows 1.16.1's unit table field by field (CUnit, slot and offset: + "the unit table: slot 0 of 1,700, order queue head (+0x74)"), the deaths and kills tables by player and unit type, + resources, switches, locations, the text lines, the local machine's keys and mouse, the current player, every + `stats()` field, and else an allowlist entry's name. The allowlist's read and write verdicts and their evidence come + with it (`dist/allowlist.json`, a copy the build makes, fetched the first time a code is looked up). +3. **The map is traced in the game.** Built as Save builds it, then `runGameTests` with two new payload fields: + `play` (3,000 frames with nobody at it, before the tests) and `trace`. `EudTracer` listens on the host's EUD tap + (scm-sim's `installEud`, which the page's test worker already gave the handle) and takes each access's line from + the cost tap's `trace`, which is told of each record before it runs, with eudplib's variables and functions under + the line that ran before them. So a unit field read inside `f_dwread_epd` is the line that called it. The rows come + back in the report (`trace`, or `traceError` saying why not). **No scm-sim change was needed.** +4. **The lines are found.** Those that touched the very dword first. In the unit table, a unit sits in a different + slot from game to game, so lines that touched the same field of another unit are found too, marked so. Accesses + under no line (eudplib's start, its work at the top of a frame, the map's other triggers) are counted apart. +5. **They are shown** as an inlay at the end of each line ("EUD ERROR 0xFFA632E3", with what the line did and what + Remastered does with it), in Problems (source "game"), in Output, and the cursor goes to the first. Like a line's + cost, they stay only while the file is the text that was traced. + +Under Node: `npx tsx scripts/test-game.mts --error=0x… [--frames=N]` runs the same path (the handle +given the EUD tap, `runGameTests` with `trace` and `play`). + +**Checked:** +- `tests/eud-error.test.ts` (11): the code both ways; the names; matching exact, by field, outside the unit table, + and a byte inside a dword; the allowlist's verdicts and `outside`; `EudTracer` over taps shaped like scm-sim's, with + CARRY; `runGameTests` playing frames for a script with no tests; `traceError` when the host or build cannot. +- In the lifted game: `probes/units.ts --error=0xFFA62F5F` (slot 3's hit points) names lines 38 (`sum += u.hp`), + 42 (`u.hp = …`), 105 (`u.hp < least`) and the other hit point lines, all as "the same field of another unit", + since no unit stood in slot 3. `probes/remastered-dump.ts --error=0xFFA632E3` names the place and the played + evidence and finds no line: step D's read is the probe's hand-written eudplib, not a line of the script. + +**Not checked:** the command in the editor itself, end to end (it needs the person's copy of the game on the +engine's page). **Found on the way:** A1's Node trace (`exact-eud.ts --trace`) keys rows by the raw origin, so +eudplib's functions are "fn:…", never a line; a unit field read through `f_dwread_epd` there looks like eudplib's +own. The tap's CARRY is what puts it on its line, which is why `--error` lives in `test-game.mts` and not in +`game-check.mts`. The same would sharpen A1's check (it currently reads those as eudplib's). + +## Slices + +| # | Slice | What it proves | Size | +| --- | --- | --- | --- | +| A0 | Spike: an eudplib map in the lifted game (scm-sim) | EUD writes through a hooked bounds check; 1.16.1 loads the built map; probes print their expectations headless | 2–4 days, no release | +| A1 + A2 | Address tracer, allowlist, tests in the game (4.0.0) | every address a run touches held against Remastered's known answers at the line; each test judged twice; coverage | 6–8 days, plus scm-sim's `run` (contract 2) | +| A3 + A4 | Desync detector, the cost of a frame (4.1.0) | several seated copies hashed every frame; actions per frame per line; the first lag number from a probe | 4–5 days, one probe (the dial) | +| A5 | The debugger in the game (4.2.0) | variables from memory, step back, watchpoints, the frame drawn | 3–4 days, no probe | +| B1 | `async` / `await`, generators (4.3.0) | tasks on the heap, a scheduler, `Promise.all`/`race`/`any`, `until()` | 6–8 days, probe | +| B2 | Events (4.4.0) | created / died / order / chat, handlers as tasks | 3–4 days, probe | +| B3 + B4 | Function values, `try` / `catch` (4.5.0) | calls through an id, the capture rule, unwinding through tasks | 5–6 days, probe | +| B5 | `Local` and `sync()` (4.6.0) | the taint pass; local reads, each probed; A3 confirms | 4–5 days, probe per read | +| B6 | `fixed` and `Math` (4.7.0) | 16.16 arithmetic, the direction tables against the game's | 2–3 days, probe | +| B7 | Typed memory (4.8.0) | generated structs with allowlist states; `unsafe` with a reason | 3–4 days, no probe of its own | +| B8 | Built from the game (4.9.0) | ground distances and regions baked in; `atBuild` | 3 days, probe | +| C | Libraries, one probe each (4.10 on) | the table above | per idea | + +Ship order for every release, as for 3.x: plugin commit and tag → scm-js pin, vendor, +guide and catalogue note → registry → ai-server prompt. Anything scm-sim needs (A0's +build, contract 2) ships first, then plugin-scm-sim, then TrigScript. + +## Work outside this repository + +- **scm-sim:** the EUD-permitting module build (A0); the trigger hooks (A1); `run(…)` with + seating, inputs, a trace and memory reads (A2, A3); UMS with several people on one + instance (A3). +- **plugin-scm-sim:** contract version 2 for `run`; the `present` and `status()` answers + saying whether the person's copy of the game allows a run. +- **plugin-eudplib:** the build returns the object address table (A1), if `trigscript.py` + cannot get it on its own. +- **scm-js:** nothing until the guide pages; `docs/trigscript.md` gains *Run in the game*. + +## Open decisions + +- ~~Does a headless test run count as play?~~ Decided 2026-10-15 (the user: "headless + test runs seem fine as long as theres no rendering"): a run in the game for tests, + traces, desyncs and costs is allowed from any copy that unlocks the editor, provided + nothing of the game is drawn. A5's "the game beside the code" draws, so it needs the + person's own copy, as Test Map does. +- **Does `Promise.race` cancel the losers?** JavaScript: no. Proposed: no, as JavaScript, + plus an explicit `task.cancel()` on what `start(…)` returns (built as `t.cancel()`). *Decided otherwise in B1's + first part (2026-10-16), and confirmed by the user:* the losers stop, because a branch is + a fixed pair of cells and a race met again cannot run beside its last run. A loser that + should go on can be started with `start()` and raced as `await t` (built in B1's second + part). +- **What a closure may capture.** Proposed above (B3): `const` by value, `let` only when + the closure does not outlive its frame. The alternative is reference-counted + environments: closer to JavaScript, more Python, and a new kind of fault. + *Built (2026-10-17) narrower:* a function that does not outlive its frame (handed down) captures anything; one + kept as a value captures only the program's own variables and values known when built. Capturing a local by value + waits for an environment on the heap, owned the way a made text owns its block — `makeCounter()` returning + `() => count++` over its own `let` needs that, and reference cells besides. +- **How a trigger becomes a line.** The candidate is the object address table (A1). If + eudplib does not keep objects in one piece, the fallback is a marker action per IR node, + which costs triggers. +- ~~Where the hook for the EUD bounds check lives.~~ Settled by A0 (2026-10-15): nowhere + in the build. The two routines are replaced at run time through the module's table of + routines reached by pointer, only in a run that asks for EUD. +- **The version number.** 4.0.0 for A1 + A2 because *Run in the game* is a new mode, or + 3.13 until the language changes in B1. Proposed: 4.0.0 at A1 + A2. +- ~~Map protection on the own build.~~ Decided 2026-10-23 (the user): none. eudplib scrambles MRGN, shuffles its + payload and adds a fake ISOM; the own build does nothing of the kind, and will not. +- **Magenta on our backend.** Decided 2026-10-23 (the user): a map with Magenta's triggers in it goes on our backend + too, not through eudplib. Magenta's build rows (text, maths, unit passes, checks, chat, keys, clicks) are expanded + today by its euddraft plugins in Python; the slice lays them out as TrigScript programs on the own layout, after + step 17. Since step 17 (2026-10-25) a map with Magenta's member is not built: the step refuses it with the reason + (`whyNotBuilt`) and Magenta's own build runs alone, since eudplib's build rewrites the strings section the payload + sits behind. The slice is next. *How, decided 2026-10-25 (the user): TrigScript is the library.* TrigScript provides a + build service as eudplib did (`services.provide`, a `contribute` with an `applies` and a `collect`), Magenta requires + plugin-trigscript and hands it its rows as IR, and both go through the one layout call on save; the Magenta refusal in + the build step goes with it. Not a third library plugin holding the backend alone: the backend consumes TrigScript's + IR and passes, and a split would drag the compiler along or put a second interface in front of it. + +## Facts this plan leans on + +- scm-sim, `src/exact/` (2026-10-12 to 14): lifted 1.16.1 with storm.dll; maps, replays, + several people on one instance; 2,200–4,100 frames a second in Node; snapshots ~45 ms / + 0.6 MB; hooks by address at generation; `callAt` between turns; `triggerView`; + extended Deaths reads work and writes are refused by 1.16.1's check. +- Remastered's executable is encrypted on disk and decrypted in memory; its EUD emulator was read from a dump of the running game and run under Unicorn (R0, 2026-10-16). Nothing of it ships: only addresses and verdicts, in `data/remastered.json`. +- Magenta's played probes 5–11 (2026-09-14/15): position, tint and image writes end the + game; tile buffer writes are not drawn; per-unit speed and cloak flags do nothing; key + hold does not exist; the scratch-box order trick and the damage/heal verb work. +- The strings spike (2026-09-19): objectives, leaderboard label, transmission line and + unit type name show a made text and are re-read every draw. +- eudplib 0.81 (checked in the wheel 2026-10-15): `EUDFuncPtr`, `EUDTypedFuncPtr`, + `EUDLoopNewUnit` / `EUDLoopNewCUnit`, `EUDLoopBullet`, `EUDLoopSprite`, + `QueueGameCommand` and its select / right-click forms. +- TrigScript 3.12 (2026-10-16): the simulator keeps the game's trigger schedule; + programs first run on frame 2; eudTurbo writes 0x6509A0 := 0 after the pass. diff --git a/editor.ts b/editor.ts index 2579b71..9f9655e 100644 --- a/editor.ts +++ b/editor.ts @@ -3,8 +3,8 @@ * declarations generated from the map (`compiler/declarations.ts`) and compiled — run — * in the worker (`compile.ts`). Apply writes the script's `trigger()` records into the * trigger list as its block (`service.ts`); saving or testing the map does the same by - * itself, and that is also when the programs are built — into the file, by the eudplib - * plugin. Play does both and hands the result to Test Map. + * itself, and that is also when the programs are built — into the file, by TrigScript's own + * build. Play does both and hands the result to Test Map. * * It is laid out as VS Code is (`shell.ts`): the files in an Explorer with the programs * and their variables under them, tabs over the editor with the run controls at their @@ -27,9 +27,12 @@ * Monaco and TypeScript come from the CDN on first open. */ import type { PluginApi } from "@scm-js/plugin-api"; -import { CompileSuperseded, retainCompileWorker } from "./compile"; -import { ENTRY_FILE, normalizePath, type CompileResult, type ScriptDiagnostic, type ScriptFiles, type TriggerSource } from "./compiler/compiler"; -import { HEAP_CELLS, HEAP_CELLS_MAX, HEAP_CELLS_MIN, STACK_CELLS_MAX, STACK_DEPTH, STACK_DEPTH_MAX, STACK_DEPTH_MIN, heapCells, stackDepth } from "./compiler/ir"; +import { CompileSuperseded, allowlistUrl, gametestUrl, retainCompileWorker } from "./compile"; +import type { Allowlist } from "./compiler/allowlist"; +import { addressOfEudError, findEudError, type EudErrorAnswer } from "./compiler/eudError"; +import type { GameTestReport } from "./compiler/gameTests"; +import { ENTRY_FILE, normalizePath, type CompileResult, type LineHint, type ScriptDiagnostic, type ScriptFiles, type TriggerSource } from "./compiler/compiler"; +import { CELL_HEAP_MOST, HEAP_CELLS, HEAP_CELLS_MAX, HEAP_CELLS_MIN, STACK_CELLS_MAX, STACK_DEPTH, STACK_DEPTH_MAX, STACK_DEPTH_MIN, heapCells, stackDepth } from "./compiler/ir"; import { largestFrame } from "./compiler/recursion"; import { entryFor } from "./compiler/names"; import { printScript } from "./compiler/print"; @@ -38,19 +41,29 @@ import { actionDef } from "./vendor/triggerDefs"; import { PlayerGroup } from "./vendor/triggers"; import { BUILD_TIME_CLASS, TEST_MARK_CLASS, createScriptEditor, loadMonaco, refreshLineHints, releaseScriptEditor, setCompilerMarkers, setLineHints, setDeclarations, setHoverVariables, setMapRefs, setMonacoTranslator, - type LocationRef, type MonacoApi, type ScriptEditor, + type LocationRef, type MonacoApi, type ScriptEditor, type ScriptWarning, } from "./monaco"; import { renamedKeys, renamesInUse, replaceReferences, type Renamed } from "./refs"; import { FILE_NAME } from "./script"; -import { isTestFile, type TestInfo, type TestRunOptions } from "./compiler/testing"; +import { isMachineLine, readTriggerMap, readVariableMap } from "./compiler/gameVariables"; +import { mergeCopies } from "./compiler/gameCopies"; +import { costByLine, shortCount, shortMs, type TestCost } from "./compiler/gameCost"; +import { MOST_MS, SMOOTH_MS } from "./compiler/remasteredCost"; +import { isTestFile, type TestInfo, type TestReport, type TestResult, type TestRunOptions } from "./compiler/testing"; import { countTests, foldPlayers, idsUnder, marksOf, mergeReport, NO_TESTS, notesOf, playersLabel, stateOf, stateOfCounts, summary, testTree, warningsOf, type TestNode, type TestState, type TestStateName } from "./testState"; import { testName } from "./service"; import { applyMoves, buildTree, filesUnder, folderOf, movesOf, refuseMoves, tabLabels, validFolder, type Moves, type TreeNode } from "./tree"; import { ProgramSimulation, type ProgramEvent, type ProgramSimulationOptions, type SimBounds, type SimUnitInit } from "./compiler/simulateIr"; -import { positionIn, type BuildRefusal, type MapNames, type ScriptArtifact, type ScriptService } from "./service"; +import { positionIn, type BuildRefusal, type MapNames, type OwnBuildEvent, type ScriptArtifact, type ScriptService } from "./service"; import { COMPACT_LAYOUT, DEFAULT_LAYOUT, SHELL_STYLE, createShell, type IconName, type ShellLayout } from "./shell"; -import type { EudplibBuildEvent, EudplibService } from "./vendor/eudplib"; +import { DEBUG_STYLE, Debugger, type DebugGameView } from "./debugger"; +import type { DebugRecording } from "./compiler/gameDebug"; +import type { ScmSimDebugView } from "./vendor/scmSim"; import type { Translate } from "./i18n"; +import { describeMedia, isImageFile, isMediaFile, isNotesFile, isSoundFile } from "./compiler/media"; +import { importMedia, showImage, showVideo } from "./videoImport"; +import { isSoundSource, showSound } from "./soundImport"; +import { isMidiSource, showNotes } from "./notesImport"; export const TEMPLATE = `// TrigScript: ordinary TypeScript that runs when you build. Every trigger() call becomes // one trigger of the map, in order; code inside program(() => { … }) runs in the game. @@ -68,7 +81,7 @@ trigger(AllPlayers, [ // It runs every frame from where it left off, until it sleeps. The underlined parts // are computed when the script is applied, everything else runs in the game. // -// A map with a program is built by the eudplib plugin when you save it, and needs +// A map with a program is built by TrigScript itself when you save it, and needs // StarCraft: Remastered to play. trigger() alone plays on every version. // // program(() => { @@ -108,6 +121,8 @@ const OWNER_COMPUTER = 5; const OWNER_HUMAN = 6; /** How many of a simulation's events the list shows before it says how many more there were. */ const SIMULATE_ROWS = 200; +/** A build's log as a person reads it: without the lines the lowering says for a run in the lifted game. */ +const withoutMachineLines = (text: string) => text.split("\n").filter((l) => !isMachineLine(l)).join("\n"); /** The most lines with a fault the Simulate view lists. */ const SIMULATE_FAULTS = 20; @@ -119,6 +134,23 @@ const STATUS_MS = 10_000; const NOTICE_MS = 8_000; /** When running every test takes longer than this, only the open file's run by themselves after a compile. */ const TESTS_SLOW_MS = 2_000; +/** + * What Remastered was seen to run in a frame with no effect: the dial (probes/frame-cost.ts, played 2026-10-16) ran + * 500 thousand conditions and actions a frame with none; from a million the screen flickers, enough to distract, + * and at 5 million the game slows. That was a fast computer (Ryzen 9 7900X): a slower one reaches each sooner. + */ +const COST_SEEN = 500_000; +/** A line that ran this many conditions and actions in one frame of a test in the game says so beside it: 2% of COST_SEEN. */ +const COST_HINT = 10_000; +/** + * Where the host weighs (cost step 1), a line that took this many milliseconds of Remastered's in one frame says so + * instead: 2% of SMOOTH_MS, the trigger work a frame took with no flicker seen (probes/frame-weights, 2026-10-18). + */ +const COST_HINT_MS = 0.3; +/** Frames the map is played with nobody at it when an EUD error is looked up, before the tests: two minutes of game time at Fastest. */ +const EUD_TRACE_FRAMES = 3_000; +/** The costliest lines a test's results name. */ +const COST_LINES = 3; /** The key the shortcuts are written with. */ const MOD = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform) ? "Cmd" : "Ctrl"; @@ -133,7 +165,7 @@ const STYLE = `${SHELL_STYLE} .tsd .tsd-settings p { margin: 0 0 8px; color: var(--text-dim); line-height: 1.45; } .tsd .tsd-settings .row { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } .tsd .tsd-settings input { width: 110px; padding: 2px 6px; font: inherit; font-family: var(--font-mono); color: var(--text); background: var(--bg-1); border: 1px solid var(--border); border-radius: 2px; } -.tsd .tsd-settings input:focus { outline: 1px solid var(--accent); outline-offset: -1px; } +.tsd .tsd-settings input:focus { outline: 1px solid var(--teal); outline-offset: -1px; } .tsd .tsd-settings button { padding: 2px 8px; font: inherit; color: var(--text); background: var(--bg-3); border: 1px solid var(--border); border-radius: 2px; cursor: pointer; } .tsd .tsd-settings button:disabled { opacity: 0.5; cursor: default; } .tsd .tsd-settings .now { color: var(--text-faint); } @@ -149,9 +181,9 @@ const STYLE = `${SHELL_STYLE} .tsd .tsd-output { margin: 0; padding: 4px 20px; font-family: var(--font-mono); font-size: var(--fs-sm); line-height: 17px; white-space: pre-wrap; overflow-wrap: anywhere; color: var(--text-dim); } .tsd .tsd-list li.tsd-warning .tsd-i { color: var(--warn); } .tsd .tsd-rows .tsd-empty { padding: 6px 12px 6px 20px; color: var(--text-faint); white-space: normal; line-height: 1.45; cursor: default; } -.tsd .tsd-test-passed { color: var(--ok); } +.tsd .tsd-test-passed, .tsd .tsd-list .tsd-i.tsd-test-passed { color: var(--ok); } .tsd .tsd-test-failed { color: var(--danger); } -.tsd .tsd-test-none, .tsd .tsd-test-skipped { color: var(--text-faint); } +.tsd .tsd-test-none, .tsd .tsd-test-skipped, .tsd .tsd-list .tsd-i.tsd-test-none, .tsd .tsd-list .tsd-i.tsd-test-skipped { color: var(--text-faint); } .${TEST_MARK_CLASS} { cursor: pointer; font: normal normal normal 14px/18px codicon; text-align: center; color: var(--text-faint); } .${TEST_MARK_CLASS}::before { content: "\\eabc"; } .${TEST_MARK_CLASS}-passed { color: var(--ok); } @@ -160,8 +192,11 @@ const STYLE = `${SHELL_STYLE} .${TEST_MARK_CLASS}-failed::before { content: "\\ea87"; } .${TEST_MARK_CLASS}-skipped::before { content: "\\eabd"; } .${TEST_MARK_CLASS}-note { color: var(--danger); font-style: italic; opacity: 0.9; } +.tsd .tsd-list li.tsd-cost { align-items: center; } +.tsd .tsd-cost-graph { flex: 1 1 auto; min-width: 0; max-width: 480px; height: 36px; } +.tsd .tsd-cost-graph path { fill: var(--teal); fill-opacity: 0.35; stroke: var(--teal); stroke-width: 1px; vector-effect: non-scaling-stroke; } .${BUILD_TIME_CLASS} { text-decoration: underline dotted rgba(153, 162, 179, 0.55); text-underline-offset: 3px; } -`; +${DEBUG_STYLE}`; /** "P1", "all players", "Force 2", "P1, P2" — who a program runs for. */ function ownerLabel(p: { owners: number[] }, t: Translate): string { @@ -288,8 +323,6 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa let simulation: { sim: Simulation; programs: ProgramSimulation | null; result: CompileResult } | null = null; /** The programs and variables of the last compile that went through: a typo does not empty the Explorer. */ let outline: Pick | null = null; - /** The eudplib plugin's service, followed while the workspace is open. */ - let library: EudplibService | null = svc.library(); /** Play is under way: the script applied, the map built as Save would, handed to Test Map. */ let testing = false; let cancelled = false; @@ -324,9 +357,11 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const moreAction = shell.action({ icon: "ellipsis", title: t("More actions…"), run: () => shell.menu(moreAction.element, [ menuItem("save"), null, - ...["import", "newFile", "newFolder"].map(menuItem), + ...["import", "newFile", "newFolder", "importVideo"].map(menuItem), + null, + ...["runTests", "runFailedTests", "runTestsInGame"].map(menuItem), null, - ...["runTests", "runFailedTests"].map(menuItem), + menuItem("findEudError"), null, ...["problems", "output", "panel", "explorer", "testing"].map(menuItem), null, @@ -339,6 +374,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa shell.section({ title: t("Script"), actions: [ { icon: "new-file", title: t("New file…: main.ts imports it with import { … } from \"./name\""), run: () => { void newFile(); } }, { icon: "new-folder", title: t("New folder…: a folder is there while a file is in it, so its first file is asked for next"), run: () => { void newFolder(); } }, + { icon: "device-camera-video", title: t("Import Video…: a video, a picture, a sound or a MIDI file as a file of the script"), run: () => { void importVideo(); } }, ] }).body.append(fileList); const programList = el("ul", { className: "tsd-rows" }); const programsSection = shell.section({ title: t("Programs") }); @@ -355,9 +391,17 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const testingView = shell.sidebarView({ id: "testing", title: t("Testing"), icon: "beaker", actions: [ { icon: "run-all", title: t("Run all tests"), run: () => { void runTests(); } }, { icon: "run-errors", title: t("Run the tests that failed"), run: () => { void runFailedTests(); } }, + { icon: "target", title: t("Run all tests in the game (the lifted game of the scm-sim plugin): each gets a second mark"), run: () => { void runTestsInGame(); } }, { icon: "filter", title: t("Show only the tests that fail"), run: () => { onlyFailing = !onlyFailing; renderTesting(); } }, ] }); testingView.body.append(testingList); + /** The debugger in the game (TrigScript 4, A5): the Debug view, its marks in the editor, its values beside the lines. */ + const debug = new Debugger({ + t, el, shell, + reveal: (file, line) => { if (!editor) return; openFile(file); editor.editor.revealLineInCenterIfOutsideViewport(line); }, + changed: () => { editor?.setDebug(debug.marks()); refreshLineHints(); }, + setStatus: (kind, text, timeout) => setStatus(kind, text, timeout), + }); /** * The map's script settings (`script.ts#ScriptSettings`): kept in the map beside the script, because the built map @@ -401,8 +445,8 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa settingsView.body.replaceChildren(el("div", { className: "tsd-settings" }, el("h4", {}, t("Memory for arrays that grow")), el("p", {}, t("Arrays a program pushes to share one pool of cells; this is its size. A single array can reach between a quarter and a half of it. When the pool runs out, nothing more is pushed and the game says so once.")), - row({ key: "heapCells", min: HEAP_CELLS_MIN, max: HEAP_CELLS_MAX, step: 1024, normal: HEAP_CELLS, fit: heapCells, said: (now) => t("cells — {size} of the built map", { size: size(now) }) }), - el("p", {}, t("{min} to {max} cells, four bytes each. A larger pool does not slow the game and hardly grows the saved file; it takes more memory while the map is played. Kept in the map, so it builds the same on any computer.", { min: count(HEAP_CELLS_MIN), max: count(HEAP_CELLS_MAX) })), + row({ key: "heapCells", min: HEAP_CELLS_MIN, max: HEAP_CELLS_MAX, step: 1024, normal: HEAP_CELLS, fit: heapCells, said: (now) => t("cells — {size} while the map is played", { size: size(now <= CELL_HEAP_MOST ? now * 19 : now) }) }), + el("p", {}, t("{min} to {max} cells. Up to {most}, a cell is 76 bytes while the map is played: four for texts, and 72 for the arrays' own cells, which a loop reads and writes in a few steps. Past that, arrays share the texts' four bytes and a read takes about three times as long. A larger pool does not slow the game and hardly grows the saved file. Kept in the map, so it builds the same on any computer.", { min: count(HEAP_CELLS_MIN), max: count(HEAP_CELLS_MAX), most: count(CELL_HEAP_MOST) })), el("h4", {}, t("Recursion depth")), el("p", {}, t("How many calls deep a function that calls itself may go. Around each such call the function's variables are kept on a stack, which is only in the built map when some function calls itself. A call past the limit stops the program, and the game says where; Simulate stops at the same call.")), row({ key: "stackDepth", min: STACK_DEPTH_MIN, max: STACK_DEPTH_MAX, step: 256, normal: STACK_DEPTH, fit: stackDepth, said: stack }), @@ -432,6 +476,25 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa /** Running every test took more than `TESTS_SLOW_MS` the last time. */ let testsSlow = false; let testsRunning = false; + /** + * The tests' last results in the lifted game (TrigScript 4, A2), by id: a mark of their own beside the simulator's, + * kept until the next run in the game. The game is the judge: where the two differ, the simulator is wrong. + */ + let gameResults = new Map(); + /** + * The files as they were when the game counted what each line costs (TrigScript 4, A4): a line's count is shown + * beside it only while its file is still that text, since an edit moves the lines. + */ + let costFiles: ScriptFiles | null = null; + /** The text of `dist/gametest.js`, fetched once. */ + let gametest: Promise | null = null; + /** What Remastered is known to allow, fetched the first time an EUD error is looked up. */ + let allowlist: Promise | null = null; + /** + * The last EUD error looked up (Find EUD Error), and the files as they were when the game was traced: its lines are + * marked only while their file is still that text, as a line's cost is. + */ + let eudFound: { files: ScriptFiles; answer: EudErrorAnswer } | null = null; /** The files with a tab, in the order they were opened. */ let openTabs: string[] = [normalizePath(options.file ?? ENTRY_FILE)]; /** What Apply, Play and the builds of the programs reported, oldest first. */ @@ -489,11 +552,12 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const where = (s: TriggerSource | null | undefined) => (s ? (Object.keys(files).length > 1 ? `${s.file}:${s.line}` : t("Ln {line}", { line: s.line })) : "?"); - /** Show a file, in a tab of its own. */ + /** Show a file, in a tab of its own; a video or an image in a viewer of its own. */ const openFile = (path: string) => { if (!editor) return; const p = normalizePath(path); if (files[p] === undefined) return; + if (isMediaFile(p)) { (isImageFile(p) ? showImage : isSoundFile(p) ? showSound : isNotesFile(p) ? showNotes : showVideo)(api, t, p, files[p]); return; } if (!openTabs.includes(p)) openTabs.push(p); editor.show(p); renderFiles(); @@ -539,6 +603,17 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa }; // Below the last row is the top of the script. acceptDrops(fileList, ""); + // A video, a picture or a sound dropped in from outside: Import Video… with it. + const fromOutside = (e: DragEvent) => !dragged && !!e.dataTransfer && [...e.dataTransfer.types].includes("Files"); + fileList.addEventListener("dragover", (e) => { if (!fromOutside(e)) return; e.preventDefault(); fileList.classList.add("tsd-drop"); }); + fileList.addEventListener("dragleave", () => fileList.classList.remove("tsd-drop")); + fileList.addEventListener("drop", (e) => { + fileList.classList.remove("tsd-drop"); + if (!fromOutside(e)) return; + e.preventDefault(); + const file = [...(e.dataTransfer?.files ?? [])].find((f) => /^(video|image)\//.test(f.type) || /\.(mkv|webm|mp4|mov|gif)$/i.test(f.name) || isSoundSource(f) || isMidiSource(f)); + if (file) void importVideo(file); + }); const renderFiles = () => { const active = editor?.active() ?? openTabs[0] ?? ENTRY_FILE; @@ -588,8 +663,8 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa } const path = node.path; const fixed = path === ENTRY_FILE; - const row = el("li", { className: path === active ? "tsd-row tsd-active" : "tsd-row", title: path, onClick: () => openFile(path) }, - el("span", { className: "tsd-ts" }, "TS"), + const row = el("li", { className: path === active ? "tsd-row tsd-active" : "tsd-row", title: isMediaFile(path) ? `${path}\n${describeMedia(path, files[path])}` : path, onClick: () => openFile(path) }, + isMediaFile(path) ? shell.icon(isImageFile(path) ? "file-media" : isSoundFile(path) ? "unmute" : isNotesFile(path) ? "music" : "device-camera-video") : el("span", { className: "tsd-ts" }, "TS"), el("span", { className: broken.has(path) ? "tsd-name tsd-problem" : "tsd-name" }, node.name), el("span", { className: "tsd-row-actions" }, !fixed ? shell.iconButton({ icon: "edit", title: t("Rename or move…"), run: () => { void renameFile(path); } }).element : undefined, @@ -635,11 +710,26 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa ])); }; + /** + * A script the build does not take: a warning on the line that says why, while the script is typed, so that the + * save that fails to build the programs comes as no surprise. + */ + const ownWarnings = (): ScriptWarning[] => { + const refusal = result?.ownRefusal; + if (!refusal || !result?.programs.length) return []; + const at = /, line (\d+)(?: of (\S+))?$/.exec(refusal); + const reason = at ? refusal.slice(0, at.index) : refusal; + const message = t("The programs will not be built: TrigScript does not take this yet ({reason}). The map is saved without them.", { reason }); + return [{ file: at?.[2] ?? ENTRY_FILE, line: at ? Number(at[1]) : 1, message, source: "build" }]; + }; + const allWarnings = (): ScriptWarning[] => [...warningsOf(tests, t), ...ownWarnings()]; + const renderProblems = () => { // A failing test and an only left in are warnings: said here, and no obstacle to a build unless the map's settings make one of them. - const warnings = warningsOf(tests, t); - problemsView.badge(diagnostics.length + warnings.length); - if (diagnostics.length + warnings.length === 0) { problemsView.body.replaceChildren(el("div", { className: "tsd-empty" }, !ready ? "" : result ? t("No problems have been detected in the script.") : t("Checking…"))); return; } + const warnings = allWarnings(); + const eud = eudLines(), found = eudFound?.answer; + problemsView.badge(diagnostics.length + warnings.length + eud.length); + if (diagnostics.length + warnings.length + eud.length === 0) { problemsView.body.replaceChildren(el("div", { className: "tsd-empty" }, !ready ? "" : result ? t("No problems have been detected in the script.") : t("Checking…"))); return; } problemsView.body.replaceChildren(el("ul", { className: "tsd-list" }, ...diagnostics.map((d) => el("li", { title: d.message, onClick: () => goTo(d.file, d.line, d.column) }, @@ -648,11 +738,18 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa el("span", { className: "src" }, d.source === "typescript" ? t("types") : d.source === "script" ? t("script") : t("compiler")), el("span", { className: "where" }, t("{file} [Ln {line}, Col {column}]", { file: d.file, line: d.line, column: d.column })), )), + ...(found ? eud.map((l) => + el("li", { title: eudLineNote(found, l), onClick: () => goTo(l.file, l.line) }, + shell.icon("error"), + el("span", { className: "msg" }, `EUD ERROR ${found.code}: ${found.place}`), + el("span", { className: "src" }, t("game")), + el("span", { className: "where" }, t("{file} [Ln {line}]", { file: l.file, line: l.line })), + )) : []), ...warnings.map((w) => el("li", { className: "tsd-warning", title: w.message, onClick: () => goTo(w.file, w.line) }, shell.icon("warning"), el("span", { className: "msg" }, w.message), - el("span", { className: "src" }, t("tests")), + el("span", { className: "src" }, w.source === "build" ? t("build") : t("tests")), el("span", { className: "where" }, t("{file} [Ln {line}]", { file: w.file, line: w.line })), )), )); @@ -692,10 +789,13 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa }; const row = el("li", { className: node.kind === "test" && node.info.id === chosenTest ? "tsd-row tsd-active" : "tsd-row", title: result?.message ?? name, onClick: open }, stateIcon(state), + // (The game's mark next to the simulator's, where a narrow sidebar still shows it.) + node.kind === "test" && gameResults.has(node.info.id) ? gameMark(gameResults.get(node.info.id)!, result) : undefined, el("span", { className: state === "failed" ? "tsd-name tsd-problem" : "tsd-name" }, name), result && result.status !== "skipped" ? el("span", { className: "tsd-about" }, t("{ms} ms", { ms: result.ms })) : undefined, el("span", { className: "tsd-row-actions" }, shell.iconButton({ icon: "play", title: node.kind === "test" ? t("Run this test") : t("Run these tests"), run: () => { void runTests(node.kind === "file" ? { files: [node.path] } : { ids }); } }).element, + node.kind === "test" ? shell.iconButton({ icon: "debug-alt", title: t("Debug this test in the game: run it once with everything it did written down, then walk it forwards and back"), run: () => { void debugTest(node.info.id); } }).element : undefined, ), ); row.style.paddingLeft = `${10 + depth * 12}px`; @@ -708,6 +808,301 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa testingList.replaceChildren(...rows); }; + /** A test's mark from the game, the second beside the simulator's: its state, ringed when the two disagree, and in words on hover. */ + const gameMark = (game: TestResult, simulated: TestResult | undefined) => { + const state: TestStateName = game.status === "failed" ? "failed" : game.status === "passed" ? "passed" : "skipped"; + const differ = simulated && simulated.status !== "skipped" && game.status !== "skipped" && simulated.status !== game.status; + return el("span", { className: differ ? "tsd-game tsd-game-differs" : "tsd-game", title: differ + ? t("In the game: {status}. The simulator and the game disagree: the game is right, so the simulator is wrong here (or the test leans on something it does differently).", { status: game.status === "failed" ? game.message ?? t("failed") : t("passed") }) + : game.status === "failed" ? t("In the game: {message}", { message: game.message ?? t("failed") }) : game.status === "skipped" ? game.message ?? t("In the game: not run") : t("In the game: passed") }, + stateIcon(state)); + }; + + /** + * The tests run in the lifted game (TrigScript 4, A2): the map built as Save builds it, handed with the script's + * JavaScript and `dist/gametest.js` to the scm-sim plugin's engine page, which runs each test beside the game. The + * simulator runs them too, so that each test has both marks. + */ + /** + * What a run in the game needs, made ready: the engine there and willing, the script applied and compiled, the map + * built as Save builds it and exported whole (a bare .chk has no room for what the build steps make, so the engine's + * page reads the scenario out of it), with the build's maps of variables and triggers from its log, and the text of + * `dist/gametest.js`. Null, the reason said in the status bar, when something is missing. + */ + const prepareInGame = async (filter: Pick, ask: "tests" | "debug" | "trace") => { + const engine = svc.engine(); + if (!engine?.present || !engine.test || (ask === "debug" && !engine.debug)) { + setStatus("error", ask === "debug" + ? t("Debugging in the game needs the scm-sim plugin (0.11 or newer) on scmjs.dev: install, update or turn it on under Plugins ▸ Manage Plugins….") + : t("Running tests in the game needs the scm-sim plugin (0.11 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….")); + return null; + } + const notRun = (message: string) => (ask === "debug" ? t("Not debugged in the game: {message}", { message }) : ask === "trace" ? t("Not looked up in the game: {message}", { message }) : t("Not run in the game: {message}", { message })); + const status = await engine.status(); + if (!status.available) { setStatus("error", notRun(status.reason ?? t("the engine cannot run a game now"))); return null; } + if (!status.tests) { setStatus("error", t("Not run in the game: the engine's page is from before tests in the game. Reload the editor and try again.")); return null; } + if (ask === "debug" && !status.debug) { setStatus("error", t("Not debugged: the engine's page is from before the debugger. Reload the editor and try again.")); return null; } + if (!(await build()) || cancelled) return null; + const a = await compileNow({ world: svc.world(), ...filter }); + if (!a || cancelled) return null; + if (!a.compiled.ok) { setStatus("error", ask === "trace" ? t("Not looked up: {n, plural, one {# problem} other {# problems}} in the script.", { n: a.compiled.diagnostics.length }) : t("Tests not run: {n, plural, one {# problem} other {# problems}} in the script.", { n: a.compiled.diagnostics.length }), NOTICE_MS); shell.showPanel("problems"); return null; } + // (An EUD error is looked up in the map played with nobody at it, then the tests when there are any.) + if (!a.compiled.modules && ask !== "trace") { setStatus("info", t("The script has no tests.")); return null; } + if (!a.compiled.ir.length) { + setStatus("info", ask === "trace" + ? t("The script has no program, so nothing of it is built into the map and no line of it can touch the game's memory: an EUD error came from the map's other triggers.") + : t("Tests run in the game only for a script with a program: without one, nothing of the script is built into the map.")); + return null; + } + const names = svc.names()?.names; + if (!names) { setStatus("error", t("No map is open.")); return null; } + setStatus("busy", t("Building the map…")); + const built = { ...files }; + // (The build says in its log where it put the programs' variables, for sim.program().) + const logged: string[] = []; + const heard = svc.onBuild((e) => { if (e.kind === "log") logged.push(e.line); else if (e.kind === "done" && !logged.length) logged.push(...e.log.split("\n")); }); + const file = await svc.withVariableMap(() => api.document.export({ format: "scx" })).finally(() => heard.dispose()); + if (!file || cancelled) { if (!file) setStatus("error", t("No map is open.")); return null; } + const variables = readVariableMap(logged), triggers = readTriggerMap(logged); + gametest ??= fetch(gametestUrl(svc.dist())).then((r) => { if (!r.ok) throw new Error(`gametest.js: ${r.status}`); return r.text(); }); + const module = await gametest.catch((err) => { gametest = null; throw err; }); + const bytes = new Uint8Array(await file.arrayBuffer()); + return { engine, status, a, names, variables, triggers, module, bytes, built }; + }; + + const runTestsInGame = async (filter: Pick = {}) => { + if (!editor || testsRunning || building || testing || !ready) return; + testsRunning = true; + renderTesting(); + try { + const ready = await prepareInGame(filter, "tests"); + if (!ready || cancelled) return; + const { engine, status, a, names, variables, triggers, module, bytes } = ready; + setStatus("busy", t("Running the tests in the game…")); + const report = await engine.test!(bytes, module, { modules: a.compiled.modules, names, files: filter.files, ids: filter.ids, variables, triggers }) as TestReport & { error?: string }; + if (cancelled) return; + if (report.error) { setStatus("error", t("Not run in the game: {message}", { message: report.error })); return; } + // A test of several players (TrigScript 4, A3): a copy of the game for each player, kept in step, where the + // engine's page can run them; where it cannot, the test is marked as not run, and why. + const several = new Map(); + for (const x of tests.list) { + if (x.kind !== "test" || !x.players || (filter.files && !filter.files.includes(x.file)) || (filter.ids && !filter.ids.some((id) => x.id === id || x.id.startsWith(`${id} > `)))) continue; + const key = x.players.join(","), g = several.get(key) ?? { players: x.players, ids: [] }; + g.ids.push(x.id); + several.set(key, g); + } + const notRun: TestResult[] = []; + if (several.size && (!engine.testCopies || !status.copies)) { + const why = t("Not run: a test of several players runs a copy of the game for each, which needs memory shared between them. This browser does not allow that on the engine's page (Chrome and Edge from version 137 do)."); + for (const g of several.values()) for (const id of g.ids) notRun.push({ id, status: "skipped", message: why, printed: [], events: [], frames: 0, ms: 0 }); + } else for (const g of several.values()) { + setStatus("busy", t("Running a test of {n} players in the game: a copy of the game for each…", { n: g.players.length })); + const out = await engine.testCopies!(bytes, module, { modules: a.compiled.modules, names, ids: g.ids, variables, triggers }, { players: g.players, triggers }); + if (cancelled) return; + report.results.push(...mergeCopies(g.ids, out)); + } + const ran = report.results.filter((x) => x.status !== "skipped"); + const asked = new Set(ran.map((x) => x.id)); + gameResults = new Map([...gameResults].filter(([id]) => !asked.has(id) && tests.list.some((x) => x.id === id))); + for (const r of ran) gameResults.set(r.id, r); + for (const r of notRun) gameResults.set(r.id, r); + costFiles = { ...files }; + refreshLineHints(); + const failed = ran.filter((x) => x.status === "failed"); + const differ = ran.filter((x) => { const s = tests.results.get(x.id); return s && s.status !== "skipped" && s.status !== x.status; }); + if (failed.length) { chosenTest = failed[0].id; shell.showPanel("tests"); } + const said = t("In the game: {passed} of {n, plural, one {# test} other {# tests}} passed.", { passed: ran.length - failed.length, n: ran.length }); + const whole = notRun.length ? `${said} ${t("{n, plural, one {# test of several players was} other {# tests of several players were}} not run: this browser cannot run copies of the game.", { n: notRun.length })}` : said; + setStatus(failed.length || differ.length ? "error" : notRun.length ? "info" : "ok", differ.length ? `${whole} ${t("The simulator disagrees on {n, plural, one {# test} other {# tests}}.", { n: differ.length })}` : whole, NOTICE_MS); + } catch (err) { + setStatus("error", t("Not run in the game: {message}", { message: (err as Error).message })); + } finally { + testsRunning = false; + render(); + } + }; + + /** + * One test debugged in the game (TrigScript 4, A5): built as for a run in the game, then run once by the engine's + * page with everything it did written down; the Debug view walks that record. The game stays with the page for + * pictures of the run's frames, drawn by a view of the scm-sim plugin's where this copy of the game may draw. + */ + const debugTest = async (id: string) => { + if (!editor || testsRunning || building || testing || !ready) return; + const info = tests.list.find((x) => x.id === id); + if (!info || info.kind !== "test") { setStatus("info", t("There is no test at the cursor.")); return; } + if (info.players) { setStatus("info", t("“{test}” is a test of several players: the debugger runs one game, and a test of several players runs as a copy of the game for each. Not yet.", { test: info.name })); return; } + testsRunning = true; + renderTesting(); + try { + const got = await prepareInGame({ ids: [id] }, "debug"); + if (!got || cancelled) return; + const { engine, a, names, variables, triggers, module, bytes, built } = got; + setStatus("busy", t("Running “{test}” in the game, writing down everything it does…", { test: info.name })); + const session = await engine.debug!(bytes, module, { modules: a.compiled.modules, names, variables, triggers, test: id }); + if (cancelled) { session.close(); return; } + const recording = session.recording as DebugRecording; + // A view to draw the pictures in, where the plugin has one for a debugged run. + let view: DebugGameView | string | null = null; + if (engine.view) { + try { + const host = debug.gameHost(); + const v = engine.view(host) as ScmSimDebugView; + if (typeof v.debug === "function" && typeof v.show === "function") { + const shown = await v.debug(bytes).then(() => null, (err: Error) => err.message); + if (shown) { v.dispose(); view = shown; } + else { + v.onError?.((message) => setStatus("error", t("No picture of the game: {message}", { message }))); + view = { element: host, show: (p) => v.show!(p), dispose: () => v.dispose() }; + } + } else { v.dispose(); view = t("The scm-sim plugin's view is from before the debugger: update the plugin for a picture of the game."); } + } catch (err) { view = (err as Error).message; } + } + debug.start({ recording, variables, pictures: session, view, files: built }); + debug.setFiles(files); + const r = recording.result; + setStatus(r.status === "failed" ? "error" : "ok", r.status === "failed" + ? t("Debugging “{test}”: it failed in the game at frame {frame}. The debugger stands there; step back to see how it came to that.", { test: info.name, frame: recording.first + recording.frames - 1 }) + : t("Debugging “{test}”: {frames, plural, one {# frame} other {# frames}} in the game, recorded in {ms} ms.", { test: info.name, frames: recording.frames, ms: recording.ms }), NOTICE_MS); + } catch (err) { + setStatus("error", t("Not debugged in the game: {message}", { message: (err as Error).message })); + } finally { + testsRunning = false; + render(); + } + }; + + const debugTestAtCursor = () => { + const at = editor?.cursor(); + const here = at ? tests.list.filter((x) => x.kind === "test" && x.file === normalizePath(at.file) && x.line <= at.line).sort((a, b) => b.line - a.line)[0] : undefined; + if (!here) { setStatus("info", t("There is no test at the cursor.")); return; } + void debugTest(here.id); + }; + + /** + * What a frame costs, beside the line (TrigScript 4, A4): after a run in the game, each line that ran at least + * COST_HINT conditions and actions in one frame of some test says how many at the most, while its file is as it + * was then. + */ + const costHints = (): LineHint[] => { + if (!costFiles) return []; + const seen = costFiles; + const costs = [...gameResults.values()].flatMap((r) => (r.cost ? [{ id: r.id, cost: r.cost }] : [])); + return costByLine(costs) + .filter((l) => (l.ms ? l.ms.peak >= COST_HINT_MS : l.peak >= COST_HINT) && seen[normalizePath(l.file)] === files[normalizePath(l.file)]) + .map((l) => l.ms ? { + file: l.file, line: l.line, + label: t("{ms} ms a frame", { ms: shortMs(l.ms.peak) }), + note: t("In the game, this line took {ms} ms of Remastered's time in one frame at the most ({n} conditions and actions): frame {frame} of the test “{test}”, which it ran in for {frames, plural, one {# frame} other {# frames}}. On a fast computer Remastered ran {smooth} ms of triggers a frame with no flicker seen, and slows past {most} ms; a slower computer gets there sooner. This is {share} of the {smooth} ms.", { ms: shortMs(l.ms.peak), n: l.peak.toLocaleString(), frame: l.ms.peakFrame, test: testName({ id: l.test }), frames: l.frames, smooth: SMOOTH_MS, most: Math.round(MOST_MS), share: shareOfMs(l.ms.peak) }), + } : { + file: l.file, line: l.line, + label: t("{n} a frame", { n: shortCount(l.peak) }), + note: t("In the game, this line ran {n} conditions and actions in one frame at the most: frame {frame} of the test “{test}”, which it ran in for {frames, plural, one {# frame} other {# frames}}. On a fast computer Remastered ran 500 thousand a frame with no visible effect; its screen flickers from a million and the game slows at 5 million. This is {share} of the 500 thousand.", { n: l.peak.toLocaleString(), frame: l.peakFrame, test: testName({ id: l.test }), frames: l.frames, share: shareOf(l.peak) }), + }); + }; + + /** The lines of the last EUD error looked up, while their file is the text that was traced. */ + const eudLines = () => { + if (!eudFound) return []; + const seen = eudFound.files; + return eudFound.answer.lines.filter((l) => seen[normalizePath(l.file)] === files[normalizePath(l.file)]); + }; + /** What a line did to the address, and what Remastered does with that, in a sentence. */ + const eudLineNote = (a: EudErrorAnswer, l: EudErrorAnswer["lines"][number]) => { + const did = l.read && l.write ? t("reads and writes") : l.write ? t("writes") : t("reads"); + const verdict = l.write ? a.write : a.read; + const said = verdict === "ends" ? t("Remastered ends the game on this.") : verdict === "ignored" ? t("Remastered does nothing with this.") : verdict === "allowed" ? t("Remastered was seen to allow this, so the game that ended did something else to it.") : t("This has not been seen in Remastered."); + return l.match === "exact" + ? t("In the game, this line {did} {place} ({times, plural, one {# time} other {# times}}). {said}", { did, place: a.place, times: l.times, said }) + : t("In the game, this line {did} the same field of another unit ({times, plural, one {# time} other {# times}}): a unit sits elsewhere in the table from one game to the next. {said}", { did, times: l.times, said }); + }; + const eudHints = (): LineHint[] => { + const a = eudFound?.answer; + return a ? eudLines().map((l) => ({ file: l.file, line: l.line, label: `EUD ERROR ${a.code}`, note: eudLineNote(a, l) })) : []; + }; + + /** + * An "EUD ERROR: 0x…" Remastered showed, back to the lines of the script (TrigScript 4): the code is the address + * the trigger named, bits inverted (R0). The map is built as Save builds it and played in the lifted game with + * nobody at it, then the tests run, every EUD access written down under its line; the lines that touched the + * address are marked, and what lies there is said. + */ + const lookUpEudError = async () => { + if (!editor || testsRunning || building || testing || !ready) return; + const answer = await api.ui.prompt(t("The code Remastered showed when the game ended (EUD ERROR: 0x…):"), { title: "TrigScript", value: "", placeholder: "EUD ERROR: 0xFFA632E3" }); + if (answer === null || cancelled) return; + const address = addressOfEudError(answer); + if (address === null) { setStatus("error", t("“{text}” is not an EUD error code: Remastered shows one as EUD ERROR: 0x and eight digits.", { text: answer.trim() })); return; } + testsRunning = true; + renderTesting(); + try { + const got = await prepareInGame({}, "trace"); + if (!got || cancelled) return; + const { engine, a, names, variables, triggers, module, bytes, built } = got; + if (!triggers) { setStatus("error", t("Not looked up: the build did not say where its triggers are. Save once more.")); return; } + setStatus("busy", t("Playing the map in the game, writing down what each line reads and writes…")); + allowlist ??= fetch(allowlistUrl(svc.dist())).then((r) => { if (!r.ok) throw new Error(`allowlist.json: ${r.status}`); return r.json() as Promise; }); + const [report, list] = await Promise.all([ + engine.test!(bytes, module, { modules: a.compiled.modules ?? [], names, variables, triggers, trace: true, play: EUD_TRACE_FRAMES }) as Promise, + allowlist.catch(() => { allowlist = null; return undefined; }), + ]); + if (cancelled) return; + if (report.error) { setStatus("error", t("Not looked up in the game: {message}", { message: report.error })); return; } + if (!report.trace) { setStatus("error", t("Not looked up in the game: {message}", { message: report.traceError ?? t("the engine's page sent back no trace") })); return; } + const found = findEudError(address, report.trace.rows, list); + eudFound = { files: built, answer: found }; + log(t("EUD ERROR {code}: {place}, at 0x{address}.", { code: found.code, place: found.place, address: found.address.toString(16) })); + for (const l of found.lines) log(` ${l.file}:${l.line} — ${eudLineNote(found, l)}`, false); + const ran = report.results.filter((r) => r.status !== "skipped").length; + const where = found.lines.slice(0, 3).map((l) => `${l.file}:${l.line}`).join(", ") + (found.lines.length > 3 ? "…" : ""); + const exact = found.lines.some((l) => l.match === "exact"); + const head = t("EUD ERROR {code} is {place}.", { code: found.code, place: found.place }); + const said = found.lines.length + ? exact + ? t("{n, plural, one {# line touched it} other {# lines touched it}} in the game: {where}.", { n: found.lines.length, where }) + : t("No line touched that very unit, but {n, plural, one {# line touches} other {# lines touch}} the same field of other units, and a unit sits elsewhere in the table from one game to the next: {where}.", { n: found.lines.length, where }) + : found.unlined.exact + found.unlined.field + ? t("No line of the script touched it: the build's own triggers did ({n, plural, one {# time} other {# times}}).", { n: found.unlined.exact + found.unlined.field }) + : ran + ? t("Nothing touched it in {frames} frames of the map played with nobody at it, nor in {tests, plural, one {# test} other {# tests}}: the line that did is one this run did not reach. A test that gets there will find it.", { frames: EUD_TRACE_FRAMES.toLocaleString(), tests: ran }) + : t("Nothing touched it in {frames} frames of the map played with nobody at it: the line that did is one this run did not reach. A test that gets there will find it.", { frames: EUD_TRACE_FRAMES.toLocaleString() }); + setStatus(found.lines.length ? "error" : "info", `${head} ${said}`); + const first = found.lines[0]; + if (first) goTo(first.file, first.line); + refreshLineHints(); + } catch (err) { + setStatus("error", t("Not looked up in the game: {message}", { message: (err as Error).message })); + } finally { + testsRunning = false; + render(); + } + }; + + /** "0.2%", "31%": a count as a share of what Remastered was seen to run in a frame. */ + const shareOf = (n: number) => `${(n / COST_SEEN * 100).toFixed(n < COST_SEEN / 10 ? 1 : 0)}%`; + /** The same for milliseconds, of SMOOTH_MS. */ + const shareOfMs = (ms: number) => `${(ms / SMOOTH_MS * 100).toFixed(ms < SMOOTH_MS / 10 ? 1 : 0)}%`; + + /** The test's frames in the game as a small graph, in milliseconds where the host weighs; the map's other triggers are not in it. */ + const costGraph = (cost: TestCost): Element => { + const series = cost.ms?.perFrame ?? cost.perFrame, peak = cost.ms?.peak ?? cost.peak; + const NS = "http://www.w3.org/2000/svg", n = Math.max(series.length, 2), top = cost.ms ? Math.max(peak, 0.001) : Math.max(peak, 1); + const svg = document.createElementNS(NS, "svg"); + svg.setAttribute("class", "tsd-cost-graph"); + svg.setAttribute("viewBox", `0 0 ${n - 1} 100`); + svg.setAttribute("preserveAspectRatio", "none"); + const points = series.map((v, i) => `${i},${(100 - (v / top) * 96).toFixed(2)}`); + const area = document.createElementNS(NS, "path"); + area.setAttribute("d", `M0,100 L${points.join(" L")} L${series.length - 1},100 Z`); + svg.append(area); + const title = document.createElementNS(NS, "title"); + title.textContent = cost.ms + ? t("Remastered's time a frame, frame {from} to {to}: {ms} ms at the most, at frame {frame}", { from: cost.from, to: cost.from + series.length - 1, ms: shortMs(cost.ms.peak), frame: cost.ms.peakFrame }) + : t("Conditions and actions a frame, frame {from} to {to}: {peak} at the most, at frame {frame}", { from: cost.from, to: cost.from + cost.perFrame.length - 1, peak: cost.peak.toLocaleString(), frame: cost.peakFrame }); + svg.append(title); + return svg; + }; + /** Test Results: the chosen test's message, what it printed, and what happened by frame, each a link to its line. */ const renderResults = () => { const failed = tests.list.filter((x) => x.kind === "test" && tests.results.get(x.id)?.status === "failed"); @@ -728,6 +1123,40 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa if (r.expected !== undefined) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, t("expected")), el("span", { className: "msg" }, r.expected))); if (r.actual !== undefined) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, t("got")), el("span", { className: "msg" }, r.actual))); } + const game = gameResults.get(info.id); + if (game && game.status !== "skipped") { + const gat = game.at ?? { file: info.file, line: info.line }; + list.append(el("li", { className: game.status === "failed" ? "tsd-fault" : "tsd-plain", title: t("Go to where it failed in the game"), onClick: () => goTo(gat.file, gat.line, gat.column ?? 1) }, + el("span", { className: "frame" }, t("in the game")), + el("span", { className: "msg" }, game.status === "failed" ? game.message ?? t("failed") : t("passed")), + el("span", { className: "where" }, game.status === "failed" ? where({ file: gat.file, line: gat.line }) : t("{frames, plural, one {# frame} other {# frames}} · {ms} ms", { frames: game.frames, ms: game.ms })))); + if (game.status === "failed" && game.actual !== undefined) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, t("got")), el("span", { className: "msg" }, game.actual))); + // (What its frames cost in the game, and the lines that cost the most.) + const cost = game.cost; + if (cost) { + const ran = cost.perFrame.filter((x) => x > 0), average = ran.length ? Math.round(ran.reduce((a, b) => a + b, 0) / ran.length) : 0; + const other = cost.other.slice(3), runtime = other.length ? Math.round(other.reduce((a, b) => a + b, 0) / other.length) : 0; + if (cost.ms) { + const ranMs = cost.ms.perFrame.filter((x) => x > 0), averageMs = ranMs.length ? ranMs.reduce((a, b) => a + b, 0) / ranMs.length : 0; + const otherMs = cost.ms.other.slice(3), runtimeMs = otherMs.length ? otherMs.reduce((a, b) => a + b, 0) / otherMs.length : 0; + list.append(el("li", { className: "tsd-plain", title: t("Counted in the lifted game and weighed by what Remastered was measured spending on each trigger, condition and action on a fast computer (Ryzen 9 7900X): the script's own triggers and the variables they go through, {average} conditions and actions a frame on average and {peak} at the most. The map's other triggers are apart: {ms} ms a frame. Remastered slows past {most} ms of triggers a frame there, and a slower computer sooner.", { average: average.toLocaleString(), peak: cost.peak.toLocaleString(), ms: shortMs(runtimeMs), most: Math.round(MOST_MS) }) }, + el("span", { className: "frame" }, t("cost")), + el("span", { className: "msg" }, t("{average} ms a frame on average, {peak} ms at the most (frame {frame}): {share} of the {smooth} ms of triggers a frame Remastered ran with no flicker seen", { average: shortMs(averageMs), peak: shortMs(cost.ms.peak), frame: cost.ms.peakFrame, share: shareOfMs(cost.ms.peak), smooth: SMOOTH_MS })))); + } else { + list.append(el("li", { className: "tsd-plain", title: t("Counted in the lifted game: the script's own triggers and the variables they go through. The map's other triggers are apart: {n} a frame.", { n: runtime.toLocaleString() }) }, + el("span", { className: "frame" }, t("cost")), + el("span", { className: "msg" }, t("{average} conditions and actions a frame on average, {peak} at the most (frame {frame}): {share} of the 500 thousand a frame Remastered has run with no visible effect", { average: average.toLocaleString(), peak: cost.peak.toLocaleString(), frame: cost.peakFrame, share: shareOf(cost.peak) })))); + } + if (cost.perFrame.length > 1) list.append(el("li", { className: "tsd-plain tsd-cost" }, el("span", { className: "frame" }), costGraph(cost))); + for (const l of cost.lines.slice(0, COST_LINES)) { + list.append(el("li", { title: t("Go to the line"), onClick: () => goTo(l.file, l.line) }, + el("span", { className: "frame" }), el("span", { className: "msg note" }, l.ms + ? t("{ms} ms a frame at the most (frame {frame}), in {frames, plural, one {# frame} other {# frames}}", { ms: shortMs(l.ms.peak), frame: l.ms.peakFrame, frames: l.frames }) + : t("{peak} a frame at the most (frame {frame}), in {frames, plural, one {# frame} other {# frames}}", { peak: l.peak.toLocaleString(), frame: l.peakFrame, frames: l.frames })), + el("span", { className: "where" }, where({ file: l.file, line: l.line })))); + } + } + } // The same line several times over — to each of eight players, say — is one row that says how often. const printed: { line: string; times: number }[] = []; for (const line of r.printed) { const last = printed[printed.length - 1]; if (last?.line === line) last.times++; else printed.push({ line, times: 1 }); } @@ -898,22 +1327,18 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa title: t("{what}. Click for its log", { what: editedSince ? t("The script changed since this build: the programs in the saved map are the older ones until the next Save") : buildState.title ?? t("The last build of the programs") }), onClick: () => shell.showPanel("output"), } : null); - // The library matters only to a script with programs: it is what builds them into the saved map. - const needsLibrary = programs > 0; - const libraryOk = !!library?.contribute && library.state() !== "failed"; - libraryItem.set(!needsLibrary ? null : { - kind: libraryOk ? undefined : "warn", - icon: libraryOk ? undefined : "warning", - text: !library - ? t("eudplib plugin not running") - : !library.contribute - ? t("eudplib plugin older than 0.4") - : `eudplib ${library.versions.eudplib} · ${library.state() === "ready" ? t("runtime ready") : library.state() === "installing" ? t("runtime downloading…") : library.state() === "failed" ? t("runtime failed") : t("runtime downloads on the first save")}`, - title: !library - ? t("The programs will not be built: install or turn on the eudplib plugin under Plugins ▸ Manage Plugins…") - : !library.contribute - ? t("Update the eudplib plugin to build the programs") - : t("The eudplib plugin builds the programs into the map when it is saved or tested; its runtime is downloaded once, the first time"), + // Who builds the programs into the saved map: TrigScript itself — unless Magenta's triggers are in the map, which + // its build cannot share yet. + const whyNotBuilt = programs > 0 ? svc.whyNotBuilt() : null; + libraryItem.set(programs === 0 ? null : !whyNotBuilt ? { + icon: "package", + text: t("built by TrigScript"), + title: t("The programs are laid out into the map by TrigScript itself when it is saved or tested: nothing to install, and the map is built in under a second"), + } : { + kind: "warn", + icon: "warning", + text: t("not built: Magenta's triggers"), + title: whyNotBuilt, }); programsItem.set(programs ? { text: t("{n, plural, one {# program} other {# programs}} · Remastered", { n: programs }), title: t("Programs are built into the saved map, which then needs StarCraft: Remastered. Click for the programs and their variables"), onClick: () => { shell.toggleSidebar(true); programsSection.expand(); } } : null); @@ -937,7 +1362,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa // All of them took long: from now on only the open file's run by themselves, and the rest wait for Run All. if (r.ok && r.tests && asked && !asked.files && !asked.ids) testsSlow = r.tests.ms > TESTS_SLOW_MS; if (chosenTest && !tests.results.has(chosenTest)) chosenTest = null; - if (editor && monaco) { setCompilerMarkers(monaco, files, diagnostics, warningsOf(tests, t)); editor.setTests(marksOf(tests, t), notesOf(tests, t)); editor.decorate(r.buildTime); refreshLineHints(); } + if (editor && monaco) { setCompilerMarkers(monaco, files, diagnostics, allWarnings()); editor.setTests(marksOf(tests, t), notesOf(tests, t)); editor.decorate(r.buildTime); refreshLineHints(); } render(); }; @@ -979,12 +1404,12 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa * A keystroke during the compile supersedes it with the newer text's check; the newer * text is what the user wants built, so it is compiled again — a few times at most. */ - const compileNow = async (asked: TestRunOptions = automaticTests()): Promise => { + const compileNow = async (asked: TestRunOptions = automaticTests(), sounds = false): Promise => { if (timer !== null) { clearTimeout(timer); timer = null; } for (let attempt = 0; attempt < 3; attempt++) { if (cancelled || !generated) return null; try { - const a = await svc.prepare(files, generated, asked); + const a = await svc.prepare(files, generated, asked, sounds); applyResult(a.compiled, asked); return a; } catch (err) { @@ -1016,7 +1441,8 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa setStatus("busy", t("Running the script…")); try { for (let attempt = 0; ; attempt++) { - const a = await compileNow(); + // (A build: the map gets its sounds, the long ones encoded now.) + const a = await compileNow(automaticTests(), true); if (!a || cancelled) return false; if (!a.compiled.ok || diagnostics.length) { const n = diagnostics.length || a.compiled.diagnostics.length; @@ -1063,7 +1489,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa if (building || testing || !ready) return; testing = true; render(); - // The library's events say how the build of the programs went; the export itself falls back to the plain map without a word. + // The build's events say how the build of the programs went; the export itself falls back to the plain map without a word. let failure: string | null = null; const heard = svc.onBuild((e) => { if (e.kind === "failed") failure = e.from && e.from !== "trigscript" ? `${e.from}: ${e.message}` : e.message; }); try { @@ -1072,7 +1498,6 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const file = await api.document.export({ format: "scx" }); if (!file) { setStatus("error", t("No map is open.")); return; } if (failure) { setStatus("error", t("Not tested: {message}", { message: failure })); return; } - if (result?.programs.length && !library?.contribute) { setStatus("error", t("Not tested: the programs need the eudplib plugin (0.4 or newer) to be built. Install or turn it on under Plugins ▸ Manage Plugins….")); return; } const bytes = new Uint8Array(await file.arrayBuffer()); const outcome = await api.document.test(bytes, file.name); const kb = Math.round(bytes.length / 1024); @@ -1093,8 +1518,14 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa }; /** A build of the programs, whoever started it (Save, Test Map, Test): its state in the status bar, its log in Output, a failure on its line. */ - const onLibraryBuild = (e: EudplibBuildEvent) => { - if (cancelled || (e.kind !== "log" && !e.contributors.includes("trigscript"))) return; + const onOwnBuild = (e: OwnBuildEvent) => { + if (cancelled || (e.kind !== "log" && e.kind !== "warning" && !e.contributors.includes("trigscript"))) return; + if (e.kind === "warning") { + // TrigScript's own build has a word for the map maker: a trigger of the map's own left out, or one that never runs. + log(t("Build warning: {message}", { message: e.message }), false); + shell.notify({ key: `build-warning:${e.message}`, kind: "warn", text: e.message, actions: [{ label: t("Show the log"), run: () => shell.showPanel("output") }] }); + return; + } if (e.kind === "start") { streamed = 0; shell.dismiss("build"); @@ -1105,14 +1536,16 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa if (failing) log(t("{n, plural, one {A test fails} other {# tests fail}}: see the Testing view.", { n: failing }), false); } else if (e.kind === "log") { streamed++; - log(e.line, false); + // (What the lowering says for a run in the lifted game is not for a person to read.) + if (!isMachineLine(e.line)) log(e.line, false); } else if (e.kind === "done") { - if (!streamed && e.log) log(e.log, false); - const kb = Math.round(e.chkBytes / 1024), seconds = (e.ms / 1000).toFixed(1); - buildState = { kind: "ok", text: t("Built {kb} KB · {seconds} s", { kb, seconds }), of: buildState?.of ?? files }; - log(t("Built: {kb} KB of scenario in {seconds} s", { kb, seconds })); + if (!streamed && e.log) log(withoutMachineLines(e.log), false); + // (A build takes tens of milliseconds, a large one seconds: said in the unit that reads.) + const kb = Math.round(e.chkBytes / 1024), took = e.ms < 1000 ? t("{ms} ms", { ms: e.ms }) : t("{seconds} s", { seconds: (e.ms / 1000).toFixed(1) }); + buildState = { kind: "ok", text: t("Built {kb} KB · {took}", { kb, took }), of: buildState?.of ?? files }; + log(t("Built: {kb} KB of scenario in {took}", { kb, took })); } else { - if (!streamed && e.log) log(e.log, false); + if (!streamed && e.log) log(withoutMachineLines(e.log), false); // Save goes on without the step: the file is the map and its script, with no programs in it. const saved = e.purpose === "save"; buildState = { @@ -1287,13 +1720,13 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa /* ── Files ── */ /** A file's name asked for until it can be one. Inside `folder` the answer is a name in it (`boss.ts`, or `big/boss.ts` for a folder under it). */ - const askName = async (message: string, value: string, folder = ""): Promise => { + const askName = async (message: string, value: string, folder = "", ext = ".ts"): Promise => { for (;;) { const answer = await api.ui.prompt(message, { title: "TrigScript", value, placeholder: "helpers.ts" }); if (answer === null) return null; let name = normalizePath(answer.trim()); if (name && folder) name = `${folder}/${name}`; - if (name && !/\.ts$/i.test(name)) name += ".ts"; + if (name && !/\.(?:tsx?|video|image|sound|notes)$/i.test(name)) name += ext; if (!FILE_NAME.test(name) || name.split("/").some((p) => p === "." || p === "..")) { value = answer; message = t("A file name is letters, digits, _ - and ., folders with /, ending in .ts."); continue; } if (files[name] !== undefined) { value = answer; message = t("There is already a {name}.", { name }); continue; } return name; @@ -1312,6 +1745,29 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa openFile(name); }; + /** + * Import Video…: a video becomes a .video file of the script, a sound a .sound file, a picture an .image file or a .ts file of its rows + * (`videoImport.ts`). Rows open in a tab; a video or an image says how a script imports it. + */ + const importVideo = async (file?: File) => { + if (!editor) return; + const info = api.document.info(); + const got = await importMedia(api, t, { mapWidth: info?.width ?? 128, mapHeight: info?.height ?? 128, taken: (p) => files[normalizePath(p)] !== undefined, file }); + if (!got || !editor || cancelled) return; + editor.add(got.path, got.text); + if (got.kind === "rows") { openFile(got.path); return; } + renderFiles(); + const stem = (got.path.split("/").pop() ?? got.path).replace(/\.(?:video|image|sound|notes)$/i, ""); + const name = stem.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c: string | undefined) => (c ? c.toUpperCase() : "")).replace(/^[^A-Za-z_]+/, "") || got.kind; + setStatus("ok", got.kind === "notes" + ? t("{path} is in the script: import {name} from \"./{path}\"; then music.midi({name}, { … }) makes a song of its tracks.", { path: got.path, name }) + : got.kind === "sound" + ? t("{path} is in the script: import {name} from \"./{path}\"; then playWav({name}) plays it inside a program.", { path: got.path, name }) + : got.kind === "image" + ? t("{path} is in the script: import {name} from \"./{path}\"; then fog.picture({name}.threshold(128)) makes a picture of it.", { path: got.path, name }) + : t("{path} is in the script: import {name} from \"./{path}\"; then await fog.play(P1, {name}) plays it.", { path: got.path, name })); + }; + /** An archive has no empty folder, so a folder is there while a file is in it: the folder's name, then its first file's. */ const newFolder = async (parent = "") => { if (!editor) return; @@ -1362,7 +1818,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const renameFile = async (path: string) => { if (!editor || path === ENTRY_FILE) return; - const name = await askName(t("Rename {path} to (a folder before the name moves it):", { path }), path); + const name = await askName(t("Rename {path} to (a folder before the name moves it):", { path }), path, "", isMediaFile(path) ? (isImageFile(path) ? ".image" : isSoundFile(path) ? ".sound" : isNotesFile(path) ? ".notes" : ".video") : ".ts"); if (!name) return; moveTo(path, name); }; @@ -1435,12 +1891,25 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa /* ── Commands ── */ /** Everything the workspace does, once: the keys, the More menu and Monaco's command palette are all read from here. */ - interface Command { id: string; label: string; key?: { code: string; mod?: boolean; shift?: boolean }; context?: boolean; run(): void } + interface Command { id: string; label: string; key?: { code: string; mod?: boolean; shift?: boolean }; context?: boolean; run(): void; when?: () => boolean } + /** While a test is debugged, its keys come before the others (F5 continues rather than plays). */ + const debugging = () => debug.active; const commands: Command[] = [ + { id: "debugContinue", label: t("Debug: Continue"), key: { code: "F5" }, when: debugging, run: () => debug.continueForward() }, + { id: "debugContinueBack", label: t("Debug: Continue Backwards"), key: { code: "F6" }, when: debugging, run: () => debug.continueBack() }, + { id: "debugStep", label: t("Debug: On a Line"), key: { code: "F10" }, when: debugging, run: () => debug.stepForward() }, + { id: "debugStepBack", label: t("Debug: Back a Line"), key: { code: "F10", shift: true }, when: debugging, run: () => debug.stepBack() }, + { id: "debugFrame", label: t("Debug: On a Frame"), key: { code: "F11" }, when: debugging, run: () => debug.nextFrame() }, + { id: "debugFrameBack", label: t("Debug: Back a Frame"), key: { code: "F11", shift: true }, when: debugging, run: () => debug.prevFrame() }, + { id: "debugStop", label: t("Debug: Stop"), key: { code: "F5", shift: true }, when: debugging, run: () => debug.end() }, + { id: "debugTestAtCursor", label: t("Debug Test at Cursor in the Game"), context: true, run: () => debugTestAtCursor() }, + { id: "toggleBreakpoint", label: t("Toggle Breakpoint"), key: { code: "F9" }, run: () => { const at = editor?.cursor(); if (at) debug.toggleBreakpoint(normalizePath(at.file), at.line); } }, // The editor's own Ctrl+S does not reach a map under a dialog, and a browser would offer to save the page. { id: "save", label: t("Save the Map"), key: { code: "KeyS", mod: true }, run: () => { void api.document.save(); } }, { id: "test", label: t("Play the Map"), key: { code: "F5" }, run: () => { void test(); } }, { id: "runTests", label: t("Run All Tests"), run: () => { void runTests(); } }, + { id: "runTestsInGame", label: t("Run All Tests in the Game"), run: () => { void runTestsInGame(); } }, + { id: "findEudError", label: t("Find an EUD Error…"), run: () => { void lookUpEudError(); } }, { id: "runTestAtCursor", label: t("Run Test at Cursor"), context: true, run: () => { void runTestAtCursor(); } }, { id: "runFailedTests", label: t("Run Failed Tests"), run: () => { void runFailedTests(); } }, { id: "testing", label: t("Show Testing"), run: () => testingView.show() }, @@ -1450,6 +1919,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa { id: "import", label: t("Import the Map's Triggers"), run: () => { void importHand(); } }, { id: "newFile", label: t("New File…"), run: () => { void newFile(); } }, { id: "newFolder", label: t("New Folder…"), run: () => { void newFolder(); } }, + { id: "importVideo", label: t("Import Video…"), run: () => { void importVideo(); } }, { id: "mode", label: mode === "dialog" ? t("Open Beside the Map") : t("Open in a Window"), run: () => switchMode() }, { id: "problems", label: t("Show Problems"), key: { code: "KeyM", mod: true, shift: true }, run: () => shell.togglePanel("problems") }, { id: "output", label: t("Show Output"), key: { code: "KeyU", mod: true, shift: true }, run: () => shell.togglePanel("output") }, @@ -1475,7 +1945,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa // F1 is the editor's list of shortcuts outside the workspace; inside it, it is the palette, as it is in VS Code. const c = (mod && e.shiftKey && e.code === "KeyP") || (!mod && !e.shiftKey && e.code === "F1") ? { run: palette } - : commands.find((x) => x.key && x.key.code === e.code && !!x.key.mod === mod && !!x.key.shift === e.shiftKey); + : commands.find((x) => x.key && x.key.code === e.code && !!x.key.mod === mod && !!x.key.shift === e.shiftKey && (!x.when || x.when())); if (!c) return; e.preventDefault(); e.stopPropagation(); @@ -1490,8 +1960,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa // Checks run on every keystroke (debounced), so the compile worker stays up while the editor is open. const releaseWorker = retainCompileWorker(); const subs = [ - svc.watchLibrary((s) => { library = s; if (!cancelled) render(); }), - svc.onBuild(onLibraryBuild), + svc.onBuild(onOwnBuild), api.events.on("settings", refreshNames), api.events.on("locations", refreshNames), api.events.on("triggers", refreshNames), @@ -1504,7 +1973,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa monaco = m; if (generated) setDeclarations(m, generated.decls); setHoverVariables(m, () => result?.variables ?? []); - setLineHints(m, () => result?.hints ?? []); + setLineHints(m, () => [...(result?.hints ?? []), ...costHints(), ...eudHints(), ...debug.hints()]); setMapRefs(m, mapRefs); // Uncover first: `done` puts the host back in its own place, and Monaco measures it where it lands. loadingCover.done(); @@ -1513,14 +1982,18 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa editor = createScriptEditor(m, hostEl, files, options.file ?? ENTRY_FILE, (path, text) => { files = { ...files, [path]: text }; svc.writeFiles(files); + debug.setFiles(files); check(); - }, (id) => { void runTests({ ids: [id] }); }); + }, (id) => { void runTests({ ids: [id] }); }, (file, line) => debug.toggleBreakpoint(file, line)); + debug.setFiles(files); + editor.setDebug(debug.marks()); const code = editor.editor; for (const c of commands) { code.addAction({ id: `trigscript.${c.id}`, label: `TrigScript: ${c.label}`, - keybindings: c.key ? [(c.key.mod ? m.KeyMod.CtrlCmd : 0) | (c.key.shift ? m.KeyMod.Shift : 0) | m.KeyCode[c.key.code as keyof typeof m.KeyCode]] : undefined, + // (A key that only means something while debugging is the workspace's to hear, not Monaco's.) + keybindings: c.key && !c.when ? [(c.key.mod ? m.KeyMod.CtrlCmd : 0) | (c.key.shift ? m.KeyMod.Shift : 0) | m.KeyCode[c.key.code as keyof typeof m.KeyCode]] : undefined, ...(c.context ? { contextMenuGroupId: "navigation", contextMenuOrder: 9 } : {}), run: () => c.run(), }); @@ -1556,6 +2029,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa if (timer !== null) clearTimeout(timer); if (statusTimer !== null) clearTimeout(statusTimer); root.removeEventListener("keydown", onKey, true); + debug.end(); shell.dispose(); editor?.dispose(); editor = null; diff --git a/ko.ts b/ko.ts index 3cefbd0..fe9ec95 100644 --- a/ko.ts +++ b/ko.ts @@ -23,6 +23,7 @@ export const KO: Record = { "Beside the map: open the script as a panel, so the map stays in reach": "맵 옆에: 스크립트를 패널로 열어 맵을 계속 다룰 수 있게 합니다", "Build failed": "빌드 실패", "Build failed: {message}": "빌드 실패: {message}", + "Build warning: {message}": "빌드 경고: {message}", "Building for Save…": "저장용으로 빌드 중…", "Building for Test Map…": "맵 테스트용으로 빌드 중…", "Building for an export…": "내보내기용으로 빌드 중…", @@ -30,8 +31,9 @@ export const KO: Record = { "Building the programs for Save": "저장용으로 프로그램 빌드", "Building the programs for Test Map": "맵 테스트용으로 프로그램 빌드", "Building the programs for an export": "내보내기용으로 프로그램 빌드", - "Built {kb} KB · {seconds} s": "빌드됨 {kb} KB · {seconds}초", - "Built: {kb} KB of scenario in {seconds} s": "빌드됨: 시나리오 {kb} KB, {seconds}초", + "Built {kb} KB · {took}": "빌드됨 {kb} KB · {took}", + "Built: {kb} KB of scenario in {took}": "빌드됨: 시나리오 {kb} KB, {took}", + "{seconds} s": "{seconds}초", "Changes not applied": "변경 사항 적용 안 됨", "Checking…": "확인하는 중…", "Clear the output": "출력 지우기", @@ -88,7 +90,6 @@ export const KO: Record = { "Not applied: {n, plural, one {# problem} other {# problems}} in the script.": "적용되지 않음: 스크립트에 문제가 {n}개 있습니다.", "Not run yet": "아직 실행 안 됨", "Not simulated: {n, plural, one {# problem} other {# problems}} in the script.": "시뮬레이션하지 않음: 스크립트에 문제가 {n}개 있습니다.", - "Not tested: the programs need the eudplib plugin (0.4 or newer) to be built. Install or turn it on under Plugins ▸ Manage Plugins….": "테스트하지 않음: 프로그램을 빌드하려면 eudplib 플러그인(0.4 이상)이 필요합니다. 플러그인 ▸ 플러그인 관리…에서 설치하거나 켜세요.", "Not tested: {message}": "테스트하지 않음: {message}", "Open Beside the Map": "맵 옆에 열기", "Open Settings": "설정 열기", @@ -148,7 +149,6 @@ export const KO: Record = { "That location is not in the script's tables yet; try again after the map's names refresh.": "그 로케이션은 아직 스크립트의 표에 없습니다. 맵의 이름이 갱신된 뒤 다시 해 보세요.", "That unit's type has no name in the script's tables.": "그 유닛의 종류는 스크립트의 표에 이름이 없습니다.", "The editor failed to load: {message}": "편집기를 불러오지 못했습니다: {message}", - "The eudplib plugin builds the programs into the map when it is saved or tested; its runtime is downloaded once, the first time": "eudplib 플러그인은 맵을 저장하거나 테스트할 때 프로그램을 맵에 빌드합니다. 런타임은 처음 한 번만 내려받습니다", "The last build of the programs": "프로그램의 마지막 빌드", "The map changed while the script ran; running it again…": "스크립트가 실행되는 동안 맵이 바뀌었습니다. 다시 실행하는 중…", "The map changed while the script was compiling; save again.": "스크립트를 컴파일하는 동안 맵이 바뀌었습니다. 다시 저장하세요.", @@ -160,7 +160,6 @@ export const KO: Record = { "The next Apply leaves them all as hand-made triggers and appends a fresh block.": "다음 적용은 모두 직접 만든 트리거로 남겨 두고 새 블록을 뒤에 추가합니다.", "The next Apply replaces the {unchanged} and keeps the {changed, plural, one {# edited one} other {# edited ones}} as hand-made triggers right after the new block.": "다음 적용은 {unchanged}개를 바꾸고, 편집된 {changed}개는 새 블록 바로 뒤에 직접 만든 트리거로 남깁니다.", "The programs were not built: {message}": "프로그램이 빌드되지 않았습니다: {message}", - "The programs will not be built: install or turn on the eudplib plugin under Plugins ▸ Manage Plugins…": "프로그램이 빌드되지 않습니다. 플러그인 ▸ 플러그인 관리…에서 eudplib 플러그인을 설치하거나 켜세요", "The script changed since it was applied: saving or testing the map applies the script by itself. Click to apply it now ({mod}+Shift+B)": "적용한 뒤 스크립트가 바뀌었습니다. 맵을 저장하거나 테스트하면 스크립트가 저절로 적용됩니다. 클릭하면 지금 적용합니다 ({mod}+Shift+B)", "The script changed since this build: the programs in the saved map are the older ones until the next Save": "이 빌드 뒤에 스크립트가 바뀌었습니다. 다음에 저장할 때까지 저장된 맵의 프로그램은 이전 것입니다", "The script has no tests yet. A test is a test(name, (sim) => { … }) imported from \"trigscript\", in any file or in one named *.test.ts: it runs here, in the simulator, after every change that compiles.": "스크립트에 아직 테스트가 없습니다. 테스트는 \"trigscript\"에서 가져온 test(name, (sim) => { … })로, 아무 파일이나 *.test.ts라는 이름의 파일에 씁니다. 컴파일되는 변경이 있을 때마다 여기 시뮬레이터에서 실행됩니다.", @@ -193,7 +192,6 @@ export const KO: Record = { "Triggers edited outside the script": "스크립트 밖에서 편집된 트리거", "Undo": "실행 취소", "Update references": "참조 업데이트", - "Update the eudplib plugin to build the programs": "프로그램을 빌드하려면 eudplib 플러그인을 업데이트하세요", "Updated {n, plural, one {# reference} other {# references}}.": "참조 {n}개를 업데이트했습니다.", "Views": "보기", "What Apply, Play and the builds of the programs report is kept here.": "적용, 플레이, 프로그램 빌드가 알리는 내용이 여기에 남습니다.", @@ -205,6 +203,7 @@ export const KO: Record = { "a number (−2 147 483 648 … 2 147 483 647, whole, wrapping at either end as `x | 0` does)": "숫자 (−2 147 483 648 … 2 147 483 647, 정수, `x | 0`처럼 양 끝에서 넘어가면 반대쪽으로 돌아감)", "a text — `length`, `s[i]` and `slice` count characters (code points), and a made text holds 1 023 bytes": "텍스트 — `length`, `s[i]`, `slice`는 문자(코드 포인트) 단위로 세며, 만든 텍스트는 1 023바이트까지 담습니다", "a u32 number (0 … 4 294 967 295, wrapping at either end as `x >>> 0` does)": "u32 숫자 (0 … 4 294 967 295, `x >>> 0`처럼 양 끝에서 넘어가면 반대쪽으로 돌아감)", + "a fixed number (−32 768 … 32 767.99998, in steps of 1/65 536): a number with a fraction": "fixed 숫자 (−32 768 … 32 767.99998, 1/65 536 단위): 소수가 있는 숫자", "a unit of the game, or none — checked before every use: once the unit is gone it reads 0 and takes no write": "게임의 유닛 또는 없음 — 쓸 때마다 확인합니다. 유닛이 사라지면 0으로 읽히고 쓰기는 무시됩니다", "a u{bits} number (0 … {max}, stopping at either end)": "u{bits} 숫자 (0 … {max}, 양 끝에서 멈춤)", "after": "종료 후", @@ -215,13 +214,11 @@ export const KO: Record = { "calls deep — no function of this script calls itself": "단계 — 이 스크립트에는 자기 자신을 호출하는 함수가 없습니다", "calls deep — {frame} cells a call here, {size} while the map is played": "단계 — 여기서는 호출당 {frame}셀, 맵을 플레이하는 동안 {size}", "calls deep — {frame} cells a call here, {size} while the map is played: more than the {max} a map may use, and it will not build": "단계 — 여기서는 호출당 {frame}셀, 맵을 플레이하는 동안 {size}: 맵이 쓸 수 있는 {max}보다 많아서 빌드되지 않습니다", - "cells — {size} of the built map": "셀 — 빌드된 맵의 {size}", + "cells — {size} while the map is played": "셀 — 맵을 플레이하는 동안 {size}", "click to run these tests": "클릭하면 이 테스트들을 실행합니다", "click to run this test": "클릭하면 이 테스트를 실행합니다", "compiler": "컴파일러", "edited since": "이후 편집됨", - "eudplib plugin not running": "eudplib 플러그인이 실행 중이 아님", - "eudplib plugin older than 0.4": "eudplib 플러그인이 0.4보다 오래됨", "expected": "예상", "failed": "실패", "frame {n}": "{n}프레임", @@ -229,10 +226,6 @@ export const KO: Record = { "per player": "플레이어별", "printed": "출력", "program {n}": "프로그램 {n}", - "runtime downloading…": "런타임 내려받는 중…", - "runtime downloads on the first save": "첫 저장 때 런타임을 내려받음", - "runtime failed": "런타임 실패", - "runtime ready": "런타임 준비됨", "script": "스크립트", "shared": "공유", "skipped": "건너뜀", @@ -250,7 +243,7 @@ export const KO: Record = { "{from} is now {to}": "{from} → {to}", "{message} (and {n, plural, one {# more time} other {# more times}} at this line)": "{message} (이 줄에서 {n}번 더)", "{min} to {max} calls. The limit costs nothing until it is reached, but each call deep keeps and brings back every variable of its function, so thousands of calls within one frame make the game stutter. Kept in the map, like the pool above.": "{min}–{max}단계. 한도는 닿기 전까지는 비용이 없지만, 한 단계 깊어질 때마다 함수의 모든 변수를 보관했다가 되돌리므로 한 프레임 안에 수천 번 호출하면 게임이 끊깁니다. 위의 풀처럼 맵에 저장됩니다.", - "{min} to {max} cells, four bytes each. A larger pool does not slow the game and hardly grows the saved file; it takes more memory while the map is played. Kept in the map, so it builds the same on any computer.": "{min}–{max}셀, 셀마다 4바이트. 풀이 커도 게임이 느려지지 않고 저장 파일도 거의 커지지 않지만, 맵을 플레이하는 동안 메모리를 더 씁니다. 맵에 저장되므로 어느 컴퓨터에서나 똑같이 빌드됩니다.", + "{min} to {max} cells. Up to {most}, a cell is 76 bytes while the map is played: four for texts, and 72 for the arrays' own cells, which a loop reads and writes in a few steps. Past that, arrays share the texts' four bytes and a read takes about three times as long. A larger pool does not slow the game and hardly grows the saved file. Kept in the map, so it builds the same on any computer.": "{min}–{max}셀. {most}셀까지는 맵을 플레이하는 동안 셀마다 76바이트입니다. 텍스트에 4바이트, 배열 자신의 셀에 72바이트이며, 루프는 이 셀을 몇 단계 만에 읽고 씁니다. 그보다 크면 배열이 텍스트의 4바이트 셀을 함께 쓰고, 읽기가 세 배쯤 오래 걸립니다. 풀이 커도 게임이 느려지지 않고 저장 파일도 거의 커지지 않습니다. 맵에 저장되므로 어느 컴퓨터에서나 똑같이 빌드됩니다.", "{ms} ms": "{ms}ms", "{n, plural, one {# fault} other {# faults}}: an array read or written past its end, or out of memory — first in the list.": "오류 {n}개: 배열의 끝을 넘어 읽거나 썼거나 메모리가 부족합니다 — 목록 맨 위에 있습니다.", "{n, plural, one {# problem} other {# problems}}": "문제 {n}개", @@ -270,4 +263,209 @@ export const KO: Record = { "{n} skipped": "{n}개 건너뜀", "{what}. Click for its log": "{what}. 클릭하면 로그를 봅니다", "{what}; {n, plural, one {# import} other {# imports}} rewritten.": "{what}. import {n}개를 고쳐 썼습니다.", + "Run all tests in the game (the lifted game of the scm-sim plugin): each gets a second mark": "모든 테스트를 게임에서 실행합니다(scm-sim 플러그인의 옮겨 온 게임). 테스트마다 두 번째 표시가 붙습니다", + "In the game: {status}. The simulator and the game disagree: the game is right, so the simulator is wrong here (or the test leans on something it does differently).": "게임에서: {status}. 시뮬레이터와 게임의 결과가 다릅니다. 게임이 옳으므로 여기서는 시뮬레이터가 틀렸습니다(또는 테스트가 둘이 다르게 하는 일에 기대고 있습니다).", + "passed": "통과", + "In the game: {message}": "게임에서: {message}", + "In the game: passed": "게임에서: 통과", + "Not run: a test of several players runs a copy of the game for each, which needs memory shared between them. This browser does not allow that on the engine's page (Chrome and Edge from version 137 do).": "실행하지 않음: 여러 플레이어의 테스트는 플레이어마다 게임을 하나씩 실행하며, 이 게임들은 메모리를 공유해야 합니다. 이 브라우저는 엔진 페이지에서 이를 허용하지 않습니다(Chrome과 Edge는 137 버전부터 허용합니다).", + "Running a test of {n} players in the game: a copy of the game for each…": "{n}명의 플레이어 테스트를 게임에서 실행하는 중: 플레이어마다 게임을 하나씩…", + "{n, plural, one {# test of several players was} other {# tests of several players were}} not run: this browser cannot run copies of the game.": "여러 플레이어의 테스트 {n}개를 실행하지 않았습니다: 이 브라우저는 게임을 여러 개 실행할 수 없습니다.", + "In the game: not run": "게임에서: 실행하지 않음", + "Running tests in the game needs the scm-sim plugin (0.11 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….": "테스트를 게임에서 실행하려면 scmjs.dev에서 scm-sim 플러그인(0.11 이상)이 필요합니다. 플러그인 ▸ 플러그인 관리…에서 설치하거나 켜세요.", + "Not run in the game: {message}": "게임에서 실행하지 못함: {message}", + "the engine cannot run a game now": "엔진이 지금 게임을 실행할 수 없습니다", + "Not run in the game: the engine's page is from before tests in the game. Reload the editor and try again.": "게임에서 실행하지 못함: 엔진 페이지가 게임 속 테스트보다 오래된 판입니다. 편집기를 새로 고친 뒤 다시 시도하세요.", + "Tests run in the game only for a script with a program: without one, nothing of the script is built into the map.": "테스트는 프로그램이 있는 스크립트만 게임에서 실행됩니다. 프로그램이 없으면 스크립트의 어떤 것도 맵에 빌드되지 않습니다.", + "Running the tests in the game…": "게임에서 테스트를 실행하는 중…", + "In the game: {passed} of {n, plural, one {# test} other {# tests}} passed.": "게임에서: 테스트 {n}개 중 {passed}개 통과.", + "The simulator disagrees on {n, plural, one {# test} other {# tests}}.": "테스트 {n}개에서 시뮬레이터의 결과가 다릅니다.", + "Go to where it failed in the game": "게임에서 실패한 곳으로 이동", + "in the game": "게임에서", + "Run All Tests in the Game": "게임에서 모든 테스트 실행", + "{n} a frame": "프레임당 {n}", + "{ms} ms a frame": "프레임당 {ms}ms", + "In the game, this line took {ms} ms of Remastered's time in one frame at the most ({n} conditions and actions): frame {frame} of the test “{test}”, which it ran in for {frames, plural, one {# frame} other {# frames}}. On a fast computer Remastered ran {smooth} ms of triggers a frame with no flicker seen, and slows past {most} ms; a slower computer gets there sooner. This is {share} of the {smooth} ms.": "게임에서 이 줄은 한 프레임에 리마스터의 시간을 최대 {ms}ms 썼습니다 (조건과 액션 {n}개): 테스트 “{test}”의 {frame}번째 프레임이며, 그 테스트에서 이 줄이 실행된 프레임은 {frames}개입니다. 빠른 컴퓨터에서 리마스터는 프레임당 트리거 {smooth}ms까지 깜빡임 없이 실행했고, {most}ms를 넘으면 게임이 느려집니다. 느린 컴퓨터는 더 일찍 그렇게 됩니다. 이는 {smooth}ms 중 {share}입니다.", + "Remastered's time a frame, frame {from} to {to}: {ms} ms at the most, at frame {frame}": "프레임당 리마스터의 시간, {from}–{to}번째 프레임: {frame}번째 프레임에서 최대 {ms}ms", + "{average} ms a frame on average, {peak} ms at the most (frame {frame}): {share} of the {smooth} ms of triggers a frame Remastered ran with no flicker seen": "프레임당 평균 {average}ms, 최대 {peak}ms ({frame}번째 프레임): 리마스터가 깜빡임 없이 실행한 프레임당 트리거 {smooth}ms 중 {share}", + "{ms} ms a frame at the most (frame {frame}), in {frames, plural, one {# frame} other {# frames}}": "프레임당 최대 {ms}ms ({frame}번째 프레임), {frames}개 프레임에서 실행", + "In the game, this line ran {n} conditions and actions in one frame at the most: frame {frame} of the test “{test}”, which it ran in for {frames, plural, one {# frame} other {# frames}}. On a fast computer Remastered ran 500 thousand a frame with no visible effect; its screen flickers from a million and the game slows at 5 million. This is {share} of the 500 thousand.": "게임에서 이 줄은 한 프레임에 조건과 액션을 최대 {n}개 실행했습니다: 테스트 “{test}”의 {frame}번째 프레임이며, 그 테스트에서 이 줄이 실행된 프레임은 {frames}개입니다. 빠른 컴퓨터에서 리마스터는 프레임당 50만 개에서 눈에 띄는 영향이 없었고, 백만 개부터 화면이 깜박이며 5백만 개에서 게임이 느려졌습니다. 이것은 50만 개 중 {share}입니다.", + "Conditions and actions a frame, frame {from} to {to}: {peak} at the most, at frame {frame}": "프레임당 조건과 액션, {from}–{to}번째 프레임: {frame}번째 프레임에서 최대 {peak}개", + "cost": "비용", + "{average} conditions and actions a frame on average, {peak} at the most (frame {frame}): {share} of the 500 thousand a frame Remastered has run with no visible effect": "프레임당 조건과 액션 평균 {average}개, 최대 {peak}개 ({frame}번째 프레임): 리마스터가 눈에 띄는 영향 없이 실행한 프레임당 50만 개 중 {share}", + "Go to the line": "그 줄로 이동", + "{peak} a frame at the most (frame {frame}), in {frames, plural, one {# frame} other {# frames}}": "프레임당 최대 {peak}개 ({frame}번째 프레임), {frames}개 프레임에서 실행", + "Debug": "디버그", + "The text lines up to this frame": "이 프레임까지의 텍스트 줄", + "The game": "게임", + "No picture of the game here.": "여기에는 게임 화면이 없습니다.", + "What this line wrote in frame {frame}, as it stands at the debugger's cursor; ×n: how many times it ran in the frame so far.": "{frame}번째 프레임에서 이 줄이 쓴 값으로, 디버거 커서 위치 기준입니다. ×n: 이 프레임에서 지금까지 실행된 횟수.", + "No picture of the game: {message}": "게임 화면 없음: {message}", + "No test is being debugged. Debug one from its row in the Testing view (the bug icon) or with Debug Test at Cursor: it runs once in the game with everything it did written down, and here you walk it, forwards and back.": "디버그 중인 테스트가 없습니다. 테스트 보기에서 테스트 줄의 벌레 아이콘이나 '커서 위치의 테스트를 게임에서 디버그'로 시작하세요. 테스트가 게임에서 한 번 실행되면서 한 일이 모두 기록되고, 여기서 그 기록을 앞뒤로 따라갈 수 있습니다.", + "The script changed since this run: its lines may have moved. Debug the test again to walk the script as it is.": "이 실행 이후 스크립트가 바뀌어 줄 위치가 달라졌을 수 있습니다. 지금의 스크립트를 따라가려면 테스트를 다시 디버그하세요.", + "Stopped at the breakpoint on line {line}.": "{line}번째 줄의 중단점에서 멈췄습니다.", + "{name} changed: {before} → {after}": "{name} 바뀜: {before} → {after}", + "The end of the run: {message}": "실행의 끝: {message}", + "the test failed": "테스트 실패", + "The start of the run.": "실행의 처음.", + "The end of the run: the test passed.": "실행의 끝: 테스트 통과.", + "The record stops at frame {frame} ({why}): the variables are not known past it.": "기록이 {frame}번째 프레임에서 끝납니다({why}). 그 뒤의 변수 값은 알 수 없습니다.", + "{test} failed: {message}": "{test} 실패: {message}", + "{test} passed.": "{test} 통과.", + "no frames": "프레임 없음", + "frame {frame}": "{frame}번째 프레임", + "end of frame": "프레임 끝", + "Continue backwards (F6): to the breakpoint or the change of a watched variable before this, or the start": "뒤로 계속 (F6): 이전의 중단점이나 조사식 변수가 바뀐 곳, 없으면 처음으로", + "Back a line (Shift+F10)": "한 줄 뒤로 (Shift+F10)", + "On a line (F10)": "한 줄 앞으로 (F10)", + "Continue (F5): to the next breakpoint or change of a watched variable, or the end": "계속 (F5): 다음 중단점이나 조사식 변수가 바뀌는 곳, 없으면 끝으로", + "Back a frame (Shift+F11)": "한 프레임 뒤로 (Shift+F11)", + "On a frame (F11): to the end of this one": "한 프레임 앞으로 (F11): 이 프레임의 끝으로", + "To the start of the run": "실행의 처음으로", + "Stop debugging (Shift+F5)": "디버깅 중지 (Shift+F5)", + "Every step": "모든 단계", + "Not recorded past frame {frame}: {why}": "{frame}번째 프레임 이후는 기록되지 않음: {why}", + "Line {line} runs in frame {frame}": "{line}번째 줄이 {frame}번째 프레임에서 실행됨", + "Frames {from} to {to}: the script's conditions and actions a frame ({peak} at the most). Red: a breakpoint's line runs; gold: a line of text was shown. Click or drag to go to a frame.": "{from}~{to}번째 프레임: 프레임당 스크립트의 조건과 액션 수 (최대 {peak}개). 빨간색: 중단점 줄이 실행됨, 금색: 텍스트 줄이 표시됨. 클릭하거나 끌어서 프레임으로 이동합니다.", + "Variables": "변수", + "Not known here: the record stops at frame {frame}.": "여기서는 알 수 없습니다: 기록이 {frame}번째 프레임에서 끝납니다.", + "Watched": "조사식", + "The build did not say where the programs' variables are.": "빌드가 프로그램 변수의 위치를 알려 주지 않았습니다.", + "Frame {frame}": "{frame}번째 프레임", + "line {line}": "{line}번째 줄", + "the test": "테스트", + "the game itself": "게임 자체", + "{n, plural, one {# write} other {# writes}}": "쓰기 {n}회", + "conditions and actions": "조건과 액션", + "… and {n} more steps": "… 외 {n}단계", + "Nothing shown yet.": "아직 표시된 것이 없습니다.", + "Stop watching {name}": "{name} 조사식 해제", + "Watch {name}: Continue stops where it changes": "{name} 조사: 계속하면 값이 바뀌는 곳에서 멈춥니다", + "Debug this test in the game: run it once with everything it did written down, then walk it forwards and back": "이 테스트를 게임에서 디버그: 한 일을 모두 기록하며 한 번 실행한 뒤, 앞뒤로 따라갑니다", + "Debugging in the game needs the scm-sim plugin (0.11 or newer) on scmjs.dev: install, update or turn it on under Plugins ▸ Manage Plugins….": "게임에서 디버그하려면 scmjs.dev의 scm-sim 플러그인(0.11 이상)이 필요합니다. 플러그인 ▸ 플러그인 관리…에서 설치하거나 업데이트하거나 켜세요.", + "Not debugged in the game: {message}": "게임에서 디버그하지 못함: {message}", + "Not debugged: the engine's page is from before the debugger. Reload the editor and try again.": "디버그하지 못함: 엔진 페이지가 디버거 이전 버전입니다. 편집기를 새로 고친 뒤 다시 시도하세요.", + "“{test}” is a test of several players: the debugger runs one game, and a test of several players runs as a copy of the game for each. Not yet.": "“{test}”는 여러 플레이어의 테스트입니다. 디버거는 게임 하나를 실행하지만, 여러 플레이어의 테스트는 플레이어마다 게임 사본을 하나씩 실행합니다. 아직 지원하지 않습니다.", + "Running “{test}” in the game, writing down everything it does…": "“{test}”를 게임에서 실행하며 하는 일을 모두 기록하는 중…", + "The scm-sim plugin's view is from before the debugger: update the plugin for a picture of the game.": "scm-sim 플러그인의 보기가 디버거 이전 버전입니다. 게임 화면을 보려면 플러그인을 업데이트하세요.", + "Debugging “{test}”: it failed in the game at frame {frame}. The debugger stands there; step back to see how it came to that.": "“{test}” 디버그 중: 게임에서 {frame}번째 프레임에 실패했습니다. 디버거가 그곳에 있습니다. 뒤로 가며 어떻게 그렇게 됐는지 보세요.", + "Debugging “{test}”: {frames, plural, one {# frame} other {# frames}} in the game, recorded in {ms} ms.": "“{test}” 디버그 중: 게임에서 {frames}개 프레임, {ms}ms에 기록했습니다.", + "Debug: Continue": "디버그: 계속", + "Debug: Continue Backwards": "디버그: 뒤로 계속", + "Debug: On a Line": "디버그: 한 줄 앞으로", + "Debug: Back a Line": "디버그: 한 줄 뒤로", + "Debug: On a Frame": "디버그: 한 프레임 앞으로", + "Debug: Back a Frame": "디버그: 한 프레임 뒤로", + "Debug: Stop": "디버그: 중지", + "Debug Test at Cursor in the Game": "커서 위치의 테스트를 게임에서 디버그", + "Toggle Breakpoint": "중단점 설정/해제", + "Breakpoint: the debugger's Continue stops before this line runs (a test's line: at the end of the frame it asks for). Click to take it away.": "중단점: 디버거에서 계속하면 이 줄이 실행되기 전에 멈춥니다(테스트의 줄이면 그 줄이 요청한 프레임의 끝에서). 클릭하면 없어집니다.", + "game": "게임", + "Not looked up in the game: {message}": "게임에서 찾지 못함: {message}", + "Not looked up: {n, plural, one {# problem} other {# problems}} in the script.": "찾지 않음: 스크립트에 문제가 {n}개 있습니다.", + "The script has no program, so nothing of it is built into the map and no line of it can touch the game's memory: an EUD error came from the map's other triggers.": "스크립트에 프로그램이 없어 맵에 빌드되는 것이 없고, 어떤 줄도 게임의 메모리에 닿을 수 없습니다. EUD 오류는 맵의 다른 트리거에서 났습니다.", + "reads and writes": "읽고 씁니다", + "writes": "씁니다", + "reads": "읽습니다", + "Remastered ends the game on this.": "리마스터는 이때 게임을 끝냅니다.", + "Remastered does nothing with this.": "리마스터는 이것으로 아무것도 하지 않습니다.", + "Remastered was seen to allow this, so the game that ended did something else to it.": "리마스터가 이를 허용하는 것이 확인되었으므로, 끝난 게임은 여기에 다른 일을 했습니다.", + "This has not been seen in Remastered.": "리마스터에서 확인된 적이 없습니다.", + "In the game, this line {did} {place} ({times, plural, one {# time} other {# times}}). {said}": "게임에서 이 줄은 {place}을(를) {did} ({times}번). {said}", + "In the game, this line {did} the same field of another unit ({times, plural, one {# time} other {# times}}): a unit sits elsewhere in the table from one game to the next. {said}": "게임에서 이 줄은 다른 유닛의 같은 필드를 {did} ({times}번). 유닛이 테이블에서 놓이는 자리는 게임마다 다릅니다. {said}", + "The code Remastered showed when the game ended (EUD ERROR: 0x…):": "게임이 끝날 때 리마스터가 보여 준 코드 (EUD ERROR: 0x…):", + "“{text}” is not an EUD error code: Remastered shows one as EUD ERROR: 0x and eight digits.": "“{text}”은(는) EUD 오류 코드가 아닙니다. 리마스터는 EUD ERROR: 0x 뒤에 여덟 자리로 보여 줍니다.", + "Playing the map in the game, writing down what each line reads and writes…": "게임에서 맵을 플레이하며 각 줄이 읽고 쓰는 것을 기록하는 중…", + "the engine's page sent back no trace": "엔진 페이지가 기록을 돌려주지 않았습니다", + "EUD ERROR {code}: {place}, at 0x{address}.": "EUD ERROR {code}: {place}, 주소 0x{address}.", + "EUD ERROR {code} is {place}.": "EUD ERROR {code}은(는) {place}입니다.", + "{n, plural, one {# line touched it} other {# lines touched it}} in the game: {where}.": "게임에서 {n}개 줄이 여기에 닿았습니다: {where}.", + "No line touched that very unit, but {n, plural, one {# line touches} other {# lines touch}} the same field of other units, and a unit sits elsewhere in the table from one game to the next: {where}.": "그 유닛에 닿은 줄은 없지만, {n}개 줄이 다른 유닛의 같은 필드에 닿습니다. 유닛이 테이블에서 놓이는 자리는 게임마다 다릅니다: {where}.", + "Nothing touched it in {frames} frames of the map played with nobody at it, nor in {tests, plural, one {# test} other {# tests}}: the line that did is one this run did not reach. A test that gets there will find it.": "아무도 없이 맵을 {frames} 프레임 플레이하고 테스트 {tests}개를 돌리는 동안 아무것도 닿지 않았습니다. 닿은 줄은 이번 실행이 이르지 못한 줄입니다. 거기까지 가는 테스트가 있으면 찾을 수 있습니다.", + "Nothing touched it in {frames} frames of the map played with nobody at it: the line that did is one this run did not reach. A test that gets there will find it.": "아무도 없이 맵을 {frames} 프레임 플레이하는 동안 아무것도 닿지 않았습니다. 닿은 줄은 이번 실행이 이르지 못한 줄입니다. 거기까지 가는 테스트가 있으면 찾을 수 있습니다.", + "Find an EUD Error…": "EUD 오류 찾기…", + "Import Video…: a video, a picture, a sound or a MIDI file as a file of the script": "동영상 가져오기…: 동영상, 그림, 소리 또는 MIDI 파일을 스크립트 파일로 만듭니다", + "{path} is in the script: import {name} from \"./{path}\"; then await fog.play(P1, {name}) plays it.": "{path}이(가) 스크립트에 들어갔습니다: import {name} from \"./{path}\"; 다음 await fog.play(P1, {name})로 재생합니다.", + "Import Video…": "동영상 가져오기…", + "{name} could not be opened: {message}": "{name}을(를) 열 수 없습니다: {message}", + "{w} × {h} tiles, {n} frames at the game's speed on Fastest ({time}).": "{w} × {h} 타일, 가장 빠름 게임 속도로 {n} 프레임 ({time}).", + "{w} × {h} tiles.": "{w} × {h} 타일.", + "Import Video": "동영상 가져오기", + "Import Image": "그림 가져오기", + "Frame": "프레임", + "Tiles across": "가로 타일", + "Tiles down": "세로 타일", + "Threshold": "기준 밝기", + "Ink": "잉크", + "Light parts": "밝은 부분", + "Dark parts": "어두운 부분", + "File": "파일", + "Light tiles are the ink: what fog.light lights. A video's frames are taken at the game's speed on Fastest, a frame of it each frame of the game.": "밝은 타일이 잉크입니다: fog.light가 밝히는 곳입니다. 동영상의 프레임은 가장 빠름 게임 속도로 뽑아, 게임 한 프레임마다 한 프레임씩 씁니다.", + "Cancel": "취소", + "Import": "가져오기", + "A file name is letters, digits, _ - and ., folders with /.": "파일 이름은 문자, 숫자, _ - . 로 이루어지고, 폴더는 /로 나눕니다.", + "Decoding: frame {done} of {total}…": "디코딩 중: {total} 프레임 중 {done}…", + "{n} frames in {seconds} s, {kb} KB.": "{n} 프레임, {seconds}초, {kb} KB.", + "Not imported: {message}": "가져오지 못했습니다: {message}", + "Frame {n} of {total}": "{total} 프레임 중 {n}", + "{n} brushes": "브러시 {n}개", + "Play": "재생", + "Pause": "일시 정지", + "The video": "동영상", + "As the fog draws it": "안개가 그리는 모습", + "{w} × {h} tiles, {n} frames of {ms} ms ({time}){from}. Import it with import name from \"./{file}\".": "{w} × {h} 타일, {ms} ms 프레임 {n}개 ({time}){from}. import name from \"./{file}\"로 가져옵니다.", + // An image (.image), imported and shown. + "Keep as": "저장 형식", + "Pixels in colour (.image)": "컬러 픽셀 (.image)", + "Pixels in grey (.image)": "회색 픽셀 (.image)", + "Rows of ink to edit (.ts)": "편집할 잉크 줄 (.ts)", + "{w} × {h} pixels, a tile each when the fog draws them.": "{w} × {h} 픽셀, 안개가 그릴 때 픽셀 하나가 타일 하나입니다.", + "The pixels are kept for the script to work on when it is built — crop, scale, flip, threshold — and draw: fog.picture(logo.threshold(128)).": "픽셀을 그대로 저장해, 스크립트가 빌드할 때 다루고(crop, scale, flip, threshold) 그릴 수 있습니다: fog.picture(logo.threshold(128)).", + "Light tiles are the ink: what fog.light lights. The rows are a .ts file to edit, which fog.picture takes.": "밝은 타일이 잉크입니다: fog.light가 밝히는 곳입니다. 줄은 편집할 수 있는 .ts 파일이 되고, fog.picture가 받습니다.", + "{path} is in the script: import {name} from \"./{path}\"; then fog.picture({name}.threshold(128)) makes a picture of it.": "{path}이(가) 스크립트에 들어갔습니다: import {name} from \"./{path}\"; 다음 fog.picture({name}.threshold(128))로 그림을 만듭니다.", + "Show": "보기", + "The image": "그림", + "Threshold, as the fog draws it": "임계값, 안개가 그리는 모습", + "Halftone, {w} × {h} cells, as the fog draws it": "하프톤, {w} × {h} 칸, 안개가 그리는 모습", + "{w} × {h} pixels{from}. Import it with import {name} from \"./{file}\".": "{w} × {h} 픽셀{from}. import {name} from \"./{file}\"로 가져옵니다.", + // A sound (.sound), imported and shown. + "{path} is in the script: import {name} from \"./{path}\"; then playWav({name}) plays it inside a program.": "{path}이(가) 스크립트에 들어갔습니다: import {name} from \"./{path}\"; 다음 program 안에서 playWav({name})로 재생합니다.", + "{seconds} seconds at {rate} Hz, about {kb} KB in the map.": "{seconds}초, {rate} Hz, 맵에서 약 {kb} KB.", + "11 kHz (smallest)": "11 kHz (가장 작음)", + "22 kHz": "22 kHz", + "44 kHz (largest)": "44 kHz (가장 큼)", + "Import Sound": "소리 가져오기", + "Stop": "정지", + "Rate": "샘플링 속도", + "Kept in mono, as the game's own sounds are. A program plays it with playWav(); clips made from it — trim, gain, pitch — are made when the script is built.": "게임의 소리처럼 모노로 저장합니다. program에서 playWav()로 재생하고, 이것으로 만든 클립(trim, gain, pitch)은 스크립트를 빌드할 때 만들어집니다.", + "{seconds} seconds at {rate} Hz{from}. Import it with import {name} from \"./{file}\", play it with playWav({name}).": "{seconds}초, {rate} Hz{from}. import {name} from \"./{file}\"로 가져오고 playWav({name})로 재생합니다.", + "{path} is in the script: import {name} from \"./{path}\"; then music.midi({name}, { … }) makes a song of its tracks.": "{path}이(가) 스크립트에 들어갔습니다. import {name} from \"./{path}\"로 가져오면 music.midi({name}, { … })가 그 트랙들로 곡을 만듭니다.", + "{tracks} tracks, {notes} notes, {bpm} beats a minute ({time}).": "트랙 {tracks}개, 음표 {notes}개, 분당 {bpm}박 ({time}).", + "The tempo changes later in the file; a song plays at the first.": "파일 뒤쪽에서 템포가 바뀝니다. 곡은 처음 템포로 재생됩니다.", + "{name} — the drums, {n} notes": "{name} — 드럼, 음표 {n}개", + "{name} — channel {channel}, instrument {program}, {n} notes": "{name} — 채널 {channel}, 악기 {program}, 음표 {n}개", + "Import MIDI": "MIDI 가져오기", + "The notes are kept, not the sounds: music.midi(notes, { Piano: keys }) plays each track you name with an instrument of the music library.": "소리가 아니라 음표를 저장합니다. music.midi(notes, { Piano: keys })는 이름을 준 트랙마다 음악 라이브러리의 악기로 연주합니다.", + "Import it with import {name} from \"./{file}\", make a song of it with music.midi({name}, { \"{track}\": music.presets.keys() }).": "import {name} from \"./{file}\"로 가져오고 music.midi({name}, { \"{track}\": music.presets.keys() })로 곡을 만듭니다.", + "The map has no script.": "맵에 스크립트가 없습니다.", + "built by TrigScript": "TrigScript가 빌드", + "build": "빌드", + "the build's own triggers": "빌드 자체의 트리거", + "Stop at every step, the build's own triggers and what the game wrote by itself included, not only at the script's lines": "스크립트의 줄뿐 아니라 빌드 자체의 트리거와 게임이 스스로 쓴 것까지 모든 단계에서 멈춥니다", + "The programs will not be built: TrigScript does not take this yet ({reason}). The map is saved without them.": "프로그램이 빌드되지 않습니다: TrigScript가 아직 이것을 받지 못합니다({reason}). 맵은 프로그램 없이 저장됩니다.", + "Not looked up: the build did not say where its triggers are. Save once more.": "찾지 않음: 빌드가 트리거의 위치를 알려 주지 않았습니다. 한 번 더 저장하세요.", + "No line of the script touched it: the build's own triggers did ({n, plural, one {# time} other {# times}}).": "스크립트의 어떤 줄도 닿지 않았습니다. 빌드 자체의 트리거가 닿았습니다 ({n}번).", + "Counted in the lifted game and weighed by what Remastered was measured spending on each trigger, condition and action on a fast computer (Ryzen 9 7900X): the script's own triggers and the variables they go through, {average} conditions and actions a frame on average and {peak} at the most. The map's other triggers are apart: {ms} ms a frame. Remastered slows past {most} ms of triggers a frame there, and a slower computer sooner.": "리프트한 게임에서 센 수에, 빠른 컴퓨터(Ryzen 9 7900X)에서 리마스터가 트리거, 조건, 액션마다 쓰는 시간을 잰 값을 곱한 것입니다: 스크립트 자신의 트리거와 그 트리거가 거치는 변수, 프레임당 조건과 액션 평균 {average}개, 최대 {peak}개. 맵의 다른 트리거는 따로입니다: 프레임당 {ms}ms. 그 컴퓨터에서 리마스터는 프레임당 트리거가 {most}ms를 넘으면 느려지고, 느린 컴퓨터는 더 일찍 느려집니다.", + "Counted in the lifted game: the script's own triggers and the variables they go through. The map's other triggers are apart: {n} a frame.": "리프트한 게임에서 센 수입니다: 스크립트 자신의 트리거와 그 트리거가 거치는 변수. 맵의 다른 트리거는 따로입니다: 프레임당 {n}개.", + "The programs are laid out into the map by TrigScript itself when it is saved or tested: nothing to install, and the map is built in under a second": "맵을 저장하거나 테스트할 때 TrigScript가 직접 프로그램을 맵에 배치합니다: 설치할 것이 없고, 1초 안에 빌드됩니다", + "not built: Magenta's triggers": "빌드 안 함: Magenta의 트리거", + "Built by TrigScript: {n, plural, one {# program} other {# programs}} laid out into the map": "TrigScript가 빌드: 프로그램 {n}개를 맵에 배치", + "The programs were not built: TrigScript does not take this yet ({reason}).": "프로그램이 빌드되지 않았습니다: TrigScript가 아직 이것을 받지 못합니다({reason}).", + "A contribution holds programs only: its trigger() calls are not written into the map.": "기여는 프로그램만 담습니다: 그 안의 trigger() 호출은 맵에 쓰이지 않습니다.", + "TrigScript does not take this yet ({reason}).": "TrigScript가 아직 이것을 받지 못합니다({reason}).", + "{label} brought the file {file}, which is not under its own folder ({folder}/).": "{label}이(가) 가져온 파일 {file}은(는) 자기 폴더({folder}/) 아래에 있지 않습니다.", + "Magenta's triggers are in this map, and this Magenta builds them through eudplib, which rewrites the part of the map TrigScript's programs go into. Update Magenta to the version that builds with TrigScript, or keep the two in different maps.": "이 맵에 Magenta의 트리거가 있고, 이 Magenta는 eudplib로 빌드하는데 그 빌드가 TrigScript의 프로그램이 들어가는 맵의 부분을 다시 씁니다. TrigScript로 빌드하는 Magenta로 업데이트하거나, 둘을 다른 맵에 두세요.", + "{label} brought trigger() calls, and a contribution holds programs only.": "{label}이(가) trigger() 호출을 가져왔지만, 기여는 프로그램만 담습니다.", + "Built by TrigScript: {n, plural, one {# program} other {# programs}} laid out into the map, {contributors} among them": "TrigScript가 빌드: 프로그램 {n}개를 맵에 배치, 그중 {contributors}의 것 포함", + "{label}: TrigScript does not take this yet ({reason}).": "{label}: TrigScript가 아직 이것을 받지 못합니다({reason}).", }; diff --git a/ledger/arrays/array-compound-store.ts b/ledger/arrays/array-compound-store.ts new file mode 100644 index 0000000..cdf504d --- /dev/null +++ b/ledger/arrays/array-compound-store.ts @@ -0,0 +1,12 @@ +// ledger: step 3, takes +// A cell of an array changed where it is: `hp[i] += 5`, `hp[i]++`. +program(() => { + const hp = [10, 20, 30]; + let i = 0; + while (i < 3) { + hp[i] += 5; + hp[i]++; + i += 1; + } + print(`${hp[1]}`); +}); diff --git a/ledger/arrays/array-destructured.ts b/ledger/arrays/array-destructured.ts new file mode 100644 index 0000000..f8a698a --- /dev/null +++ b/ledger/arrays/array-destructured.ts @@ -0,0 +1,12 @@ +// ledger: step 3, takes +// The items of an array taken out into variables: `const [a, b] = xs`. +program(() => { + const xs = [3, 4, 5]; + let i = 0; + while (i < 3) { + xs[i] += i; + i += 1; + } + const [a, b] = xs; + print(`${a + b}`); +}); diff --git a/ledger/arrays/array-fill.ts b/ledger/arrays/array-fill.ts new file mode 100644 index 0000000..5bf0f82 --- /dev/null +++ b/ledger/arrays/array-fill.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// Every cell of an array of the program set to a value that changes while the game runs: `board.fill(v)`. +program(() => { + const board = [1, 2, 3, 4]; + let v = 0; + while (v < 5) v += 2; + board.fill(v); + print(`${board[3]}`); +}); diff --git a/ledger/arrays/array-for-of.ts b/ledger/arrays/array-for-of.ts new file mode 100644 index 0000000..5a50f18 --- /dev/null +++ b/ledger/arrays/array-for-of.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// A loop over the cells of an array of the program, within one frame: `for (const x of xs)`. +program(() => { + const xs = [4, 8, 15, 16]; + xs[0] = 5; + let total = 0; + for (const x of xs) total += x; + print(`${total}`); +}); diff --git a/ledger/arrays/array-from.ts b/ledger/arrays/array-from.ts new file mode 100644 index 0000000..36b6742 --- /dev/null +++ b/ledger/arrays/array-from.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// An array made from a length written in the script and a function of each place: +// `Array.from({ length: 4 }, (_, i) => i * 2)`. +program(() => { + const xs = Array.from({ length: 4 }, (_, i) => i * 2); + let i = 0; + while (i < 3) i += 1; + xs[0] = i; + print(`${xs[i] + xs[0]}`); +}); diff --git a/ledger/arrays/array-given-to-a-function.ts b/ledger/arrays/array-given-to-a-function.ts new file mode 100644 index 0000000..637042d --- /dev/null +++ b/ledger/arrays/array-given-to-a-function.ts @@ -0,0 +1,14 @@ +// ledger: step 3, takes +// An array handed to a function of the script, which reads and stores into the array itself. +function bump(xs: number[], i: number) { + xs[i] += 10; +} +program(() => { + const xs = [1, 2, 3]; + let i = 0; + while (i < 2) { + bump(xs, i); + i += 1; + } + print(`${xs[0] + xs[1]}`); +}); diff --git a/ledger/arrays/array-in-a-function-that-calls-itself.ts b/ledger/arrays/array-in-a-function-that-calls-itself.ts new file mode 100644 index 0000000..a42537f --- /dev/null +++ b/ledger/arrays/array-in-a-function-that-calls-itself.ts @@ -0,0 +1,13 @@ +// ledger: step 9, takes +// An array that grows, declared in a function that calls itself: each call its own, kept on the stack around the calls it makes. +function depth(n: number): number { + const seen: number[] = []; + seen.push(n); + if (n > 0) seen.push(depth(n - 1)); + return seen.length + seen[0]; +} +program(() => { + let n = 0; + while (n < 4) n += 1; + print(`${depth(n)}`); +}); diff --git a/ledger/arrays/array-in-a-record.ts b/ledger/arrays/array-in-a-record.ts new file mode 100644 index 0000000..2c36ca7 --- /dev/null +++ b/ledger/arrays/array-in-a-record.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// An array of a fixed length that is a field of a record: `shop.stock[i]`. +program(() => { + const shop = { gold: 10, stock: [3, 3, 3] }; + let i = 0; + while (i < 2) i += 1; + shop.stock[i] -= 1; + print(`${shop.stock[i] + shop.gold}`); +}); diff --git a/ledger/arrays/array-includes.ts b/ledger/arrays/array-includes.ts new file mode 100644 index 0000000..059a00f --- /dev/null +++ b/ledger/arrays/array-includes.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// Whether an array of the program has a value, a loop the game runs: `xs.includes(v)`. +program(() => { + const xs = [4, 8, 15, 16]; + xs[0] = 5; + let v = 0; + while (v < 15) v += 5; + if (xs.includes(v)) print("found"); + else print("not found"); +}); diff --git a/ledger/arrays/array-index-of.ts b/ledger/arrays/array-index-of.ts new file mode 100644 index 0000000..7eb3064 --- /dev/null +++ b/ledger/arrays/array-index-of.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// Where a value is in an array of the program, −1 when it is not: `xs.indexOf(v)`. +program(() => { + const xs = [4, 8, 15, 16]; + xs[0] = 5; + let v = 0; + while (v < 15) v += 5; + print(`${xs.indexOf(v)}`); +}); diff --git a/ledger/arrays/array-length-of-a-fixed-array.ts b/ledger/arrays/array-length-of-a-fixed-array.ts new file mode 100644 index 0000000..79e0002 --- /dev/null +++ b/ledger/arrays/array-length-of-a-fixed-array.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// The length of an array of a fixed length, known when the map is built: `xs.length`. +program(() => { + const xs = [4, 8, 15, 16]; + xs[0] = 5; + let i = 0; + while (i < xs.length) i += 3; + print(`${i}`); +}); diff --git a/ledger/arrays/array-new-filled.ts b/ledger/arrays/array-new-filled.ts new file mode 100644 index 0000000..042d5d4 --- /dev/null +++ b/ledger/arrays/array-new-filled.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// An array of a length written in the script, every cell one value: `new Array(8).fill(3)`. +program(() => { + const board = new Array(8).fill(3); + let i = 0; + while (i < 5) i += 2; + board[i] = 7; + print(`${board[i] + board[0]}`); +}); diff --git a/ledger/arrays/array-of-booleans.ts b/ledger/arrays/array-of-booleans.ts new file mode 100644 index 0000000..437f9f9 --- /dev/null +++ b/ledger/arrays/array-of-booleans.ts @@ -0,0 +1,14 @@ +// ledger: step 3, takes +// An array of booleans, stored and tested at a place that changes while the game runs: `seen[i] = true`. +program(() => { + const seen = [false, false, false, false]; + let i = 0; + while (i < 4) { + seen[i] = i > 1; + i += 1; + } + let j = 0; + while (j < 3) j += 1; + if (seen[j]) print("seen"); + else print("not seen"); +}); diff --git a/ledger/arrays/array-of-instances.ts b/ledger/arrays/array-of-instances.ts new file mode 100644 index 0000000..8e1d09c --- /dev/null +++ b/ledger/arrays/array-of-instances.ts @@ -0,0 +1,15 @@ +// ledger: step 3, takes +// An array of instances of a class of the script, a method called on one: `towers[i].upgrade()`. +class Tower { + level = 1; + upgrade() { + this.level += 1; + } +} +program(() => { + const towers = [new Tower(), new Tower()]; + let i = 0; + while (i < 1) i += 1; + towers[i].upgrade(); + print(`${towers[0].level} ${towers[1].level}`); +}); diff --git a/ledger/arrays/array-of-records-that-grows.ts b/ledger/arrays/array-of-records-that-grows.ts new file mode 100644 index 0000000..0d780a4 --- /dev/null +++ b/ledger/arrays/array-of-records-that-grows.ts @@ -0,0 +1,11 @@ +// ledger: step 9, takes +// An array of records that grows a record at a time: `enemies.push({ hp, x })`. +program(() => { + const enemies: { hp: number; x: number }[] = []; + let x = 0; + while (x < 10) { + enemies.push({ hp: 20, x }); + x += 4; + } + print(`${enemies.length} ${enemies[1].x}`); +}); diff --git a/ledger/arrays/array-of-records.ts b/ledger/arrays/array-of-records.ts new file mode 100644 index 0000000..64e4188 --- /dev/null +++ b/ledger/arrays/array-of-records.ts @@ -0,0 +1,13 @@ +// ledger: step 3, takes +// An array of records, each with its fields, read and written at a place that changes while the game runs: +// `waves[i].count`. +program(() => { + const waves = [ + { count: 5, gold: 10 }, + { count: 8, gold: 15 }, + ]; + let i = 0; + while (i < 1) i += 1; + waves[i].count += 2; + print(`${waves[i].count + waves[i].gold}`); +}); diff --git a/ledger/arrays/array-read-at-a-value.ts b/ledger/arrays/array-read-at-a-value.ts new file mode 100644 index 0000000..347219c --- /dev/null +++ b/ledger/arrays/array-read-at-a-value.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// A cell of an array of the program read at a value that changes while the game runs: `hp[i]`. +program(() => { + const hp = [10, 20, 30, 40]; + hp[0] = 15; + let i = 0; + while (i < 2) i += 1; + print(`${hp[i]}`); +}); diff --git a/ledger/arrays/array-read-past-the-end.ts b/ledger/arrays/array-read-past-the-end.ts new file mode 100644 index 0000000..5387f17 --- /dev/null +++ b/ledger/arrays/array-read-past-the-end.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// A cell read at a place past an array's end, which reads 0 (and is said when the script is tested). +program(() => { + const hp = [10, 20, 30]; + hp[0] = 5; + let i = 0; + while (i < 5) i += 2; + print(`${hp[i]}`); +}); diff --git a/ledger/arrays/array-spread-into-a-fixed-array.ts b/ledger/arrays/array-spread-into-a-fixed-array.ts new file mode 100644 index 0000000..9c743de --- /dev/null +++ b/ledger/arrays/array-spread-into-a-fixed-array.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// An array of a fixed length made from another and more: `[...xs, 9]`. +program(() => { + const xs = [3, 4, 5]; + let i = 0; + while (i < 2) i += 1; + xs[i] = 8; + const ys = [...xs, 9]; + print(`${ys[2] + ys[3]}`); +}); diff --git a/ledger/arrays/array-store-at-a-value.ts b/ledger/arrays/array-store-at-a-value.ts new file mode 100644 index 0000000..97e1611 --- /dev/null +++ b/ledger/arrays/array-store-at-a-value.ts @@ -0,0 +1,11 @@ +// ledger: step 3, takes +// A value stored into a cell of an array of the program at a place that changes while the game runs: `hp[i] = v`. +program(() => { + const hp = [0, 0, 0, 0]; + let i = 0; + while (i < 4) { + hp[i] = i * 10 + 5; + i += 1; + } + print(`${hp[2]}`); +}); diff --git a/ledger/arrays/array-store-past-the-end.ts b/ledger/arrays/array-store-past-the-end.ts new file mode 100644 index 0000000..ac99af0 --- /dev/null +++ b/ledger/arrays/array-store-past-the-end.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// A store at a place past an array's end, which stores nothing (and is said when the script is tested). +program(() => { + const hp = [10, 20, 30]; + let i = 0; + while (i < 5) i += 2; + hp[i] = 99; + print(`${hp[0] + hp[1] + hp[2]}`); +}); diff --git a/ledger/arrays/array-swap.ts b/ledger/arrays/array-swap.ts new file mode 100644 index 0000000..d694697 --- /dev/null +++ b/ledger/arrays/array-swap.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// Two cells swapped by a pattern: `[xs[i], xs[j]] = [xs[j], xs[i]]`. +program(() => { + const xs = [3, 4, 5]; + let i = 0; + let j = 0; + while (j < 2) j += 1; + [xs[i], xs[j]] = [xs[j], xs[i]]; + print(`${xs[0]}`); +}); diff --git a/ledger/arrays/at.ts b/ledger/arrays/at.ts new file mode 100644 index 0000000..46007f3 --- /dev/null +++ b/ledger/arrays/at.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// A value at a place counted from the end when below zero: `xs.at(-1)`. +program(() => { + const xs = [1, 2, 3, 4]; + let v = 0; + while (v < 10) v += 3; + xs[3] = v; + print(`${xs.at(-1) ?? 0}`); +}); diff --git a/ledger/arrays/concat.ts b/ledger/arrays/concat.ts new file mode 100644 index 0000000..9015417 --- /dev/null +++ b/ledger/arrays/concat.ts @@ -0,0 +1,11 @@ +// ledger: step 9, takes +// A new array of two arrays one after the other: `xs.concat(ys)`. +program(() => { + const xs = [5, 2]; + const ys = [8, 1]; + let v = 0; + while (v < 10) v += 3; + ys[0] = v; + const zs = xs.concat(ys); + print(`${zs[2]}`); +}); diff --git a/ledger/arrays/every.ts b/ledger/arrays/every.ts new file mode 100644 index 0000000..aefa47c --- /dev/null +++ b/ledger/arrays/every.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// Whether a function says yes to every value of an array: `xs.every((x) => x > 1)`. +program(() => { + const xs = [1, 2, 3, 4]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + if (xs.every((x) => x > 1)) print("all are"); + else print("not all are"); +}); diff --git a/ledger/arrays/fill-range.ts b/ledger/arrays/fill-range.ts new file mode 100644 index 0000000..09475e0 --- /dev/null +++ b/ledger/arrays/fill-range.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// `fill(value, start, end)`: a part of an array set, the places counted from the end when below zero and cut to the array. +program(() => { + const xs = [1, 2, 3, 4, 5]; + let v = 9; + let s = 1; + xs.fill(v, s, 3); + xs.fill(0, -1); + print(`${xs[0]} ${xs[1]} ${xs[2]} ${xs[3]} ${xs[4]}`); +}); diff --git a/ledger/arrays/filter.ts b/ledger/arrays/filter.ts new file mode 100644 index 0000000..cb37310 --- /dev/null +++ b/ledger/arrays/filter.ts @@ -0,0 +1,10 @@ +// ledger: step 9, takes +// A new array of the values of another a function says yes to: `xs.filter((x) => x > 2)`. +program(() => { + const xs = [1, 2, 3, 4]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + const ys = xs.filter((x) => x > 2); + print(`${ys[0]}`); +}); diff --git a/ledger/arrays/find-index.ts b/ledger/arrays/find-index.ts new file mode 100644 index 0000000..9dade02 --- /dev/null +++ b/ledger/arrays/find-index.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// The place of the first value of an array a function says yes to, −1 for none: `xs.findIndex((x) => x > 2)`. +program(() => { + const xs = [1, 2, 3, 4]; + let v = 0; + while (v < 10) v += 3; + xs[1] = v; + print(`${xs.findIndex((x) => x > 2)}`); +}); diff --git a/ledger/arrays/find-last.ts b/ledger/arrays/find-last.ts new file mode 100644 index 0000000..5e37aaa --- /dev/null +++ b/ledger/arrays/find-last.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// The last value of an array a function says yes to, and its place: `xs.findLast(…)`, `xs.findLastIndex(…)`. +program(() => { + const xs = [1, 2, 3, 4]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + print(`${xs.findLast((x) => x < 4) ?? -1} ${xs.findLastIndex((x) => x > 9)}`); +}); diff --git a/ledger/arrays/find.ts b/ledger/arrays/find.ts new file mode 100644 index 0000000..5f84f9a --- /dev/null +++ b/ledger/arrays/find.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// The first value of an array a function says yes to, or none: `xs.find((x) => x > 2) ?? -1`. +program(() => { + const xs = [1, 2, 3, 4]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + print(`${xs.find((x) => x > 2) ?? -1}`); +}); diff --git a/ledger/arrays/for-each.ts b/ledger/arrays/for-each.ts new file mode 100644 index 0000000..21e8535 --- /dev/null +++ b/ledger/arrays/for-each.ts @@ -0,0 +1,13 @@ +// ledger: step 3, takes +// A function run for each value of an array: `xs.forEach((x) => { total += x; })`. +program(() => { + const xs = [1, 2, 3, 4]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + let total = 0; + xs.forEach((x) => { + total += x; + }); + print(`${total}`); +}); diff --git a/ledger/arrays/grid-made-when-built.ts b/ledger/arrays/grid-made-when-built.ts new file mode 100644 index 0000000..4f6c79b --- /dev/null +++ b/ledger/arrays/grid-made-when-built.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// A grid made the ways people make one, worked out when the map is built: +// `Array.from({ length: 3 }, () => new Array(4).fill(0))`. +program(() => { + const grid = Array.from({ length: 3 }, () => new Array(4).fill(0)); + let y = 0; + while (y < 2) y += 1; + grid[y][3] = 5; + print(`${grid[2][3]}`); +}); diff --git a/ledger/arrays/grid-read-and-store.ts b/ledger/arrays/grid-read-and-store.ts new file mode 100644 index 0000000..5e0ad29 --- /dev/null +++ b/ledger/arrays/grid-read-and-store.ts @@ -0,0 +1,16 @@ +// ledger: step 3, takes +// A grid, an array of rows, read and stored at two places that change while the game runs: `grid[y][x]`. +program(() => { + const grid = [ + [0, 0, 0], + [0, 0, 0], + ]; + let x = 0; + let y = 0; + while (x < 2) { + x += 1; + y = 1; + } + grid[y][x] = 7; + print(`${grid[1][2]}`); +}); diff --git a/ledger/arrays/grid-row.ts b/ledger/arrays/grid-row.ts new file mode 100644 index 0000000..3676bca --- /dev/null +++ b/ledger/arrays/grid-row.ts @@ -0,0 +1,15 @@ +// ledger: step 3, takes +// A row of a grid kept in a variable and looped over: `const row = grid[y]`, which is the grid's own cells. +program(() => { + const grid = [ + [1, 2, 3], + [4, 5, 6], + ]; + grid[0][0] = 9; + let y = 0; + while (y < 1) y += 1; + const row = grid[y]; + let total = 0; + for (const c of row) total += c; + print(`${total}`); +}); diff --git a/ledger/arrays/join.ts b/ledger/arrays/join.ts new file mode 100644 index 0000000..8c58dc0 --- /dev/null +++ b/ledger/arrays/join.ts @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// The values of an array of numbers as one text, with a separator between them: `xs.join(", ")`. +program(() => { + const xs = [5, 2, 8]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + print(xs.join(", ")); +}); diff --git a/ledger/arrays/last-index-of.ts b/ledger/arrays/last-index-of.ts new file mode 100644 index 0000000..9c26974 --- /dev/null +++ b/ledger/arrays/last-index-of.ts @@ -0,0 +1,9 @@ +// ledger: step 3, takes +// Where a value is last in an array, −1 when it is not: `xs.lastIndexOf(v)`. +program(() => { + const xs = [4, 8, 4, 8]; + let v = 0; + while (v < 8) v += 4; + xs[0] = 5; + print(`${xs.lastIndexOf(v)}`); +}); diff --git a/ledger/arrays/length-of-a-growing-array.ts b/ledger/arrays/length-of-a-growing-array.ts new file mode 100644 index 0000000..18ef944 --- /dev/null +++ b/ledger/arrays/length-of-a-growing-array.ts @@ -0,0 +1,11 @@ +// ledger: step 9, takes +// How many values an array that grows holds: `xs.length`. +program(() => { + const xs: number[] = []; + let v = 0; + while (v < 10) { + xs.push(v); + v += 3; + } + print(`${xs.length}`); +}); diff --git a/ledger/arrays/length-set.ts b/ledger/arrays/length-set.ts new file mode 100644 index 0000000..920dd0e --- /dev/null +++ b/ledger/arrays/length-set.ts @@ -0,0 +1,10 @@ +// ledger: step 9, takes +// An array that grows cut short, or emptied, by setting its length: `xs.length = 1`. +program(() => { + const xs = [1, 2, 3]; + let v = 0; + while (v < 10) v += 3; + xs.push(v); + xs.length = 1; + print(`${xs[0]}`); +}); diff --git a/ledger/arrays/list-of-records-read-at-a-value.ts b/ledger/arrays/list-of-records-read-at-a-value.ts new file mode 100644 index 0000000..5a446f2 --- /dev/null +++ b/ledger/arrays/list-of-records-read-at-a-value.ts @@ -0,0 +1,12 @@ +// ledger: step 3, takes +// A list of records written in the script, looked up at a value that changes while the game runs: `WAVES[i].count`. +const WAVES = [ + { count: 5, gold: 10 }, + { count: 8, gold: 15 }, + { count: 13, gold: 25 }, +]; +program(() => { + let i = 0; + while (i < 2) i += 1; + print(`${WAVES[i].count + WAVES[i].gold}`); +}); diff --git a/ledger/arrays/list-read-at-a-constant.ts b/ledger/arrays/list-read-at-a-constant.ts new file mode 100644 index 0000000..98026fd --- /dev/null +++ b/ledger/arrays/list-read-at-a-constant.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// A list written in the script, read at a place written in the script, which is its value when the map is built: +// `PRICES[2]`. +const PRICES = [50, 75, 100, 150]; +program(() => { + let n = 0; + while (n < PRICES[2]) n += 30; + print(`${n}`); +}); diff --git a/ledger/arrays/list-read-at-a-value.ts b/ledger/arrays/list-read-at-a-value.ts new file mode 100644 index 0000000..404a8c7 --- /dev/null +++ b/ledger/arrays/list-read-at-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 3, takes +// A list written in the script, looked up at a value that changes while the game runs: `PRICES[i]`. +const PRICES = [50, 75, 100, 150]; +program(() => { + let i = 0; + while (i < 2) i += 1; + print(`${PRICES[i]}`); +}); diff --git a/ledger/arrays/map-for-of.ts b/ledger/arrays/map-for-of.ts new file mode 100644 index 0000000..8df27e0 --- /dev/null +++ b/ledger/arrays/map-for-of.ts @@ -0,0 +1,13 @@ +// ledger: step 9, takes +// A loop over a `Map`'s keys and values, in the order the keys went in: `for (const [key, value] of map)`. +program(() => { + const scores = new Map(); + let key = 1; + while (key < 20) { + scores.set(key, key + 1); + key += 6; + } + let total = 0; + for (const [k, v] of scores) total += k + v; + print(`${total}`); +}); diff --git a/ledger/arrays/map-keyed-by-game-ids.ts b/ledger/arrays/map-keyed-by-game-ids.ts new file mode 100644 index 0000000..cd05066 --- /dev/null +++ b/ledger/arrays/map-keyed-by-game-ids.ts @@ -0,0 +1,15 @@ +// ledger: step 3, takes +// A `Map` keyed by ids of the game, at a key that changes while the game runs: `get`, `set`, `has`, `delete` and +// `size`. +program(() => { + const bounty = new Map(); + let type: UnitType = units.ZergZergling; + let n = 0; + while (n < 5) { + bounty.set(type, n); + type = units.ZergHydralisk; + n += 2; + } + bounty.delete(units.ZergZergling); + if (bounty.has(type)) print(`${bounty.get(type) ?? 0} ${bounty.size}`); +}); diff --git a/ledger/arrays/map-over-any-number.ts b/ledger/arrays/map-over-any-number.ts new file mode 100644 index 0000000..22d3d90 --- /dev/null +++ b/ledger/arrays/map-over-any-number.ts @@ -0,0 +1,11 @@ +// ledger: step 9, takes +// A `Map` keyed by any number, below zero and large too: `scores.set(key, v)`, `scores.get(key) ?? 0`. +program(() => { + const scores = new Map(); + let key = 1000; + while (key < 100000) { + scores.set(key, key / 1000); + key *= 7; + } + print(`${scores.get(7000) ?? 0} ${scores.size}`); +}); diff --git a/ledger/arrays/map.ts b/ledger/arrays/map.ts new file mode 100644 index 0000000..12d05dc --- /dev/null +++ b/ledger/arrays/map.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// A new array of each value of another put through a function: `xs.map((x) => x * 2)`. +program(() => { + const xs = [1, 2, 3]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + const ys = xs.map((x) => x * 2); + print(`${ys[0]}`); +}); diff --git a/ledger/arrays/pop-of-a-row.ts b/ledger/arrays/pop-of-a-row.ts new file mode 100644 index 0000000..f80af28 --- /dev/null +++ b/ledger/arrays/pop-of-a-row.ts @@ -0,0 +1,11 @@ +// ledger: step 9, takes +// `rows[i].pop() ?? -1`: a pop of a row of an array of arrays, the other value when the row is empty. +program(() => { + const rows = [[1, 2], [3]]; + let i = 1; + let a = 0; + let b = 0; + a = rows[i].pop() ?? -1; + b = rows[i].pop() ?? -1; + print(`${a} ${b}`); +}); diff --git a/ledger/arrays/pop.ts b/ledger/arrays/pop.ts new file mode 100644 index 0000000..96f913b --- /dev/null +++ b/ledger/arrays/pop.ts @@ -0,0 +1,10 @@ +// ledger: step 9, takes +// The last value of an array taken off it: `xs.pop()`. +program(() => { + const xs = [1, 2, 3]; + let v = 0; + while (v < 10) v += 3; + xs.push(v); + const last = xs.pop() ?? 0; + print(`${last}`); +}); diff --git a/ledger/arrays/push.ts b/ledger/arrays/push.ts new file mode 100644 index 0000000..c77acf8 --- /dev/null +++ b/ledger/arrays/push.ts @@ -0,0 +1,11 @@ +// ledger: step 9, takes +// A value added at the end of an array, which grows: `xs.push(v)`. +program(() => { + const xs: number[] = []; + let v = 0; + while (v < 10) { + xs.push(v); + v += 3; + } + print(`${xs[2]}`); +}); diff --git a/ledger/arrays/read-at-a-shifted-index.ts b/ledger/arrays/read-at-a-shifted-index.ts new file mode 100644 index 0000000..9520e00 --- /dev/null +++ b/ledger/arrays/read-at-a-shifted-index.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// A cell read at a place worked out by a shift, as the halves of a list are: `xs[a >> 1]` (stored there, or with the +// place kept in a variable first, it is taken). +program(() => { + const xs = [1, 2, 3, 4, 5, 6, 7]; + xs[0] = 9; + let a = 0; + while (a < 10) a += 3; + print(`${xs[a >> 1]}`); +}); diff --git a/ledger/arrays/record-keyed-by-game-ids.ts b/ledger/arrays/record-keyed-by-game-ids.ts new file mode 100644 index 0000000..67b9dbb --- /dev/null +++ b/ledger/arrays/record-keyed-by-game-ids.ts @@ -0,0 +1,14 @@ +// ledger: step 3, takes +// A record keyed by an id of the game, a cell for every id, read and written at a key that changes while the game +// runs: `const score: Record = { [P1]: 0 }`, then `score[p] += 7`. +program(() => { + const score: Record = { [P1]: 0 }; + let p: Player = P1; + let n = 0; + while (n < 3) { + score[p] += 7; + p = P2; + n += 1; + } + print(`${score[P1]} ${score[p]}`); +}); diff --git a/ledger/arrays/reduce.ts b/ledger/arrays/reduce.ts new file mode 100644 index 0000000..683f598 --- /dev/null +++ b/ledger/arrays/reduce.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// The values of an array put together into one, a function at a time: `xs.reduce((so, x) => so + x, 0)`. +program(() => { + const xs = [1, 2, 3, 4]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + const total = xs.reduce((so, x) => so + x, 0); + print(`${total}`); +}); diff --git a/ledger/arrays/rest-of-an-array.ts b/ledger/arrays/rest-of-an-array.ts new file mode 100644 index 0000000..6258466 --- /dev/null +++ b/ledger/arrays/rest-of-an-array.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// The rest of an array taken out by a pattern, an array of its own: `const [first, ...rest] = xs`. +program(() => { + const xs = [5, 2, 8, 1]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + const [first, ...rest] = xs; + print(`${first} ${rest[0]}`); +}); diff --git a/ledger/arrays/reverse.ts b/ledger/arrays/reverse.ts new file mode 100644 index 0000000..3bf7414 --- /dev/null +++ b/ledger/arrays/reverse.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// An array turned round in place: `xs.reverse()`. +program(() => { + const xs = [5, 2, 8, 1]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + xs.reverse(); + print(`${xs[0]} ${xs[3]}`); +}); diff --git a/ledger/arrays/rows-that-grow.ts b/ledger/arrays/rows-that-grow.ts new file mode 100644 index 0000000..c9a5adc --- /dev/null +++ b/ledger/arrays/rows-that-grow.ts @@ -0,0 +1,12 @@ +// ledger: step 9, takes +// An array of arrays each of its own length, a row pushed to: `rows[i].push(v)`. +program(() => { + const rows: number[][] = [[], [], []]; + let i = 0; + while (i < 2) { + rows[i].push(i + 5); + i += 1; + } + rows[1].push(9); + print(`${rows[1].length} ${rows[1][1]}`); +}); diff --git a/ledger/arrays/set-keyed-by-game-ids.ts b/ledger/arrays/set-keyed-by-game-ids.ts new file mode 100644 index 0000000..e0a0f94 --- /dev/null +++ b/ledger/arrays/set-keyed-by-game-ids.ts @@ -0,0 +1,14 @@ +// ledger: step 3, takes +// A `Set` of ids of the game, at a key that changes while the game runs: `add`, `has`, `delete` and `size`. +program(() => { + const seen = new Set(); + let type: UnitType = units.ZergZergling; + let n = 0; + while (n < 5) { + seen.add(type); + type = units.ZergHydralisk; + n += 2; + } + seen.delete(units.ZergZergling); + if (seen.has(type)) print(`${seen.size}`); +}); diff --git a/ledger/arrays/set.ts b/ledger/arrays/set.ts new file mode 100644 index 0000000..20527a2 --- /dev/null +++ b/ledger/arrays/set.ts @@ -0,0 +1,12 @@ +// ledger: step 9, takes +// A `Set` of numbers: `add`, `has`, `delete` and `size`. +program(() => { + const seen = new Set(); + let v = 0; + while (v < 20) { + seen.add(v); + v += 6; + } + seen.delete(6); + if (seen.has(12)) print(`${seen.size}`); +}); diff --git a/ledger/arrays/shift.ts b/ledger/arrays/shift.ts new file mode 100644 index 0000000..087ada3 --- /dev/null +++ b/ledger/arrays/shift.ts @@ -0,0 +1,10 @@ +// ledger: step 9, takes +// The first value of an array taken off its front: `xs.shift()`. +program(() => { + const xs = [1, 2, 3]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + const first = xs.shift() ?? 0; + print(`${first} ${xs[0]}`); +}); diff --git a/ledger/arrays/signal.ts b/ledger/arrays/signal.ts new file mode 100644 index 0000000..23e2346 --- /dev/null +++ b/ledger/arrays/signal.ts @@ -0,0 +1,16 @@ +// ledger: step 9, takes +// An event of the script's own from `trigscript/signals`: handlers added with `on` and run with `emit`. +import { Signal } from "trigscript/signals"; +program(() => { + const cleared = new Signal(); + let total = 0; + cleared.on((wave) => { + total += wave; + }); + let wave = 0; + while (wave < 3) { + wave += 1; + cleared.emit(wave); + } + print(`${total}`); +}); diff --git a/ledger/arrays/slice.ts b/ledger/arrays/slice.ts new file mode 100644 index 0000000..bb3dc24 --- /dev/null +++ b/ledger/arrays/slice.ts @@ -0,0 +1,10 @@ +// ledger: step 9, takes +// A copy of part of an array: `xs.slice(1, 3)`. +program(() => { + const xs = [5, 2, 8, 1]; + let v = 0; + while (v < 10) v += 3; + xs[1] = v; + const ys = xs.slice(1, 3); + print(`${ys[0]}`); +}); diff --git a/ledger/arrays/some.ts b/ledger/arrays/some.ts new file mode 100644 index 0000000..fcb62c0 --- /dev/null +++ b/ledger/arrays/some.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// Whether a function says yes to any value of an array: `xs.some((x) => x > 10)`. +program(() => { + const xs = [1, 2, 3, 4]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + if (xs.some((x) => x > 10)) print("one is"); + else print("none is"); +}); diff --git a/ledger/arrays/sort.ts b/ledger/arrays/sort.ts new file mode 100644 index 0000000..86274a9 --- /dev/null +++ b/ledger/arrays/sort.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// An array sorted in place by a function that compares two values: `xs.sort((a, b) => a - b)`. +program(() => { + const xs = [5, 2, 8, 1]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + xs.sort((a, b) => a - b); + print(`${xs[0]} ${xs[3]}`); +}); diff --git a/ledger/arrays/splice.ts b/ledger/arrays/splice.ts new file mode 100644 index 0000000..b68a364 --- /dev/null +++ b/ledger/arrays/splice.ts @@ -0,0 +1,9 @@ +// ledger: step 9, takes +// Values taken out of an array and others put in their place: `xs.splice(i, 1, v)`. +program(() => { + const xs = [1, 2, 3, 4]; + let i = 0; + while (i < 2) i += 1; + xs.splice(i, 1, 9, 9); + print(`${xs[2]} ${xs[3]}`); +}); diff --git a/ledger/arrays/spread-into-a-call.ts b/ledger/arrays/spread-into-a-call.ts new file mode 100644 index 0000000..34602c1 --- /dev/null +++ b/ledger/arrays/spread-into-a-call.ts @@ -0,0 +1,12 @@ +// ledger: step 3, takes +// An array of a fixed length spread into a call's arguments: `f(...xs)`. +function sum3(a: number, b: number, c: number): number { + return a + b + c; +} +program(() => { + const xs: [number, number, number] = [1, 2, 3]; + let v = 0; + while (v < 10) v += 3; + xs[1] = v; + print(`${sum3(...xs)}`); +}); diff --git a/ledger/arrays/spread-of-a-growing-array.ts b/ledger/arrays/spread-of-a-growing-array.ts new file mode 100644 index 0000000..3e2b256 --- /dev/null +++ b/ledger/arrays/spread-of-a-growing-array.ts @@ -0,0 +1,12 @@ +// ledger: step 9, takes +// An array that grows made from another that grows and more: `[...xs, 9]`. +program(() => { + const xs: number[] = []; + let v = 0; + while (v < 10) { + xs.push(v); + v += 3; + } + const ys = [...xs, 9]; + print(`${ys.length}`); +}); diff --git a/ledger/arrays/store-at-the-end.ts b/ledger/arrays/store-at-the-end.ts new file mode 100644 index 0000000..2877d30 --- /dev/null +++ b/ledger/arrays/store-at-the-end.ts @@ -0,0 +1,9 @@ +// ledger: step 9, takes +// A value stored one past the end of an array, which grows it as `push` does: `xs[xs.length] = v`. +program(() => { + const xs = [1, 2]; + let v = 0; + while (v < 10) v += 3; + xs[xs.length] = v; + print(`${xs[2]}`); +}); diff --git a/ledger/arrays/to-reversed.ts b/ledger/arrays/to-reversed.ts new file mode 100644 index 0000000..4ee244b --- /dev/null +++ b/ledger/arrays/to-reversed.ts @@ -0,0 +1,10 @@ +// ledger: step 9, takes +// A copy of an array turned round, the array left as it was: `xs.toReversed()`. +program(() => { + const xs = [5, 2, 8, 1]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + const ys = xs.toReversed(); + print(`${ys[3]} ${xs[0]}`); +}); diff --git a/ledger/arrays/to-sorted.ts b/ledger/arrays/to-sorted.ts new file mode 100644 index 0000000..111c34b --- /dev/null +++ b/ledger/arrays/to-sorted.ts @@ -0,0 +1,10 @@ +// ledger: step 9, takes +// A sorted copy of an array, the array left as it was: `xs.toSorted((a, b) => a - b)`. +program(() => { + const xs = [5, 2, 8, 1]; + let v = 0; + while (v < 10) v += 3; + xs[0] = v; + const ys = xs.toSorted((a, b) => a - b); + print(`${ys[0]} ${xs[0]}`); +}); diff --git a/ledger/arrays/u16-array.ts b/ledger/arrays/u16-array.ts new file mode 100644 index 0000000..119ed5c --- /dev/null +++ b/ledger/arrays/u16-array.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// An array of `u16` cells, each within 0 … 65 535: a store below zero is 0. +program(() => { + const xs: u16[] = [5, 5, 5]; + let i = 0; + while (i < 2) i += 1; + xs[i] -= 10; + print(`${xs[i]}`); +}); diff --git a/ledger/arrays/u32-array.ts b/ledger/arrays/u32-array.ts new file mode 100644 index 0000000..18f37f0 --- /dev/null +++ b/ledger/arrays/u32-array.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// An array of `u32` cells, read from 0 up: a cell set to −1 is larger than 100. +program(() => { + const xs: u32[] = [u32(0), u32(0), u32(0)]; + let i = 0; + while (i < 2) i += 1; + xs[i] = u32(-1); + if (xs[i] > 100) print("large"); + else print("small"); +}); diff --git a/ledger/arrays/u8-array.ts b/ledger/arrays/u8-array.ts new file mode 100644 index 0000000..141b24b --- /dev/null +++ b/ledger/arrays/u8-array.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// An array of `u8` cells, each within 0 … 255: a store below zero is 0, above 255 is 255. +program(() => { + const xs: u8[] = [250, 250, 250]; + let i = 0; + while (i < 2) i += 1; + xs[i] += 10; + print(`${xs[i]}`); +}); diff --git a/ledger/arrays/unshift.ts b/ledger/arrays/unshift.ts new file mode 100644 index 0000000..202f246 --- /dev/null +++ b/ledger/arrays/unshift.ts @@ -0,0 +1,9 @@ +// ledger: step 9, takes +// A value put at the front of an array: `xs.unshift(v)`. +program(() => { + const xs = [1, 2, 3]; + let v = 0; + while (v < 10) v += 3; + xs.unshift(v); + print(`${xs[0]} ${xs[1]}`); +}); diff --git a/ledger/arrays/written-where-used.ts b/ledger/arrays/written-where-used.ts new file mode 100644 index 0000000..266ba77 --- /dev/null +++ b/ledger/arrays/written-where-used.ts @@ -0,0 +1,10 @@ +// ledger: step 8, takes +// An array written out where it is used, a value of the program in it: joined, its length, searched, walked, mapped, +// and spread into Math.max — as an array kept in a variable is. +program(() => { + let n = 0; + while (n < 12) n += 5; + print([n, 2, 8].join(", ")); + print(`${[1, n].length} ${[n, 4].indexOf(4)} ${[n, 4].includes(15)} ${[n, 7].map((x) => x * 2).join("+")} ${Math.max(...[n, 4])}`); + for (const x of [n, 1]) print(`${x}`); +}); diff --git a/ledger/game/action-raw-record.ts b/ledger/game/action-raw-record.ts new file mode 100644 index 0000000..50c4b5f --- /dev/null +++ b/ledger/game/action-raw-record.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// An action by its raw type number and fields, `action(type, …)`: what StarEdit stores, for a type the +// editor does not know (here 10, Center View). +program(() => { + action(10, 64, 0, 0, 0, 0, 0, 0, 0); +}); diff --git a/ledger/game/center-location-with-size.ts b/ledger/game/center-location-with-size.ts new file mode 100644 index 0000000..6cfbcd2 --- /dev/null +++ b/ledger/game/center-location-with-size.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A location centred on a point and given a size, all in pixels: `centerLocation(location, x, y, width, height)`. +const spot = reserveLocation("ledger: where the box is"); +program(() => { + let x = 64; + x += 32; + centerLocation(spot, x, 96, 32, 32); +}); diff --git a/ledger/game/center-location.ts b/ledger/game/center-location.ts new file mode 100644 index 0000000..d9cfe44 --- /dev/null +++ b/ledger/game/center-location.ts @@ -0,0 +1,9 @@ +// ledger: step 5, takes +// A location centred on a point of the map, in pixels from values of the program, its size kept: +// `centerLocation(location, x, y)`. +const spot = reserveLocation("ledger: where the point is"); +program(() => { + let x = 64; + x += 32; + centerLocation(spot, x, 96); +}); diff --git a/ledger/game/center-view.ts b/ledger/game/center-view.ts new file mode 100644 index 0000000..1bbee45 --- /dev/null +++ b/ledger/game/center-view.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Center View: the player's screen moved to a location. +program(() => { + centerView(locations.Anywhere); +}); diff --git a/ledger/game/condition-accumulate.ts b/ledger/game/condition-accumulate.ts new file mode 100644 index 0000000..1f1583c --- /dev/null +++ b/ledger/game/condition-accumulate.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Accumulate as a test in an `if`: a player has at least so many minerals. +program(() => { + if (accumulate(P1, ">=", 500, "ore")) print("yes"); +}); diff --git a/ledger/game/condition-always.ts b/ledger/game/condition-always.ts new file mode 100644 index 0000000..8ff8de4 --- /dev/null +++ b/ledger/game/condition-always.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Always as a test in an `if`. +program(() => { + if (always()) print("yes"); +}); diff --git a/ledger/game/condition-bring.ts b/ledger/game/condition-bring.ts new file mode 100644 index 0000000..4011794 --- /dev/null +++ b/ledger/game/condition-bring.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Bring as a test in an `if`: a player has units of a type at a location. +program(() => { + if (bring(P1, units.TerranMarine, locations.Anywhere, ">=", 5)) print("yes"); +}); diff --git a/ledger/game/condition-command-the-least-at.ts b/ledger/game/condition-command-the-least-at.ts new file mode 100644 index 0000000..a48da5c --- /dev/null +++ b/ledger/game/condition-command-the-least-at.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Command the Least At as a test in an `if`: the fewest units of a type at a location. +program(() => { + if (commandTheLeastAt(units.TerranMarine, locations.Anywhere)) print("yes"); +}); diff --git a/ledger/game/condition-command-the-least.ts b/ledger/game/condition-command-the-least.ts new file mode 100644 index 0000000..de7e1a5 --- /dev/null +++ b/ledger/game/condition-command-the-least.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Command the Least as a test in an `if`: the current player has the fewest units of a type. +program(() => { + if (commandTheLeast(units.TerranMarine)) print("yes"); +}); diff --git a/ledger/game/condition-command-the-most-at.ts b/ledger/game/condition-command-the-most-at.ts new file mode 100644 index 0000000..3944bb1 --- /dev/null +++ b/ledger/game/condition-command-the-most-at.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Command the Most At as a test in an `if`: the most units of a type at a location. +program(() => { + if (commandTheMostAt(units.TerranMarine, locations.Anywhere)) print("yes"); +}); diff --git a/ledger/game/condition-command-the-most.ts b/ledger/game/condition-command-the-most.ts new file mode 100644 index 0000000..99ff6dd --- /dev/null +++ b/ledger/game/condition-command-the-most.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Command the Most as a test in an `if`: the current player has the most units of a type. +program(() => { + if (commandTheMost(units.TerranMarine)) print("yes"); +}); diff --git a/ledger/game/condition-command.ts b/ledger/game/condition-command.ts new file mode 100644 index 0000000..e4489c2 --- /dev/null +++ b/ledger/game/condition-command.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Command as a test in an `if`: a player has so many units of a type anywhere. +program(() => { + if (command(P1, units.TerranMarine, ">=", 5)) print("yes"); +}); diff --git a/ledger/game/condition-countdown-timer.ts b/ledger/game/condition-countdown-timer.ts new file mode 100644 index 0000000..f0a83b3 --- /dev/null +++ b/ledger/game/condition-countdown-timer.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Countdown Timer as a test in an `if`: the timer is at most so many seconds. +program(() => { + if (countdownTimer("<=", 10)) print("yes"); +}); diff --git a/ledger/game/condition-deaths.ts b/ledger/game/condition-deaths.ts new file mode 100644 index 0000000..79f22d0 --- /dev/null +++ b/ledger/game/condition-deaths.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Deaths as a test in an `if`: the counter a map keeps in the death table. +program(() => { + if (deaths(P1, units.TerranMarine, ">=", 3)) print("yes"); +}); diff --git a/ledger/game/condition-elapsed-time.ts b/ledger/game/condition-elapsed-time.ts new file mode 100644 index 0000000..8438afb --- /dev/null +++ b/ledger/game/condition-elapsed-time.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Elapsed Time as a test in an `if`: so many game seconds have gone by. +program(() => { + if (elapsedTime(">=", 60)) print("yes"); +}); diff --git a/ledger/game/condition-highest-score.ts b/ledger/game/condition-highest-score.ts new file mode 100644 index 0000000..2063f84 --- /dev/null +++ b/ledger/game/condition-highest-score.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Highest Score as a test in an `if`: the current player has the highest score of a kind. +program(() => { + if (highestScore("kills")) print("yes"); +}); diff --git a/ledger/game/condition-in-a-while.ts b/ledger/game/condition-in-a-while.ts new file mode 100644 index 0000000..d466b32 --- /dev/null +++ b/ledger/game/condition-in-a-while.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A condition as the test of a `while`: the loop goes on, a frame at a time, until the switch is set. +program(() => { + while (!switchIs(switches.Switch1, "set")) sleep(frames(1)); + print("open"); +}); diff --git a/ledger/game/condition-kill.ts b/ledger/game/condition-kill.ts new file mode 100644 index 0000000..18ae8b8 --- /dev/null +++ b/ledger/game/condition-kill.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Kill as a test in an `if`: a player has killed so many units of a type. +program(() => { + if (kill(P1, units.ZergZergling, ">=", 10)) print("yes"); +}); diff --git a/ledger/game/condition-least-kills.ts b/ledger/game/condition-least-kills.ts new file mode 100644 index 0000000..49dfbaf --- /dev/null +++ b/ledger/game/condition-least-kills.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Least Kills as a test in an `if`. +program(() => { + if (leastKills(units.ZergZergling)) print("yes"); +}); diff --git a/ledger/game/condition-least-resources.ts b/ledger/game/condition-least-resources.ts new file mode 100644 index 0000000..72d547c --- /dev/null +++ b/ledger/game/condition-least-resources.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Least Resources as a test in an `if`. +program(() => { + if (leastResources("ore")) print("yes"); +}); diff --git a/ledger/game/condition-lowest-score.ts b/ledger/game/condition-lowest-score.ts new file mode 100644 index 0000000..aef61a3 --- /dev/null +++ b/ledger/game/condition-lowest-score.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Lowest Score as a test in an `if`. +program(() => { + if (lowestScore("total")) print("yes"); +}); diff --git a/ledger/game/condition-most-kills.ts b/ledger/game/condition-most-kills.ts new file mode 100644 index 0000000..c058c89 --- /dev/null +++ b/ledger/game/condition-most-kills.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Most Kills as a test in an `if`. +program(() => { + if (mostKills(units.ZergZergling)) print("yes"); +}); diff --git a/ledger/game/condition-most-resources.ts b/ledger/game/condition-most-resources.ts new file mode 100644 index 0000000..227a00e --- /dev/null +++ b/ledger/game/condition-most-resources.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Most Resources as a test in an `if`. +program(() => { + if (mostResources("oreAndGas")) print("yes"); +}); diff --git a/ledger/game/condition-negated.ts b/ledger/game/condition-negated.ts new file mode 100644 index 0000000..2dfb8a6 --- /dev/null +++ b/ledger/game/condition-negated.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// A condition turned around with `!` in an `if`: `if (!bring(…))`, which works for every condition. +program(() => { + if (!bring(P1, units.AnyUnit, locations.Anywhere, ">=", 1)) print("yes"); +}); diff --git a/ledger/game/condition-never.ts b/ledger/game/condition-never.ts new file mode 100644 index 0000000..bc33d39 --- /dev/null +++ b/ledger/game/condition-never.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Never as a test in an `if`. +program(() => { + if (never()) print("yes"); +}); diff --git a/ledger/game/condition-opponents.ts b/ledger/game/condition-opponents.ts new file mode 100644 index 0000000..90ff6ab --- /dev/null +++ b/ledger/game/condition-opponents.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Opponents as a test in an `if`: how many opponents a player has left. +program(() => { + if (opponents(P1, "==", 0)) print("yes"); +}); diff --git a/ledger/game/condition-raw-record.ts b/ledger/game/condition-raw-record.ts new file mode 100644 index 0000000..a5793ca --- /dev/null +++ b/ledger/game/condition-raw-record.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A condition by its raw type number and fields, `condition(type, …)`: what StarEdit stores, for a type the +// editor does not know (here 15, Deaths). +program(() => { + if (condition(15, 0, 0, 3, 0, 0, 0)) print("yes"); +}); diff --git a/ledger/game/condition-score.ts b/ledger/game/condition-score.ts new file mode 100644 index 0000000..b68eb63 --- /dev/null +++ b/ledger/game/condition-score.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Score as a test in an `if`: a player has so many points of a kind. +program(() => { + if (score(P1, "kills", ">=", 1000)) print("yes"); +}); diff --git a/ledger/game/condition-switch.ts b/ledger/game/condition-switch.ts new file mode 100644 index 0000000..668a1de --- /dev/null +++ b/ledger/game/condition-switch.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The condition Switch as a test in an `if`: one of the map's switches (`switches.*`) is set. +program(() => { + if (switchIs(switches.Switch1, "set")) print("yes"); +}); diff --git a/ledger/game/cover.ts b/ledger/game/cover.ts new file mode 100644 index 0000000..1e28bd3 --- /dev/null +++ b/ledger/game/cover.ts @@ -0,0 +1,14 @@ +// ledger: step 3, takes +// `cover(picture, shapes)`: the brushes that draw a picture, worked out when the map is built; the +// program reads them from an array at a place it works out. +const brushes = cover([".##.", "####", "####"], [["#@#"]]); +const xs = brushes.map((b) => b.x); +program(() => { + let i = 0; + let sum = 0; + while (i < xs.length) { + sum += xs[i]; + i += 1; + } + print(`${sum}`); +}); diff --git a/ledger/game/create-unit-count-from-a-value.ts b/ledger/game/create-unit-count-from-a-value.ts new file mode 100644 index 0000000..f2b6c3e --- /dev/null +++ b/ledger/game/create-unit-count-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Create Unit, how many from a value of the program. +program(() => { + let n = 2; + n += 1; + createUnit(P1, units.TerranMarine, n, locations.Anywhere); +}); diff --git a/ledger/game/create-unit-type-from-a-value.ts b/ledger/game/create-unit-type-from-a-value.ts new file mode 100644 index 0000000..0083cd0 --- /dev/null +++ b/ledger/game/create-unit-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Create Unit, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + createUnit(P1, kind, 1, locations.Anywhere); +}); diff --git a/ledger/game/create-unit-with-properties-count-from-a-value.ts b/ledger/game/create-unit-with-properties-count-from-a-value.ts new file mode 100644 index 0000000..95da2f4 --- /dev/null +++ b/ledger/game/create-unit-with-properties-count-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Create Unit with Properties, how many from a value of the program. +program(() => { + let n = 1; + n += 2; + createUnitWithProperties(P1, units.TerranMarine, n, locations.Anywhere, 1); +}); diff --git a/ledger/game/create-unit-with-properties-type-from-a-value.ts b/ledger/game/create-unit-with-properties-type-from-a-value.ts new file mode 100644 index 0000000..17bf543 --- /dev/null +++ b/ledger/game/create-unit-with-properties-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Create Unit with Properties, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + createUnitWithProperties(P1, kind, 1, locations.Anywhere, 1); +}); diff --git a/ledger/game/create-unit-with-properties.ts b/ledger/game/create-unit-with-properties.ts new file mode 100644 index 0000000..f1ec8cb --- /dev/null +++ b/ledger/game/create-unit-with-properties.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The action Create Unit with Properties, all its fields known when the map is built: units made with +// one of the map's unit property sets (hit points, energy, …). +program(() => { + createUnitWithProperties(P1, units.TerranMarine, 1, locations.Anywhere, 1); +}); diff --git a/ledger/game/create-unit.ts b/ledger/game/create-unit.ts new file mode 100644 index 0000000..d3d4520 --- /dev/null +++ b/ledger/game/create-unit.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Create Unit, all its fields known when the map is built. +program(() => { + createUnit(P1, units.TerranMarine, 1, locations.Anywhere); +}); diff --git a/ledger/game/defeat.ts b/ledger/game/defeat.ts new file mode 100644 index 0000000..a342f9f --- /dev/null +++ b/ledger/game/defeat.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Defeat: the player loses the game. +program(() => { + defeat(); +}); diff --git a/ledger/game/display-text-with-a-number.ts b/ledger/game/display-text-with-a-number.ts new file mode 100644 index 0000000..bd90167 --- /dev/null +++ b/ledger/game/display-text-with-a-number.ts @@ -0,0 +1,8 @@ +// ledger: step 2, takes +// Display Text Message with a number of the program in it: `displayText(`${n} left`)` shows it to the +// current player, as `print` does. +program(() => { + let n = 3; + n += 1; + displayText(`${n} waves left`); +}); diff --git a/ledger/game/display-text.ts b/ledger/game/display-text.ts new file mode 100644 index 0000000..7f5663d --- /dev/null +++ b/ledger/game/display-text.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Display Text Message, its text written in the script: `displayText("…")`. +program(() => { + displayText("The gates are open."); +}); diff --git a/ledger/game/draw.ts b/ledger/game/draw.ts new file mode 100644 index 0000000..3dc8464 --- /dev/null +++ b/ledger/game/draw.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Draw: the game ends in a draw. +program(() => { + draw(); +}); diff --git a/ledger/game/give-units-count-from-a-value.ts b/ledger/game/give-units-count-from-a-value.ts new file mode 100644 index 0000000..f86fa48 --- /dev/null +++ b/ledger/game/give-units-count-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Give Units to Player, how many from a value of the program. +program(() => { + let n = 1; + n += 1; + giveUnits(P1, P2, units.TerranMarine, n, locations.Anywhere); +}); diff --git a/ledger/game/give-units-type-from-a-value.ts b/ledger/game/give-units-type-from-a-value.ts new file mode 100644 index 0000000..f4ba457 --- /dev/null +++ b/ledger/game/give-units-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Give Units to Player, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + giveUnits(P1, P2, kind, "All", locations.Anywhere); +}); diff --git a/ledger/game/give-units.ts b/ledger/game/give-units.ts new file mode 100644 index 0000000..5700687 --- /dev/null +++ b/ledger/game/give-units.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Give Units to Player, all its fields known when the map is built. +program(() => { + giveUnits(P1, P2, units.TerranMarine, 1, locations.Anywhere); +}); diff --git a/ledger/game/has-left.ts b/ledger/game/has-left.ts new file mode 100644 index 0000000..4282c52 --- /dev/null +++ b/ledger/game/has-left.ts @@ -0,0 +1,5 @@ +// ledger: step 7, takes +// Whether a player has left the game: `hasLeft(P2)`. +program(() => { + if (hasLeft(P2)) print("P2 left"); +}); diff --git a/ledger/game/is-human.ts b/ledger/game/is-human.ts new file mode 100644 index 0000000..9a2b0e4 --- /dev/null +++ b/ledger/game/is-human.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// Whether a person plays a slot: `isHuman(P2)`. +program(() => { + if (isHuman(P2)) print("two players"); +}); diff --git a/ledger/game/kill-unit-at-count-from-a-value.ts b/ledger/game/kill-unit-at-count-from-a-value.ts new file mode 100644 index 0000000..3c2d4bd --- /dev/null +++ b/ledger/game/kill-unit-at-count-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Kill Unit at Location, how many from a value of the program. +program(() => { + let n = 1; + n += 1; + killUnitAt(P2, units.ZergZergling, n, locations.Anywhere); +}); diff --git a/ledger/game/kill-unit-at-type-from-a-value.ts b/ledger/game/kill-unit-at-type-from-a-value.ts new file mode 100644 index 0000000..2e997ab --- /dev/null +++ b/ledger/game/kill-unit-at-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Kill Unit at Location, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.ZergZergling; + let wave = 3; + if (wave > 2) kind = units.ZergHydralisk; + killUnitAt(P2, kind, "All", locations.Anywhere); +}); diff --git a/ledger/game/kill-unit-at.ts b/ledger/game/kill-unit-at.ts new file mode 100644 index 0000000..aa95802 --- /dev/null +++ b/ledger/game/kill-unit-at.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Kill Unit at Location, all its fields known when the map is built. +program(() => { + killUnitAt(P2, units.ZergZergling, "All", locations.Anywhere); +}); diff --git a/ledger/game/kill-unit-type-from-a-value.ts b/ledger/game/kill-unit-type-from-a-value.ts new file mode 100644 index 0000000..9a1f8d0 --- /dev/null +++ b/ledger/game/kill-unit-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Kill Unit, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.ZergZergling; + let wave = 3; + if (wave > 2) kind = units.ZergHydralisk; + killUnit(P2, kind); +}); diff --git a/ledger/game/kill-unit.ts b/ledger/game/kill-unit.ts new file mode 100644 index 0000000..fb067ee --- /dev/null +++ b/ledger/game/kill-unit.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Kill Unit: every unit of a type a player has dies. +program(() => { + killUnit(P2, units.ZergZergling); +}); diff --git a/ledger/game/leaderboard-computer-players.ts b/ledger/game/leaderboard-computer-players.ts new file mode 100644 index 0000000..905fc11 --- /dev/null +++ b/ledger/game/leaderboard-computer-players.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leaderboard Computer Players: whether the computers show on the leader board. +program(() => { + leaderboardComputerPlayers("disable"); +}); diff --git a/ledger/game/leaderboard-control-at-type-from-a-value.ts b/ledger/game/leaderboard-control-at-type-from-a-value.ts new file mode 100644 index 0000000..0874751 --- /dev/null +++ b/ledger/game/leaderboard-control-at-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Leader Board (Control at Location), the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + leaderboardControlAt("Men on the hill", kind, locations.Anywhere); +}); diff --git a/ledger/game/leaderboard-control-at.ts b/ledger/game/leaderboard-control-at.ts new file mode 100644 index 0000000..229dfc7 --- /dev/null +++ b/ledger/game/leaderboard-control-at.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board (Control at Location): how many units of a type each player has at a location. +program(() => { + leaderboardControlAt("Marines on the hill", units.TerranMarine, locations.Anywhere); +}); diff --git a/ledger/game/leaderboard-control-type-from-a-value.ts b/ledger/game/leaderboard-control-type-from-a-value.ts new file mode 100644 index 0000000..12afea1 --- /dev/null +++ b/ledger/game/leaderboard-control-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Leader Board (Control), the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + leaderboardControl("Men", kind); +}); diff --git a/ledger/game/leaderboard-control.ts b/ledger/game/leaderboard-control.ts new file mode 100644 index 0000000..574f7e6 --- /dev/null +++ b/ledger/game/leaderboard-control.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board (Control): how many units of a type each player has, under a label. +program(() => { + leaderboardControl("Marines", units.TerranMarine); +}); diff --git a/ledger/game/leaderboard-goal-control-at-type-from-a-value.ts b/ledger/game/leaderboard-goal-control-at-type-from-a-value.ts new file mode 100644 index 0000000..a36f9a3 --- /dev/null +++ b/ledger/game/leaderboard-goal-control-at-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Leader Board Goal (Control at Location), the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + leaderboardGoalControlAt("Men on the hill", kind, 10, locations.Anywhere); +}); diff --git a/ledger/game/leaderboard-goal-control-at.ts b/ledger/game/leaderboard-goal-control-at.ts new file mode 100644 index 0000000..931ff18 --- /dev/null +++ b/ledger/game/leaderboard-goal-control-at.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board Goal (Control at Location): units of a type at a location, against a goal. +program(() => { + leaderboardGoalControlAt("Marines on the hill", units.TerranMarine, 10, locations.Anywhere); +}); diff --git a/ledger/game/leaderboard-goal-control-type-from-a-value.ts b/ledger/game/leaderboard-goal-control-type-from-a-value.ts new file mode 100644 index 0000000..ad06368 --- /dev/null +++ b/ledger/game/leaderboard-goal-control-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Leader Board Goal (Control), the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + leaderboardGoalControl("Men to 20", kind, 20); +}); diff --git a/ledger/game/leaderboard-goal-control.ts b/ledger/game/leaderboard-goal-control.ts new file mode 100644 index 0000000..6339194 --- /dev/null +++ b/ledger/game/leaderboard-goal-control.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board Goal (Control): units of a type each player has, against a goal. +program(() => { + leaderboardGoalControl("Marines to 20", units.TerranMarine, 20); +}); diff --git a/ledger/game/leaderboard-goal-kills-type-from-a-value.ts b/ledger/game/leaderboard-goal-kills-type-from-a-value.ts new file mode 100644 index 0000000..c604316 --- /dev/null +++ b/ledger/game/leaderboard-goal-kills-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Leader Board Goal (Kills), the unit type from a value of the program. +program(() => { + let kind: UnitType = units.ZergZergling; + let wave = 3; + if (wave > 2) kind = units.ZergHydralisk; + leaderboardGoalKills("Kill 50", kind, 50); +}); diff --git a/ledger/game/leaderboard-goal-kills.ts b/ledger/game/leaderboard-goal-kills.ts new file mode 100644 index 0000000..9e90f59 --- /dev/null +++ b/ledger/game/leaderboard-goal-kills.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board Goal (Kills): units of a type each player has killed, against a goal. +program(() => { + leaderboardGoalKills("Kill 50 Zerglings", units.ZergZergling, 50); +}); diff --git a/ledger/game/leaderboard-goal-points.ts b/ledger/game/leaderboard-goal-points.ts new file mode 100644 index 0000000..e9f3972 --- /dev/null +++ b/ledger/game/leaderboard-goal-points.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board Goal (Points): a score of each player, against a goal. +program(() => { + leaderboardGoalPoints("Kills to 1000", "kills", 1000); +}); diff --git a/ledger/game/leaderboard-goal-resources.ts b/ledger/game/leaderboard-goal-resources.ts new file mode 100644 index 0000000..114a25a --- /dev/null +++ b/ledger/game/leaderboard-goal-resources.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board Goal (Resources): each player's resources, against a goal. +program(() => { + leaderboardGoalResources("Gas to 800", 800, "gas"); +}); diff --git a/ledger/game/leaderboard-greed.ts b/ledger/game/leaderboard-greed.ts new file mode 100644 index 0000000..dee1661 --- /dev/null +++ b/ledger/game/leaderboard-greed.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board Greed: who first gathers so many minerals and gas. +program(() => { + leaderboardGreed(5000); +}); diff --git a/ledger/game/leaderboard-kills-type-from-a-value.ts b/ledger/game/leaderboard-kills-type-from-a-value.ts new file mode 100644 index 0000000..7605ee9 --- /dev/null +++ b/ledger/game/leaderboard-kills-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Leader Board (Kills), the unit type from a value of the program. +program(() => { + let kind: UnitType = units.ZergZergling; + let wave = 3; + if (wave > 2) kind = units.ZergHydralisk; + leaderboardKills("Kills", kind); +}); diff --git a/ledger/game/leaderboard-kills.ts b/ledger/game/leaderboard-kills.ts new file mode 100644 index 0000000..c2b3a84 --- /dev/null +++ b/ledger/game/leaderboard-kills.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board (Kills): how many units of a type each player has killed. +program(() => { + leaderboardKills("Zerglings killed", units.ZergZergling); +}); diff --git a/ledger/game/leaderboard-label-from-the-program.ts b/ledger/game/leaderboard-label-from-the-program.ts new file mode 100644 index 0000000..a5fbdba --- /dev/null +++ b/ledger/game/leaderboard-label-from-the-program.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// A leader board's label made while the game runs: a number of the program in it. +program(() => { + let goal = 10; + goal += 5; + leaderboardControl(`Marines (goal ${goal})`, units.TerranMarine); +}); diff --git a/ledger/game/leaderboard-points.ts b/ledger/game/leaderboard-points.ts new file mode 100644 index 0000000..7e90236 --- /dev/null +++ b/ledger/game/leaderboard-points.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board (Points): a score of each player. +program(() => { + leaderboardPoints("Score", "total"); +}); diff --git a/ledger/game/leaderboard-resources.ts b/ledger/game/leaderboard-resources.ts new file mode 100644 index 0000000..4861518 --- /dev/null +++ b/ledger/game/leaderboard-resources.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Leader Board (Resources): each player's minerals, gas or both. +program(() => { + leaderboardResources("Minerals", "ore"); +}); diff --git a/ledger/game/minimap-ping.ts b/ledger/game/minimap-ping.ts new file mode 100644 index 0000000..07bdc2a --- /dev/null +++ b/ledger/game/minimap-ping.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Minimap Ping: a flash on the minimap at a location. +program(() => { + minimapPing(locations.Anywhere); +}); diff --git a/ledger/game/modify-energy-type-from-a-value.ts b/ledger/game/modify-energy-type-from-a-value.ts new file mode 100644 index 0000000..6c27c96 --- /dev/null +++ b/ledger/game/modify-energy-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Modify Unit Energy, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranGhost; + let wave = 3; + if (wave > 2) kind = units.TerranMedic; + modifyEnergy(P1, kind, 100, "All", locations.Anywhere); +}); diff --git a/ledger/game/modify-energy.ts b/ledger/game/modify-energy.ts new file mode 100644 index 0000000..48a3463 --- /dev/null +++ b/ledger/game/modify-energy.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Modify Unit Energy: units at a location get a percentage of their energy. +program(() => { + modifyEnergy(P1, units.TerranGhost, 100, "All", locations.Anywhere); +}); diff --git a/ledger/game/modify-hangar-count-type-from-a-value.ts b/ledger/game/modify-hangar-count-type-from-a-value.ts new file mode 100644 index 0000000..c0652db --- /dev/null +++ b/ledger/game/modify-hangar-count-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Modify Unit Hanger Count, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.ProtossCarrier; + let wave = 3; + if (wave > 2) kind = units.ProtossReaver; + modifyHangarCount(P1, kind, 4, "All", locations.Anywhere); +}); diff --git a/ledger/game/modify-hangar-count.ts b/ledger/game/modify-hangar-count.ts new file mode 100644 index 0000000..dafafe6 --- /dev/null +++ b/ledger/game/modify-hangar-count.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Modify Unit Hanger Count: Interceptors or Scarabs added to the units at a location. +program(() => { + modifyHangarCount(P1, units.ProtossCarrier, 4, "All", locations.Anywhere); +}); diff --git a/ledger/game/modify-hit-points-type-from-a-value.ts b/ledger/game/modify-hit-points-type-from-a-value.ts new file mode 100644 index 0000000..6ba50da --- /dev/null +++ b/ledger/game/modify-hit-points-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Modify Unit Hit Points, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + modifyHitPoints(P1, kind, 100, "All", locations.Anywhere); +}); diff --git a/ledger/game/modify-hit-points.ts b/ledger/game/modify-hit-points.ts new file mode 100644 index 0000000..16b61d1 --- /dev/null +++ b/ledger/game/modify-hit-points.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Modify Unit Hit Points: units at a location get a percentage of their hit points. +program(() => { + modifyHitPoints(P2, units.ZergZergling, 50, "All", locations.Anywhere); +}); diff --git a/ledger/game/modify-resource-amount.ts b/ledger/game/modify-resource-amount.ts new file mode 100644 index 0000000..46103a4 --- /dev/null +++ b/ledger/game/modify-resource-amount.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Modify Unit Resource Amount: what the mineral fields and geysers at a location hold. +program(() => { + modifyResourceAmount(players.Neutral, 500, "All", locations.Anywhere); +}); diff --git a/ledger/game/modify-shields-type-from-a-value.ts b/ledger/game/modify-shields-type-from-a-value.ts new file mode 100644 index 0000000..c071041 --- /dev/null +++ b/ledger/game/modify-shields-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Modify Unit Shield Points, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.ProtossZealot; + let wave = 3; + if (wave > 2) kind = units.ProtossDragoon; + modifyShields(P1, kind, 100, "All", locations.Anywhere); +}); diff --git a/ledger/game/modify-shields.ts b/ledger/game/modify-shields.ts new file mode 100644 index 0000000..f15b142 --- /dev/null +++ b/ledger/game/modify-shields.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Modify Unit Shield Points: units at a location get a percentage of their shields. +program(() => { + modifyShields(P1, units.ProtossZealot, 100, "All", locations.Anywhere); +}); diff --git a/ledger/game/move-location-type-from-a-value.ts b/ledger/game/move-location-type-from-a-value.ts new file mode 100644 index 0000000..70e644d --- /dev/null +++ b/ledger/game/move-location-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Move Location, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + moveLocation(P1, kind, locations.Anywhere, locations.Anywhere); +}); diff --git a/ledger/game/move-location.ts b/ledger/game/move-location.ts new file mode 100644 index 0000000..fba7095 --- /dev/null +++ b/ledger/game/move-location.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Move Location: a location centred on a unit of a type at another location. +program(() => { + moveLocation(P1, units.TerranMarine, locations.Anywhere, locations.Anywhere); +}); diff --git a/ledger/game/move-unit-type-from-a-value.ts b/ledger/game/move-unit-type-from-a-value.ts new file mode 100644 index 0000000..cfdc765 --- /dev/null +++ b/ledger/game/move-unit-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Move Unit, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + moveUnit(P1, kind, "All", locations.Anywhere, locations.Anywhere); +}); diff --git a/ledger/game/move-unit.ts b/ledger/game/move-unit.ts new file mode 100644 index 0000000..69fecf6 --- /dev/null +++ b/ledger/game/move-unit.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Move Unit: units taken from one location to another at once. +program(() => { + moveUnit(P1, units.TerranMarine, 4, locations.Anywhere, locations.Anywhere); +}); diff --git a/ledger/game/mute-unit-speech.ts b/ledger/game/mute-unit-speech.ts new file mode 100644 index 0000000..fde5351 --- /dev/null +++ b/ledger/game/mute-unit-speech.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Mute Unit Speech: the units' voices quiet, for a cinematic. +program(() => { + muteUnitSpeech(); +}); diff --git a/ledger/game/not-of-a-condition.ts b/ledger/game/not-of-a-condition.ts new file mode 100644 index 0000000..2368b71 --- /dev/null +++ b/ledger/game/not-of-a-condition.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A condition turned around by `not(…)`, worked out when the map is built: a program tests the +// flipped condition ("at least 1" becomes "at most 0"). +program(() => { + if (not(bring(P1, units.AnyUnit, locations.Anywhere, ">=", 1))) print("yes"); +}); diff --git a/ledger/game/once.ts b/ledger/game/once.ts new file mode 100644 index 0000000..f90780d --- /dev/null +++ b/ledger/game/once.ts @@ -0,0 +1,11 @@ +// ledger: step 5, takes +// `once(…)`: true the first time its test holds, never again. +program(() => { + let n = 0; + let high = false; + while (n < 6) { + high = n > 2; + if (once(high)) print(`first at ${n}`); + n += 1; + } +}); diff --git a/ledger/game/order.ts b/ledger/game/order.ts new file mode 100644 index 0000000..0ccbf30 --- /dev/null +++ b/ledger/game/order.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Order: units at a location sent to another, to move, patrol or attack — "attack" or orders.AttackMove, +// one of the three orders the action gives (any other of orders.* is a compile error). +program(() => { + order(P1, units.TerranMarine, locations.Anywhere, locations.Anywhere, "attack"); + order(P1, units.TerranMarine, locations.Anywhere, locations.Anywhere, orders.Move); +}); diff --git a/ledger/game/pause-game.ts b/ledger/game/pause-game.ts new file mode 100644 index 0000000..78bb98e --- /dev/null +++ b/ledger/game/pause-game.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Pause Game (single player only). +program(() => { + pauseGame(); +}); diff --git a/ledger/game/pause-timer.ts b/ledger/game/pause-timer.ts new file mode 100644 index 0000000..97a4a7f --- /dev/null +++ b/ledger/game/pause-timer.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Pause Timer: the countdown timer stops. +program(() => { + pauseTimer(); +}); diff --git a/ledger/game/read-accumulate.ts b/ledger/game/read-accumulate.ts new file mode 100644 index 0000000..10d934f --- /dev/null +++ b/ledger/game/read-accumulate.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A condition that compares a quantity, read as a value: `accumulate(P1, "gas")`. +program(() => { + const n = accumulate(P1, "gas"); + print(`${n}`); +}); diff --git a/ledger/game/read-bring.ts b/ledger/game/read-bring.ts new file mode 100644 index 0000000..998c1eb --- /dev/null +++ b/ledger/game/read-bring.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A condition that compares a quantity, read as a value: `bring(P1, unit, location)`. +program(() => { + const n = bring(P1, units.AnyUnit, locations.Anywhere); + print(`${n}`); +}); diff --git a/ledger/game/read-command.ts b/ledger/game/read-command.ts new file mode 100644 index 0000000..3f0cf9a --- /dev/null +++ b/ledger/game/read-command.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A condition that compares a quantity, read as a value: `command(P1, unit)`. +program(() => { + const n = command(P1, units.TerranMarine); + print(`${n}`); +}); diff --git a/ledger/game/read-count-units-at.ts b/ledger/game/read-count-units-at.ts new file mode 100644 index 0000000..fe451bf --- /dev/null +++ b/ledger/game/read-count-units-at.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// How many units of a type a player has at a location, read when the line runs: `countUnits(P1, …, location)`. +program(() => { + const n = countUnits(P1, units.TerranMarine, locations.Anywhere); + print(`${n}`); +}); diff --git a/ledger/game/read-count-units.ts b/ledger/game/read-count-units.ts new file mode 100644 index 0000000..83a0d45 --- /dev/null +++ b/ledger/game/read-count-units.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// How many units of a type a player has anywhere, read when the line runs: `countUnits(P1, …)`. +program(() => { + const n = countUnits(P1, units.TerranMarine); + print(`${n}`); +}); diff --git a/ledger/game/read-countdown-timer.ts b/ledger/game/read-countdown-timer.ts new file mode 100644 index 0000000..eaf8b6c --- /dev/null +++ b/ledger/game/read-countdown-timer.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A condition that compares a quantity, read as a value: `countdownTimer()`. +program(() => { + const n = countdownTimer(); + print(`${n}`); +}); diff --git a/ledger/game/read-countdown.ts b/ledger/game/read-countdown.ts new file mode 100644 index 0000000..1958073 --- /dev/null +++ b/ledger/game/read-countdown.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The countdown timer in game seconds, read when the line runs: `countdown()`. +program(() => { + const n = countdown(); + print(`${n}`); +}); diff --git a/ledger/game/read-deaths.ts b/ledger/game/read-deaths.ts new file mode 100644 index 0000000..fe53975 --- /dev/null +++ b/ledger/game/read-deaths.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The death counter of a player and a unit type, read as a value: `deaths(P1, unit)`. +program(() => { + const n = deaths(P1, units.TerranMarine); + print(`${n}`); +}); diff --git a/ledger/game/read-elapsed-time.ts b/ledger/game/read-elapsed-time.ts new file mode 100644 index 0000000..7acb227 --- /dev/null +++ b/ledger/game/read-elapsed-time.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A condition that compares a quantity, read as a value: `elapsedTime()`. +program(() => { + const n = elapsedTime(); + print(`${n}`); +}); diff --git a/ledger/game/read-elapsed.ts b/ledger/game/read-elapsed.ts new file mode 100644 index 0000000..472d0d9 --- /dev/null +++ b/ledger/game/read-elapsed.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// Game seconds since the start, read when the line runs: `elapsed()`. +program(() => { + const n = elapsed(); + print(`${n}`); +}); diff --git a/ledger/game/read-gas.ts b/ledger/game/read-gas.ts new file mode 100644 index 0000000..d544d5b --- /dev/null +++ b/ledger/game/read-gas.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A player's gas read when the line runs: `gas(P1)`. +program(() => { + const n = gas(P1); + print(`${n}`); +}); diff --git a/ledger/game/read-in-arithmetic.ts b/ledger/game/read-in-arithmetic.ts new file mode 100644 index 0000000..81733f9 --- /dev/null +++ b/ledger/game/read-in-arithmetic.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A read of the game in the program's arithmetic: `minerals(P1) - gas(P1)`. +program(() => { + const n = minerals(P1) - gas(P1); + print(`${n}`); +}); diff --git a/ledger/game/read-kill.ts b/ledger/game/read-kill.ts new file mode 100644 index 0000000..20ec449 --- /dev/null +++ b/ledger/game/read-kill.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A condition that compares a quantity, read as a value: `kill(P1, unit)`. +program(() => { + const n = kill(P1, units.ZergZergling); + print(`${n}`); +}); diff --git a/ledger/game/read-kills.ts b/ledger/game/read-kills.ts new file mode 100644 index 0000000..48b9fc5 --- /dev/null +++ b/ledger/game/read-kills.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// How many units of a type a player has killed, read when the line runs: `kills(P1, …)`. +program(() => { + const n = kills(P1, units.ZergZergling); + print(`${n}`); +}); diff --git a/ledger/game/read-minerals.ts b/ledger/game/read-minerals.ts new file mode 100644 index 0000000..c059d2c --- /dev/null +++ b/ledger/game/read-minerals.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A player's minerals read when the line runs: `minerals(P1)`. +program(() => { + const n = minerals(P1); + print(`${n}`); +}); diff --git a/ledger/game/read-opponents.ts b/ledger/game/read-opponents.ts new file mode 100644 index 0000000..a29dd80 --- /dev/null +++ b/ledger/game/read-opponents.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// How many opponents a player has left, read as a value: `opponents(P1)`. +program(() => { + const n = opponents(P1); + print(`${n}`); +}); diff --git a/ledger/game/read-race.ts b/ledger/game/read-race.ts new file mode 100644 index 0000000..0da5728 --- /dev/null +++ b/ledger/game/read-race.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The race a player plays, as one of `races.*`: `race(P1) == races.Zerg`. +program(() => { + if (race(P1) == races.Zerg) print("Zerg"); +}); diff --git a/ledger/game/read-resources.ts b/ledger/game/read-resources.ts new file mode 100644 index 0000000..a7a3ba7 --- /dev/null +++ b/ledger/game/read-resources.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A player's minerals and gas added up, read when the line runs: `resources(P1, "oreAndGas")`. +program(() => { + const n = resources(P1, "oreAndGas"); + print(`${n}`); +}); diff --git a/ledger/game/read-score.ts b/ledger/game/read-score.ts new file mode 100644 index 0000000..0245678 --- /dev/null +++ b/ledger/game/read-score.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A player's score of a kind, read as a value: `score(P1, "kills")`. +program(() => { + const n = score(P1, "kills"); + print(`${n}`); +}); diff --git a/ledger/game/read-slot.ts b/ledger/game/read-slot.ts new file mode 100644 index 0000000..91e63e7 --- /dev/null +++ b/ledger/game/read-slot.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// What holds a player's slot, as one of `slots.*`: `slot(P3) == slots.Computer`. +program(() => { + if (slot(P3) == slots.Computer) print("a computer"); +}); diff --git a/ledger/game/read-supply-max.ts b/ledger/game/read-supply-max.ts new file mode 100644 index 0000000..8702d83 --- /dev/null +++ b/ledger/game/read-supply-max.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A player's supply cap, read when the line runs: `supply(P1, "max")`. +program(() => { + const n = supply(P1, "max"); + print(`${n}`); +}); diff --git a/ledger/game/read-supply-provided-of-a-race.ts b/ledger/game/read-supply-provided-of-a-race.ts new file mode 100644 index 0000000..e24c931 --- /dev/null +++ b/ledger/game/read-supply-provided-of-a-race.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The supply a player's depots, overlords or pylons provide, of one race: `supply(P1, "provided", races.Zerg)`. +program(() => { + const n = supply(P1, "provided", races.Zerg); + print(`${n}`); +}); diff --git a/ledger/game/read-supply-used.ts b/ledger/game/read-supply-used.ts new file mode 100644 index 0000000..860db16 --- /dev/null +++ b/ledger/game/read-supply-used.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A player's supply used, of the race they play, read when the line runs: `supply(P1)`. +program(() => { + const n = supply(P1); + print(`${n}`); +}); diff --git a/ledger/game/remove-unit-at-count-from-a-value.ts b/ledger/game/remove-unit-at-count-from-a-value.ts new file mode 100644 index 0000000..5e30cd9 --- /dev/null +++ b/ledger/game/remove-unit-at-count-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Remove Unit at Location, how many from a value of the program. +program(() => { + let n = 1; + n += 1; + removeUnitAt(P2, units.ZergZergling, n, locations.Anywhere); +}); diff --git a/ledger/game/remove-unit-at-type-from-a-value.ts b/ledger/game/remove-unit-at-type-from-a-value.ts new file mode 100644 index 0000000..21ebb09 --- /dev/null +++ b/ledger/game/remove-unit-at-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Remove Unit at Location, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.ZergZergling; + let wave = 3; + if (wave > 2) kind = units.ZergHydralisk; + removeUnitAt(P2, kind, "All", locations.Anywhere); +}); diff --git a/ledger/game/remove-unit-at.ts b/ledger/game/remove-unit-at.ts new file mode 100644 index 0000000..b1631aa --- /dev/null +++ b/ledger/game/remove-unit-at.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Remove Unit at Location, all its fields known when the map is built. +program(() => { + removeUnitAt(P2, units.ZergZergling, 2, locations.Anywhere); +}); diff --git a/ledger/game/remove-unit-type-from-a-value.ts b/ledger/game/remove-unit-type-from-a-value.ts new file mode 100644 index 0000000..536214a --- /dev/null +++ b/ledger/game/remove-unit-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Remove Unit, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.ZergZergling; + let wave = 3; + if (wave > 2) kind = units.ZergHydralisk; + removeUnit(P2, kind); +}); diff --git a/ledger/game/remove-unit.ts b/ledger/game/remove-unit.ts new file mode 100644 index 0000000..ca74e16 --- /dev/null +++ b/ledger/game/remove-unit.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Remove Unit: every unit of a type a player has taken off the map, without a death. +program(() => { + removeUnit(P2, units.ZergZergling); +}); diff --git a/ledger/game/reserve-location.ts b/ledger/game/reserve-location.ts new file mode 100644 index 0000000..f68a255 --- /dev/null +++ b/ledger/game/reserve-location.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A location the map does not use, taken when the map is built: `reserveLocation(why)`. Worked out when +// the map is built: the program has its number. +const spot = reserveLocation("ledger: a spot of the library's own"); +program(() => { + let n = spot; + print(`location ${n}`); +}); diff --git a/ledger/game/rose.ts b/ledger/game/rose.ts new file mode 100644 index 0000000..cc68486 --- /dev/null +++ b/ledger/game/rose.ts @@ -0,0 +1,11 @@ +// ledger: step 5, takes +// `rose(…)`: true on the frame its test becomes true, false until it becomes false and true again. +program(() => { + let n = 0; + let high = false; + while (n < 6) { + high = n > 2; + if (rose(high)) print(`up at ${n}`); + n += 1; + } +}); diff --git a/ledger/game/run-ai-script-at.ts b/ledger/game/run-ai-script-at.ts new file mode 100644 index 0000000..0604000 --- /dev/null +++ b/ledger/game/run-ai-script-at.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Run AI Script at Location. +program(() => { + runAiScriptAt(aiScripts.ZergCustomLevel, locations.Anywhere); +}); diff --git a/ledger/game/run-ai-script.ts b/ledger/game/run-ai-script.ts new file mode 100644 index 0000000..9656eab --- /dev/null +++ b/ledger/game/run-ai-script.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Run AI Script: one of the game's AI scripts, by name (`aiScripts.*`). +program(() => { + runAiScript(aiScripts.TerranCustomLevel); +}); diff --git a/ledger/game/screen-max-pixels.ts b/ledger/game/screen-max-pixels.ts new file mode 100644 index 0000000..f34c9bc --- /dev/null +++ b/ledger/game/screen-max-pixels.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// The screen library's most pixels a screen has, `MAX_PIXELS`: a number known when the map is built. +import { MAX_PIXELS } from "trigscript/screen"; +program(() => { + let rows = MAX_PIXELS >> 5; + print(`${rows} rows of 32`); +}); diff --git a/ledger/game/screen-owners.ts b/ledger/game/screen-owners.ts new file mode 100644 index 0000000..79b4fb0 --- /dev/null +++ b/ledger/game/screen-owners.ts @@ -0,0 +1,9 @@ +// ledger: step 6, takes +// The screen library's pixel owners, `OWNERS`, a player a colour: each given the colour of its pixels. +import { OWNERS } from "trigscript/screen"; +const LOOK = ["teal", "yellow", "purple", "green", "red", "blue", "orange"] as const; +program(() => { + OWNERS.forEach((owner, k) => { + stats(owner).color = LOOK[k]; + }); +}); diff --git a/ledger/game/screen-sprite.ts b/ledger/game/screen-sprite.ts new file mode 100644 index 0000000..2531017 --- /dev/null +++ b/ledger/game/screen-sprite.ts @@ -0,0 +1,11 @@ +// ledger: step 5, takes +// The screen library: a picture made of rows of text when the map is built (`sprite`), drawn on a screen +// where the program says (`blit`). +import { Screen, sprite } from "trigscript/screen"; +const SHIP = sprite([".1.", "111", "1.1"]); +program(async () => { + const screen = new Screen(4, 4, 8, 8); + let x = 2; + screen.blit(SHIP, x, 4); + screen.present(); +}); diff --git a/ledger/game/screen-text-colors.tsx b/ledger/game/screen-text-colors.tsx new file mode 100644 index 0000000..7e7387c --- /dev/null +++ b/ledger/game/screen-text-colors.tsx @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// The screen library's colours in the text lines, `TEXT_COLORS`: a pixel's colour, 1 to 7, as a square of text. +import { TEXT_COLORS } from "trigscript/screen"; +program(async () => { + let c = 2; + c += 1; + print(); +}); diff --git a/ledger/game/screen-view.tsx b/ledger/game/screen-view.tsx new file mode 100644 index 0000000..b45987e --- /dev/null +++ b/ledger/game/screen-view.tsx @@ -0,0 +1,10 @@ +// ledger: step 8, takes +// The screen library: a screen's pixels shown in the text lines, a coloured square each: +// ``. +import { Screen, ScreenView } from "trigscript/screen"; +program(async () => { + const screen = new Screen(0, 0, 4, 3); + let x = 1; + screen.set(x, 1, 3); + print(); +}); diff --git a/ledger/game/screen.ts b/ledger/game/screen.ts new file mode 100644 index 0000000..eefcd75 --- /dev/null +++ b/ledger/game/screen.ts @@ -0,0 +1,10 @@ +// ledger: step 5, takes +// The screen library: a grid of pixels drawn by the program and shown on the map as units, a pixel +// each: `new Screen(x, y, width, height)`, `set`, `present()`. +import { Screen } from "trigscript/screen"; +program(async () => { + const screen = new Screen(4, 4, 8, 8); + let x = 2; + screen.set(x, 3, 2); + screen.present(); +}); diff --git a/ledger/game/set-alliance-status.ts b/ledger/game/set-alliance-status.ts new file mode 100644 index 0000000..0b6c14e --- /dev/null +++ b/ledger/game/set-alliance-status.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Alliance Status: the current player's stance toward another, ally or enemy. +program(() => { + setAllianceStatus(P2, "ally"); +}); diff --git a/ledger/game/set-countdown-timer-from-a-value.ts b/ledger/game/set-countdown-timer-from-a-value.ts new file mode 100644 index 0000000..2d4ad53 --- /dev/null +++ b/ledger/game/set-countdown-timer-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Set Countdown Timer, the seconds from a value of the program. +program(() => { + let seconds = 60; + seconds += 30; + setCountdownTimer("set", seconds); +}); diff --git a/ledger/game/set-countdown-timer.ts b/ledger/game/set-countdown-timer.ts new file mode 100644 index 0000000..3deab58 --- /dev/null +++ b/ledger/game/set-countdown-timer.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Countdown Timer, its seconds known when the map is built. +program(() => { + setCountdownTimer("set", 90); +}); diff --git a/ledger/game/set-deaths-amount-from-a-value.ts b/ledger/game/set-deaths-amount-from-a-value.ts new file mode 100644 index 0000000..9cbbdb7 --- /dev/null +++ b/ledger/game/set-deaths-amount-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Set Deaths, the amount from a value of the program. +program(() => { + let n = 4; + n += 1; + setDeaths(P1, units.TerranMarine, "set", n); +}); diff --git a/ledger/game/set-deaths-unit-from-a-value.ts b/ledger/game/set-deaths-unit-from-a-value.ts new file mode 100644 index 0000000..bf89806 --- /dev/null +++ b/ledger/game/set-deaths-unit-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Set Deaths, the unit type (which counter) from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + setDeaths(P1, kind, "add", 1); +}); diff --git a/ledger/game/set-deaths.ts b/ledger/game/set-deaths.ts new file mode 100644 index 0000000..473a618 --- /dev/null +++ b/ledger/game/set-deaths.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Deaths, all its fields known when the map is built: the counter a map keeps in the death table. +program(() => { + setDeaths(P1, units.TerranMarine, "add", 1); +}); diff --git a/ledger/game/set-doodad-state-type-from-a-value.ts b/ledger/game/set-doodad-state-type-from-a-value.ts new file mode 100644 index 0000000..8db8872 --- /dev/null +++ b/ledger/game/set-doodad-state-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Set Doodad State, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.FloorGunTrap; + let wave = 3; + if (wave > 2) kind = units.LeftWallMissileTrap; + setDoodadState(players.Neutral, kind, locations.Anywhere, "disable"); +}); diff --git a/ledger/game/set-doodad-state.ts b/ledger/game/set-doodad-state.ts new file mode 100644 index 0000000..7bbbd10 --- /dev/null +++ b/ledger/game/set-doodad-state.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Doodad State: a trap or a door at a location turned on or off. +program(() => { + setDoodadState(players.Neutral, units.FloorGunTrap, locations.Anywhere, "disable"); +}); diff --git a/ledger/game/set-invincibility-type-from-a-value.ts b/ledger/game/set-invincibility-type-from-a-value.ts new file mode 100644 index 0000000..485c398 --- /dev/null +++ b/ledger/game/set-invincibility-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Set Invincibility, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + setInvincibility(P1, kind, locations.Anywhere, "enable"); +}); diff --git a/ledger/game/set-invincibility.ts b/ledger/game/set-invincibility.ts new file mode 100644 index 0000000..f34b2c2 --- /dev/null +++ b/ledger/game/set-invincibility.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Invincibility: units at a location cannot be hurt, or can again. +program(() => { + setInvincibility(P1, units.TerranMarine, locations.Anywhere, "enable"); +}); diff --git a/ledger/game/set-mission-objectives-text-from-the-program.ts b/ledger/game/set-mission-objectives-text-from-the-program.ts new file mode 100644 index 0000000..c291a2d --- /dev/null +++ b/ledger/game/set-mission-objectives-text-from-the-program.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Set Mission Objectives, its text made while the game runs: a number of the program in it. +program(() => { + let wave = 2; + wave += 1; + setMissionObjectives(`Survive wave ${wave}`); +}); diff --git a/ledger/game/set-mission-objectives.ts b/ledger/game/set-mission-objectives.ts new file mode 100644 index 0000000..9de91f4 --- /dev/null +++ b/ledger/game/set-mission-objectives.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Mission Objectives, its text written in the script. +program(() => { + setMissionObjectives("Hold the bridge."); +}); diff --git a/ledger/game/set-next-scenario.ts b/ledger/game/set-next-scenario.ts new file mode 100644 index 0000000..e0cbad9 --- /dev/null +++ b/ledger/game/set-next-scenario.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Next Scenario: the map a campaign goes on to. +program(() => { + setNextScenario("Chapter 2"); +}); diff --git a/ledger/game/set-resources-amount-from-a-read.ts b/ledger/game/set-resources-amount-from-a-read.ts new file mode 100644 index 0000000..9d4b226 --- /dev/null +++ b/ledger/game/set-resources-amount-from-a-read.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Resources, the amount read from the game when the line runs: `setResources(P2, "set", minerals(P1), "ore")`. +program(() => { + setResources(P2, "set", minerals(P1), "ore"); +}); diff --git a/ledger/game/set-resources-amount-from-a-value.ts b/ledger/game/set-resources-amount-from-a-value.ts new file mode 100644 index 0000000..e9212ef --- /dev/null +++ b/ledger/game/set-resources-amount-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Set Resources, the amount from a value of the program. +program(() => { + let bounty = 50; + bounty += 25; + setResources(P1, "add", bounty, "ore"); +}); diff --git a/ledger/game/set-resources.ts b/ledger/game/set-resources.ts new file mode 100644 index 0000000..81a77c8 --- /dev/null +++ b/ledger/game/set-resources.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Resources, all its fields known when the map is built. +program(() => { + setResources(P1, "add", 500, "ore"); +}); diff --git a/ledger/game/set-score-amount-from-a-value.ts b/ledger/game/set-score-amount-from-a-value.ts new file mode 100644 index 0000000..e50c375 --- /dev/null +++ b/ledger/game/set-score-amount-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Set Score, the amount from a value of the program. +program(() => { + let points = 10; + points += 5; + setScore(P1, "add", points, "custom"); +}); diff --git a/ledger/game/set-score.ts b/ledger/game/set-score.ts new file mode 100644 index 0000000..4654a7c --- /dev/null +++ b/ledger/game/set-score.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Score, all its fields known when the map is built. +program(() => { + setScore(P1, "add", 10, "custom"); +}); diff --git a/ledger/game/set-switch.ts b/ledger/game/set-switch.ts new file mode 100644 index 0000000..021b6dc --- /dev/null +++ b/ledger/game/set-switch.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Set Switch: one of the map's switches (`switches.*`) set, cleared, toggled or randomized. +program(() => { + setSwitch(switches.Switch1, "set"); +}); diff --git a/ledger/game/talking-portrait-type-from-a-value.ts b/ledger/game/talking-portrait-type-from-a-value.ts new file mode 100644 index 0000000..5a0027c --- /dev/null +++ b/ledger/game/talking-portrait-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Talking Portrait, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + talkingPortrait(kind, 2000); +}); diff --git a/ledger/game/talking-portrait.ts b/ledger/game/talking-portrait.ts new file mode 100644 index 0000000..465f6a4 --- /dev/null +++ b/ledger/game/talking-portrait.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Talking Portrait: a unit's portrait talks for so many milliseconds. +program(() => { + talkingPortrait(units.TerranMarine, 2000); +}); diff --git a/ledger/game/transmission-text-from-the-program.ts b/ledger/game/transmission-text-from-the-program.ts new file mode 100644 index 0000000..9f69639 --- /dev/null +++ b/ledger/game/transmission-text-from-the-program.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Transmission, its text made while the game runs: a number of the program in it. +program(() => { + let left = 4; + left -= 1; + transmission(`${left} bunkers left`, units.TerranMarine, locations.Anywhere, "add", 0, "sound\\glue\\mousedown2.wav", 500); +}); diff --git a/ledger/game/transmission-type-from-a-value.ts b/ledger/game/transmission-type-from-a-value.ts new file mode 100644 index 0000000..50f192d --- /dev/null +++ b/ledger/game/transmission-type-from-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Transmission, the unit type from a value of the program. +program(() => { + let kind: UnitType = units.TerranMarine; + let wave = 3; + if (wave > 2) kind = units.TerranFirebat; + transmission("Report in.", kind, locations.Anywhere, "add", 0, "sound\\glue\\mousedown2.wav", 500); +}); diff --git a/ledger/game/transmission.ts b/ledger/game/transmission.ts new file mode 100644 index 0000000..985674c --- /dev/null +++ b/ledger/game/transmission.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Transmission: a text, a sound and a talking portrait, the unit pinged on the minimap. +program(() => { + transmission("Report in.", units.TerranMarine, locations.Anywhere, "add", 0, "sound\\glue\\mousedown2.wav", 500); +}); diff --git a/ledger/game/unmute-unit-speech.ts b/ledger/game/unmute-unit-speech.ts new file mode 100644 index 0000000..63633b9 --- /dev/null +++ b/ledger/game/unmute-unit-speech.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Unmute Unit Speech: the units' voices back. +program(() => { + unmuteUnitSpeech(); +}); diff --git a/ledger/game/unpause-game.ts b/ledger/game/unpause-game.ts new file mode 100644 index 0000000..69710ac --- /dev/null +++ b/ledger/game/unpause-game.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Unpause Game. +program(() => { + unpauseGame(); +}); diff --git a/ledger/game/unpause-timer.ts b/ledger/game/unpause-timer.ts new file mode 100644 index 0000000..9671c56 --- /dev/null +++ b/ledger/game/unpause-timer.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Unpause Timer: the countdown timer runs on. +program(() => { + unpauseTimer(); +}); diff --git a/ledger/game/victory.ts b/ledger/game/victory.ts new file mode 100644 index 0000000..1ff8908 --- /dev/null +++ b/ledger/game/victory.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Victory: the player wins the game. +program(() => { + victory(); +}); diff --git a/ledger/game/wait.ts b/ledger/game/wait.ts new file mode 100644 index 0000000..ab23c50 --- /dev/null +++ b/ledger/game/wait.ts @@ -0,0 +1,5 @@ +// ledger: step 5, refused: the action Wait +// The action Wait, inside a program: the trigger waits so many milliseconds. +program(() => { + wait(500); +}); diff --git a/ledger/input/chatted-number.ts b/ledger/input/chatted-number.ts new file mode 100644 index 0000000..2927d7a --- /dev/null +++ b/ledger/input/chatted-number.ts @@ -0,0 +1,9 @@ +// ledger: step 12, takes +// A number read out of a typed line: `const m = chatted(P1, "-give {n}"); if (m) … m.n …`. +program(async () => { + while (true) { + const m = chatted(P1, "-give {n}"); + if (m) print(`${m.n}`); + await nextFrame(); + } +}); diff --git a/ledger/input/chatted-unit-type.ts b/ledger/input/chatted-unit-type.ts new file mode 100644 index 0000000..f2d8769 --- /dev/null +++ b/ledger/input/chatted-unit-type.ts @@ -0,0 +1,9 @@ +// ledger: step 12, takes +// A unit type read out of a typed line by its name: `chatted(P1, "-spawn {what:unit}")`. +program(async () => { + while (true) { + const m = chatted(P1, "-spawn {what:unit}"); + if (m && m.what == units.TerranMarine) print("a marine"); + await nextFrame(); + } +}); diff --git a/ledger/input/chatted-word.ts b/ledger/input/chatted-word.ts new file mode 100644 index 0000000..b73ab26 --- /dev/null +++ b/ledger/input/chatted-word.ts @@ -0,0 +1,9 @@ +// ledger: step 12, takes +// One of a list of words read out of a typed line, as its place in the list: `chatted(P1, "-take {kind:ore|gas}")`. +program(async () => { + while (true) { + const m = chatted(P1, "-take {kind:ore|gas}"); + if (m) print(`${m.kind}`); + await nextFrame(); + } +}); diff --git a/ledger/input/chatted.ts b/ledger/input/chatted.ts new file mode 100644 index 0000000..acb545e --- /dev/null +++ b/ledger/input/chatted.ts @@ -0,0 +1,8 @@ +// ledger: step 12, takes +// A line a player typed, matched whole: `if (chatted(P1, "-help"))`. +program(async () => { + while (true) { + if (chatted(P1, "-help")) print("help"); + await nextFrame(); + } +}); diff --git a/ledger/input/clicked-middle.ts b/ledger/input/clicked-middle.ts new file mode 100644 index 0000000..602e3fe --- /dev/null +++ b/ledger/input/clicked-middle.ts @@ -0,0 +1,8 @@ +// ledger: step 12, takes +// A click of the middle mouse button: `clicked(P1, "middle")`. +program(async () => { + while (true) { + if (clicked(P1, "middle")) print("middle"); + await nextFrame(); + } +}); diff --git a/ledger/input/clicked-right.ts b/ledger/input/clicked-right.ts new file mode 100644 index 0000000..bef65ed --- /dev/null +++ b/ledger/input/clicked-right.ts @@ -0,0 +1,8 @@ +// ledger: step 12, takes +// A click of the right mouse button: `clicked(P1, "right")`. +program(async () => { + while (true) { + if (clicked(P1, "right")) print("right"); + await nextFrame(); + } +}); diff --git a/ledger/input/clicked.ts b/ledger/input/clicked.ts new file mode 100644 index 0000000..ebdc71c --- /dev/null +++ b/ledger/input/clicked.ts @@ -0,0 +1,8 @@ +// ledger: step 12, takes +// A click of the left mouse button, true on the frame it arrives: `clicked(P1)`. +program(async () => { + while (true) { + if (clicked(P1)) print("click"); + await nextFrame(); + } +}); diff --git a/ledger/input/key-held.ts b/ledger/input/key-held.ts new file mode 100644 index 0000000..4bff5aa --- /dev/null +++ b/ledger/input/key-held.ts @@ -0,0 +1,10 @@ +// ledger: step 12, takes +// A key held down, true on every frame it is down: `keyHeld(P1, "D")`. +program(async () => { + let x = 0; + while (true) { + if (keyHeld(P1, "D")) x += 1; + print(`${x}`); + await nextFrame(); + } +}); diff --git a/ledger/input/key-pressed-current-player.ts b/ledger/input/key-pressed-current-player.ts new file mode 100644 index 0000000..6e605ee --- /dev/null +++ b/ledger/input/key-pressed-current-player.ts @@ -0,0 +1,8 @@ +// ledger: step 12, takes +// A key pressed by the player the program runs as: `keyPressed(CurrentPlayer, "Space")`. +program(async () => { + while (true) { + if (keyPressed(CurrentPlayer, "Space")) print("jump"); + await nextFrame(); + } +}, { owner: P2 }); diff --git a/ledger/input/key-pressed.ts b/ledger/input/key-pressed.ts new file mode 100644 index 0000000..cb502bd --- /dev/null +++ b/ledger/input/key-pressed.ts @@ -0,0 +1,8 @@ +// ledger: step 12, takes +// A key a player pressed, true on the frame the press arrives: `keyPressed(P1, "F7")`, looked for every frame. +program(async () => { + while (true) { + if (keyPressed(P1, "F7")) print("help"); + await nextFrame(); + } +}); diff --git a/ledger/input/local-camera.ts b/ledger/input/local-camera.ts new file mode 100644 index 0000000..7aa4e3f --- /dev/null +++ b/ledger/input/local-camera.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// Where this computer's screen is on the map: `local.camera.x`, `local.camera.y`. +program(() => { + print(`${local.camera.x}, ${local.camera.y}`); +}); diff --git a/ledger/input/local-mouse.ts b/ledger/input/local-mouse.ts new file mode 100644 index 0000000..b39889b --- /dev/null +++ b/ledger/input/local-mouse.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// Where this computer's mouse is on the screen: `local.mouse.x`, `local.mouse.y`. +program(() => { + print(`${local.mouse.x}, ${local.mouse.y}`); +}); diff --git a/ledger/input/local-player.ts b/ledger/input/local-player.ts new file mode 100644 index 0000000..673c6be --- /dev/null +++ b/ledger/input/local-player.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// The player sitting at this computer, which every computer knows differently: `local.player`. +program(() => { + print(`${local.player}`); +}); diff --git a/ledger/input/local-selected-selected-again.ts b/ledger/input/local-selected-selected-again.ts new file mode 100644 index 0000000..e44497a --- /dev/null +++ b/ledger/input/local-selected-selected-again.ts @@ -0,0 +1,5 @@ +// ledger: step 12, takes +// The unit this computer has selected, made the player's selection on every computer: `select(P1, local.selected)`. +program(() => { + select(P1, local.selected!); +}); diff --git a/ledger/input/local-selected-unit.ts b/ledger/input/local-selected-unit.ts new file mode 100644 index 0000000..d5a3da5 --- /dev/null +++ b/ledger/input/local-selected-unit.ts @@ -0,0 +1,6 @@ +// ledger: step 12, takes +// The unit this computer has selected, kept and its hit points shown: `const u = local.selected; if (u) … u.hp`. +program(() => { + const u = local.selected; + if (u) print(`${u.hp}`); +}); diff --git a/ledger/input/local-selected.ts b/ledger/input/local-selected.ts new file mode 100644 index 0000000..2d14a06 --- /dev/null +++ b/ledger/input/local-selected.ts @@ -0,0 +1,6 @@ +// ledger: step 12, takes +// Whether this computer has a unit selected: `local.selected`, shown but not acted on. +program(() => { + if (local.selected) print("selected"); + else print("none"); +}); diff --git a/ledger/input/local-selection-count.ts b/ledger/input/local-selection-count.ts new file mode 100644 index 0000000..1ad6795 --- /dev/null +++ b/ledger/input/local-selection-count.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// How many units this computer has selected: `local.selectionCount`. +program(() => { + print(`${local.selectionCount}`); +}); diff --git a/ledger/input/local-shown-only.ts b/ledger/input/local-shown-only.ts new file mode 100644 index 0000000..008d13f --- /dev/null +++ b/ledger/input/local-shown-only.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A value only this computer knows, tested to choose what is shown here: `if (local.camera.x > 300) print(…)`. +program(() => { + if (local.camera.x > 300) print("right side"); +}); diff --git a/ledger/input/mouse-places-a-location.ts b/ledger/input/mouse-places-a-location.ts new file mode 100644 index 0000000..80ca6c3 --- /dev/null +++ b/ledger/input/mouse-places-a-location.ts @@ -0,0 +1,10 @@ +// ledger: step 12, takes +// A location moved to where a player's mouse is: `centerLocation(spot, at.x, at.y)` after `const at = mouse(P1)`. +const spot = reserveLocation("where the mouse is"); +program(async () => { + while (true) { + const at = mouse(P1); + centerLocation(spot, at.x, at.y); + await nextFrame(); + } +}); diff --git a/ledger/input/mouse.ts b/ledger/input/mouse.ts new file mode 100644 index 0000000..c65fefc --- /dev/null +++ b/ledger/input/mouse.ts @@ -0,0 +1,9 @@ +// ledger: step 12, takes +// Where a player's mouse is on the map, the same on every computer: `mouse(P1).x`, `mouse(P1).y`. +program(async () => { + while (true) { + const at = mouse(P1); + print(`${at.x}, ${at.y}`); + await nextFrame(); + } +}); diff --git a/ledger/input/on-chat.ts b/ledger/input/on-chat.ts new file mode 100644 index 0000000..7edfeab --- /dev/null +++ b/ledger/input/on-chat.ts @@ -0,0 +1,7 @@ +// ledger: step 12, takes +// A function run each time a player types a line that fits a pattern: `on.chat("-give {n}", (p, { n }) => …)`. +program(async () => { + on.chat("-give {n}", (p, { n }) => { + setResources(p, "add", n, "ore"); + }); +}); diff --git a/ledger/input/on-key-from.ts b/ledger/input/on-key-from.ts new file mode 100644 index 0000000..31f1b30 --- /dev/null +++ b/ledger/input/on-key-from.ts @@ -0,0 +1,9 @@ +// ledger: step 12, takes +// A function run each time one player presses a key: `on.key("Space", (p) => …, { from: P1 })`. +program(async () => { + let n = 0; + on.key("Space", (p) => { + n += 1; + print(`${n}`); + }, { from: P1 }); +}); diff --git a/ledger/input/on-key.ts b/ledger/input/on-key.ts new file mode 100644 index 0000000..33e45bd --- /dev/null +++ b/ledger/input/on-key.ts @@ -0,0 +1,7 @@ +// ledger: step 12, takes +// A function run each time a player presses a key: `on.key("F7", (p) => …)`. +program(async () => { + on.key("F7", (p) => { + print("help"); + }); +}); diff --git a/ledger/input/sync-several.ts b/ledger/input/sync-several.ts new file mode 100644 index 0000000..58beddf --- /dev/null +++ b/ledger/input/sync-several.ts @@ -0,0 +1,6 @@ +// ledger: step 12, takes +// Several of one player's values sent together, read on the same frame: `const [x, y] = await sync(P1, a, b)`. +program(async () => { + const [x, y] = await sync(P1, local.camera.x + local.mouse.x, local.camera.y + local.mouse.y); + print(`${x}, ${y}`); +}); diff --git a/ledger/input/sync.ts b/ledger/input/sync.ts new file mode 100644 index 0000000..7c8ff55 --- /dev/null +++ b/ledger/input/sync.ts @@ -0,0 +1,6 @@ +// ledger: step 12, takes +// One player's value only their computer knows, sent to every computer: `const x = await sync(P1, local.camera.x)`. +program(async () => { + const x = await sync(P1, local.camera.x); + print(`${x}`); +}); diff --git a/ledger/input/under-mouse-filter.ts b/ledger/input/under-mouse-filter.ts new file mode 100644 index 0000000..ddccb5f --- /dev/null +++ b/ledger/input/under-mouse-filter.ts @@ -0,0 +1,8 @@ +// ledger: step 12, takes +// The nearest unit to a player's mouse that a filter matches, within so many pixels: `underMouse(P1, { owner: P2, within: 32 })`. +program(async () => { + while (true) { + if (underMouse(P1, { owner: P2, within: 32 })) print("over an enemy"); + await nextFrame(); + } +}); diff --git a/ledger/input/under-mouse.ts b/ledger/input/under-mouse.ts new file mode 100644 index 0000000..34ff382 --- /dev/null +++ b/ledger/input/under-mouse.ts @@ -0,0 +1,8 @@ +// ledger: step 12, takes +// The unit nearest a player's mouse, or none: `underMouse(P1)`. +program(async () => { + while (true) { + if (underMouse(P1)) print("over a unit"); + await nextFrame(); + } +}); diff --git a/ledger/language/and.ts b/ledger/language/and.ts new file mode 100644 index 0000000..e5e71c8 --- /dev/null +++ b/ledger/language/and.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `&&` of two tests: true when both hold. +program(() => { + let n = 0; + let count = 0; + while (n < 10) { + if (n > 2 && n < 7) count += 1; + n += 1; + } + print(`${count}`); +}); diff --git a/ledger/language/array-destructuring-rest.ts b/ledger/language/array-destructuring-rest.ts new file mode 100644 index 0000000..ea48d1a --- /dev/null +++ b/ledger/language/array-destructuring-rest.ts @@ -0,0 +1,8 @@ +// ledger: step 3, takes +// Destructuring the first cell of an array and the rest into another: `const [first, ...rest] = xs`. +program(() => { + let xs = [1, 2, 3]; + xs[2] += 5; + const [first, ...rest] = xs; + print(`${first} ${rest[1]}`); +}); diff --git a/ledger/language/array-destructuring.ts b/ledger/language/array-destructuring.ts new file mode 100644 index 0000000..bcf3582 --- /dev/null +++ b/ledger/language/array-destructuring.ts @@ -0,0 +1,8 @@ +// ledger: step 3, takes +// Destructuring an array of numbers into variables: `const [a, b] = xs`. +program(() => { + let xs = [1, 2, 3]; + xs[1] += 5; + const [a, b] = xs; + print(`${a} ${b}`); +}); diff --git a/ledger/language/array-of-instances.ts b/ledger/language/array-of-instances.ts new file mode 100644 index 0000000..124632c --- /dev/null +++ b/ledger/language/array-of-instances.ts @@ -0,0 +1,19 @@ +// ledger: step 3, takes +// A fixed-length array of instances of a class, a row picked by an index that changes while the game runs. +class Wave { + constructor(public left: number) {} + spawn() { + if (this.left > 0) this.left -= 1; + } +} +program(() => { + const waves = [new Wave(2), new Wave(5)]; + let i = 0; + let which = 0; + while (i < 4) { + waves[which].spawn(); + which = 1 - which; + i += 1; + } + print(`${waves[0].left} ${waves[1].left}`); +}); diff --git a/ledger/language/array-spread.ts b/ledger/language/array-spread.ts new file mode 100644 index 0000000..f7d2bef --- /dev/null +++ b/ledger/language/array-spread.ts @@ -0,0 +1,10 @@ +// ledger: step 3, takes +// An array made from another with `...`: `[...xs, 4]`, of a fixed length. +program(() => { + let xs = [1, 2, 3]; + xs[0] += 5; + const ys = [...xs, 4]; + let total = 0; + for (const y of ys) total += y; + print(`${total}`); +}); diff --git a/ledger/language/arrow-function.ts b/ledger/language/arrow-function.ts new file mode 100644 index 0000000..5200c07 --- /dev/null +++ b/ledger/language/arrow-function.ts @@ -0,0 +1,8 @@ +// ledger: step 1, takes +// A function written as an arrow kept in a `const`: `const twice = (x: number) => x + x`. +const twice = (x: number) => x + x; +program(() => { + let n = 1; + while (n < 50) n = twice(n) + 1; + print(`${twice(n)}`); +}); diff --git a/ledger/language/arrow-in-program.ts b/ledger/language/arrow-in-program.ts new file mode 100644 index 0000000..f394a3d --- /dev/null +++ b/ledger/language/arrow-in-program.ts @@ -0,0 +1,15 @@ +// ledger: step 1, takes +// An arrow function declared in the program that reads and writes the program's variables. +program(() => { + let total = 0; + const add = (n: number) => { + total += n; + }; + let n = 0; + while (n < 4) { + add(n); + add(2); + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/assignment-in-condition.ts b/ledger/language/assignment-in-condition.ts new file mode 100644 index 0000000..a77d742 --- /dev/null +++ b/ledger/language/assignment-in-condition.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// An assignment used as a value inside a test: `while ((n = n + 2) < 9)`. +program(() => { + let n = 0; + let turns = 0; + while ((n = n + 2) < 9) turns += 1; + print(`${n} ${turns}`); +}); diff --git a/ledger/language/async-arrow.ts b/ledger/language/async-arrow.ts new file mode 100644 index 0000000..c5ffba5 --- /dev/null +++ b/ledger/language/async-arrow.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// An `async` arrow function kept in a `const` and awaited. +const pause = async (n: number) => { + await sleep(frames(2)); + return n + 1; +}; +program(async () => { + let n = 0; + while (n < 3) n = await pause(n); + print(`${n}`); +}); diff --git a/ledger/language/async-function.ts b/ledger/language/async-function.ts new file mode 100644 index 0000000..f43fbb7 --- /dev/null +++ b/ledger/language/async-function.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// An `async` function that waits, awaited by the program: the program goes on when it returns, with its result. +async function slowDouble(n: number) { + await sleep(frames(3)); + return n + n; +} +program(async () => { + let n = 0; + while (n < 3) n += 1; + n = await slowDouble(n); + print(`${n}`); +}); diff --git a/ledger/language/block-scope.ts b/ledger/language/block-scope.ts new file mode 100644 index 0000000..97e4d56 --- /dev/null +++ b/ledger/language/block-scope.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// A variable declared in a block is that block's own, and a name inside may shadow one outside. +program(() => { + let n = 1; + let total = 0; + while (total < 10) { + let n = 3; + n += total; + total += n; + } + n += total; + print(`${n} ${total}`); +}); diff --git a/ledger/language/boolean-declared-first.ts b/ledger/language/boolean-declared-first.ts new file mode 100644 index 0000000..53a8100 --- /dev/null +++ b/ledger/language/boolean-declared-first.ts @@ -0,0 +1,8 @@ +// ledger: step 7, takes +// A program whose first statement declares a boolean false, tested before it changes: `let done = false; if (!done) …`. +program(() => { + let done = false; + if (!done) print("not yet"); + done = true; + if (done) print("done"); +}); diff --git a/ledger/language/boolean-equality.ts b/ledger/language/boolean-equality.ts new file mode 100644 index 0000000..a2466af --- /dev/null +++ b/ledger/language/boolean-equality.ts @@ -0,0 +1,16 @@ +// ledger: step 0, takes +// Two booleans compared, `a == b`, `a != b`. +program(() => { + let n = 0; + let same = 0; + let a = false; + let b = true; + while (n < 6) { + a = n > 2; + b = n < 4; + if (a == b) same += 1; + if (a != b) same += 10; + n += 1; + } + print(`${same}`); +}); diff --git a/ledger/language/boolean-variable.ts b/ledger/language/boolean-variable.ts new file mode 100644 index 0000000..74992f2 --- /dev/null +++ b/ledger/language/boolean-variable.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A boolean variable, set from a test while the game runs and tested later. +program(() => { + let n = 0; + let seen = false; + while (n < 5) { + seen = n == 3; + if (seen) print("three"); + n += 1; + } +}); diff --git a/ledger/language/break.ts b/ledger/language/break.ts new file mode 100644 index 0000000..5b241b3 --- /dev/null +++ b/ledger/language/break.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// `break` leaves the loop it is in. +program(() => { + let n = 0; + while (true) { + n += 3; + if (n > 10) break; + } + print(`${n}`); +}); diff --git a/ledger/language/callback-argument.ts b/ledger/language/callback-argument.ts new file mode 100644 index 0000000..20b0f77 --- /dev/null +++ b/ledger/language/callback-argument.ts @@ -0,0 +1,14 @@ +// ledger: step 1, takes +// An arrow function handed to a function as an argument and called inside it: worked out where it is called. +function twice(f: (n: number) => number, n: number) { + return f(f(n)); +} +program(() => { + let n = 1; + let turns = 0; + while (turns < 3) { + n = twice((x) => x + x + 1, n); + turns += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/clamp.ts b/ledger/language/clamp.ts new file mode 100644 index 0000000..8fe991d --- /dev/null +++ b/ledger/language/clamp.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `clamp(value, low, high)`: the value kept within low … high. +program(() => { + let n = -2; + let total = 0; + while (n < 8) { + total += clamp(n, 0, 5); + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/class-abstract.ts b/ledger/language/class-abstract.ts new file mode 100644 index 0000000..9680dc1 --- /dev/null +++ b/ledger/language/class-abstract.ts @@ -0,0 +1,22 @@ +// ledger: step 1, takes +// An `abstract` class with an abstract method, and a class that extends it and gives the method a body. +abstract class Shape { + abstract area(): number; + doubled() { + return this.area() + this.area(); + } +} +class Square extends Shape { + constructor(public side: number) { + super(); + } + area() { + return this.side + this.side + this.side + this.side; + } +} +program(() => { + let k = 0; + while (k < 3) k += 1; + const s = new Square(k); + print(`${s.doubled()}`); +}); diff --git a/ledger/language/class-array-field.ts b/ledger/language/class-array-field.ts new file mode 100644 index 0000000..862e553 --- /dev/null +++ b/ledger/language/class-array-field.ts @@ -0,0 +1,18 @@ +// ledger: step 3, takes +// A class with a field that is a fixed-length array of numbers. +class Squad { + hps = [40, 40, 40]; + hurt(i: number, n: number) { + this.hps[i] -= n; + } +} +program(() => { + const s = new Squad(); + let i = 0; + while (i < 3) { + s.hurt(i, i + 5); + s.hurt(i, 1); + i += 1; + } + print(`${s.hps[0]} ${s.hps[2]}`); +}); diff --git a/ledger/language/class-by-reference.ts b/ledger/language/class-by-reference.ts new file mode 100644 index 0000000..8bddf4b --- /dev/null +++ b/ledger/language/class-by-reference.ts @@ -0,0 +1,12 @@ +// ledger: step 5, takes +// An instance kept by reference: a variable that may hold one instance or another, or none, chosen while the game runs. +class Node { + constructor(public v: number) {} +} +program(() => { + let a: Node | null = null; + let k = 0; + while (k < 3) k += 1; + a = new Node(k); + print(`${a.v}`); +}); diff --git a/ledger/language/class-extends-super.ts b/ledger/language/class-extends-super.ts new file mode 100644 index 0000000..82d30a2 --- /dev/null +++ b/ledger/language/class-extends-super.ts @@ -0,0 +1,18 @@ +// ledger: step 0, takes +// A class that `extends` another: `super(…)` runs the constructor of what it extends first. +class Animal { + legs = 4; + constructor(public size: number) {} +} +class Bird extends Animal { + constructor(size: number, public wings: number) { + super(size + size); + this.legs = 2; + } +} +program(() => { + let k = 0; + while (k < 4) k += 1; + const b = new Bird(k, 7); + print(`${b.legs} ${b.size} ${b.wings}`); +}); diff --git a/ledger/language/class-field-from-field.ts b/ledger/language/class-field-from-field.ts new file mode 100644 index 0000000..6be222e --- /dev/null +++ b/ledger/language/class-field-from-field.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A field's initial value worked out from another field: `max = 40; hp = this.max`. +class Marine { + max = 40; + hp = this.max; +} +program(() => { + const m = new Marine(); + while (m.hp > 10) m.hp -= 7; + print(`${m.hp} ${m.max}`); +}); diff --git a/ledger/language/class-fields-constructor.ts b/ledger/language/class-fields-constructor.ts new file mode 100644 index 0000000..64f921c --- /dev/null +++ b/ledger/language/class-fields-constructor.ts @@ -0,0 +1,15 @@ +// ledger: step 0, takes +// A class with fields and a constructor: an instance is a record whose fields the constructor sets. +class Wave { + left = 0; + constructor(public count: number) { + this.left = count; + } +} +program(() => { + let n = 0; + while (n < 3) n += 1; + const w = new Wave(n); + while (w.left > 0) w.left -= 1; + print(`${w.count} ${w.left}`); +}); diff --git a/ledger/language/class-generator-method.ts b/ledger/language/class-generator-method.ts new file mode 100644 index 0000000..c130467 --- /dev/null +++ b/ledger/language/class-generator-method.ts @@ -0,0 +1,19 @@ +// ledger: step 0, takes +// A generator method, `*[Symbol.iterator]()`: an instance walked by `for…of`. +class Squad { + size = 3; + *[Symbol.iterator]() { + let i = 0; + while (i < this.size) { + yield i; + i += 1; + } + } +} +program(() => { + const s = new Squad(); + while (s.size < 5) s.size += 1; + let total = 0; + for (const x of s) total += x; + print(`${total}`); +}); diff --git a/ledger/language/class-getter-setter.ts b/ledger/language/class-getter-setter.ts new file mode 100644 index 0000000..d9b43c7 --- /dev/null +++ b/ledger/language/class-getter-setter.ts @@ -0,0 +1,21 @@ +// ledger: step 0, takes +// A getter and a setter: `get twice()` read like a field, `set twice(v)` written like one. +class Counter { + n = 0; + get twice() { + return this.n + this.n; + } + set twice(v: number) { + this.n = v >> 1; + } +} +program(() => { + const c = new Counter(); + let k = 0; + while (c.twice < 20) { + c.n += 3; + k += 1; + } + c.twice = 40; + print(`${c.n} ${k}`); +}); diff --git a/ledger/language/class-implements.ts b/ledger/language/class-implements.ts new file mode 100644 index 0000000..043e668 --- /dev/null +++ b/ledger/language/class-implements.ts @@ -0,0 +1,17 @@ +// ledger: step 0, takes +// A class that `implements` an interface: the interface is a type only, gone in the map. +interface Scored { + score(): number; +} +class Kill implements Scored { + constructor(public worth: number) {} + score() { + return this.worth + 1; + } +} +program(() => { + let k = 0; + while (k < 5) k += 1; + const s: Scored = new Kill(k); + print(`${s.score()}`); +}); diff --git a/ledger/language/class-instance-alias.ts b/ledger/language/class-instance-alias.ts new file mode 100644 index 0000000..e08feb8 --- /dev/null +++ b/ledger/language/class-instance-alias.ts @@ -0,0 +1,15 @@ +// ledger: step 0, takes +// An instance under a second name is the same instance: a change through one is seen through the other. +class Counter { + n = 0; +} +program(() => { + const a = new Counter(); + const same = a; + let k = 0; + while (k < 4) { + same.n += k; + k += 1; + } + print(`${a.n}`); +}); diff --git a/ledger/language/class-instance-in-function.ts b/ledger/language/class-instance-in-function.ts new file mode 100644 index 0000000..e719ec3 --- /dev/null +++ b/ledger/language/class-instance-in-function.ts @@ -0,0 +1,19 @@ +// ledger: step 1, takes +// An instance handed to a function, which changes it: the same instance, not a copy. +class Counter { + n = 0; +} +function twice(c: Counter, by: number) { + c.n += by; + c.n += by; +} +program(() => { + const c = new Counter(); + let k = 0; + while (k < 3) { + twice(c, k); + twice(c, 1); + k += 1; + } + print(`${c.n}`); +}); diff --git a/ledger/language/class-method.ts b/ledger/language/class-method.ts new file mode 100644 index 0000000..5a1daf2 --- /dev/null +++ b/ledger/language/class-method.ts @@ -0,0 +1,18 @@ +// ledger: step 1, takes +// A method that changes its instance, called from more than one place: a function with the instance first. +class Counter { + n = 0; + bump(by: number) { + this.n += by; + } +} +program(() => { + const c = new Counter(); + let k = 0; + while (k < 4) { + c.bump(k); + c.bump(1); + k += 1; + } + print(`${c.n}`); +}); diff --git a/ledger/language/class-override-super-method.ts b/ledger/language/class-override-super-method.ts new file mode 100644 index 0000000..86f2ed1 --- /dev/null +++ b/ledger/language/class-override-super-method.ts @@ -0,0 +1,20 @@ +// ledger: step 0, takes +// A method overridden in a class that extends another, `super.speak()` reaching the one it overrides. +class Animal { + constructor(public size: number) {} + speak() { + return this.size + 1; + } +} +class Bird extends Animal { + speak() { + return super.speak() + 100; + } +} +program(() => { + let k = 0; + while (k < 4) k += 1; + const a = new Animal(k); + const b = new Bird(k); + print(`${a.speak()} ${b.speak()}`); +}); diff --git a/ledger/language/class-parameter-properties.ts b/ledger/language/class-parameter-properties.ts new file mode 100644 index 0000000..c1b8e4d --- /dev/null +++ b/ledger/language/class-parameter-properties.ts @@ -0,0 +1,15 @@ +// ledger: step 0, takes +// Fields declared in the constructor's parameters: `constructor(public x: number, private y: number, readonly z = 9)`. +class P { + constructor(public x: number, private y: number, readonly z = 9) {} + sum() { + return this.x + this.y + this.z; + } +} +program(() => { + let k = 0; + while (k < 3) k += 1; + const p = new P(1, k); + while (p.x < 11) p.x += 1; + print(`${p.sum()}`); +}); diff --git a/ledger/language/class-private-field.ts b/ledger/language/class-private-field.ts new file mode 100644 index 0000000..fd96b0e --- /dev/null +++ b/ledger/language/class-private-field.ts @@ -0,0 +1,22 @@ +// ledger: step 0, takes +// A `#private` field, and one marked `private`: reached only from the class's own code. +class Bank { + #gold = 0; + private spent = 0; + pay(n: number) { + this.#gold += n; + this.spent += 1; + } + get gold() { + return this.#gold - this.spent; + } +} +program(() => { + const b = new Bank(); + let k = 0; + while (k < 4) { + b.pay(k + 2); + k += 1; + } + print(`${b.gold}`); +}); diff --git a/ledger/language/class-protected.ts b/ledger/language/class-protected.ts new file mode 100644 index 0000000..edf278b --- /dev/null +++ b/ledger/language/class-protected.ts @@ -0,0 +1,17 @@ +// ledger: step 0, takes +// A `protected` field: reached from the class and from classes that extend it. +class Soldier { + protected hp = 10; +} +class Hero extends Soldier { + hurt(n: number) { + this.hp -= n; + return this.hp; + } +} +program(() => { + const h = new Hero(); + let left = 10; + while (left > 0) left = h.hurt(3); + print(`${left}`); +}); diff --git a/ledger/language/class-readonly.ts b/ledger/language/class-readonly.ts new file mode 100644 index 0000000..9c09676 --- /dev/null +++ b/ledger/language/class-readonly.ts @@ -0,0 +1,16 @@ +// ledger: step 0, takes +// A `readonly` field: set once by the constructor, read after. +class Price { + readonly cost: number; + constructor(cost: number) { + this.cost = cost; + } +} +program(() => { + let n = 0; + while (n < 50) n += 1; + const p = new Price(n); + let ore = 400; + while (ore >= p.cost) ore -= p.cost; + print(`${ore}`); +}); diff --git a/ledger/language/class-recursive-method.ts b/ledger/language/class-recursive-method.ts new file mode 100644 index 0000000..cdb25ad --- /dev/null +++ b/ledger/language/class-recursive-method.ts @@ -0,0 +1,16 @@ +// ledger: step 1, takes +// A method that calls itself. +class M { + calls = 0; + fib(n: number): number { + this.calls++; + if (n < 2) return n; + return this.fib(n - 1) + this.fib(n - 2); + } +} +program(() => { + const m = new M(); + let n = 0; + while (n < 7) n += 1; + print(`${m.fib(n)} ${m.calls}`); +}); diff --git a/ledger/language/class-static.ts b/ledger/language/class-static.ts new file mode 100644 index 0000000..27c1948 --- /dev/null +++ b/ledger/language/class-static.ts @@ -0,0 +1,23 @@ +// ledger: step 0, takes +// A `static` field and a `static` method: one for the class, shared by every instance. +class Id { + static next = 1; + id = 0; + constructor() { + this.id = Id.next; + Id.next++; + } + static made() { + return Id.next - 1; + } +} +program(() => { + const a = new Id(); + const b = new Id(); + let k = 0; + while (k < 3) { + Id.next += 1; + k += 1; + } + print(`${a.id} ${b.id} ${Id.made()}`); +}); diff --git a/ledger/language/closure-keeps-a-count.ts b/ledger/language/closure-keeps-a-count.ts new file mode 100644 index 0000000..8437cd7 --- /dev/null +++ b/ledger/language/closure-keeps-a-count.ts @@ -0,0 +1,18 @@ +// ledger: step 9, takes +// A closure that keeps a count, made in the program by a function of the script's (`const next = counter()`) and +// called at each turn of a loop: 1, 2, 3. +function counter() { + let n = 0; + return () => { + n += 1; + return n; + }; +} +program(() => { + const next = counter(); + let k = 0; + while (k < 3) { + print(`${next()}`); + k += 1; + } +}); diff --git a/ledger/language/closure-made-in-loop.ts b/ledger/language/closure-made-in-loop.ts new file mode 100644 index 0000000..264d810 --- /dev/null +++ b/ledger/language/closure-made-in-loop.ts @@ -0,0 +1,14 @@ +// ledger: step 9, takes +// A closure made at each turn of a loop over that turn's `const`, as JavaScript has it. +program(() => { + let total = 0; + let f: () => number = () => 0; + let n = 0; + while (n < 5) { + const k = n; + f = () => k + k; + total += f(); + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/closure.ts b/ledger/language/closure.ts new file mode 100644 index 0000000..961864f --- /dev/null +++ b/ledger/language/closure.ts @@ -0,0 +1,13 @@ +// ledger: step 9, takes +// A closure over a value worked out while the game runs: `adder(k)` gives a function that keeps its `k`. +function adder(k: number) { + return (x: number) => x + k; +} +program(() => { + let n = 0; + while (n < 5) n += 1; + const add = adder(n); + let total = 0; + while (total < 20) total = add(total); + print(`${total}`); +}); diff --git a/ledger/language/comma-operator.ts b/ledger/language/comma-operator.ts new file mode 100644 index 0000000..2b7a1e7 --- /dev/null +++ b/ledger/language/comma-operator.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// The comma operator, in a `for` loop's update: both sides run, in order. +program(() => { + let a = 0; + let b = 10; + let end = 0; + while (end < 5) end += 1; + for (let i = 0; i < end; i++, a += 2) b -= 1; + print(`${a} ${b}`); +}); diff --git a/ledger/language/comparison-as-an-argument.ts b/ledger/language/comparison-as-an-argument.ts new file mode 100644 index 0000000..c927caf --- /dev/null +++ b/ledger/language/comparison-as-an-argument.ts @@ -0,0 +1,12 @@ +// ledger: step 1, takes +// A comparison of the program's variables handed to a boolean parameter. +function step(n: number, early: boolean) { + return early ? n + 1 : n; +} +program(() => { + let turns = 1; + let n = 5; + n = step(n, turns < 2); + n = step(n, turns > 2); + print(`${n}`); +}); diff --git a/ledger/language/comparisons.ts b/ledger/language/comparisons.ts new file mode 100644 index 0000000..14eb9eb --- /dev/null +++ b/ledger/language/comparisons.ts @@ -0,0 +1,16 @@ +// ledger: step 0, takes +// The comparisons `<`, `<=`, `>`, `>=`, `==` and `!=` of two values that change while the game runs. +program(() => { + let a = 0; + let b = 5; + let count = 0; + while (a < 10) { + if (a <= b) count += 1; + if (a > b) count += 10; + if (a >= b) count += 100; + if (a == b) count += 1000; + if (a != b) count += 10000; + a += 1; + } + print(`${count}`); +}); diff --git a/ledger/language/compound-assignment.ts b/ledger/language/compound-assignment.ts new file mode 100644 index 0000000..67278e2 --- /dev/null +++ b/ledger/language/compound-assignment.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// Compound assignment that takes a step: `+=`, `-=`, and `<<=` by a constant. +program(() => { + let n = 1; + let turns = 0; + while (n < 100) { + n <<= 2; + n += 1; + n -= 2; + turns += 1; + } + print(`${n} ${turns}`); +}); diff --git a/ledger/language/condition-in-a-const.ts b/ledger/language/condition-in-a-const.ts new file mode 100644 index 0000000..920c136 --- /dev/null +++ b/ledger/language/condition-in-a-const.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// Conditions of the game joined by && and kept in a const, asked where the const is tested. +program(() => { + const both = command(P1, units.TerranMarine, ">=", 1) && bring(P1, units.TerranMarine, locations.Anywhere, ">=", 1); + if (both) print("both"); +}); diff --git a/ledger/language/const-enum.ts b/ledger/language/const-enum.ts new file mode 100644 index 0000000..34973fd --- /dev/null +++ b/ledger/language/const-enum.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A `const enum` with values given: each member the number it was given. +const enum Cost { + Marine = 50, + Medic = 75, +} +program(() => { + let ore = 400; + while (ore >= Cost.Medic) ore -= Cost.Marine; + print(`${ore}`); +}); diff --git a/ledger/language/const-from-build-time-code.ts b/ledger/language/const-from-build-time-code.ts new file mode 100644 index 0000000..0e8b282 --- /dev/null +++ b/ledger/language/const-from-build-time-code.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// A `const` worked out by code that runs when the map is built (an array's `reduce`, a loop), used in the program as a number. +const COSTS = [50, 75, 100, 150]; +const TOTAL = COSTS.reduce((a, b) => a + b, 0); +program(() => { + let spent = 0; + spent += TOTAL; + print(`${spent}`); +}); diff --git a/ledger/language/const-in-program.ts b/ledger/language/const-in-program.ts new file mode 100644 index 0000000..695e3f0 --- /dev/null +++ b/ledger/language/const-in-program.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A `const` inside the program holding a value worked out while the game runs: it cannot be changed after. +program(() => { + let n = 0; + while (n < 7) n += 1; + const twice = n + n; + print(`${twice}`); +}); diff --git a/ledger/language/const-known-when-built.ts b/ledger/language/const-known-when-built.ts new file mode 100644 index 0000000..c1f31f0 --- /dev/null +++ b/ledger/language/const-known-when-built.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// A `const` whose value is known when the map is built: worked out then, and used as that number. +const WAVES = 4; +const PER_WAVE = WAVES * 5 + 2; +program(() => { + let left = PER_WAVE; + left -= 1; + print(`${left}`); +}); diff --git a/ledger/language/continue.ts b/ledger/language/continue.ts new file mode 100644 index 0000000..9889efb --- /dev/null +++ b/ledger/language/continue.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// `continue` goes on to the loop's next turn, skipping the rest of the body. +program(() => { + let n = 0; + let counted = 0; + while (n < 9) { + n += 1; + if (n == 3 || n == 6) continue; + counted += 1; + } + print(`${counted}`); +}); diff --git a/ledger/language/default-parameter.ts b/ledger/language/default-parameter.ts new file mode 100644 index 0000000..df74bcd --- /dev/null +++ b/ledger/language/default-parameter.ts @@ -0,0 +1,15 @@ +// ledger: step 1, takes +// A parameter with a default value, `step = 1`, used when the call leaves it out. +function advance(n: number, step = 1) { + return n + step; +} +program(() => { + let n = 0; + let turns = 0; + while (turns < 4) { + n = advance(n); + n = advance(n, 10); + turns += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/destructuring-parameter.ts b/ledger/language/destructuring-parameter.ts new file mode 100644 index 0000000..1b06e4b --- /dev/null +++ b/ledger/language/destructuring-parameter.ts @@ -0,0 +1,15 @@ +// ledger: step 0, takes +// A function whose parameter is destructured: `function area({ w, h }: Size)`. +function area({ w, h }: { w: number; h: number }) { + return w + h + h; +} +program(() => { + let s = { w: 2, h: 3 }; + let total = 0; + while (s.w < 8) { + total += area(s); + total += area({ w: 1, h: s.w }); + s.w += 2; + } + print(`${total}`); +}); diff --git a/ledger/language/do-while-break-continue.ts b/ledger/language/do-while-break-continue.ts new file mode 100644 index 0000000..86b2e52 --- /dev/null +++ b/ledger/language/do-while-break-continue.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// `break` and `continue` inside a `do … while` loop. +program(() => { + let n = 0; + let total = 0; + do { + n += 1; + if (n == 2) continue; + if (n > 5) break; + total += n; + } while (n < 10); + print(`${total}`); +}); diff --git a/ledger/language/do-while-loop.ts b/ledger/language/do-while-loop.ts new file mode 100644 index 0000000..8797a8e --- /dev/null +++ b/ledger/language/do-while-loop.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// A `do … while` loop: the body runs once before the test is asked. +program(() => { + let n = 0; + do { + n += 4; + } while (n < 10); + print(`${n}`); +}); diff --git a/ledger/language/double-not.ts b/ledger/language/double-not.ts new file mode 100644 index 0000000..5485f5a --- /dev/null +++ b/ledger/language/double-not.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// `!!n`: a number as a boolean, true when it is not 0. +program(() => { + let n = 3; + let on = false; + let turns = 0; + while (turns < 5) { + on = !!n; + if (on) n -= 1; + turns += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/else-if-chain.ts b/ledger/language/else-if-chain.ts new file mode 100644 index 0000000..d09f8a6 --- /dev/null +++ b/ledger/language/else-if-chain.ts @@ -0,0 +1,15 @@ +// ledger: step 0, takes +// A chain of `else if`: the first test that holds picks the branch. +program(() => { + let n = 0; + let small = 0; + let middle = 0; + let large = 0; + while (n < 12) { + if (n < 4) small += 1; + else if (n < 8) middle += 1; + else large += 1; + n += 1; + } + print(`${small} ${middle} ${large}`); +}); diff --git a/ledger/language/enum.ts b/ledger/language/enum.ts new file mode 100644 index 0000000..85c16de --- /dev/null +++ b/ledger/language/enum.ts @@ -0,0 +1,16 @@ +// ledger: step 0, takes +// An `enum` declared outside the program: its members are numbers known when the map is built. +enum Phase { + Build, + Fight, + Done, +} +program(() => { + let phase = Phase.Build; + let turns = 0; + while (phase != Phase.Done) { + phase = phase == Phase.Build ? Phase.Fight : Phase.Done; + turns += 1; + } + print(`${turns}`); +}); diff --git a/ledger/language/error-class.ts b/ledger/language/error-class.ts new file mode 100644 index 0000000..1d4b4d7 --- /dev/null +++ b/ledger/language/error-class.ts @@ -0,0 +1,14 @@ +// ledger: step 11, takes +// A class of error of the script's own, `class Late extends Error`, told apart in a `catch` with `instanceof`. +class Late extends Error {} +program(() => { + let k = 0; + while (k < 2) k += 1; + try { + if (k > 1) throw new Late("late"); + throw new RangeError("range"); + } catch (e) { + if (e instanceof Late) print("late"); + else print("other"); + } +}); diff --git a/ledger/language/error-fields.ts b/ledger/language/error-fields.ts new file mode 100644 index 0000000..0771b45 --- /dev/null +++ b/ledger/language/error-fields.ts @@ -0,0 +1,18 @@ +// ledger: step 11, takes +// A class of error with a field of its own, its `name`, and a message made while the game runs, read in the `catch`. +class OutOfGold extends Error { + need: number; + constructor(need: number) { + super(`need ${need} more`); + this.need = need; + } +} +program(() => { + let gold = 0; + while (gold < 20) gold += 7; + try { + if (gold < 50) throw new OutOfGold(50 - gold); + } catch (e) { + if (e instanceof OutOfGold) print(`${e.name}: ${e.message} (${e.need})`); + } +}); diff --git a/ledger/language/error-stack.ts b/ledger/language/error-stack.ts new file mode 100644 index 0000000..9434151 --- /dev/null +++ b/ledger/language/error-stack.ts @@ -0,0 +1,14 @@ +// ledger: step 11, takes +// `e.stack`: the error's class, message and the line it was thrown at, made where it is thrown because it is read. +function check(n: number) { + if (n > 1) throw new RangeError(`n is ${n}`); +} +program(() => { + let k = 0; + while (k < 2) k += 1; + try { + check(k); + } catch (e) { + if (e instanceof Error) print(e.stack ?? ""); + } +}); diff --git a/ledger/language/finally-on-return.ts b/ledger/language/finally-on-return.ts new file mode 100644 index 0000000..e1138bb --- /dev/null +++ b/ledger/language/finally-on-return.ts @@ -0,0 +1,22 @@ +// ledger: step 11, takes +// `finally` in a function: it runs on a `return` out of the `try`, and on an error's way out to the caller's `catch`. +program(() => { + let k = 0; + while (k < 2) k += 1; + let cleaned = 0; + function guarded(n: number): number { + try { + if (n > 2) throw new Error("too big"); + return n * 2; + } finally { + cleaned += 1; + } + } + let total = guarded(k - 1); + try { + total += guarded(k + 5); + } catch { + total += 100; + } + print(`${total} ${cleaned}`); +}); diff --git a/ledger/language/for-break-continue.ts b/ledger/language/for-break-continue.ts new file mode 100644 index 0000000..c7a24e0 --- /dev/null +++ b/ledger/language/for-break-continue.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// `break` and `continue` inside a `for` loop that stays a loop in the map. +program(() => { + let end = 0; + while (end < 15) end += 1; + let total = 0; + for (let i = 0; i < end; i++) { + if (i == 2) continue; + if (i > 6) break; + total += i; + } + print(`${total}`); +}); diff --git a/ledger/language/for-loop-unrolled.ts b/ledger/language/for-loop-unrolled.ts new file mode 100644 index 0000000..3494588 --- /dev/null +++ b/ledger/language/for-loop-unrolled.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// A `for` loop over bounds known when the map is built: worked out then, its body written out once a turn. +program(() => { + let total = 0; + for (let i = 0; i < 4; i++) { + total += i; + sleep(frames(1)); + } + print(`${total}`); +}); diff --git a/ledger/language/for-loop.ts b/ledger/language/for-loop.ts new file mode 100644 index 0000000..6c87e3d --- /dev/null +++ b/ledger/language/for-loop.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// A `for` loop whose end is a value that changes while the game runs, so it stays a loop in the map. +program(() => { + let end = 0; + while (end < 5) end += 1; + let total = 0; + for (let i = 0; i < end; i++) total += i; + print(`${total}`); +}); diff --git a/ledger/language/for-of-array.ts b/ledger/language/for-of-array.ts new file mode 100644 index 0000000..a793860 --- /dev/null +++ b/ledger/language/for-of-array.ts @@ -0,0 +1,13 @@ +// ledger: step 3, takes +// `for (const x of xs)` over a fixed-length array of the program's, its cells changed while the game runs. +program(() => { + let xs = [3, 5, 8]; + let n = 0; + while (n < 3) { + xs[n] += n; + n += 1; + } + let total = 0; + for (const x of xs) total += x; + print(`${total}`); +}); diff --git a/ledger/language/for-of-known-list.ts b/ledger/language/for-of-known-list.ts new file mode 100644 index 0000000..b3856f4 --- /dev/null +++ b/ledger/language/for-of-known-list.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// `for (const x of [3, 5, 8])`: a loop over a list known when the map is built, written out once an item. +program(() => { + let total = 0; + for (const cost of [3, 5, 8]) { + total += cost; + sleep(frames(1)); + } + print(`${total}`); +}); diff --git a/ledger/language/function-boolean-parameter.ts b/ledger/language/function-boolean-parameter.ts new file mode 100644 index 0000000..efd8c9e --- /dev/null +++ b/ledger/language/function-boolean-parameter.ts @@ -0,0 +1,18 @@ +// ledger: step 1, takes +// A function that takes a boolean parameter, given a boolean variable. +function step(n: number, up: boolean) { + if (up) return n + 1; + return n - 1; +} +program(() => { + let n = 5; + let turns = 0; + let up = true; + while (turns < 6) { + up = turns < 2; + n = step(n, up); + n = step(n, false); + turns += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/function-boolean-result.ts b/ledger/language/function-boolean-result.ts new file mode 100644 index 0000000..240d15b --- /dev/null +++ b/ledger/language/function-boolean-result.ts @@ -0,0 +1,15 @@ +// ledger: step 1, takes +// A function that returns a boolean, called from more than one place and tested. +function isBig(n: number): boolean { + return n > 5; +} +program(() => { + let n = 0; + let count = 0; + while (n < 9) { + if (isBig(n)) count += 1; + if (isBig(n + 3)) count += 10; + n += 1; + } + print(`${count}`); +}); diff --git a/ledger/language/function-called.ts b/ledger/language/function-called.ts new file mode 100644 index 0000000..c45bcf2 --- /dev/null +++ b/ledger/language/function-called.ts @@ -0,0 +1,13 @@ +// ledger: step 1, takes +// A function called from more than one place: one copy in the map, its number parameter set by each call and its number result read back. +function bonus(n: number) { + return n + n + 1; +} +program(() => { + let a = 0; + while (a < 3) a += 1; + let b = a + 4; + a = bonus(a); + b = bonus(b); + print(`${a} ${b}`); +}); diff --git a/ledger/language/function-early-return.ts b/ledger/language/function-early-return.ts new file mode 100644 index 0000000..5f246ee --- /dev/null +++ b/ledger/language/function-early-return.ts @@ -0,0 +1,17 @@ +// ledger: step 1, takes +// `return` from the middle of a function: what follows it in the function does not run. +function cost(n: number) { + if (n > 3) return 100; + if (n > 1) return 10; + return 1; +} +program(() => { + let n = 0; + let total = 0; + while (n < 6) { + total += cost(n); + n += 1; + } + total += cost(total); + print(`${total}`); +}); diff --git a/ledger/language/function-in-program.ts b/ledger/language/function-in-program.ts new file mode 100644 index 0000000..cf62948 --- /dev/null +++ b/ledger/language/function-in-program.ts @@ -0,0 +1,10 @@ +// ledger: step 1, takes +// A function declared inside the program, beside the variables it uses. +program(() => { + let n = 0; + function twice(x: number) { + return x + x; + } + while (n < 50) n = twice(n) + 1; + print(`${twice(n)}`); +}); diff --git a/ledger/language/function-inlined.ts b/ledger/language/function-inlined.ts new file mode 100644 index 0000000..7081e79 --- /dev/null +++ b/ledger/language/function-inlined.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A function called once: its body put where it is called, its parameter the value it was given. +function bonus(n: number) { + return n + n + 1; +} +program(() => { + let gold = 0; + while (gold < 15) gold += 1; + gold = bonus(gold); + print(`${gold}`); +}); diff --git a/ledger/language/function-overloads.ts b/ledger/language/function-overloads.ts new file mode 100644 index 0000000..675d93b --- /dev/null +++ b/ledger/language/function-overloads.ts @@ -0,0 +1,17 @@ +// ledger: step 1, takes +// A function with overload signatures: the types are checked, the one body is what runs. +function add(a: number): number; +function add(a: number, b: number): number; +function add(a: number, b = 1) { + return a + b; +} +program(() => { + let n = 0; + let turns = 0; + while (turns < 3) { + n = add(n, 2); + n = add(n, turns); + turns += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/function-that-sleeps.ts b/ledger/language/function-that-sleeps.ts new file mode 100644 index 0000000..e629749 --- /dev/null +++ b/ledger/language/function-that-sleeps.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// A function that sleeps: put where each call is, the program waiting inside it. +function pause(n: number) { + sleep(frames(2)); + return n + 1; +} +program(() => { + let n = 0; + n = pause(n); + n = pause(n); + print(`${n}`); +}); diff --git a/ledger/language/function-value-array.ts b/ledger/language/function-value-array.ts new file mode 100644 index 0000000..e395fc2 --- /dev/null +++ b/ledger/language/function-value-array.ts @@ -0,0 +1,21 @@ +// ledger: step 3, takes +// An array of functions, one picked by an index that changes while the game runs. +function inc(n: number) { + return n + 1; +} +function triple(n: number) { + return n + n + n; +} +program(() => { + const steps = [inc, triple]; + let n = 1; + let which = 0; + let turns = 0; + while (turns < 4) { + n = steps[which](n); + n = steps[1 - which](n); + which = 1 - which; + turns += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/function-value.ts b/ledger/language/function-value.ts new file mode 100644 index 0000000..ae99df4 --- /dev/null +++ b/ledger/language/function-value.ts @@ -0,0 +1,19 @@ +// ledger: step 1, takes +// A function kept as a value and chosen while the game runs: `const f = turns < 3 ? inc : dec; f(n)`. +function inc(n: number) { + return n + 1; +} +function dec(n: number) { + return n - 1; +} +program(() => { + let n = 10; + let turns = 0; + while (turns < 5) { + const f = turns < 3 ? inc : dec; + n = f(n); + n = f(n + 1); + turns += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/function-void.ts b/ledger/language/function-void.ts new file mode 100644 index 0000000..4e2ed1f --- /dev/null +++ b/ledger/language/function-void.ts @@ -0,0 +1,15 @@ +// ledger: step 1, takes +// A function with no result that changes the program's variables: called as a statement. +program(() => { + let total = 0; + function add(n: number) { + total += n; + } + let n = 0; + while (n < 4) { + add(n); + add(1); + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/game-function.ts b/ledger/language/game-function.ts new file mode 100644 index 0000000..2e612db --- /dev/null +++ b/ledger/language/game-function.ts @@ -0,0 +1,13 @@ +// ledger: step 1, takes +// `game(…)`: a function for programs to call, which may be exported from another file; its body follows a program's rules. +const award = game((n: number) => n + n + 5); +program(() => { + let gold = 0; + let turns = 0; + while (turns < 3) { + gold = award(gold); + gold = award(turns); + turns += 1; + } + print(`${gold}`); +}); diff --git a/ledger/language/game-loop.ts b/ledger/language/game-loop.ts new file mode 100644 index 0000000..133e842 --- /dev/null +++ b/ledger/language/game-loop.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// A loop that goes on for ever, `while (true)`, giving the frame back with `sleep(frames(1))` at every turn. +program(() => { + let n = 0; + while (true) { + n += 1; + if (n == 48) print("two seconds"); + sleep(frames(1)); + } +}); diff --git a/ledger/language/generator-early-return.ts b/ledger/language/generator-early-return.ts new file mode 100644 index 0000000..71bbeb2 --- /dev/null +++ b/ledger/language/generator-early-return.ts @@ -0,0 +1,17 @@ +// ledger: step 0, takes +// A generator that `return`s before its end: the `for…of` walking it ends there. +function* untilBig(limit: number) { + let i = 0; + while (true) { + if (i > limit) return; + yield i; + i += 2; + } +} +program(() => { + let n = 0; + while (n < 7) n += 1; + let total = 0; + for (const x of untilBig(n)) total += x; + print(`${total}`); +}); diff --git a/ledger/language/generator-for-of.ts b/ledger/language/generator-for-of.ts new file mode 100644 index 0000000..a2988b2 --- /dev/null +++ b/ledger/language/generator-for-of.ts @@ -0,0 +1,16 @@ +// ledger: step 0, takes +// A generator, `function*`, walked by `for…of`: the loop's body put where each `yield` is. +function* upTo(n: number) { + let i = 0; + while (i < n) { + yield i; + i += 1; + } +} +program(() => { + let end = 0; + while (end < 5) end += 1; + let total = 0; + for (const x of upTo(end)) total += x; + print(`${total}`); +}); diff --git a/ledger/language/generator-yield-star.ts b/ledger/language/generator-yield-star.ts new file mode 100644 index 0000000..8d4ab47 --- /dev/null +++ b/ledger/language/generator-yield-star.ts @@ -0,0 +1,20 @@ +// ledger: step 0, takes +// `yield*` of another generator inside a generator; `break` leaves both. +function* pair(n: number) { + yield n; + yield n + 1; +} +function* all(n: number) { + yield* pair(n); + yield* pair(n + 10); +} +program(() => { + let k = 0; + while (k < 2) k += 1; + let total = 0; + for (const x of all(k)) { + if (x > 12) break; + total += x; + } + print(`${total}`); +}); diff --git a/ledger/language/generic-function.ts b/ledger/language/generic-function.ts new file mode 100644 index 0000000..7cd3379 --- /dev/null +++ b/ledger/language/generic-function.ts @@ -0,0 +1,15 @@ +// ledger: step 1, takes +// A generic function, `function larger(…)`: the type is gone in the map, the function the same for every call. +function larger(a: T, b: T): T { + return a > b ? a : b; +} +program(() => { + let n = 0; + let total = 0; + while (n < 4) { + total += larger(n, 2); + total += larger(5, n); + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/i32-cast.ts b/ledger/language/i32-cast.ts new file mode 100644 index 0000000..7b7a427 --- /dev/null +++ b/ledger/language/i32-cast.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `i32(x)`: the same 32 bits read as a signed number again. +program(() => { + let n = u32(5); + let count = 0; + while (n > 0) { + if (i32(n) < 3) count += 1; + n -= 1; + } + print(`${count}`); +}); diff --git a/ledger/language/if-else.ts b/ledger/language/if-else.ts new file mode 100644 index 0000000..ced5850 --- /dev/null +++ b/ledger/language/if-else.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// `if` with an `else`, on a number that changes while the game runs. +program(() => { + let n = 0; + while (n < 7) { + if (n < 3) n += 2; + else n += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/iife.ts b/ledger/language/iife.ts new file mode 100644 index 0000000..ccb0c78 --- /dev/null +++ b/ledger/language/iife.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A function called where it is written, `(() => { … })()`. +program(() => { + let n = 0; + while (n < 4) n += 1; + const doubled = ((x: number) => x + x)(n); + print(`${doubled}`); +}); diff --git a/ledger/language/in-at-build.ts b/ledger/language/in-at-build.ts new file mode 100644 index 0000000..036f05e --- /dev/null +++ b/ledger/language/in-at-build.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// `"field" in record` worked out when the map is built. +const BOSS: { hp: number } | { hp: number; shield: number } = { hp: 100, shield: 50 }; +program(() => { + let total = 0; + if ("shield" in BOSS) total += BOSS.shield; + total += BOSS.hp; + total -= 1; + print(`${total}`); +}); diff --git a/ledger/language/increment-as-value.ts b/ledger/language/increment-as-value.ts new file mode 100644 index 0000000..53c1bd9 --- /dev/null +++ b/ledger/language/increment-as-value.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `n++` and `++n` as values: the number before the step, and the one after. +program(() => { + let n = 0; + let total = 0; + while (total < 20) { + total += n++; + total += ++n; + } + print(`${total} ${n}`); +}); diff --git a/ledger/language/increment-decrement.ts b/ledger/language/increment-decrement.ts new file mode 100644 index 0000000..6b0b428 --- /dev/null +++ b/ledger/language/increment-decrement.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `++` and `--` on a variable, before and after. +program(() => { + let up = 0; + let down = 10; + while (up < 5) { + up++; + --down; + } + print(`${up} ${down}`); +}); diff --git a/ledger/language/instanceof.ts b/ledger/language/instanceof.ts new file mode 100644 index 0000000..9d9ac84 --- /dev/null +++ b/ledger/language/instanceof.ts @@ -0,0 +1,17 @@ +// ledger: step 0, takes +// `instanceof`, answered when the map is built: the class of each instance is known then. +class Animal { + constructor(public size: number) {} +} +class Bird extends Animal { + wings = 2; +} +function describe(a: Animal) { + if (a instanceof Bird) return a.wings + a.size; + return a.size; +} +program(() => { + let k = 0; + while (k < 4) k += 1; + print(`${describe(new Animal(k))} ${describe(new Bird(k))}`); +}); diff --git a/ledger/language/labeled-break.ts b/ledger/language/labeled-break.ts new file mode 100644 index 0000000..e33209c --- /dev/null +++ b/ledger/language/labeled-break.ts @@ -0,0 +1,16 @@ +// ledger: step 0, takes +// A label on a loop, and `break outer` from a loop inside it: both loops are left. +program(() => { + let i = 0; + let turns = 0; + outer: while (i < 5) { + let j = 0; + while (j < 5) { + if (i + j > 4) break outer; + turns += 1; + j += 1; + } + i += 1; + } + print(`${turns}`); +}); diff --git a/ledger/language/labeled-continue.ts b/ledger/language/labeled-continue.ts new file mode 100644 index 0000000..048f257 --- /dev/null +++ b/ledger/language/labeled-continue.ts @@ -0,0 +1,16 @@ +// ledger: step 0, takes +// `continue outer` from a loop inside another: the outer loop goes on to its next turn. +program(() => { + let i = 0; + let turns = 0; + outer: while (i < 4) { + i += 1; + let j = 0; + while (j < 4) { + j += 1; + if (j > i) continue outer; + turns += 1; + } + } + print(`${turns}`); +}); diff --git a/ledger/language/let-variable.ts b/ledger/language/let-variable.ts new file mode 100644 index 0000000..3394ab8 --- /dev/null +++ b/ledger/language/let-variable.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A `let` variable: a number the program keeps, set and changed while the game runs. +program(() => { + let gold = 50; + while (gold < 100) gold += 25; + print(`${gold}`); +}); diff --git a/ledger/language/math-min-max-abs.ts b/ledger/language/math-min-max-abs.ts new file mode 100644 index 0000000..514b887 --- /dev/null +++ b/ledger/language/math-min-max-abs.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `Math.min`, `Math.max` and `Math.abs` of values that change while the game runs. +program(() => { + let n = -3; + let total = 0; + while (n < 4) { + total += Math.min(n, 1) + Math.max(n, 0) + Math.abs(n); + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/method-returning-boolean.ts b/ledger/language/method-returning-boolean.ts new file mode 100644 index 0000000..90ccfb2 --- /dev/null +++ b/ledger/language/method-returning-boolean.ts @@ -0,0 +1,18 @@ +// ledger: step 1, takes +// A method that returns a boolean, called from more than one place and tested in an `if`. +class Timer { + left = 5; + done(): boolean { + return this.left <= 0; + } +} +program(() => { + const t = new Timer(); + let ticks = 0; + while (ticks < 10) { + if (t.done()) break; + t.left -= 1; + ticks += 1; + } + if (t.done()) print(`${ticks}`); +}); diff --git a/ledger/language/mutual-recursion.ts b/ledger/language/mutual-recursion.ts new file mode 100644 index 0000000..9a41caf --- /dev/null +++ b/ledger/language/mutual-recursion.ts @@ -0,0 +1,16 @@ +// ledger: step 1, takes +// Two functions that call each other. +function isEven(n: number): boolean { + if (n == 0) return true; + return isOdd(n - 1); +} +function isOdd(n: number): boolean { + if (n == 0) return false; + return isEven(n - 1); +} +program(() => { + let n = 0; + while (n < 7) n += 1; + if (isEven(n)) print("even"); + else print("odd"); +}); diff --git a/ledger/language/namespace.ts b/ledger/language/namespace.ts new file mode 100644 index 0000000..30e8100 --- /dev/null +++ b/ledger/language/namespace.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A `namespace` outside the program holding constants known when the map is built. +namespace Costs { + export const Marine = 50; + export const Medic = 75; +} +program(() => { + let ore = 400; + while (ore >= Costs.Medic) ore -= Costs.Marine; + print(`${ore}`); +}); diff --git a/ledger/language/negation.ts b/ledger/language/negation.ts new file mode 100644 index 0000000..a9f92d9 --- /dev/null +++ b/ledger/language/negation.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A number turned negative, `-n`, while the game runs. +program(() => { + let n = 4; + let total = 0; + while (n > 0) { + total += -n; + n -= 1; + } + print(`${total}`); +}); diff --git a/ledger/language/nested-destructuring.ts b/ledger/language/nested-destructuring.ts new file mode 100644 index 0000000..e33debb --- /dev/null +++ b/ledger/language/nested-destructuring.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// Destructuring a record inside a record: `const { at: { x } } = p`. +program(() => { + let p = { hp: 5, at: { x: 1, y: 2 } }; + p.at.x += 6; + const { hp, at: { x } } = p; + print(`${hp} ${x}`); +}); diff --git a/ledger/language/nested-function.ts b/ledger/language/nested-function.ts new file mode 100644 index 0000000..b2b22ba --- /dev/null +++ b/ledger/language/nested-function.ts @@ -0,0 +1,18 @@ +// ledger: step 1, takes +// A function declared inside another function, using its parameter. +function total(n: number) { + function sq(k: number) { + return k + k; + } + return sq(n) + sq(n + 1); +} +program(() => { + let n = 0; + let sum = 0; + while (n < 3) { + sum += total(n); + sum += total(n + 5); + n += 1; + } + print(`${sum}`); +}); diff --git a/ledger/language/nested-loops.ts b/ledger/language/nested-loops.ts new file mode 100644 index 0000000..0eee254 --- /dev/null +++ b/ledger/language/nested-loops.ts @@ -0,0 +1,15 @@ +// ledger: step 0, takes +// A loop inside another, both running while the game runs. +program(() => { + let i = 0; + let total = 0; + while (i < 4) { + let j = 0; + while (j < i) { + total += 1; + j += 1; + } + i += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/nested-ternary.ts b/ledger/language/nested-ternary.ts new file mode 100644 index 0000000..1080caf --- /dev/null +++ b/ledger/language/nested-ternary.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `?:` inside another `?:`. +program(() => { + let n = 0; + let total = 0; + while (n < 9) { + total += n < 3 ? 1 : n < 6 ? 10 : 100; + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/non-null-assertion.ts b/ledger/language/non-null-assertion.ts new file mode 100644 index 0000000..6670af4 --- /dev/null +++ b/ledger/language/non-null-assertion.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// The non-null assertion `x!`: it changes nothing in the map. +program(() => { + let boss: { hp: number } | null = { hp: 10 }; + let hits = 0; + while (boss!.hp > 0) { + boss!.hp -= 3; + hits += 1; + } + print(`${hits}`); +}); diff --git a/ledger/language/not.ts b/ledger/language/not.ts new file mode 100644 index 0000000..a6d362e --- /dev/null +++ b/ledger/language/not.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `!` turns a test around. +program(() => { + let n = 0; + let count = 0; + while (n < 10) { + if (!(n < 4)) count += 1; + n += 1; + } + print(`${count}`); +}); diff --git a/ledger/language/nullish-coalescing.ts b/ledger/language/nullish-coalescing.ts new file mode 100644 index 0000000..e5bcd10 --- /dev/null +++ b/ledger/language/nullish-coalescing.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// `??` on a number that may be `undefined`: the other side when it is. +program(() => { + let k = 0; + let total = 0; + while (k < 6) { + let n: number | undefined = undefined; + if (k > 2) n = k; + total += n ?? 10; + k += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/number-as-test.ts b/ledger/language/number-as-test.ts new file mode 100644 index 0000000..fa81e06 --- /dev/null +++ b/ledger/language/number-as-test.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A number used as a test, `while (n)`: true when it is not 0. +program(() => { + let n = 3; + let turns = 0; + while (n) { + n -= 1; + turns += 1; + } + print(`${turns}`); +}); diff --git a/ledger/language/number-literals.ts b/ledger/language/number-literals.ts new file mode 100644 index 0000000..c7f7c8e --- /dev/null +++ b/ledger/language/number-literals.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// Numbers written in hexadecimal, binary and with separators: `0x10`, `0b101`, `1_000`. +program(() => { + let n = 0; + while (n < 1_000) n += 0x10 + 0b101; + print(`${n}`); +}); diff --git a/ledger/language/object-destructuring-rename-default.ts b/ledger/language/object-destructuring-rename-default.ts new file mode 100644 index 0000000..c7e50a8 --- /dev/null +++ b/ledger/language/object-destructuring-rename-default.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// Destructuring that renames a field and gives one that is missing a default: `const { x: left, z = 5 } = …`. +program(() => { + let pos: { x: number; z?: number } = { x: 1 }; + while (pos.x < 3) pos.x += 1; + const { x: left, z = 5 } = pos; + print(`${left} ${z}`); +}); diff --git a/ledger/language/object-destructuring.ts b/ledger/language/object-destructuring.ts new file mode 100644 index 0000000..9ca18df --- /dev/null +++ b/ledger/language/object-destructuring.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// Destructuring a record into variables: `const { x, y } = pos`. +program(() => { + let pos = { x: 1, y: 2 }; + while (pos.x < 5) pos.x += 1; + const { x, y } = pos; + print(`${x + y}`); +}); diff --git a/ledger/language/object-rest.ts b/ledger/language/object-rest.ts new file mode 100644 index 0000000..6b8ab18 --- /dev/null +++ b/ledger/language/object-rest.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// Destructuring one field and the rest into another record: `const { x, ...others } = p`. +program(() => { + let p = { x: 1, y: 2, z: 3 }; + while (p.z < 7) p.z += 1; + const { x, ...others } = p; + print(`${x} ${others.z}`); +}); diff --git a/ledger/language/optional-chaining.ts b/ledger/language/optional-chaining.ts new file mode 100644 index 0000000..fb257c1 --- /dev/null +++ b/ledger/language/optional-chaining.ts @@ -0,0 +1,16 @@ +// ledger: step 5, takes +// `?.` reading a number through an instance that may be `null`: `a?.v ?? 0`. +class Node { + constructor(public v: number) {} +} +program(() => { + let a: Node | null = null; + let k = 0; + let total = 0; + while (k < 4) { + if (k == 2) a = new Node(k); + total += a?.v ?? 10; + k += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/optional-parameter.ts b/ledger/language/optional-parameter.ts new file mode 100644 index 0000000..baf7e7c --- /dev/null +++ b/ledger/language/optional-parameter.ts @@ -0,0 +1,16 @@ +// ledger: step 0, takes +// An optional parameter, `extra?: number`, tested for `undefined` inside the function. +function advance(n: number, extra?: number) { + if (extra !== undefined) return n + extra; + return n + 1; +} +program(() => { + let n = 0; + let turns = 0; + while (turns < 4) { + n = advance(n); + n = advance(n, 10); + turns += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/or.ts b/ledger/language/or.ts new file mode 100644 index 0000000..0ac772f --- /dev/null +++ b/ledger/language/or.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `||` of two tests: true when either holds. +program(() => { + let n = 0; + let count = 0; + while (n < 10) { + if (n < 2 || n > 7) count += 1; + n += 1; + } + print(`${count}`); +}); diff --git a/ledger/language/player-variable.ts b/ledger/language/player-variable.ts new file mode 100644 index 0000000..586bc69 --- /dev/null +++ b/ledger/language/player-variable.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A player kept in a variable, changed and compared while the game runs: `let p: Player = P10`. +program(() => { + let p: Player = P10; + let n = 0; + while (n < 3) { + n += 1; + if (n == 2) p = P11; + } + if (p == P11) print("eleven"); +}); diff --git a/ledger/language/players-compared-when-built.ts b/ledger/language/players-compared-when-built.ts new file mode 100644 index 0000000..910507c --- /dev/null +++ b/ledger/language/players-compared-when-built.ts @@ -0,0 +1,14 @@ +// ledger: step 0, takes +// Players compared in a function run when the map is built: `P1`, `P2`, `P9`, `players.P12`, each a slot known then. +function slotOf(p: Player) { + if (p == P1) return 1; + if (p == P2) return 2; + if (p == P9) return 9; + if (p == players.P12) return 12; + return 0; +} +program(() => { + let total = slotOf(P2) + slotOf(P9) + slotOf(P12); + total += 1; + print(`${total}`); +}); diff --git a/ledger/language/print-to-current-player.ts b/ledger/language/print-to-current-player.ts new file mode 100644 index 0000000..f1fa57f --- /dev/null +++ b/ledger/language/print-to-current-player.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A print to `CurrentPlayer`, the player the program runs as. +program(() => { + let n = 0; + while (n < 3) n += 1; + print(`${n}`, { to: CurrentPlayer }); +}); diff --git a/ledger/language/program-for-all-players.ts b/ledger/language/program-for-all-players.ts new file mode 100644 index 0000000..2dd0418 --- /dev/null +++ b/ledger/language/program-for-all-players.ts @@ -0,0 +1,10 @@ +// ledger: step 7, takes +// A program run for every player, `{ owner: AllPlayers }`: `CurrentPlayer` is the one it is running for. +program( + () => { + let n = 0; + while (n < 3) n += 1; + print(`${n}`, { to: CurrentPlayer }); + }, + { owner: AllPlayers }, +); diff --git a/ledger/language/program-for-several-players.ts b/ledger/language/program-for-several-players.ts new file mode 100644 index 0000000..3bcfeea --- /dev/null +++ b/ledger/language/program-for-several-players.ts @@ -0,0 +1,10 @@ +// ledger: step 7, takes +// A program run once for each of several players at the same time, `{ owner: [P4, P5, P6] }`, each with variables of their own. +program( + () => { + let n = 0; + while (n < 3) n += 1; + print(`${n}`, { to: CurrentPlayer }); + }, + { owner: [P4, P5, P6] }, +); diff --git a/ledger/language/program-owner.ts b/ledger/language/program-owner.ts new file mode 100644 index 0000000..8817dd1 --- /dev/null +++ b/ledger/language/program-owner.ts @@ -0,0 +1,10 @@ +// ledger: step 7, takes +// A program run as another player than the first: `program(…, { owner: P3 })`. +program( + () => { + let n = 0; + while (n < 3) n += 1; + print(`${n}`); + }, + { owner: P3 }, +); diff --git a/ledger/language/record-assigned-whole.ts b/ledger/language/record-assigned-whole.ts new file mode 100644 index 0000000..21ca923 --- /dev/null +++ b/ledger/language/record-assigned-whole.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A whole record given a new record: every field set at once. +program(() => { + let pos = { x: 1, y: 2 }; + let turns = 0; + while (turns < 3) { + pos = { x: pos.y, y: pos.x + 1 }; + turns += 1; + } + print(`${pos.x} ${pos.y}`); +}); diff --git a/ledger/language/record-from-function.ts b/ledger/language/record-from-function.ts new file mode 100644 index 0000000..642b325 --- /dev/null +++ b/ledger/language/record-from-function.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A function that returns a record, destructured where it is called. +function split(n: number) { + return { big: n > 4, half: n >> 1 }; +} +program(() => { + let n = 0; + while (n < 7) n += 1; + const { big, half } = split(n); + if (big) print(`${half}`); +}); diff --git a/ledger/language/record-index-known.ts b/ledger/language/record-index-known.ts new file mode 100644 index 0000000..0ae31c7 --- /dev/null +++ b/ledger/language/record-index-known.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// A record's field named by a text known when the map is built: `p["hp"]`. +program(() => { + let p = { hp: 10, shield: 5 }; + while (p["hp"] > 0) { + p.hp -= 3; + p.shield += 1; + } + print(`${p["hp"]} ${p["shield"]}`); +}); diff --git a/ledger/language/record-nested.ts b/ledger/language/record-nested.ts new file mode 100644 index 0000000..3f15167 --- /dev/null +++ b/ledger/language/record-nested.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// A record that holds another record: `p.at.x`. +program(() => { + let p = { hp: 10, at: { x: 0, y: 0 } }; + while (p.hp > 0) { + p.at.x += 2; + p.hp -= 3; + } + print(`${p.at.x} ${p.hp}`); +}); diff --git a/ledger/language/record-shorthand.ts b/ledger/language/record-shorthand.ts new file mode 100644 index 0000000..3e3d05a --- /dev/null +++ b/ledger/language/record-shorthand.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// A record written with shorthand fields: `{ x, y }` takes each from the variable of that name. +program(() => { + let x = 0; + while (x < 3) x += 1; + const y = x + 1; + const p = { x, y }; + print(`${p.x} ${p.y}`); +}); diff --git a/ledger/language/record-spread.ts b/ledger/language/record-spread.ts new file mode 100644 index 0000000..0ad1baf --- /dev/null +++ b/ledger/language/record-spread.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// A record made from another with `...`, one field changed: `{ ...pos, x: 9 }`. +program(() => { + let pos = { x: 1, y: 2 }; + while (pos.y < 5) pos.y += 1; + let moved = { ...pos, x: 9 }; + moved.x += pos.x; + print(`${moved.x} ${moved.y}`); +}); diff --git a/ledger/language/record-type-annotation.ts b/ledger/language/record-type-annotation.ts new file mode 100644 index 0000000..4dfd6a8 --- /dev/null +++ b/ledger/language/record-type-annotation.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// A record with a declared type (an `interface`), and a field marked optional. +interface Wave { + count: number; + delay: number; + boss?: boolean; +} +program(() => { + let w: Wave = { count: 3, delay: 10 }; + while (w.count < 8) w.count += w.delay > 5 ? 2 : 1; + print(`${w.count}`); +}); diff --git a/ledger/language/record.ts b/ledger/language/record.ts new file mode 100644 index 0000000..b43c36f --- /dev/null +++ b/ledger/language/record.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// A record, `{ x: 1, y: 2 }`: each field a variable of its own, read and written while the game runs. +program(() => { + let pos = { x: 1, y: 2 }; + while (pos.x < 10) { + pos.x += 3; + pos.y += 1; + } + print(`${pos.x} ${pos.y}`); +}); diff --git a/ledger/language/recursion.ts b/ledger/language/recursion.ts new file mode 100644 index 0000000..3992ba7 --- /dev/null +++ b/ledger/language/recursion.ts @@ -0,0 +1,11 @@ +// ledger: step 1, takes +// A function that calls itself: each call has its own parameters and locals, kept on a stack. +function fib(n: number): number { + if (n < 2) return n; + return fib(n - 1) + fib(n - 2); +} +program(() => { + let n = 0; + while (n < 7) n += 1; + print(`${fib(n)}`); +}); diff --git a/ledger/language/rest-parameter.ts b/ledger/language/rest-parameter.ts new file mode 100644 index 0000000..2aa8931 --- /dev/null +++ b/ledger/language/rest-parameter.ts @@ -0,0 +1,18 @@ +// ledger: step 3, takes +// A rest parameter, `...xs: number[]`: the arguments a call gives, however many, as a fixed-length array. +function sum(...xs: number[]) { + let t = 0; + for (const x of xs) t += x; + return t; +} +program(() => { + let a = 1; + let b = 2; + let turns = 0; + while (turns < 3) { + a = sum(a, b, 1); + b = sum(b, a); + turns += 1; + } + print(`${a} ${b}`); +}); diff --git a/ledger/language/rethrow.ts b/ledger/language/rethrow.ts new file mode 100644 index 0000000..ddce6cd --- /dev/null +++ b/ledger/language/rethrow.ts @@ -0,0 +1,16 @@ +// ledger: step 11, takes +// An error caught and thrown again, `catch (e) { …; throw e; }`, to a `try` further out. +program(() => { + let k = 0; + while (k < 2) k += 1; + try { + try { + if (k > 1) throw new Error("inner"); + } catch (e) { + print("seen inside"); + throw e; + } + } catch { + print("caught outside"); + } +}); diff --git a/ledger/language/return-at-the-top.ts b/ledger/language/return-at-the-top.ts new file mode 100644 index 0000000..bad18cb --- /dev/null +++ b/ledger/language/return-at-the-top.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// `return` at the top of a program: the program ends there, for good. +program(() => { + let n = 1; + print("a"); + if (n > 0) return; + print("b"); +}); diff --git a/ledger/language/satisfies.ts b/ledger/language/satisfies.ts new file mode 100644 index 0000000..bbc93b3 --- /dev/null +++ b/ledger/language/satisfies.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// `satisfies`, a check of a type: it changes nothing in the map. +const LIMITS = { low: 5, high: 20 } satisfies Record; +program(() => { + let n = 0; + while (n < LIMITS.high) n += LIMITS.low; + print(`${n}`); +}); diff --git a/ledger/language/several-declarations.ts b/ledger/language/several-declarations.ts new file mode 100644 index 0000000..cad459c --- /dev/null +++ b/ledger/language/several-declarations.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// Several variables declared in one statement: `let a = 1, b = 2;`. +program(() => { + let a = 1, + b = 2, + turns = 0; + while (turns < 4) { + a += b; + turns += 1; + } + print(`${a} ${b}`); +}); diff --git a/ledger/language/short-circuit.ts b/ledger/language/short-circuit.ts new file mode 100644 index 0000000..c519f3e --- /dev/null +++ b/ledger/language/short-circuit.ts @@ -0,0 +1,17 @@ +// ledger: step 1, takes +// `&&` that does not ask its right side when the left is false: a call on the right runs only then. +program(() => { + let calls = 0; + function big(n: number) { + calls += 1; + return n > 3; + } + let n = 0; + let count = 0; + while (n < 6) { + if (n > 1 && big(n)) count += 1; + if (n > 4 || big(n + 1)) count += 10; + n += 1; + } + print(`${count} ${calls}`); +}); diff --git a/ledger/language/sleep-cycles.ts b/ledger/language/sleep-cycles.ts new file mode 100644 index 0000000..4dde319 --- /dev/null +++ b/ledger/language/sleep-cycles.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// `sleep(cycles(n))`, the older word for `frames(n)`. +program(() => { + let n = 0; + while (n < 4) { + sleep(cycles(3)); + n += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/sleep-in-async-program.ts b/ledger/language/sleep-in-async-program.ts new file mode 100644 index 0000000..db2c591 --- /dev/null +++ b/ledger/language/sleep-in-async-program.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// `await sleep(…)` in an `async` program: the same pause as `sleep(…)` on its own. +program(async () => { + let n = 0; + while (n < 3) { + await sleep(frames(5)); + n += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/sleep-minutes.ts b/ledger/language/sleep-minutes.ts new file mode 100644 index 0000000..8a46944 --- /dev/null +++ b/ledger/language/sleep-minutes.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// `sleep(minutes(n))`: the program goes on that many minutes later. +program(() => { + let n = 0; + while (n < 2) { + sleep(minutes(1)); + n += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/sleep-seconds.ts b/ledger/language/sleep-seconds.ts new file mode 100644 index 0000000..b2bf2ac --- /dev/null +++ b/ledger/language/sleep-seconds.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// `sleep(seconds(n))`: the program goes on that many seconds later, twenty-four frames a second. +program(() => { + let n = 0; + while (n < 3) { + sleep(seconds(2)); + n += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/spread-arguments.ts b/ledger/language/spread-arguments.ts new file mode 100644 index 0000000..fc23915 --- /dev/null +++ b/ledger/language/spread-arguments.ts @@ -0,0 +1,15 @@ +// ledger: step 0, takes +// Arguments spread from a list known when the map is built: `mix(n, ...PAIR)`. +function mix(a: number, b: number, c: number) { + return a + b + b + c; +} +const PAIR = [3, 4] as const; +program(() => { + let n = 0; + let turns = 0; + while (turns < 3) { + n = mix(n, ...PAIR); + turns += 1; + } + print(`${n}`); +}); diff --git a/ledger/language/stack-overflow-caught.ts b/ledger/language/stack-overflow-caught.ts new file mode 100644 index 0000000..5ee8e70 --- /dev/null +++ b/ledger/language/stack-overflow-caught.ts @@ -0,0 +1,15 @@ +// ledger: step 11, takes +// A function that calls itself past the stack's depth throws a RangeError, as JavaScript's does, which a `catch` takes. +function deep(n: number): number { + if (n > 1000000) return 0; + return deep(n + 1) + 1; +} +program(() => { + let k = 0; + while (k < 2) k += 1; + try { + print(`${deep(k)}`); + } catch (e) { + if (e instanceof RangeError) print(e.message); + } +}); diff --git a/ledger/language/strict-equality.ts b/ledger/language/strict-equality.ts new file mode 100644 index 0000000..1c69ed5 --- /dev/null +++ b/ledger/language/strict-equality.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// `===` and `!==`, the same as `==` and `!=` for numbers. +program(() => { + let n = 0; + let count = 0; + while (n < 6) { + if (n === 2) count += 1; + if (n !== 4) count += 10; + n += 1; + } + print(`${count}`); +}); diff --git a/ledger/language/swap-by-destructuring.ts b/ledger/language/swap-by-destructuring.ts new file mode 100644 index 0000000..991a459 --- /dev/null +++ b/ledger/language/swap-by-destructuring.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// Two variables swapped with a destructuring assignment: `[a, b] = [b, a]`. +program(() => { + let a = 1; + let b = 7; + let turns = 0; + while (turns < 3) { + [a, b] = [b, a]; + a += 1; + turns += 1; + } + print(`${a} ${b}`); +}); diff --git a/ledger/language/switch-case-block.ts b/ledger/language/switch-case-block.ts new file mode 100644 index 0000000..28f246a --- /dev/null +++ b/ledger/language/switch-case-block.ts @@ -0,0 +1,21 @@ +// ledger: step 0, takes +// A `case` with a block of its own, `case 1: { const x = …; … }`, its variables only its own. +program(() => { + let n = 0; + let total = 0; + while (n < 3) { + switch (n) { + case 1: { + const bonus = n + 10; + total += bonus; + break; + } + default: { + const bonus = n + 1; + total += bonus; + } + } + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/switch-fall-through.ts b/ledger/language/switch-fall-through.ts new file mode 100644 index 0000000..8caed17 --- /dev/null +++ b/ledger/language/switch-fall-through.ts @@ -0,0 +1,19 @@ +// ledger: step 0, takes +// A `case` with no `break` goes on into the next one, as JavaScript's does. +program(() => { + let n = 0; + let score = 0; + while (n < 3) { + switch (n) { + case 0: + score += 1; + case 1: + score += 10; + break; + case 2: + score += 100; + } + n += 1; + } + print(`${score}`); +}); diff --git a/ledger/language/switch-number.ts b/ledger/language/switch-number.ts new file mode 100644 index 0000000..8dda69b --- /dev/null +++ b/ledger/language/switch-number.ts @@ -0,0 +1,15 @@ +// ledger: step 0, takes +// `switch` on a number that changes while the game runs, each `case` ending in `break`, and a `default`. +program(() => { + let n = 0; + let score = 0; + while (n < 5) { + switch (n) { + case 0: score += 1; break; + case 2: score += 10; break; + default: score += 100; break; + } + n += 1; + } + print(`${score}`); +}); diff --git a/ledger/language/switch-on-a-known-text.ts b/ledger/language/switch-on-a-known-text.ts new file mode 100644 index 0000000..ae0b1df --- /dev/null +++ b/ledger/language/switch-on-a-known-text.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// A `switch` on a text known when the script is built takes its case then: no text is compared in the game. +const MODE: string = "fast"; +program(() => { + let n = 0; + switch (MODE) { + case "slow": n = 2; break; + case "fast": n = 1; break; + default: n = 9; + } + print(`${n}`); +}); diff --git a/ledger/language/switch-return.ts b/ledger/language/switch-return.ts new file mode 100644 index 0000000..10cab53 --- /dev/null +++ b/ledger/language/switch-return.ts @@ -0,0 +1,19 @@ +// ledger: step 1, takes +// A `switch` inside a function whose cases `return` the function's result. +function price(kind: number) { + switch (kind) { + case 0: return 50; + case 1: return 75; + default: return 100; + } +} +program(() => { + let n = 0; + let total = 0; + while (n < 4) { + total += price(n); + total += price(n + 1); + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/switch-text.ts b/ledger/language/switch-text.ts new file mode 100644 index 0000000..4fe7603 --- /dev/null +++ b/ledger/language/switch-text.ts @@ -0,0 +1,12 @@ +// ledger: step 8, takes +// `switch` on a text the program holds, its cases texts. +program(() => { + let n = 0; + let word = "red"; + while (n < 3) n += 1; + if (n > 2) word = "blue"; + switch (word) { + case "red": print("stop"); break; + case "blue": print("go"); break; + } +}); diff --git a/ledger/language/ternary-boolean.ts b/ledger/language/ternary-boolean.ts new file mode 100644 index 0000000..61def18 --- /dev/null +++ b/ledger/language/ternary-boolean.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// `?:` choosing between two booleans, its test asked while the game runs. +program(() => { + let n = 0; + let count = 0; + let pick = false; + while (n < 6) { + pick = n > 2 ? n < 4 : n > 0; + if (pick) count += 1; + n += 1; + } + print(`${count}`); +}); diff --git a/ledger/language/ternary-number.ts b/ledger/language/ternary-number.ts new file mode 100644 index 0000000..e567cff --- /dev/null +++ b/ledger/language/ternary-number.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `?:` choosing between two numbers, its test asked while the game runs. +program(() => { + let n = 0; + let total = 0; + while (n < 6) { + total += n < 3 ? 1 : 10; + n += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/this-in-an-arrow.ts b/ledger/language/this-in-an-arrow.ts new file mode 100644 index 0000000..94857ec --- /dev/null +++ b/ledger/language/this-in-an-arrow.ts @@ -0,0 +1,18 @@ +// ledger: step 1, takes +// An arrow written inside a method reads the instance through `this`, and a field read straight off `new`. +class M { + x = 1; + f() { + const g = () => this.x + 1; + return g() + this.x; + } +} +class P { + constructor(public x: number) {} +} +program(() => { + const m = new M(); + m.x = 5; + let k = 4; + print(`${m.f()} ${new P(k).x}`); +}); diff --git a/ledger/language/throw-from-function.ts b/ledger/language/throw-from-function.ts new file mode 100644 index 0000000..a11835a --- /dev/null +++ b/ledger/language/throw-from-function.ts @@ -0,0 +1,19 @@ +// ledger: step 11, takes +// An error thrown in a called function, caught by the `try` around the call. +function check(n: number) { + if (n > 2) throw new Error("too big"); + return n + 1; +} +program(() => { + let n = 0; + let total = 0; + try { + while (n < 5) { + total += check(n); + n += 1; + } + } catch { + print(`stopped at ${n}`); + } + print(`${total}`); +}); diff --git a/ledger/language/throw-through-recursion.ts b/ledger/language/throw-through-recursion.ts new file mode 100644 index 0000000..e075a38 --- /dev/null +++ b/ledger/language/throw-through-recursion.ts @@ -0,0 +1,15 @@ +// ledger: step 11, takes +// An error thrown at the bottom of a function that calls itself, caught around the first call: up through every frame. +function down(n: number): number { + if (n == 0) throw new Error("bottom"); + return down(n - 1) + 1; +} +program(() => { + let k = 0; + while (k < 2) k += 1; + try { + print(`${down(k + 3)}`); + } catch { + print("caught at the top"); + } +}); diff --git a/ledger/language/throw-uncaught.ts b/ledger/language/throw-uncaught.ts new file mode 100644 index 0000000..5233102 --- /dev/null +++ b/ledger/language/throw-uncaught.ts @@ -0,0 +1,9 @@ +// ledger: step 11, takes +// A `throw` nothing catches: the game says the error, and the program stops there. +program(() => { + let k = 0; + while (k < 2) k += 1; + print("before"); + if (k > 1) throw new RangeError("k is too large"); + print("after"); +}); diff --git a/ledger/language/throw-when-built-in-a-try.ts b/ledger/language/throw-when-built-in-a-try.ts new file mode 100644 index 0000000..f1006e6 --- /dev/null +++ b/ledger/language/throw-when-built-in-a-try.ts @@ -0,0 +1,15 @@ +// ledger: step 11, takes +// A call worked out when the script is built that throws, inside a try: the program's error, which the catch takes. +function check(n: number) { + if (n > 5) throw new RangeError("too big: " + n); + return n; +} +program(() => { + let total = 0; + try { + total += check(9); + } catch (e) { + total = e instanceof RangeError ? -1 : -2; + } + print(`${total}`); +}); diff --git a/ledger/language/try-catch.ts b/ledger/language/try-catch.ts new file mode 100644 index 0000000..b133a51 --- /dev/null +++ b/ledger/language/try-catch.ts @@ -0,0 +1,12 @@ +// ledger: step 11, takes +// `try { … } catch { … }`: an error thrown inside the `try` goes to its `catch`. +program(() => { + let k = 0; + while (k < 2) k += 1; + try { + if (k > 1) throw new Error("too many"); + print("fine"); + } catch { + print("caught"); + } +}); diff --git a/ledger/language/try-finally.ts b/ledger/language/try-finally.ts new file mode 100644 index 0000000..ee5b4e2 --- /dev/null +++ b/ledger/language/try-finally.ts @@ -0,0 +1,15 @@ +// ledger: step 11, takes +// `finally`: what runs however the `try` is left. +program(() => { + let k = 0; + let cleaned = 0; + while (k < 3) { + try { + if (k == 1) break; + } finally { + cleaned += 1; + } + k += 1; + } + print(`${cleaned}`); +}); diff --git a/ledger/language/tuple-result.ts b/ledger/language/tuple-result.ts new file mode 100644 index 0000000..a576827 --- /dev/null +++ b/ledger/language/tuple-result.ts @@ -0,0 +1,11 @@ +// ledger: step 3, takes +// A function that returns two numbers as a tuple, `return [a, b] as const`, destructured where it is called. +function split(n: number) { + return [n + 1, n + 2] as const; +} +program(() => { + let n = 0; + while (n < 4) n += 1; + const [a, b] = split(n); + print(`${a} ${b}`); +}); diff --git a/ledger/language/two-programs.ts b/ledger/language/two-programs.ts new file mode 100644 index 0000000..86e2f33 --- /dev/null +++ b/ledger/language/two-programs.ts @@ -0,0 +1,18 @@ +// ledger: step 7, takes +// Two programs in one map, each with an owner of its own, running side by side. +program( + () => { + let n = 0; + while (n < 3) n += 1; + print(`${n}`); + }, + { owner: P7 }, +); +program( + () => { + let n = 0; + while (n < 5) n += 1; + print(`${n}`); + }, + { owner: P8 }, +); diff --git a/ledger/language/type-alias-union.ts b/ledger/language/type-alias-union.ts new file mode 100644 index 0000000..47ef522 --- /dev/null +++ b/ledger/language/type-alias-union.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// A type alias of a union of numbers, `type Lane = 0 | 1 | 2`: a type only, the values numbers. +type Lane = 0 | 1 | 2; +program(() => { + let lane: Lane = 0; + let turns = 0; + while (turns < 5) { + lane = lane == 2 ? 0 : lane == 1 ? 2 : 1; + turns += 1; + } + print(`${lane}`); +}); diff --git a/ledger/language/type-assertion.ts b/ledger/language/type-assertion.ts new file mode 100644 index 0000000..a606df7 --- /dev/null +++ b/ledger/language/type-assertion.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A type assertion, `value as number`: it changes nothing in the map. +program(() => { + let n = 4 as number; + let total = 0; + while (total < 20) total += n; + print(`${total}`); +}); diff --git a/ledger/language/typeof-at-build.ts b/ledger/language/typeof-at-build.ts new file mode 100644 index 0000000..5f63e69 --- /dev/null +++ b/ledger/language/typeof-at-build.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// `typeof` worked out when the map is built: only the branch it picks is left in the map. +const LIMIT: number | string = 30; +program(() => { + let n = 0; + if (typeof LIMIT === "number") { + while (n < LIMIT) n += 7; + } + print(`${n}`); +}); diff --git a/ledger/language/u32-cast.ts b/ledger/language/u32-cast.ts new file mode 100644 index 0000000..af20947 --- /dev/null +++ b/ledger/language/u32-cast.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// `u32(x)`: the same 32 bits read as a number from 0 up, so `-1` compares as the largest. +program(() => { + let n = 3; + let count = 0; + while (n > -3) { + if (u32(n) > 100) count += 1; + n -= 1; + } + print(`${count}`); +}); diff --git a/ledger/language/uncaught-in-function.ts b/ledger/language/uncaught-in-function.ts new file mode 100644 index 0000000..33fff6f --- /dev/null +++ b/ledger/language/uncaught-in-function.ts @@ -0,0 +1,12 @@ +// ledger: step 11, takes +// An error thrown in a called function that nothing catches: said in red with the line of the `throw`, and the program stops. +function explode(k: number) { + throw new RangeError(`k is ${k}`); +} +program(() => { + let k = 0; + while (k < 2) k += 1; + print("before"); + if (k > 1) explode(k); + print("after"); +}); diff --git a/ledger/language/undefined-check.ts b/ledger/language/undefined-check.ts new file mode 100644 index 0000000..51cd1f9 --- /dev/null +++ b/ledger/language/undefined-check.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// A number that may be `undefined`, tested with `!== undefined` before it is used. +program(() => { + let k = 0; + let total = 0; + while (k < 6) { + let n: number | undefined = undefined; + if (k > 2) n = k; + if (n !== undefined) total += n; + k += 1; + } + print(`${total}`); +}); diff --git a/ledger/language/var-declaration.ts b/ledger/language/var-declaration.ts new file mode 100644 index 0000000..03d5aa1 --- /dev/null +++ b/ledger/language/var-declaration.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A `var` declaration: the same as a `let` in a program. +program(() => { + var n = 0; + while (n < 7) n += 2; + print(`${n}`); +}); diff --git a/ledger/language/while-loop.ts b/ledger/language/while-loop.ts new file mode 100644 index 0000000..784f94f --- /dev/null +++ b/ledger/language/while-loop.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A `while` loop over a number, within one frame. +program(() => { + let n = 0; + while (n < 10) n += 3; + print(`${n}`); +}); diff --git a/ledger/memory/deaths-cells-12-to-26.ts b/ledger/memory/deaths-cells-12-to-26.ts new file mode 100644 index 0000000..c8d2927 --- /dev/null +++ b/ledger/memory/deaths-cells-12-to-26.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// memory() and setMemory() of the deaths table's dwords 12 to 26, which the game would read as player groups: named as the deaths of player e mod 12 of unit e div 12. +program(() => { + setMemory(0x58a364 + 4 * 13, "set", 7); + if (memory(0x58a364 + 4 * 13, ">=", 7)) print("the cell"); +}); diff --git a/ledger/memory/fog-columns.ts b/ledger/memory/fog-columns.ts new file mode 100644 index 0000000..3c7549c --- /dev/null +++ b/ledger/memory/fog-columns.ts @@ -0,0 +1,12 @@ +// ledger: step 7, takes +// The fog library: columns lit for this frame, each as tall as an array of the program says, laid out +// when the map is built: `fog.columns(P1, x, y, layout, heights)` (a bar chart, a raycaster's walls). +// The library adds a program of its own, the canvas that wipes the fog every frame. +import { fog } from "trigscript/fog"; +const VIEW = { count: 4, height: 16 } as const; +program(async () => { + const heights = [4, 8, 12, 16]; + heights[0] = 6; + fog.columns(P1, 1, 0, VIEW, heights); + await nextFrame(); +}); diff --git a/ledger/memory/fog-light.ts b/ledger/memory/fog-light.ts new file mode 100644 index 0000000..535d40d --- /dev/null +++ b/ledger/memory/fog-light.ts @@ -0,0 +1,13 @@ +// ledger: step 7, takes +// The fog library: a disc of fog lit for this frame, its size a value of the program: +// `light(P1, x, y, size)`. +// The library adds a program of its own, the canvas that wipes the fog every frame. +import { light } from "trigscript/fog"; +program(async () => { + let size = 1; + while (size < 6) { + light(P1, 30, 20, size); + await nextFrame(); + size += 1; + } +}); diff --git a/ledger/memory/fog-picture.ts b/ledger/memory/fog-picture.ts new file mode 100644 index 0000000..525a664 --- /dev/null +++ b/ledger/memory/fog-picture.ts @@ -0,0 +1,9 @@ +// ledger: step 7, takes +// The fog library: a picture made of rows of text when the map is built (`fog.picture`), lit for a frame. +// The library adds a program of its own, the canvas that wipes the fog every frame. +import { fog } from "trigscript/fog"; +const heart = fog.picture([".##.##.", "#######", ".#####.", "..###.."]); +program(async () => { + fog.light(P1, 60, 40, heart); + await nextFrame(); +}); diff --git a/ledger/memory/fog-play.ts b/ledger/memory/fog-play.ts new file mode 100644 index 0000000..a4875f9 --- /dev/null +++ b/ledger/memory/fog-play.ts @@ -0,0 +1,11 @@ +// ledger: step 7, takes +// The fog library: a video played in the fog, a frame of it every frame of the game: `await fog.play(P1, video)`. +// A video comes only from a .video file the script imports, which a script of the ledger, one file, cannot: +// this one has none, and the call is left out when the map is built. +// The library adds a program of its own, the canvas that wipes the fog every frame. +import { fog } from "trigscript/fog"; +const film: Video | null = null; +program(async () => { + if (film) await fog.play(P1, film); + fog.reveal(P1); +}); diff --git a/ledger/memory/fog-rect.ts b/ledger/memory/fog-rect.ts new file mode 100644 index 0000000..7ca74bf --- /dev/null +++ b/ledger/memory/fog-rect.ts @@ -0,0 +1,13 @@ +// ledger: step 7, takes +// The fog library: a rectangle of tiles lit for this frame, every side from values of the program: +// `fog.rect(P1, x, y, width, height)`. +// The library adds a program of its own, the canvas that wipes the fog every frame. +import { fog } from "trigscript/fog"; +program(async () => { + let tall = 4; + while (tall < 12) { + fog.rect(P1, 10, 20, 3, tall); + await nextFrame(); + tall += 2; + } +}); diff --git a/ledger/memory/fog-reveal.ts b/ledger/memory/fog-reveal.ts new file mode 100644 index 0000000..413c41e --- /dev/null +++ b/ledger/memory/fog-reveal.ts @@ -0,0 +1,7 @@ +// ledger: step 7, takes +// The fog library: the whole map lit once, so that from then on dark is fog, not black: `fog.reveal(P1)`. +// The library adds a program of its own, the canvas that wipes the fog every frame. +import { fog } from "trigscript/fog"; +program(async () => { + fog.reveal(P1); +}); diff --git a/ledger/memory/fog-shapes.ts b/ledger/memory/fog-shapes.ts new file mode 100644 index 0000000..cda8087 --- /dev/null +++ b/ledger/memory/fog-shapes.ts @@ -0,0 +1,16 @@ +// ledger: step 7, takes +// The fog library's brushes, `SHAPES`: the tiles each size of brush reveals, for a renderer of the +// script's own (`cover(picture, SHAPES)`), worked out when the map is built. +// The library adds a program of its own, the canvas that wipes the fog every frame. +import { SHAPES } from "trigscript/fog"; +const brushes = cover(["#####", "#####", "#####", "#####"], SHAPES); +const sizes = brushes.map((b) => b.shape + 1); +program(() => { + let i = 0; + let sum = 0; + while (i < sizes.length) { + sum += sizes[i]; + i += 1; + } + print(`${sum}`); +}); diff --git a/ledger/memory/fog-show.ts b/ledger/memory/fog-show.ts new file mode 100644 index 0000000..2eb84bf --- /dev/null +++ b/ledger/memory/fog-show.ts @@ -0,0 +1,8 @@ +// ledger: step 7, takes +// The fog library: what was lit this frame kept for so many frames, and waited out: `await fog.show(2)`. +// The library adds a program of its own, the canvas that wipes the fog every frame. +import { fog } from "trigscript/fog"; +program(async () => { + fog.light(P1, 30, 20, 4); + await fog.show(2); +}); diff --git a/ledger/memory/game-frame-read.ts b/ledger/memory/game-frame-read.ts new file mode 100644 index 0000000..c77b85b --- /dev/null +++ b/ledger/memory/game-frame-read.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// Game frames since the game began: `memory.game.frame`. +program(() => { + const frame = memory.game.frame; + print(`${frame}`); +}); diff --git a/ledger/memory/location-bounds-read.ts b/ledger/memory/location-bounds-read.ts new file mode 100644 index 0000000..fb73a61 --- /dev/null +++ b/ledger/memory/location-bounds-read.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// A location's edges where it is now: `bounds(location)`, each of left, top, right, bottom a read of the game's location table. +program(() => { + const b = bounds(locations.Anywhere); + print(`${b.right - b.left} by ${b.bottom - b.top}`); +}); diff --git a/ledger/memory/location-bounds-written.ts b/ledger/memory/location-bounds-written.ts new file mode 100644 index 0000000..3f9b1f9 --- /dev/null +++ b/ledger/memory/location-bounds-written.ts @@ -0,0 +1,9 @@ +// ledger: step 6, takes +// A location moved by a write to its edges: `bounds(location).left += 32` and the rest, where it is now. +program(() => { + const b = bounds(locations.Anywhere); + b.left += 32; + b.right += 32; + b.top = 0; + b.bottom -= 16; +}); diff --git a/ledger/memory/map-size-read.ts b/ledger/memory/map-size-read.ts new file mode 100644 index 0000000..ac3928e --- /dev/null +++ b/ledger/memory/map-size-read.ts @@ -0,0 +1,7 @@ +// ledger: step 6, takes +// The map's width and height in tiles: `memory.map.width`, `memory.map.height`. +program(() => { + const w = memory.map.width; + const h = memory.map.height; + print(`${w} by ${h}`); +}); diff --git a/ledger/memory/memory-condition.ts b/ledger/memory/memory-condition.ts new file mode 100644 index 0000000..addfd75 --- /dev/null +++ b/ledger/memory/memory-condition.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// A cell of the game's memory compared, by its address (EUD): `memory(address, ">=", value)`. +program(() => { + if (memory(0x58A364, ">=", 1)) print("set"); +}); diff --git a/ledger/memory/player-color-by-name.ts b/ledger/memory/player-color-by-name.ts new file mode 100644 index 0000000..8f2d0ba --- /dev/null +++ b/ledger/memory/player-color-by-name.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A player's colour by its name: `stats(P2).color = "teal"`. +program(() => { + stats(P2).color = "teal"; +}); diff --git a/ledger/memory/player-color-written.ts b/ledger/memory/player-color-written.ts new file mode 100644 index 0000000..bb6670b --- /dev/null +++ b/ledger/memory/player-color-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A player's colour, one of `colors.*`, at once: `stats(P1).color = colors.green`. +program(() => { + stats(P1).color = colors.green; +}); diff --git a/ledger/memory/player-researched-read.ts b/ledger/memory/player-researched-read.ts new file mode 100644 index 0000000..0019c1f --- /dev/null +++ b/ledger/memory/player-researched-read.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// Whether a player has a technology, tested in an `if`. +program(() => { + if (stats(P1).researched[techs.StimPacks]) print("stim ready"); +}); diff --git a/ledger/memory/player-researched-written.ts b/ledger/memory/player-researched-written.ts new file mode 100644 index 0000000..b42e7aa --- /dev/null +++ b/ledger/memory/player-researched-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// Whether a player has a technology, given: `stats(P1).researched[techs.Lockdown] = true`. +program(() => { + stats(P1).researched[techs.Lockdown] = true; +}); diff --git a/ledger/memory/player-upgrade-level-read.ts b/ledger/memory/player-upgrade-level-read.ts new file mode 100644 index 0000000..ef580ba --- /dev/null +++ b/ledger/memory/player-upgrade-level-read.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// A player's level of an upgrade, read: `stats(P1).upgrades[upgrades.*]`. +program(() => { + const level = stats(P1).upgrades[upgrades.TerranInfantryWeapons]; + print(`${level}`); +}); diff --git a/ledger/memory/player-upgrade-level-written.ts b/ledger/memory/player-upgrade-level-written.ts new file mode 100644 index 0000000..eab697e --- /dev/null +++ b/ledger/memory/player-upgrade-level-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A player's level of an upgrade: `stats(P1).upgrades[upgrades.*] = 3`. +program(() => { + stats(P1).upgrades[upgrades.TerranInfantryWeapons] = 3; +}); diff --git a/ledger/memory/set-memory.ts b/ledger/memory/set-memory.ts new file mode 100644 index 0000000..2dd60d5 --- /dev/null +++ b/ledger/memory/set-memory.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// A cell of the game's memory written, by its address (EUD): `setMemory(address, "add", value)`. +program(() => { + setMemory(0x58A364, "add", 1); +}); diff --git a/ledger/memory/tech-cost-written.ts b/ledger/memory/tech-cost-written.ts new file mode 100644 index 0000000..5dbb510 --- /dev/null +++ b/ledger/memory/tech-cost-written.ts @@ -0,0 +1,8 @@ +// ledger: step 6, takes +// A technology's research cost and time, and the energy a cast takes, written: `stats(techs.*)`. +program(() => { + stats(techs.StimPacks).minerals = 50; + stats(techs.StimPacks).gas = 50; + stats(techs.StimPacks).time = 40; + stats(techs.Lockdown).energy = 75; +}); diff --git a/ledger/memory/tech-energy-read.ts b/ledger/memory/tech-energy-read.ts new file mode 100644 index 0000000..285cf60 --- /dev/null +++ b/ledger/memory/tech-energy-read.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// The energy a spell costs, read: `stats(techs.Lockdown).energy`. +program(() => { + const energy = stats(techs.Lockdown).energy; + print(`${energy}`); +}); diff --git a/ledger/memory/unit-air-weapon-written.ts b/ledger/memory/unit-air-weapon-written.ts new file mode 100644 index 0000000..cdd2c28 --- /dev/null +++ b/ledger/memory/unit-air-weapon-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's air weapon: `weapons.None` for none. +program(() => { + stats(units.TerranMarine).airWeapon = weapons.None; +}); diff --git a/ledger/memory/unit-armor-added-to.ts b/ledger/memory/unit-armor-added-to.ts new file mode 100644 index 0000000..3c65e96 --- /dev/null +++ b/ledger/memory/unit-armor-added-to.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's armour raised by one: `stats(…).armor += 1`. +program(() => { + stats(units.TerranMarine).armor += 1; +}); diff --git a/ledger/memory/unit-build-time-fraction.ts b/ledger/memory/unit-build-time-fraction.ts new file mode 100644 index 0000000..35651b9 --- /dev/null +++ b/ledger/memory/unit-build-time-fraction.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's build time written as a fraction known when the map is built: `buildTime = 1.5`. +program(() => { + stats(units.TerranMarine).buildTime = 1.5; +}); diff --git a/ledger/memory/unit-cost-read.ts b/ledger/memory/unit-cost-read.ts new file mode 100644 index 0000000..0e91513 --- /dev/null +++ b/ledger/memory/unit-cost-read.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// A unit type's mineral cost read from the game's tables: `stats(units.TerranMarine).minerals`. +program(() => { + const cost = stats(units.TerranMarine).minerals; + print(`${cost}`); +}); diff --git a/ledger/memory/unit-cost-written.ts b/ledger/memory/unit-cost-written.ts new file mode 100644 index 0000000..a256cd9 --- /dev/null +++ b/ledger/memory/unit-cost-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's mineral cost written: `stats(units.TerranMarine).minerals = 25`. +program(() => { + stats(units.TerranMarine).minerals = 25; +}); diff --git a/ledger/memory/unit-flag-read.ts b/ledger/memory/unit-flag-read.ts new file mode 100644 index 0000000..1806c77 --- /dev/null +++ b/ledger/memory/unit-flag-read.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's flag tested in an `if`: `if (stats(units.TerranMarine).organic)`. +program(() => { + if (stats(units.TerranMarine).organic) print("a Medic heals it"); +}); diff --git a/ledger/memory/unit-flag-written.ts b/ledger/memory/unit-flag-written.ts new file mode 100644 index 0000000..b230787 --- /dev/null +++ b/ledger/memory/unit-flag-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's flag set: `stats(units.TerranMarine).detector = true`. +program(() => { + stats(units.TerranMarine).detector = true; +}); diff --git a/ledger/memory/unit-flags-of-a-kind.ts b/ledger/memory/unit-flags-of-a-kind.ts new file mode 100644 index 0000000..ab8d51c --- /dev/null +++ b/ledger/memory/unit-flags-of-a-kind.ts @@ -0,0 +1,12 @@ +// ledger: step 6, takes +// The rest of a unit type's flags: cloak, burrow, regeneration, invincible, hero, mechanical, robotic. +program(() => { + stats(units.TerranMarine).permanentCloak = true; + stats(units.TerranMarine).cloakable = true; + stats(units.TerranMarine).burrowable = true; + stats(units.TerranMarine).regenerates = true; + stats(units.TerranMarine).invincible = false; + stats(units.TerranMarine).hero = true; + stats(units.TerranMarine).mechanical = true; + stats(units.TerranMarine).robotic = false; +}); diff --git a/ledger/memory/unit-gas-written.ts b/ledger/memory/unit-gas-written.ts new file mode 100644 index 0000000..d087326 --- /dev/null +++ b/ledger/memory/unit-gas-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's gas cost written: `stats(units.TerranGhost).gas = 50`. +program(() => { + stats(units.TerranGhost).gas = 50; +}); diff --git a/ledger/memory/unit-ground-weapon-written.ts b/ledger/memory/unit-ground-weapon-written.ts new file mode 100644 index 0000000..7a32a41 --- /dev/null +++ b/ledger/memory/unit-ground-weapon-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's ground weapon switched to another of `weapons.*`. +program(() => { + stats(units.TerranMarine).groundWeapon = weapons.C10CanisterRifle; +}); diff --git a/ledger/memory/unit-max-hp-from-a-value.ts b/ledger/memory/unit-max-hp-from-a-value.ts new file mode 100644 index 0000000..f45eb61 --- /dev/null +++ b/ledger/memory/unit-max-hp-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 6, takes +// A unit type's hit points written from a value of the program, for the units made after. +program(() => { + let hp = 40; + hp += 5; + stats(units.TerranMarine).maxHp = hp; +}); diff --git a/ledger/memory/unit-max-shields-written.ts b/ledger/memory/unit-max-shields-written.ts new file mode 100644 index 0000000..eb6fecf --- /dev/null +++ b/ledger/memory/unit-max-shields-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's shield points written. +program(() => { + stats(units.ProtossZealot).maxShields = 80; +}); diff --git a/ledger/memory/unit-name-written.ts b/ledger/memory/unit-name-written.ts new file mode 100644 index 0000000..277ca65 --- /dev/null +++ b/ledger/memory/unit-name-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// The name shown for a unit type, a text known when the map is built: `stats(…).name = "…"`. +program(() => { + stats(units.TerranMarine).name = "Space Marine"; +}); diff --git a/ledger/memory/unit-sight-written.ts b/ledger/memory/unit-sight-written.ts new file mode 100644 index 0000000..60f16ef --- /dev/null +++ b/ledger/memory/unit-sight-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's sight range in tiles. +program(() => { + stats(units.TerranMarine).sight = 9; +}); diff --git a/ledger/memory/unit-size-read.ts b/ledger/memory/unit-size-read.ts new file mode 100644 index 0000000..3246b67 --- /dev/null +++ b/ledger/memory/unit-size-read.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// A unit type's size (small, medium, large), read: what concussive and explosive damage scale by. +program(() => { + const size = stats(units.TerranMarine).size; + print(`${size}`); +}); diff --git a/ledger/memory/unit-speed-written.ts b/ledger/memory/unit-speed-written.ts new file mode 100644 index 0000000..1f8ec3f --- /dev/null +++ b/ledger/memory/unit-speed-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A unit type's top speed in pixels a frame, for units made after: `stats(…).speed = 6`. +program(() => { + stats(units.TerranMarine).speed = 6; +}); diff --git a/ledger/memory/unit-supply-provided-written.ts b/ledger/memory/unit-supply-provided-written.ts new file mode 100644 index 0000000..e449ae6 --- /dev/null +++ b/ledger/memory/unit-supply-provided-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// Supply a unit type provides, for the ones made after. +program(() => { + stats(units.TerranSupplyDepot).supplyProvided = 10; +}); diff --git a/ledger/memory/unit-supply-used-half.ts b/ledger/memory/unit-supply-used-half.ts new file mode 100644 index 0000000..bfd76c4 --- /dev/null +++ b/ledger/memory/unit-supply-used-half.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// Supply a unit type takes, a half: `stats(units.ZergZergling).supplyUsed = 0.5`. +program(() => { + stats(units.ZergZergling).supplyUsed = 0.5; +}); diff --git a/ledger/memory/upgrade-cost-written.ts b/ledger/memory/upgrade-cost-written.ts new file mode 100644 index 0000000..55d704a --- /dev/null +++ b/ledger/memory/upgrade-cost-written.ts @@ -0,0 +1,7 @@ +// ledger: step 6, takes +// An upgrade's cost and time written: `stats(upgrades.*)`. +program(() => { + stats(upgrades.TerranInfantryArmor).minerals = 50; + stats(upgrades.TerranInfantryArmor).gas = 50; + stats(upgrades.TerranInfantryArmor).time = 30; +}); diff --git a/ledger/memory/upgrade-max-level-read.ts b/ledger/memory/upgrade-max-level-read.ts new file mode 100644 index 0000000..c83c9a0 --- /dev/null +++ b/ledger/memory/upgrade-max-level-read.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// How many times an upgrade can be researched, read: `stats(upgrades.*).maxLevel`. +program(() => { + const most = stats(upgrades.TerranInfantryArmor).maxLevel; + print(`${most}`); +}); diff --git a/ledger/memory/vision-countdown-read.ts b/ledger/memory/vision-countdown-read.ts new file mode 100644 index 0000000..2661ff9 --- /dev/null +++ b/ledger/memory/vision-countdown-read.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// The game's vision countdown read: `memory.vision.countdown`. +program(() => { + const left = memory.vision.countdown; + print(`${left}`); +}); diff --git a/ledger/memory/vision-countdown-written.ts b/ledger/memory/vision-countdown-written.ts new file mode 100644 index 0000000..47ec439 --- /dev/null +++ b/ledger/memory/vision-countdown-written.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// The game's vision countdown written: `memory.vision.countdown = 1` wipes the fog on the next frame. +program(() => { + memory.vision.countdown = 1; +}); diff --git a/ledger/memory/weapon-damage-added-to.ts b/ledger/memory/weapon-damage-added-to.ts new file mode 100644 index 0000000..93f4d2e --- /dev/null +++ b/ledger/memory/weapon-damage-added-to.ts @@ -0,0 +1,5 @@ +// ledger: step 6, takes +// A weapon's damage raised: `stats(weapons.GaussRifle).damage += 2`. +program(() => { + stats(weapons.GaussRifle).damage += 2; +}); diff --git a/ledger/memory/weapon-fields-written.ts b/ledger/memory/weapon-fields-written.ts new file mode 100644 index 0000000..4d1fbb7 --- /dev/null +++ b/ledger/memory/weapon-fields-written.ts @@ -0,0 +1,8 @@ +// ledger: step 6, takes +// A weapon's other fields written: upgrade bonus, cooldown, hits per attack, least range. +program(() => { + stats(weapons.GaussRifle).bonus = 2; + stats(weapons.GaussRifle).cooldown = 10; + stats(weapons.GaussRifle).factor = 2; + stats(weapons.GaussRifle).minRange = 0; +}); diff --git a/ledger/memory/weapon-range-read.ts b/ledger/memory/weapon-range-read.ts new file mode 100644 index 0000000..9403be5 --- /dev/null +++ b/ledger/memory/weapon-range-read.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// A weapon's range in pixels read from the game's tables: `stats(weapons.GaussRifle).range`. +program(() => { + const range = stats(weapons.GaussRifle).range; + print(`${range}`); +}); diff --git a/ledger/numbers/abs-of-a-value.ts b/ledger/numbers/abs-of-a-value.ts new file mode 100644 index 0000000..73f84ca --- /dev/null +++ b/ledger/numbers/abs-of-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A value without its sign: `Math.abs(a)`. +program(() => { + let a = 5; + while (a > -10) a -= 4; + print(`${Math.abs(a)}`); +}); diff --git a/ledger/numbers/and-of-two-values.ts b/ledger/numbers/and-of-two-values.ts new file mode 100644 index 0000000..6ea6d46 --- /dev/null +++ b/ledger/numbers/and-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// The bits two values that change while the game runs both have: `a & b`. +program(() => { + let a = 0; + let b = 1; + while (a < 50) { + a += 9; + b += 5; + } + print(`${a & b}`); +}); diff --git a/ledger/numbers/and-with-a-constant-as-a-value.ts b/ledger/numbers/and-with-a-constant-as-a-value.ts new file mode 100644 index 0000000..7e94abd --- /dev/null +++ b/ledger/numbers/and-with-a-constant-as-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A value's low bits kept, as a value: `a & 7`. +program(() => { + let a = 0; + while (a < 50) a += 9; + print(`${a & 7}`); +}); diff --git a/ledger/numbers/and-with-a-constant-tested.ts b/ledger/numbers/and-with-a-constant-tested.ts new file mode 100644 index 0000000..bae8e3e --- /dev/null +++ b/ledger/numbers/and-with-a-constant-tested.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// Whether a bit of a value is set, in an `if`: `if ((a & 4) !== 0)`. +program(() => { + let a = 0; + while (a < 50) a += 9; + if ((a & 4) !== 0) print("set"); + else print("clear"); +}); diff --git a/ledger/numbers/arctangent-of-two-values.ts b/ledger/numbers/arctangent-of-two-values.ts new file mode 100644 index 0000000..af519c3 --- /dev/null +++ b/ledger/numbers/arctangent-of-two-values.ts @@ -0,0 +1,13 @@ +// ledger: step 4, takes +// The angle from the x axis to a point, in radians: `Math.atan2(y, x)`. +program(() => { + let x = 0; + let y = 0; + while (x < 10) { + x += 3; + y += 2; + } + const a = Math.atan2(y, x); + if (a > 0.5) print("more than 0.5"); + else print("0.5 or less"); +}); diff --git a/ledger/numbers/arctangent.ts b/ledger/numbers/arctangent.ts new file mode 100644 index 0000000..b450a82 --- /dev/null +++ b/ledger/numbers/arctangent.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// The angle whose tangent is a value, in radians: `Math.atan(t)`. +program(() => { + let t = 0; + while (t < 1) t += 0.25; + const a = Math.atan(t); + if (a > 0.7) print("more than 0.7"); + else print("0.7 or less"); +}); diff --git a/ledger/numbers/bitwise-not.ts b/ledger/numbers/bitwise-not.ts new file mode 100644 index 0000000..6517f07 --- /dev/null +++ b/ledger/numbers/bitwise-not.ts @@ -0,0 +1,8 @@ +// ledger: step 4, takes +// `~a`: every bit the other way, a ^ −1 — JavaScript's −a − 1 of a signed number. +program(() => { + let a = 5; + let b = 0; + b = ~a; + print(`${b} ${~a + a}`); +}); diff --git a/ledger/numbers/ceil-of-fixed.ts b/ledger/numbers/ceil-of-fixed.ts new file mode 100644 index 0000000..84abef6 --- /dev/null +++ b/ledger/numbers/ceil-of-fixed.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A number with a fraction rounded up to a whole number: `Math.ceil(x)`. +program(() => { + let x = 0.5; + while (x < 3) x += 0.75; + print(`${Math.ceil(x)}`); +}); diff --git a/ledger/numbers/clamp.ts b/ledger/numbers/clamp.ts new file mode 100644 index 0000000..8782418 --- /dev/null +++ b/ledger/numbers/clamp.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A value kept between a low and a high: `clamp(a, 0, 10)`. +program(() => { + let a = 0; + while (a < 20) a += 7; + print(`${clamp(a, 0, 10)}`); +}); diff --git a/ledger/numbers/coin-toss.ts b/ledger/numbers/coin-toss.ts new file mode 100644 index 0000000..9273ea5 --- /dev/null +++ b/ledger/numbers/coin-toss.ts @@ -0,0 +1,6 @@ +// ledger: step 4, takes +// A coin toss, true or false alike: `random()`. +program(() => { + if (random()) print("heads"); + else print("tails"); +}); diff --git a/ledger/numbers/constant-worked-out-when-built.ts b/ledger/numbers/constant-worked-out-when-built.ts new file mode 100644 index 0000000..c027a06 --- /dev/null +++ b/ledger/numbers/constant-worked-out-when-built.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A number the script works out from numbers written in it, when the map is built: `const SIZE = (64 * 4 + 2) / 3 % 50`. +const SIZE = ((64 * 4 + 2) / 3) % 50; +program(() => { + let n = 0; + while (n < SIZE) n += 4; + print(`${n}`); +}); diff --git a/ledger/numbers/cosine.ts b/ledger/numbers/cosine.ts new file mode 100644 index 0000000..5510476 --- /dev/null +++ b/ledger/numbers/cosine.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// The cosine of an angle in radians: `Math.cos(a)`. +program(() => { + let a = 0; + while (a < 1) a += 0.25; + const c = Math.cos(a); + if (c > 0.5) print("more than 0.5"); + else print("0.5 or less"); +}); diff --git a/ledger/numbers/difference-of-two-values.ts b/ledger/numbers/difference-of-two-values.ts new file mode 100644 index 0000000..6657d64 --- /dev/null +++ b/ledger/numbers/difference-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A difference of two values that change while the game runs: `a - b`. +program(() => { + let a = 0; + let b = 0; + while (a < 20) { + a += 7; + b += 2; + } + print(`${a - b}`); +}); diff --git a/ledger/numbers/fixed-compared.ts b/ledger/numbers/fixed-compared.ts new file mode 100644 index 0000000..e5851b2 --- /dev/null +++ b/ledger/numbers/fixed-compared.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A number with a fraction compared with a whole number and with another fraction, below zero too: `x < -1`. +program(() => { + let x = 0.75; + while (x > -2) x -= 0.5; + if (x < -1) print("below -1"); + if (x >= -2.25) print("not below -2.25"); +}); diff --git a/ledger/numbers/fixed-from-a-whole-number.ts b/ledger/numbers/fixed-from-a-whole-number.ts new file mode 100644 index 0000000..ef1fc4c --- /dev/null +++ b/ledger/numbers/fixed-from-a-whole-number.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// A whole number made a fixed one, so that what is worked out with it keeps a fraction: `fixed(n)`. +program(() => { + let n = 0; + while (n < 5) n += 2; + const x = fixed(n) + 0.5; + if (x > 6) print("more than six"); + else print("six or less"); +}); diff --git a/ledger/numbers/fixed-product-by-a-constant.ts b/ledger/numbers/fixed-product-by-a-constant.ts new file mode 100644 index 0000000..16b161c --- /dev/null +++ b/ledger/numbers/fixed-product-by-a-constant.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// A number with a fraction times a fraction written in the script: `x * 1.5`. +program(() => { + let x = 0.5; + while (x < 3) x += 0.75; + const p = x * 1.5; + if (p > 4) print("more than 4"); + else print("4 or less"); +}); diff --git a/ledger/numbers/fixed-product.ts b/ledger/numbers/fixed-product.ts new file mode 100644 index 0000000..b1ec17e --- /dev/null +++ b/ledger/numbers/fixed-product.ts @@ -0,0 +1,13 @@ +// ledger: step 4, takes +// A product of two numbers with a fraction that change while the game runs: `x * y`. +program(() => { + let x = 0.5; + let y = 1.25; + while (x < 3) { + x += 0.75; + y += 0.5; + } + const p = x * y; + if (p > 8) print("more than 8"); + else print("8 or less"); +}); diff --git a/ledger/numbers/fixed-quotient-by-a-constant.ts b/ledger/numbers/fixed-quotient-by-a-constant.ts new file mode 100644 index 0000000..2125732 --- /dev/null +++ b/ledger/numbers/fixed-quotient-by-a-constant.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// A number with a fraction divided by a number written in the script: `x / 3`. +program(() => { + let x = 0.5; + while (x < 3) x += 0.75; + const q = x / 3; + if (q > 1) print("more than 1"); + else print("1 or less"); +}); diff --git a/ledger/numbers/fixed-quotient.ts b/ledger/numbers/fixed-quotient.ts new file mode 100644 index 0000000..d017f7c --- /dev/null +++ b/ledger/numbers/fixed-quotient.ts @@ -0,0 +1,13 @@ +// ledger: step 4, takes +// A quotient of two numbers with a fraction that change while the game runs: `x / y`. +program(() => { + let x = 0.5; + let y = 0.25; + while (x < 3) { + x += 0.75; + y += 0.5; + } + const q = x / y; + if (q > 1.5) print("more than 1.5"); + else print("1.5 or less"); +}); diff --git a/ledger/numbers/fixed-reciprocal.ts b/ledger/numbers/fixed-reciprocal.ts new file mode 100644 index 0000000..e748963 --- /dev/null +++ b/ledger/numbers/fixed-reciprocal.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// A number written in the script divided by a number with a fraction: `1 / x`. +program(() => { + let x = 0.5; + while (x < 3) x += 0.75; + const q = 1 / x; + if (q < 0.5) print("below 0.5"); + else print("0.5 or more"); +}); diff --git a/ledger/numbers/fixed-sum.ts b/ledger/numbers/fixed-sum.ts new file mode 100644 index 0000000..bf30d7a --- /dev/null +++ b/ledger/numbers/fixed-sum.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// Numbers with a fraction added and subtracted: `x += 0.25`, `x - 0.5`. +program(() => { + let x = 0.5; + let n = 0; + while (n < 5) { + x += 0.25; + n += 1; + } + if (x - 0.5 > 1.2) print("past 1.2"); + else print("not past 1.2"); +}); diff --git a/ledger/numbers/floor-of-fixed.ts b/ledger/numbers/floor-of-fixed.ts new file mode 100644 index 0000000..6820d80 --- /dev/null +++ b/ledger/numbers/floor-of-fixed.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A number with a fraction rounded down to a whole number: `Math.floor(x)`. +program(() => { + let x = 0.5; + while (x < 3) x += 0.75; + print(`${Math.floor(x)}`); +}); diff --git a/ledger/numbers/hypotenuse.ts b/ledger/numbers/hypotenuse.ts new file mode 100644 index 0000000..391acfc --- /dev/null +++ b/ledger/numbers/hypotenuse.ts @@ -0,0 +1,13 @@ +// ledger: step 4, takes +// The length of a line from its two sides: `Math.hypot(dx, dy)`. +program(() => { + let dx = 0; + let dy = 0; + while (dx < 10) { + dx += 3; + dy += 4; + } + const d = Math.hypot(dx, dy); + if (d > 19.5) print("more than 19.5"); + else print("19.5 or less"); +}); diff --git a/ledger/numbers/i32-of-a-u32.ts b/ledger/numbers/i32-of-a-u32.ts new file mode 100644 index 0000000..1b91e4a --- /dev/null +++ b/ledger/numbers/i32-of-a-u32.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// A `u32` read back as a signed number: `i32(x)`. +program(() => { + let x: u32 = u32(0); + let n = 0; + while (n < 3) { + x = u32(x - 1); + n += 1; + } + if (i32(x) < 0) print("below zero"); + else print("not below zero"); +}); diff --git a/ledger/numbers/increment-and-decrement.ts b/ledger/numbers/increment-and-decrement.ts new file mode 100644 index 0000000..a2424e8 --- /dev/null +++ b/ledger/numbers/increment-and-decrement.ts @@ -0,0 +1,14 @@ +// ledger: step 0, takes +// A variable counted up and down, and changed by an amount: `n++`, `n--`, `n += k`, `n -= k`. +program(() => { + let n = 0; + let k = 1; + while (k < 6) { + n++; + n += k; + n -= 2; + k++; + } + n--; + print(`${n}`); +}); diff --git a/ledger/numbers/math-angle-to.ts b/ledger/numbers/math-angle-to.ts new file mode 100644 index 0000000..0e6ef2e --- /dev/null +++ b/ledger/numbers/math-angle-to.ts @@ -0,0 +1,14 @@ +// ledger: step 4, takes +// The angle from one point to another, in radians, from `trigscript/math`: `angleTo(x1, y1, x2, y2)`. +import { angleTo } from "trigscript/math"; +program(() => { + let x = 0; + let y = 0; + while (x < 10) { + x += 3; + y += 4; + } + const a = angleTo(0, 0, x, y); + if (a > 0.9) print("more than 0.9"); + else print("0.9 or less"); +}); diff --git a/ledger/numbers/math-approach.ts b/ledger/numbers/math-approach.ts new file mode 100644 index 0000000..c6ae549 --- /dev/null +++ b/ledger/numbers/math-approach.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// A number moved towards another by a step at most, never past it, from `trigscript/math`: `approach(x, target, step)`. +import { approach } from "trigscript/math"; +program(() => { + let x = 0; + let n = 0; + while (n < 5) { + x = approach(x, 12, 5); + n += 1; + } + print(`${x}`); +}); diff --git a/ledger/numbers/math-clamp01.ts b/ledger/numbers/math-clamp01.ts new file mode 100644 index 0000000..c5752bb --- /dev/null +++ b/ledger/numbers/math-clamp01.ts @@ -0,0 +1,9 @@ +// ledger: step 0, takes +// A fraction kept between 0 and 1, from `trigscript/math`: `clamp01(t)`. +import { clamp01 } from "trigscript/math"; +program(() => { + let t = 0; + while (t < 1.2) t += 0.5; + if (clamp01(t) === 1) print("one"); + else print("below one"); +}); diff --git a/ledger/numbers/math-degrees.ts b/ledger/numbers/math-degrees.ts new file mode 100644 index 0000000..e1a4577 --- /dev/null +++ b/ledger/numbers/math-degrees.ts @@ -0,0 +1,10 @@ +// ledger: step 4, takes +// Radians as degrees, from `trigscript/math`: `degrees(r)`. +import { degrees } from "trigscript/math"; +program(() => { + let r = 0; + while (r < 1.5) r += 0.25; + const d = degrees(r); + if (d > 90) print("more than 90"); + else print("90 or less"); +}); diff --git a/ledger/numbers/math-distance-squared.ts b/ledger/numbers/math-distance-squared.ts new file mode 100644 index 0000000..2a695c8 --- /dev/null +++ b/ledger/numbers/math-distance-squared.ts @@ -0,0 +1,12 @@ +// ledger: step 4, takes +// The distance between two points squared, a whole number, from `trigscript/math`: `distanceSq(x1, y1, x2, y2)`. +import { distanceSq } from "trigscript/math"; +program(() => { + let x = 0; + let y = 0; + while (x < 10) { + x += 3; + y += 4; + } + print(`${distanceSq(0, 0, x, y)}`); +}); diff --git a/ledger/numbers/math-distance.ts b/ledger/numbers/math-distance.ts new file mode 100644 index 0000000..0b328ac --- /dev/null +++ b/ledger/numbers/math-distance.ts @@ -0,0 +1,14 @@ +// ledger: step 4, takes +// The distance between two points as the crow flies, from `trigscript/math`: `distance(x1, y1, x2, y2)`. +import { distance } from "trigscript/math"; +program(() => { + let x = 0; + let y = 0; + while (x < 10) { + x += 3; + y += 4; + } + const d = distance(0, 0, x, y); + if (d > 19.5) print("more than 19.5"); + else print("19.5 or less"); +}); diff --git a/ledger/numbers/math-easing.ts b/ledger/numbers/math-easing.ts new file mode 100644 index 0000000..4e18201 --- /dev/null +++ b/ledger/numbers/math-easing.ts @@ -0,0 +1,17 @@ +// ledger: step 4, takes +// The easing curves of `trigscript/math`, how far along a motion is at a fraction of its time: `easeInQuad`, +// `easeOutQuad`, `easeInOutQuad`, `easeInCubic`, `easeOutCubic`, `easeOutBack` and `smoothstep`, each of `t`. +import { easeInCubic, easeInOutQuad, easeInQuad, easeOutBack, easeOutCubic, easeOutQuad, smoothstep } from "trigscript/math"; +program(() => { + let t = 0; + while (t < 0.5) t += 0.125; + let past = 0; + if (easeInQuad(t) > 0.5) past += 1; + if (easeOutQuad(t) > 0.5) past += 1; + if (easeInOutQuad(t) > 0.5) past += 1; + if (easeInCubic(t) > 0.5) past += 1; + if (easeOutCubic(t) > 0.5) past += 1; + if (easeOutBack(t) > 0.5) past += 1; + if (smoothstep(t) > 0.5) past += 1; + print(`${past} past half`); +}); diff --git a/ledger/numbers/math-inverse-lerp.ts b/ledger/numbers/math-inverse-lerp.ts new file mode 100644 index 0000000..69dcdb4 --- /dev/null +++ b/ledger/numbers/math-inverse-lerp.ts @@ -0,0 +1,16 @@ +// ledger: step 4, takes +// Where a number is between two others, as a fraction, from `trigscript/math`: `inverseLerp(a, b, v)`. +import { inverseLerp } from "trigscript/math"; +program(() => { + let a = 0; + let b = 10; + let v = 0; + while (v < 14) { + a += 1; + b += 3; + v += 3.5; + } + const t = inverseLerp(a, b, v); + if (t > 0.4) print("past 0.4"); + else print("not past 0.4"); +}); diff --git a/ledger/numbers/math-lerp.ts b/ledger/numbers/math-lerp.ts new file mode 100644 index 0000000..a013b2e --- /dev/null +++ b/ledger/numbers/math-lerp.ts @@ -0,0 +1,16 @@ +// ledger: step 4, takes +// From one number to another by a fraction, from `trigscript/math`: `lerp(a, b, t)`. +import { lerp } from "trigscript/math"; +program(() => { + let a = 0; + let b = 10; + let t = 0; + while (t < 0.5) { + a += 1; + b += 3; + t += 0.125; + } + const x = lerp(a, b, t); + if (x > 14) print("past 14"); + else print("not past 14"); +}); diff --git a/ledger/numbers/math-manhattan.ts b/ledger/numbers/math-manhattan.ts new file mode 100644 index 0000000..e64a10e --- /dev/null +++ b/ledger/numbers/math-manhattan.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// The distance between two points along the axes, |dx| + |dy|, from `trigscript/math`: `manhattan(x1, y1, x2, y2)`. +import { manhattan } from "trigscript/math"; +program(() => { + let x = 0; + let y = 0; + while (x < 10) { + x += 3; + y -= 4; + } + print(`${manhattan(0, 0, x, y)}`); +}); diff --git a/ledger/numbers/math-pi.ts b/ledger/numbers/math-pi.ts new file mode 100644 index 0000000..e9c7e8b --- /dev/null +++ b/ledger/numbers/math-pi.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// π from `trigscript/math`: `PI`, a number with a fraction known when the map is built. +import { PI } from "trigscript/math"; +program(() => { + let a = 0; + let steps = 0; + while (a < PI) { + a += 0.5; + steps += 1; + } + print(`${steps}`); +}); diff --git a/ledger/numbers/math-radians.ts b/ledger/numbers/math-radians.ts new file mode 100644 index 0000000..52ebf76 --- /dev/null +++ b/ledger/numbers/math-radians.ts @@ -0,0 +1,10 @@ +// ledger: step 4, takes +// Degrees as radians, from `trigscript/math`: `radians(d)`. +import { radians } from "trigscript/math"; +program(() => { + let d = 0; + while (d < 90) d += 25; + const r = radians(d); + if (r > 1.6) print("more than 1.6"); + else print("1.6 or less"); +}); diff --git a/ledger/numbers/math-remap.ts b/ledger/numbers/math-remap.ts new file mode 100644 index 0000000..7dafe18 --- /dev/null +++ b/ledger/numbers/math-remap.ts @@ -0,0 +1,16 @@ +// ledger: step 4, takes +// A number moved from one range into another, from `trigscript/math`: `remap(v, a1, b1, a2, b2)`. +import { remap } from "trigscript/math"; +program(() => { + let low = 0; + let high = 10; + let v = 0; + while (v < 4) { + low += 1; + high += 2; + v += 1.5; + } + const x = remap(v, low, high, 100, 200); + if (x > 140) print("past 140"); + else print("not past 140"); +}); diff --git a/ledger/numbers/math-sign.ts b/ledger/numbers/math-sign.ts new file mode 100644 index 0000000..28ab277 --- /dev/null +++ b/ledger/numbers/math-sign.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// −1, 0 or 1 by whether a number is below, at or above zero, from `trigscript/math`: `sign(n)`. +import { sign } from "trigscript/math"; +program(() => { + let n = 5; + while (n > -10) n -= 4; + print(`${sign(n)}`); +}); diff --git a/ledger/numbers/math-tau.ts b/ledger/numbers/math-tau.ts new file mode 100644 index 0000000..e135035 --- /dev/null +++ b/ledger/numbers/math-tau.ts @@ -0,0 +1,14 @@ +// ledger: step 0, takes +// A whole turn from `trigscript/math`: `TAU`, 2π, to keep an angle within one turn. +import { TAU } from "trigscript/math"; +program(() => { + let a = 0; + let n = 0; + while (n < 10) { + a += 1.5; + if (a >= TAU) a -= TAU; + n += 1; + } + if (a < 3) print("first half"); + else print("second half"); +}); diff --git a/ledger/numbers/math-vec-length.ts b/ledger/numbers/math-vec-length.ts new file mode 100644 index 0000000..5dfd668 --- /dev/null +++ b/ledger/numbers/math-vec-length.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// How long a `Vec` from `trigscript/math` is: `v.length()`, the square root of its sides' squares. +import { Vec } from "trigscript/math"; +program(() => { + let v = new Vec(fixed(0), fixed(0)); + while (v.x < 3) v = v.add(new Vec(fixed(1), fixed(1))); + if (v.length() > 4) print("longer than 4"); + else print("4 or shorter"); +}); diff --git a/ledger/numbers/math-vec.ts b/ledger/numbers/math-vec.ts new file mode 100644 index 0000000..859d5b5 --- /dev/null +++ b/ledger/numbers/math-vec.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// A point of two numbers with a fraction, from `trigscript/math`: `new Vec(x, y)`, moved by another with `add`. +import { Vec } from "trigscript/math"; +program(() => { + let p = new Vec(fixed(0), fixed(0)); + const step = new Vec(fixed(1), 0.5); + while (p.x < 5) p = p.add(step); + if (p.y > 2) print("past 2"); + else print("not past 2"); +}); diff --git a/ledger/numbers/math-wrap.ts b/ledger/numbers/math-wrap.ts new file mode 100644 index 0000000..1b2617c --- /dev/null +++ b/ledger/numbers/math-wrap.ts @@ -0,0 +1,8 @@ +// ledger: step 4, takes +// A number brought round into 0 … size − 1, below zero too, from `trigscript/math`: `wrap(n, size)` (−1 is size − 1). +import { wrap } from "trigscript/math"; +program(() => { + let n = 0; + while (n > -10) n -= 3; + print(`${wrap(n, 10)}`); +}); diff --git a/ledger/numbers/max-of-two-values.ts b/ledger/numbers/max-of-two-values.ts new file mode 100644 index 0000000..34b3909 --- /dev/null +++ b/ledger/numbers/max-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// The larger of two values that change while the game runs: `Math.max(a, b)`. +program(() => { + let a = 0; + let b = 30; + while (a < 20) { + a += 3; + b -= 2; + } + print(`${Math.max(a, b)}`); +}); diff --git a/ledger/numbers/min-of-two-values.ts b/ledger/numbers/min-of-two-values.ts new file mode 100644 index 0000000..6e8aafb --- /dev/null +++ b/ledger/numbers/min-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// The smaller of two values that change while the game runs: `Math.min(a, b)`. +program(() => { + let a = 0; + let b = 30; + while (a < 20) { + a += 3; + b -= 2; + } + print(`${Math.min(a, b)}`); +}); diff --git a/ledger/numbers/negative-of-a-value.ts b/ledger/numbers/negative-of-a-value.ts new file mode 100644 index 0000000..4a34460 --- /dev/null +++ b/ledger/numbers/negative-of-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A value's negative: `-a`. +program(() => { + let a = 0; + while (a < 10) a += 3; + const b = -a; + print(`${b}`); +}); diff --git a/ledger/numbers/number-chosen-by-a-condition.ts b/ledger/numbers/number-chosen-by-a-condition.ts new file mode 100644 index 0000000..5cefac8 --- /dev/null +++ b/ledger/numbers/number-chosen-by-a-condition.ts @@ -0,0 +1,12 @@ +// ledger: step 0, takes +// One number or another, by a test while the game runs: `a > b ? a : b + 1`. +program(() => { + let a = 0; + let b = 12; + while (a < 20) { + a += 3; + b += 1; + } + const c = a > b ? a : b + 1; + print(`${c}`); +}); diff --git a/ledger/numbers/number-wraps-past-the-top.ts b/ledger/numbers/number-wraps-past-the-top.ts new file mode 100644 index 0000000..9afe21e --- /dev/null +++ b/ledger/numbers/number-wraps-past-the-top.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A number goes round past 2 147 483 647 to −2 147 483 648, as a 32-bit number does. +program(() => { + let a = 2147483640; + let n = 0; + while (n < 10) { + a += 1; + n += 1; + } + print(`${a}`); +}); diff --git a/ledger/numbers/or-of-two-values.ts b/ledger/numbers/or-of-two-values.ts new file mode 100644 index 0000000..c9f251d --- /dev/null +++ b/ledger/numbers/or-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// The bits either of two values that change while the game runs has: `a | b`. +program(() => { + let a = 0; + let b = 1; + while (a < 50) { + a += 9; + b += 5; + } + print(`${a | b}`); +}); diff --git a/ledger/numbers/or-with-a-constant.ts b/ledger/numbers/or-with-a-constant.ts new file mode 100644 index 0000000..cc79bc7 --- /dev/null +++ b/ledger/numbers/or-with-a-constant.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A value with bits set, as a value: `a | 1`. +program(() => { + let a = 0; + while (a < 50) a += 10; + print(`${a | 1}`); +}); diff --git a/ledger/numbers/power.ts b/ledger/numbers/power.ts new file mode 100644 index 0000000..d2ffe10 --- /dev/null +++ b/ledger/numbers/power.ts @@ -0,0 +1,8 @@ +// ledger: step 4, takes +// `a ** n` and `Math.pow(a, n)` by a power known when the script is built; `Math.sign` and `Math.log2` (of a whole number: rounded down). +program(() => { + let a = 3; + let b = -7; + let c = 1000; + print(`${a ** 2} ${Math.pow(a, 3)} ${(a + 1) ** 0} ${Math.sign(a)} ${Math.sign(b)} ${Math.sign(a - a)} ${Math.log2(c)}`); +}); diff --git a/ledger/numbers/product-by-a-constant.ts b/ledger/numbers/product-by-a-constant.ts new file mode 100644 index 0000000..7c73f4e --- /dev/null +++ b/ledger/numbers/product-by-a-constant.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A value times a whole number written in the script: `a * 3`. +program(() => { + let a = 0; + let n = 0; + while (n < 5) { + a = n * 3; + n += 1; + } + print(`${a}`); +}); diff --git a/ledger/numbers/product-of-two-values.ts b/ledger/numbers/product-of-two-values.ts new file mode 100644 index 0000000..56450b6 --- /dev/null +++ b/ledger/numbers/product-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// A product of two values that change while the game runs: `a * b`. +program(() => { + let a = 1; + let i = 1; + while (i < 5) { + a = a * i; + i += 1; + } + print(`${a}`); +}); diff --git a/ledger/numbers/quotient-by-a-constant.ts b/ledger/numbers/quotient-by-a-constant.ts new file mode 100644 index 0000000..3327609 --- /dev/null +++ b/ledger/numbers/quotient-by-a-constant.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A value divided by a whole number written in the script, rounded towards zero: `a / 7`. +program(() => { + let a = 0; + while (a < 50) a += 9; + print(`${a / 7}`); +}); diff --git a/ledger/numbers/quotient-by-a-power-of-two.ts b/ledger/numbers/quotient-by-a-power-of-two.ts new file mode 100644 index 0000000..dcc2eeb --- /dev/null +++ b/ledger/numbers/quotient-by-a-power-of-two.ts @@ -0,0 +1,8 @@ +// ledger: step 4, takes +// A value divided by a power of two written in the script, rounded towards zero as JavaScript does: `a / 4` is −2 for +// −9, where `a >> 2` is −3. +program(() => { + let a = 0; + while (a > -8) a -= 3; + print(`${a / 4}`); +}); diff --git a/ledger/numbers/quotient-of-two-values.ts b/ledger/numbers/quotient-of-two-values.ts new file mode 100644 index 0000000..4669d2a --- /dev/null +++ b/ledger/numbers/quotient-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// A quotient of two values that change while the game runs, rounded towards zero: `a / b`. +program(() => { + let a = 0; + let b = 1; + while (a < 20) { + a += 7; + b += 1; + } + print(`${a / b}`); +}); diff --git a/ledger/numbers/random-chance.ts b/ledger/numbers/random-chance.ts new file mode 100644 index 0000000..53c943a --- /dev/null +++ b/ledger/numbers/random-chance.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// True a number of times in a hundred, from `trigscript/random`: `chance(25)`. +import { chance } from "trigscript/random"; +program(() => { + if (chance(25)) print("lucky"); + else print("not this time"); +}); diff --git a/ledger/numbers/random-dice.ts b/ledger/numbers/random-dice.ts new file mode 100644 index 0000000..aeb0d09 --- /dev/null +++ b/ledger/numbers/random-dice.ts @@ -0,0 +1,8 @@ +// ledger: step 4, takes +// The total of dice thrown, from `trigscript/random`: `dice(3, 6)`, from 3 to 18. +import { dice } from "trigscript/random"; +program(() => { + const total = dice(3, 6); + if (total > 10) print("high"); + else print("low"); +}); diff --git a/ledger/numbers/random-number-below-a-value.ts b/ledger/numbers/random-number-below-a-value.ts new file mode 100644 index 0000000..3f38cc3 --- /dev/null +++ b/ledger/numbers/random-number-below-a-value.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// A random whole number below a bound that changes while the game runs: `random(n)`. +program(() => { + let n = 1; + while (n < 10) n += 4; + const roll = random(n); + if (roll < 5) print("low"); + else print("high"); +}); diff --git a/ledger/numbers/random-number.ts b/ledger/numbers/random-number.ts new file mode 100644 index 0000000..5fbb5a0 --- /dev/null +++ b/ledger/numbers/random-number.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A random whole number from 0 to n − 1, the game's own random numbers: `random(6)`. +program(() => { + const roll = random(6); + if (roll < 3) print("low"); + else print("high"); +}); diff --git a/ledger/numbers/random-pick.ts b/ledger/numbers/random-pick.ts new file mode 100644 index 0000000..6093d57 --- /dev/null +++ b/ledger/numbers/random-pick.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// One of a list at random, from `trigscript/random`: `pick(list)`. +import { pick } from "trigscript/random"; +const WAVES = [5, 8, 13]; +program(() => { + const size = pick(WAVES); + if (size > 6) print("a big wave"); + else print("a small wave"); +}); diff --git a/ledger/numbers/random-range.ts b/ledger/numbers/random-range.ts new file mode 100644 index 0000000..8a9e5a7 --- /dev/null +++ b/ledger/numbers/random-range.ts @@ -0,0 +1,8 @@ +// ledger: step 4, takes +// A random whole number from a low to a high, both included, from `trigscript/random`: `range(1, 3)`. +import { range } from "trigscript/random"; +program(() => { + const lane = range(1, 3); + if (lane === 2) print("the middle lane"); + else print("a side lane"); +}); diff --git a/ledger/numbers/random-seeded-generator.ts b/ledger/numbers/random-seeded-generator.ts new file mode 100644 index 0000000..d71bd95 --- /dev/null +++ b/ledger/numbers/random-seeded-generator.ts @@ -0,0 +1,10 @@ +// ledger: step 4, takes +// A random number generator of the script's own, seeded, the same numbers every game, from `trigscript/random`: +// `new Random(seed)`, then `rng.range(0, 63)`. +import { Random } from "trigscript/random"; +program(() => { + const rng = new Random(1234); + const x = rng.range(0, 63); + if (x < 32) print("left"); + else print("right"); +}); diff --git a/ledger/numbers/random-shuffle.ts b/ledger/numbers/random-shuffle.ts new file mode 100644 index 0000000..d44386a --- /dev/null +++ b/ledger/numbers/random-shuffle.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// An array of the program put in a random order, in place, from `trigscript/random`: `shuffle(order)`. +import { shuffle } from "trigscript/random"; +program(() => { + const order = [1, 2, 3, 4]; + shuffle(order); + if (order[0] === 1) print("1 stayed first"); + else print("1 moved"); +}); diff --git a/ledger/numbers/random-weighted.ts b/ledger/numbers/random-weighted.ts new file mode 100644 index 0000000..62167a0 --- /dev/null +++ b/ledger/numbers/random-weighted.ts @@ -0,0 +1,8 @@ +// ledger: step 4, takes +// The place of one of some weights, each as likely as its share, from `trigscript/random`: `weighted([70, 25, 5])`. +import { weighted } from "trigscript/random"; +program(() => { + const tier = weighted([70, 25, 5]); + if (tier === 0) print("common"); + else print("rare"); +}); diff --git a/ledger/numbers/remainder-by-a-constant.ts b/ledger/numbers/remainder-by-a-constant.ts new file mode 100644 index 0000000..6c5799f --- /dev/null +++ b/ledger/numbers/remainder-by-a-constant.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A remainder by a whole number written in the script: `a % 7`. +program(() => { + let a = 0; + while (a < 50) a += 9; + print(`${a % 7}`); +}); diff --git a/ledger/numbers/remainder-by-a-power-of-two.ts b/ledger/numbers/remainder-by-a-power-of-two.ts new file mode 100644 index 0000000..b621348 --- /dev/null +++ b/ledger/numbers/remainder-by-a-power-of-two.ts @@ -0,0 +1,8 @@ +// ledger: step 4, takes +// A remainder by a power of two written in the script, which keeps the sign of the value as JavaScript's does: `a % 8` +// is −1 for −9, where `a & 7` is 7. +program(() => { + let a = 0; + while (a > -8) a -= 3; + print(`${a % 8}`); +}); diff --git a/ledger/numbers/remainder-of-two-values.ts b/ledger/numbers/remainder-of-two-values.ts new file mode 100644 index 0000000..6ae5a07 --- /dev/null +++ b/ledger/numbers/remainder-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// A remainder of two values that change while the game runs: `a % b`. +program(() => { + let a = 0; + let b = 1; + while (a < 20) { + a += 7; + b += 2; + } + print(`${a % b}`); +}); diff --git a/ledger/numbers/round-of-fixed.ts b/ledger/numbers/round-of-fixed.ts new file mode 100644 index 0000000..939cf34 --- /dev/null +++ b/ledger/numbers/round-of-fixed.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A number with a fraction rounded to the nearest whole number: `Math.round(x)`. +program(() => { + let x = 0.5; + while (x < 3) x += 0.75; + print(`${Math.round(x)}`); +}); diff --git a/ledger/numbers/shift-left-by-a-constant.ts b/ledger/numbers/shift-left-by-a-constant.ts new file mode 100644 index 0000000..97eed39 --- /dev/null +++ b/ledger/numbers/shift-left-by-a-constant.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A value shifted left by a number written in the script, as a value: `a << 2` (the value times 4). +program(() => { + let a = 0; + let n = 0; + while (n < 5) { + a = n << 2; + n += 1; + } + print(`${a}`); +}); diff --git a/ledger/numbers/shift-left-by-a-value.ts b/ledger/numbers/shift-left-by-a-value.ts new file mode 100644 index 0000000..e93191a --- /dev/null +++ b/ledger/numbers/shift-left-by-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A value shifted left by a number that changes while the game runs: `1 << n`. +program(() => { + let n = 0; + while (n < 5) n += 2; + print(`${1 << n}`); +}); diff --git a/ledger/numbers/shift-right-by-a-constant.ts b/ledger/numbers/shift-right-by-a-constant.ts new file mode 100644 index 0000000..a446af2 --- /dev/null +++ b/ledger/numbers/shift-right-by-a-constant.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A value shifted right by a number written in the script, keeping its sign, as a value: `a >> 2`. +program(() => { + let a = 0; + while (a > -50) a -= 9; + print(`${a >> 2}`); +}); diff --git a/ledger/numbers/shift-right-by-a-value.ts b/ledger/numbers/shift-right-by-a-value.ts new file mode 100644 index 0000000..400cd1c --- /dev/null +++ b/ledger/numbers/shift-right-by-a-value.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// A value shifted right by a number that changes while the game runs: `a >> n`. +program(() => { + let a = 1000; + let n = 0; + while (n < 5) { + a += 9; + n += 2; + } + print(`${a >> n}`); +}); diff --git a/ledger/numbers/sine.ts b/ledger/numbers/sine.ts new file mode 100644 index 0000000..9748cb4 --- /dev/null +++ b/ledger/numbers/sine.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// The sine of an angle in radians: `Math.sin(a)`. +program(() => { + let a = 0; + while (a < 1) a += 0.25; + const s = Math.sin(a); + if (s > 0.8) print("more than 0.8"); + else print("0.8 or less"); +}); diff --git a/ledger/numbers/square-root-of-a-whole-number.ts b/ledger/numbers/square-root-of-a-whole-number.ts new file mode 100644 index 0000000..f347d58 --- /dev/null +++ b/ledger/numbers/square-root-of-a-whole-number.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// The square root of a whole number, as a number with a fraction: `Math.sqrt(n)`. +program(() => { + let n = 0; + while (n < 20) n += 7; + const r = Math.sqrt(n); + if (r > 4.5) print("more than 4.5"); + else print("4.5 or less"); +}); diff --git a/ledger/numbers/square-root-of-fixed.ts b/ledger/numbers/square-root-of-fixed.ts new file mode 100644 index 0000000..a6e8ab6 --- /dev/null +++ b/ledger/numbers/square-root-of-fixed.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// The square root of a number with a fraction: `Math.sqrt(x)`. +program(() => { + let x = 0.5; + while (x < 3) x += 0.75; + const r = Math.sqrt(x); + if (r > 1.5) print("more than 1.5"); + else print("1.5 or less"); +}); diff --git a/ledger/numbers/sum-of-two-values.ts b/ledger/numbers/sum-of-two-values.ts new file mode 100644 index 0000000..06f8841 --- /dev/null +++ b/ledger/numbers/sum-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A sum of two values that change while the game runs: `a + b`. +program(() => { + let a = 0; + let b = 0; + while (a < 20) { + a += 3; + b += 5; + } + print(`${a + b}`); +}); diff --git a/ledger/numbers/tangent.ts b/ledger/numbers/tangent.ts new file mode 100644 index 0000000..03f99b2 --- /dev/null +++ b/ledger/numbers/tangent.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// The tangent of an angle in radians, its sine over its cosine: `Math.tan(a)`. +program(() => { + let a = 0; + while (a < 1) a += 0.25; + const t = Math.tan(a); + if (t > 1.5) print("more than 1.5"); + else print("1.5 or less"); +}); diff --git a/ledger/numbers/trunc-of-fixed.ts b/ledger/numbers/trunc-of-fixed.ts new file mode 100644 index 0000000..44f72ca --- /dev/null +++ b/ledger/numbers/trunc-of-fixed.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A number with a fraction cut to its whole part, towards zero: `Math.trunc(x)` (−2.5 is −2). +program(() => { + let x = 0.5; + while (x > -2) x -= 0.75; + print(`${Math.trunc(x)}`); +}); diff --git a/ledger/numbers/trunc-with-or-zero.ts b/ledger/numbers/trunc-with-or-zero.ts new file mode 100644 index 0000000..3c879bd --- /dev/null +++ b/ledger/numbers/trunc-with-or-zero.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A number with a fraction cut to its whole part the JavaScript way: `x | 0`. +program(() => { + let x = 0.5; + while (x > -2) x -= 0.75; + print(`${x | 0}`); +}); diff --git a/ledger/numbers/u16-variable.ts b/ledger/numbers/u16-variable.ts new file mode 100644 index 0000000..586052d --- /dev/null +++ b/ledger/numbers/u16-variable.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// A `u16` variable, which stays within 0 … 65 535: stored below zero it is 0. +program(() => { + let a: u16 = 5; + let n = 0; + while (n < 3) { + a -= 4; + n += 1; + } + print(`${a}`); +}); diff --git a/ledger/numbers/u32-against-a-signed-number.ts b/ledger/numbers/u32-against-a-signed-number.ts new file mode 100644 index 0000000..f98b58b --- /dev/null +++ b/ledger/numbers/u32-against-a-signed-number.ts @@ -0,0 +1,13 @@ +// ledger: step 0, takes +// A `u32` compared with a plain number below zero, exactly: any `u32` is larger than −1, however its bits read signed. +program(() => { + let a = 0; + let n = 0; + while (a > -10) { + a -= 3; + n -= 1; + } + const x: u32 = u32(a); + if (x > n) print("larger"); + else print("not larger"); +}); diff --git a/ledger/numbers/u32-compared.ts b/ledger/numbers/u32-compared.ts new file mode 100644 index 0000000..d3b0753 --- /dev/null +++ b/ledger/numbers/u32-compared.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A number read as a `u32`, 0 … 4 294 967 295, compared as one: `u32(a) > 100` is true for a number below zero. +program(() => { + let a = 0; + while (a > -10) a -= 3; + if (u32(a) > 100) print("large"); + else print("small"); +}); diff --git a/ledger/numbers/u32-quotient.ts b/ledger/numbers/u32-quotient.ts new file mode 100644 index 0000000..33b4e29 --- /dev/null +++ b/ledger/numbers/u32-quotient.ts @@ -0,0 +1,9 @@ +// ledger: step 4, takes +// A `u32` divided, as an unsigned number: `u32(a) / 2` of −2 is 2 147 483 647. +program(() => { + let a = 0; + while (a > -2) a -= 1; + const q: u32 = u32(a) / 2; + if (q > 1000) print("large"); + else print("small"); +}); diff --git a/ledger/numbers/u32-with-shift-zero.ts b/ledger/numbers/u32-with-shift-zero.ts new file mode 100644 index 0000000..d25b2e7 --- /dev/null +++ b/ledger/numbers/u32-with-shift-zero.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A number read as a `u32` the JavaScript way: `a >>> 0`, which costs nothing. +program(() => { + let a = 0; + while (a > -10) a -= 3; + if (a >>> 0 > 100) print("large"); + else print("small"); +}); diff --git a/ledger/numbers/u8-variable.ts b/ledger/numbers/u8-variable.ts new file mode 100644 index 0000000..d60222d --- /dev/null +++ b/ledger/numbers/u8-variable.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// A `u8` variable, which stays within 0 … 255: stored below zero it is 0, above 255 it is 255. +program(() => { + let a: u8 = 250; + let n = 0; + while (n < 3) { + a += 4; + n += 1; + } + print(`${a}`); +}); diff --git a/ledger/numbers/unsigned-shift-right-by-a-constant.ts b/ledger/numbers/unsigned-shift-right-by-a-constant.ts new file mode 100644 index 0000000..05cf209 --- /dev/null +++ b/ledger/numbers/unsigned-shift-right-by-a-constant.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A value shifted right by a number written in the script, its top bits zero: `a >>> 28` (a number below zero is large). +program(() => { + let a = 0; + while (a > -50) a -= 9; + print(`${a >>> 28}`); +}); diff --git a/ledger/numbers/unsigned-shift-right-by-a-value.ts b/ledger/numbers/unsigned-shift-right-by-a-value.ts new file mode 100644 index 0000000..5fd164f --- /dev/null +++ b/ledger/numbers/unsigned-shift-right-by-a-value.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// A value shifted right by a number that changes while the game runs, its top bits zero: `a >>> n`. +program(() => { + let a = 0; + let n = 20; + while (a > -50) { + a -= 9; + n += 2; + } + print(`${a >>> n}`); +}); diff --git a/ledger/numbers/whole-divided-by-fixed.ts b/ledger/numbers/whole-divided-by-fixed.ts new file mode 100644 index 0000000..b272fdd --- /dev/null +++ b/ledger/numbers/whole-divided-by-fixed.ts @@ -0,0 +1,13 @@ +// ledger: step 4, takes +// A whole number divided by one made fixed, which keeps the fraction: `n / fixed(d)` (3 / fixed(2) is 1.5). +program(() => { + let n = 0; + let d = 1; + while (n < 5) { + n += 3; + d += 1; + } + const q = n / fixed(d); + if (q > 2) print("more than 2"); + else print("2 or less"); +}); diff --git a/ledger/numbers/xor-of-two-values.ts b/ledger/numbers/xor-of-two-values.ts new file mode 100644 index 0000000..0159de0 --- /dev/null +++ b/ledger/numbers/xor-of-two-values.ts @@ -0,0 +1,11 @@ +// ledger: step 4, takes +// The bits one of two values that change while the game runs has and the other has not: `a ^ b`. +program(() => { + let a = 0; + let b = 1; + while (a < 50) { + a += 9; + b += 5; + } + print(`${a ^ b}`); +}); diff --git a/ledger/numbers/xor-with-a-constant.ts b/ledger/numbers/xor-with-a-constant.ts new file mode 100644 index 0000000..6dc26b6 --- /dev/null +++ b/ledger/numbers/xor-with-a-constant.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A value with bits flipped, as a value: `a ^ 5`. +program(() => { + let a = 0; + while (a < 50) a += 9; + print(`${a ^ 5}`); +}); diff --git a/ledger/programs/array-per-player.ts b/ledger/programs/array-per-player.ts new file mode 100644 index 0000000..4803073 --- /dev/null +++ b/ledger/programs/array-per-player.ts @@ -0,0 +1,11 @@ +// ledger: step 7, takes +// An array in a program for several players: each player's run its own cells, `const marks = [0, 0, 0]`. +program(() => { + const marks = [0, 0, 0]; + let i = 0; + while (i < 3) { + marks[i] += i + 1; + i += 1; + } + print(`${marks[0] + marks[1] + marks[2]}`); +}, { owner: [P1, P2] }); diff --git a/ledger/programs/current-player.ts b/ledger/programs/current-player.ts new file mode 100644 index 0000000..fa9f158 --- /dev/null +++ b/ledger/programs/current-player.ts @@ -0,0 +1,5 @@ +// ledger: step 7, takes +// The player a program runs as, named in it: `CurrentPlayer`, here a player's own minerals in a program for each. +program(() => { + setResources(CurrentPlayer, "add", 50, "ore"); +}, { owner: AllPlayers }); diff --git a/ledger/programs/game-function-two-programs.ts b/ledger/programs/game-function-two-programs.ts new file mode 100644 index 0000000..5b0bc67 --- /dev/null +++ b/ledger/programs/game-function-two-programs.ts @@ -0,0 +1,9 @@ +// ledger: step 7, takes +// One `game(…)` function called from two programs. +const award = game((n: number): number => n + 5); +program(() => { + print(`${award(1)}`); +}); +program(() => { + print(`${award(2)}`); +}, { owner: P2 }); diff --git a/ledger/programs/game-function.ts b/ledger/programs/game-function.ts new file mode 100644 index 0000000..95eb8b7 --- /dev/null +++ b/ledger/programs/game-function.ts @@ -0,0 +1,8 @@ +// ledger: step 1, takes +// A function for programs to call, written with `game(…)`: `const award = game((n: number) => …)`. +const award = game((n: number): number => n + 5); +program(() => { + let k = 1; + while (k < 4) k += 1; + print(`${award(k)}`); +}); diff --git a/ledger/programs/listener-at-the-top.ts b/ledger/programs/listener-at-the-top.ts new file mode 100644 index 0000000..4eac16a --- /dev/null +++ b/ledger/programs/listener-at-the-top.ts @@ -0,0 +1,8 @@ +// ledger: step 11, takes +// A listener at the top of a script, beside a program: a program of its own that listens from the start of the game. +on.created((u) => { + u.energy = 50; +}); +program(() => { + print("started"); +}); diff --git a/ledger/programs/owner-all-players-keys.ts b/ledger/programs/owner-all-players-keys.ts new file mode 100644 index 0000000..edd3a3e --- /dev/null +++ b/ledger/programs/owner-all-players-keys.ts @@ -0,0 +1,8 @@ +// ledger: step 12, takes +// Each player's own keys in a program for every player: `keyPressed(CurrentPlayer, "Space")` with `{ owner: AllPlayers }`. +program(async () => { + while (true) { + if (keyPressed(CurrentPlayer, "Space")) print("jump"); + await nextFrame(); + } +}, { owner: AllPlayers }); diff --git a/ledger/programs/owner-all-players-tasks.ts b/ledger/programs/owner-all-players-tasks.ts new file mode 100644 index 0000000..f70801f --- /dev/null +++ b/ledger/programs/owner-all-players-tasks.ts @@ -0,0 +1,9 @@ +// ledger: step 11, takes +// Each player's own tasks in a program for every player: `start(…)` with `{ owner: AllPlayers }`. +async function hello(n: number) { + await sleep(frames(n)); + print(`hello ${n}`); +} +program(async () => { + start(hello(2)); +}, { owner: AllPlayers }); diff --git a/ledger/programs/owner-all-players.ts b/ledger/programs/owner-all-players.ts new file mode 100644 index 0000000..c62fb40 --- /dev/null +++ b/ledger/programs/owner-all-players.ts @@ -0,0 +1,7 @@ +// ledger: step 7, takes +// A program that runs once for each player at the same time, each with their own variables: `{ owner: AllPlayers }`. +program(() => { + let n = 0; + while (n < 3) n += 1; + print(`${n}`); +}, { owner: AllPlayers }); diff --git a/ledger/programs/owner-another-player.ts b/ledger/programs/owner-another-player.ts new file mode 100644 index 0000000..d271279 --- /dev/null +++ b/ledger/programs/owner-another-player.ts @@ -0,0 +1,5 @@ +// ledger: step 0, takes +// A program that runs as another player than Player 1: `program(…, { owner: P2 })`. +program(() => { + print("for P2"); +}, { owner: P2 }); diff --git a/ledger/programs/owner-force.ts b/ledger/programs/owner-force.ts new file mode 100644 index 0000000..035b579 --- /dev/null +++ b/ledger/programs/owner-force.ts @@ -0,0 +1,5 @@ +// ledger: step 7, takes +// A program that runs once for each player of a force: `{ owner: players.Force1 }`. +program(() => { + print("for my force"); +}, { owner: players.Force1 }); diff --git a/ledger/programs/owner-list.ts b/ledger/programs/owner-list.ts new file mode 100644 index 0000000..6808611 --- /dev/null +++ b/ledger/programs/owner-list.ts @@ -0,0 +1,5 @@ +// ledger: step 7, takes +// A program that runs once for each player of a list: `{ owner: [P1, P2] }`. +program(() => { + print("for each of us"); +}, { owner: [P1, P2] }); diff --git a/ledger/programs/owner-one-of-a-list.ts b/ledger/programs/owner-one-of-a-list.ts new file mode 100644 index 0000000..da8ca19 --- /dev/null +++ b/ledger/programs/owner-one-of-a-list.ts @@ -0,0 +1,5 @@ +// ledger: step 0, takes +// A program whose list of owners has one player in it: `{ owner: [P3] }`. +program(() => { + print("for P3"); +}, { owner: [P3] }); diff --git a/ledger/programs/owner-player-twelve.ts b/ledger/programs/owner-player-twelve.ts new file mode 100644 index 0000000..f6bae30 --- /dev/null +++ b/ledger/programs/owner-player-twelve.ts @@ -0,0 +1,5 @@ +// ledger: step 7, refused: a program owned by P12: a program runs for players 1 to 8, All Players or a force +// A program owned by Player 12, who runs no triggers: `{ owner: P12 }`, refused as the Python side refuses it. +program(() => { + print("never"); +}, { owner: P12 }); diff --git a/ledger/programs/owner-players-four-to-seven.ts b/ledger/programs/owner-players-four-to-seven.ts new file mode 100644 index 0000000..aa20767 --- /dev/null +++ b/ledger/programs/owner-players-four-to-seven.ts @@ -0,0 +1,5 @@ +// ledger: step 7, takes +// A program for the players numbered four to seven, once for each of them: `{ owner: [P4, P5, P6, P7] }`. +program(() => { + print("for each of us"); +}, { owner: [P4, P5, P6, P7] }); diff --git a/ledger/programs/print-to-a-force.ts b/ledger/programs/print-to-a-force.ts new file mode 100644 index 0000000..affd1f9 --- /dev/null +++ b/ledger/programs/print-to-a-force.ts @@ -0,0 +1,6 @@ +// ledger: step 7, takes +// A text for the players of a force: `print(text, { to: players.Force1 })` — each human and computer player of the force, as +// the map's settings have them. +program(() => { + print("for the first force", { to: players.Force1 }); +}); diff --git a/ledger/programs/print-to-all-players.ts b/ledger/programs/print-to-all-players.ts new file mode 100644 index 0000000..1eeb51d --- /dev/null +++ b/ledger/programs/print-to-all-players.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// A text shown to every player: `print("…", { to: AllPlayers })`. +program(() => { + print("for everyone", { to: AllPlayers }); +}); diff --git a/ledger/programs/print-to-another-player.ts b/ledger/programs/print-to-another-player.ts new file mode 100644 index 0000000..4ec75d8 --- /dev/null +++ b/ledger/programs/print-to-another-player.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// A text shown to another player than the one the program runs as: `print("…", { to: P2 })`. +program(() => { + print("for P2", { to: P2 }); +}); diff --git a/ledger/programs/print-to-current-player.ts b/ledger/programs/print-to-current-player.ts new file mode 100644 index 0000000..82ade99 --- /dev/null +++ b/ledger/programs/print-to-current-player.ts @@ -0,0 +1,5 @@ +// ledger: step 0, takes +// A text shown to the player the program runs as, named so: `print("…", { to: CurrentPlayer })`. +program(() => { + print("for me", { to: CurrentPlayer }); +}, { owner: P2 }); diff --git a/ledger/programs/print-to-the-owner-by-name.ts b/ledger/programs/print-to-the-owner-by-name.ts new file mode 100644 index 0000000..8f04edd --- /dev/null +++ b/ledger/programs/print-to-the-owner-by-name.ts @@ -0,0 +1,5 @@ +// ledger: step 0, takes +// A text shown to the program's own player, named: `print("…", { to: P2 })` in a program that runs as P2. +program(() => { + print("for me", { to: P2 }); +}, { owner: P2 }); diff --git a/ledger/programs/program-name.ts b/ledger/programs/program-name.ts new file mode 100644 index 0000000..227d533 --- /dev/null +++ b/ledger/programs/program-name.ts @@ -0,0 +1,5 @@ +// ledger: step 0, takes +// A program given a name, for the Explorer's list and the tests: `program(…, { name: "waves" })`. +program(() => { + print("named"); +}, { name: "waves" }); diff --git a/ledger/programs/recursion-per-player.ts b/ledger/programs/recursion-per-player.ts new file mode 100644 index 0000000..75f5aaf --- /dev/null +++ b/ledger/programs/recursion-per-player.ts @@ -0,0 +1,11 @@ +// ledger: step 7, takes +// A function that calls itself, in a program for several players: each player's run its own stack. +function fib(n: number): number { + if (n < 2) return n; + return fib(n - 1) + fib(n - 2); +} +program(() => { + let n = 0; + while (n < 7) n += 1; + print(`${fib(n)}`); +}, { owner: [P1, P2] }); diff --git a/ledger/programs/shared-array.ts b/ledger/programs/shared-array.ts new file mode 100644 index 0000000..6c2c5ae --- /dev/null +++ b/ledger/programs/shared-array.ts @@ -0,0 +1,7 @@ +// ledger: step 7, takes +// An array every player of a program for several players shares: `const seen = shared([0, 0, 0, 0])`. +program(() => { + const seen = shared([0, 0, 0, 0]); + seen[0] += 1; + print(`${seen[0]}`); +}, { owner: AllPlayers }); diff --git a/ledger/programs/shared-boolean.ts b/ledger/programs/shared-boolean.ts new file mode 100644 index 0000000..fa185b1 --- /dev/null +++ b/ledger/programs/shared-boolean.ts @@ -0,0 +1,7 @@ +// ledger: step 7, takes +// A boolean every player of a program for several players shares: `let done = shared(false)`. +program(() => { + let done = shared(false); + if (!done) print("first here"); + done = true; +}, { owner: AllPlayers }); diff --git a/ledger/programs/shared-in-one-program.ts b/ledger/programs/shared-in-one-program.ts new file mode 100644 index 0000000..fa9f423 --- /dev/null +++ b/ledger/programs/shared-in-one-program.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// `shared()` in a program for one player, where there is only one copy to share: `let total = shared(0)`. +program(() => { + let total = shared(0); + while (total < 5) total += 2; + print(`${total}`); +}); diff --git a/ledger/programs/shared-number.ts b/ledger/programs/shared-number.ts new file mode 100644 index 0000000..8799dbf --- /dev/null +++ b/ledger/programs/shared-number.ts @@ -0,0 +1,7 @@ +// ledger: step 7, takes +// A number every player of a program for several players shares, one cell for them all: `let total = shared(0)`. +program(() => { + let total = shared(0); + total += 1; + print(`${total}`); +}, { owner: AllPlayers }); diff --git a/ledger/programs/two-programs.ts b/ledger/programs/two-programs.ts new file mode 100644 index 0000000..ca08b74 --- /dev/null +++ b/ledger/programs/two-programs.ts @@ -0,0 +1,8 @@ +// ledger: step 7, takes +// Two programs in one script, each running beside the other. +program(() => { + print("first"); +}); +program(() => { + print("second"); +}); diff --git a/ledger/sounds/music-arrange.ts b/ledger/sounds/music-arrange.ts new file mode 100644 index 0000000..304f789 --- /dev/null +++ b/ledger/sounds/music-arrange.ts @@ -0,0 +1,10 @@ +// ledger: step 5, takes +// A song in sections: parts named once (`music.part`), each section saying what they play +// (`music.section`), put in order by `music.arrange`. +import { music } from "trigscript/music"; +const parts = { lead: music.part(music.synth({ release: 0.05 })) }; +const verse = music.section(1, { lead: "C5 E5 G5 E5" }); +const tune = music.arrange({ bpm: 120, steps: 1 }, parts, verse, verse.with({ lead: "G5 E5 C5 ." })); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-bars.ts b/ledger/sounds/music-bars.ts new file mode 100644 index 0000000..dd40fe6 --- /dev/null +++ b/ledger/sounds/music-bars.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// How long bars last at a tempo, as a song plays them: `music.bars(120, 1)`, the length of a riser. +import { music } from "trigscript/music"; +const rise = music.riser(music.bars(240, 1), { volume: 0.3 }); +program(() => { + playWav(rise, 0); +}); diff --git a/ledger/sounds/music-beat.ts b/ledger/sounds/music-beat.ts new file mode 100644 index 0000000..8768342 --- /dev/null +++ b/ledger/sounds/music-beat.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A drum pattern from the steps each drum hits, when the map is built: `music.beat({ k: [0, 4], h: […] }, 8)`. +import { music } from "trigscript/music"; +const drums = music.kit({ k: tone([[0, 150], [0.08, 45]], 0.2, { volume: 0.6 }), h: noise(0.03, { volume: 0.2 }) }); +const groove = music.song({ bpm: 120 }, music.track(drums, music.beat({ k: [0, 4], h: [0, 2, 4, 6] }, 8))); +program(async () => { + await music.play(groove); +}); diff --git a/ledger/sounds/music-chord.ts b/ledger/sounds/music-chord.ts new file mode 100644 index 0000000..3030bde --- /dev/null +++ b/ledger/sounds/music-chord.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A chord by its name, its notes sounding together: `music.chord("Am")`. +import { music } from "trigscript/music"; +const keys = music.synth({ volume: 0.1, release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(keys, `${music.chord("Am")} - - .`)); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-chords.ts b/ledger/sounds/music-chords.ts new file mode 100644 index 0000000..24e321f --- /dev/null +++ b/ledger/sounds/music-chords.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A chord progression by name, each drawn by a template: `music.chords("Am F", "1+2+3 - . .")`. +import { music } from "trigscript/music"; +const keys = music.synth({ volume: 0.1, release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(keys, music.chords("Am F", "1+2+3 - . ."))); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-conduct.ts b/ledger/sounds/music-conduct.ts new file mode 100644 index 0000000..9986023 --- /dev/null +++ b/ledger/sounds/music-conduct.ts @@ -0,0 +1,13 @@ +// ledger: step 6, takes +// Music that follows the game: a song cut into bars and layers (`music.stems`), played by +// `music.conduct(stems, band)` as a `Band` says: which section, which layers, a stinger on the next beat. +import { music, Band } from "trigscript/music"; +const parts = { lead: music.part(music.synth({ release: 0.05 })) }; +const verse = music.section(1, { lead: "C5 E5 G5 E5" }, { name: "verse" }); +const tune = music.arrange({ bpm: 120, steps: 1 }, parts, verse); +const cut = music.stems(tune, { layers: { lead: ["lead"] }, rate: 22050, tail: 0.2 }); +program(async () => { + const band = new Band(); + band.go(cut.section("verse")); + await music.conduct(cut, band); +}); diff --git a/ledger/sounds/music-drums.ts b/ledger/sounds/music-drums.ts new file mode 100644 index 0000000..d489dc1 --- /dev/null +++ b/ledger/sounds/music-drums.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A drum kit made from nothing, a letter a drum: `music.drums()`. +import { music } from "trigscript/music"; +const kit = music.drums(); +const groove = music.song({ bpm: 120 }, music.track(kit, "kh h sh h")); +program(async () => { + await music.play(groove); +}); diff --git a/ledger/sounds/music-fm-synth.ts b/ledger/sounds/music-fm-synth.ts new file mode 100644 index 0000000..37f4b31 --- /dev/null +++ b/ledger/sounds/music-fm-synth.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// An FM instrument: `music.fmSynth({ ratio, index })`. +import { music } from "trigscript/music"; +const keys = music.fmSynth({ ratio: 1, index: 2, release: 0.1 }); +const tune = music.song({ bpm: 120 }, music.track(keys, "E4 - G4 -")); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-impact.ts b/ledger/sounds/music-impact.ts new file mode 100644 index 0000000..c3377e1 --- /dev/null +++ b/ledger/sounds/music-impact.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// A boom to land on, made when the map is built: `music.impact()`. +import { music } from "trigscript/music"; +const boom = music.impact({ seconds: 0.5 }); +program(() => { + playWav(boom, 0); +}); diff --git a/ledger/sounds/music-kit.ts b/ledger/sounds/music-kit.ts new file mode 100644 index 0000000..1e1ff2b --- /dev/null +++ b/ledger/sounds/music-kit.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A drum kit of the script's own clips, a letter each: `music.kit({ k: kick, h: hat })`. +import { music } from "trigscript/music"; +const drums = music.kit({ k: tone([[0, 150], [0.08, 45]], 0.2, { volume: 0.6 }), h: noise(0.03, { volume: 0.2 }) }); +const groove = music.song({ bpm: 120 }, music.track(drums, "kh h kh h")); +program(async () => { + await music.play(groove); +}); diff --git a/ledger/sounds/music-loop.ts b/ledger/sounds/music-loop.ts new file mode 100644 index 0000000..3c252ea --- /dev/null +++ b/ledger/sounds/music-loop.ts @@ -0,0 +1,8 @@ +// ledger: step 11, takes +// A song played again and again beside the program: `start(music.loop(song))`. +import { music } from "trigscript/music"; +const lead = music.synth({ release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(lead, "C5 . E5 .")); +program(async () => { + start(music.loop(tune)); +}); diff --git a/ledger/sounds/music-midi.ts b/ledger/sounds/music-midi.ts new file mode 100644 index 0000000..b6b01c7 --- /dev/null +++ b/ledger/sounds/music-midi.ts @@ -0,0 +1,11 @@ +// ledger: step 5, takes +// A song of notes (as a MIDI file imports them), each track played by an instrument: `music.midi(notes, { Piano: keys })`. +import { music } from "trigscript/music"; +const notes: Notes = { + bpm: 120, beats: 4, tempoChanges: 0, + tracks: [{ name: "Piano", channel: 1, program: 1, drums: false, notes: [{ at: 0, beats: 1, pitch: 60, note: "C4", velocity: 100 }, { at: 1, beats: 1, pitch: 64, note: "E4", velocity: 100 }] }], +}; +const tune = music.midi(notes, { Piano: music.synth({ release: 0.05 }) }); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-over.ts b/ledger/sounds/music-over.ts new file mode 100644 index 0000000..c4e67e2 --- /dev/null +++ b/ledger/sounds/music-over.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A chord's notes drawn by a template, when the map is built: `music.over("C4+E4+G4", "1 2 3 2")`. +import { music } from "trigscript/music"; +const lead = music.synth({ release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(lead, music.over("C4+E4+G4", "1 2 3 2"))); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-play.ts b/ledger/sounds/music-play.ts new file mode 100644 index 0000000..047017b --- /dev/null +++ b/ledger/sounds/music-play.ts @@ -0,0 +1,9 @@ +// ledger: step 5, takes +// A song played note by note from the music library: `music.synth`, `music.track`, `music.song`, and +// `await music.play(song)` in an async program. +import { music } from "trigscript/music"; +const lead = music.synth({ release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(lead, "C5 - E5 . G5 - - .")); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-presets.ts b/ledger/sounds/music-presets.ts new file mode 100644 index 0000000..642452b --- /dev/null +++ b/ledger/sounds/music-presets.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// An instrument to start from: `music.presets.chip()`. +import { music } from "trigscript/music"; +const chip = music.presets.chip(); +const tune = music.song({ bpm: 120 }, music.track(chip, "C5 E5 G5 E5")); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-pump.ts b/ledger/sounds/music-pump.ts new file mode 100644 index 0000000..814765b --- /dev/null +++ b/ledger/sounds/music-pump.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A part that breathes with the beat, each note ducked on it: `{ fx: music.pump(beat) }`. +import { music } from "trigscript/music"; +const pad = music.synth({ volume: 0.1, release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(pad, "C4+E4 - - -", { fx: music.pump(0.125) })); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-render.ts b/ledger/sounds/music-render.ts new file mode 100644 index 0000000..5d642ab --- /dev/null +++ b/ledger/sounds/music-render.ts @@ -0,0 +1,9 @@ +// ledger: step 5, takes +// A song mixed into one clip when the map is built, every note in time: `music.render(song)`. +import { music } from "trigscript/music"; +const lead = music.synth({ volume: 0.2, release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(lead, "C5 E5 G5 .")); +const mixed = music.render(tune, { rate: 22050 }); +program(() => { + playWav(mixed, 0); +}); diff --git a/ledger/sounds/music-repeat.ts b/ledger/sounds/music-repeat.ts new file mode 100644 index 0000000..856d82c --- /dev/null +++ b/ledger/sounds/music-repeat.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A pattern played several times over, when the map is built: `music.repeat(pattern, 2)`. +import { music } from "trigscript/music"; +const lead = music.synth({ release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(lead, music.repeat("C5 G4", 2))); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-riser.ts b/ledger/sounds/music-riser.ts new file mode 100644 index 0000000..1e454bf --- /dev/null +++ b/ledger/sounds/music-riser.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// Noise rising through an opening filter, made when the map is built: `music.riser(seconds)`. +import { music } from "trigscript/music"; +const rise = music.riser(1, { volume: 0.3 }); +program(() => { + playWav(rise, 0); +}); diff --git a/ledger/sounds/music-sampler.ts b/ledger/sounds/music-sampler.ts new file mode 100644 index 0000000..b1d3b39 --- /dev/null +++ b/ledger/sounds/music-sampler.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// An instrument from a clip, played at each note's pitch: `music.sampler(clip, root)`. +import { music } from "trigscript/music"; +const harp = music.sampler(pluck("C4", 0.5), "C4"); +const tune = music.song({ bpm: 120 }, music.track(harp, "C4 E4 G4 C5")); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-stretch.ts b/ledger/sounds/music-stretch.ts new file mode 100644 index 0000000..d389212 --- /dev/null +++ b/ledger/sounds/music-stretch.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A pattern made slower, each step held longer, when the map is built: `music.stretch(pattern, 2)`. +import { music } from "trigscript/music"; +const lead = music.synth({ release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(lead, music.stretch("C5 E5", 2))); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-strings.ts b/ledger/sounds/music-strings.ts new file mode 100644 index 0000000..c95d8d0 --- /dev/null +++ b/ledger/sounds/music-strings.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A plucked-string instrument: `music.strings()`. +import { music } from "trigscript/music"; +const guitar = music.strings({ brightness: 0.5, decay: 0.6 }); +const tune = music.song({ bpm: 120 }, music.track(guitar, "A3 C4 E4 A4")); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/music-transpose.ts b/ledger/sounds/music-transpose.ts new file mode 100644 index 0000000..8627f0a --- /dev/null +++ b/ledger/sounds/music-transpose.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// A pattern moved up or down by semitones when the map is built: `music.transpose(pattern, 12)`. +import { music } from "trigscript/music"; +const lead = music.synth({ release: 0.05 }); +const tune = music.song({ bpm: 120 }, music.track(lead, music.transpose("C4 E4 G4 .", 12))); +program(async () => { + await music.play(tune); +}); diff --git a/ledger/sounds/play-wav-chosen-by-a-value.ts b/ledger/sounds/play-wav-chosen-by-a-value.ts new file mode 100644 index 0000000..ef40742 --- /dev/null +++ b/ledger/sounds/play-wav-chosen-by-a-value.ts @@ -0,0 +1,9 @@ +// ledger: step 5, takes +// The action Play WAV of one of two clips, chosen by a value of the program. +const low = tone("C4", 0.1, { volume: 0.3 }); +const high = tone("C5", 0.1, { volume: 0.3 }); +program(() => { + let n = 3; + if (n > 2) playWav(high, 0); + else playWav(low, 0); +}); diff --git a/ledger/sounds/play-wav-clip-effects.ts b/ledger/sounds/play-wav-clip-effects.ts new file mode 100644 index 0000000..2d882a3 --- /dev/null +++ b/ledger/sounds/play-wav-clip-effects.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The action Play WAV of a clip put through effects when the map is built: a filter, an echo, a reverb, a limiter. +const zap = tone("A3", 0.4, { wave: "saw", volume: 0.4 }).filter("lowpass", [[0, 300], [0.4, 3000]]).delay(0.1).reverb({ decay: 0.3 }).limit(); +program(() => { + playWav(zap, 0); +}); diff --git a/ledger/sounds/play-wav-fm.ts b/ledger/sounds/play-wav-fm.ts new file mode 100644 index 0000000..659f512 --- /dev/null +++ b/ledger/sounds/play-wav-fm.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The action Play WAV of a clip made by `fm()` when the map is built: a bell. +const bell = fm("E5", 1, { ratio: 3.5, index: [[0, 6], [1, 0.3]], volume: 0.3 }).fade(0, 0.5); +program(() => { + playWav(bell, 0); +}); diff --git a/ledger/sounds/play-wav-game-file.ts b/ledger/sounds/play-wav-game-file.ts new file mode 100644 index 0000000..5294585 --- /dev/null +++ b/ledger/sounds/play-wav-game-file.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Play WAV of one of the game's own sound files, by its path. +program(() => { + playWav("sound\\glue\\mousedown2.wav", 0); +}); diff --git a/ledger/sounds/play-wav-map-sound.ts b/ledger/sounds/play-wav-map-sound.ts new file mode 100644 index 0000000..5df4bf1 --- /dev/null +++ b/ledger/sounds/play-wav-map-sound.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Play WAV of one of the map's own sounds (the Sound Editor's list) by its name, `sounds.Name`. +// The ledger's map has none, so one of the game's own files, chosen when the map is built. +const own = Object.values(sounds) as Sound[]; +program(() => { + playWav(own.length > 0 ? own[0] : "sound\\glue\\mousedown2.wav", 0); +}); diff --git a/ledger/sounds/play-wav-mixdown.ts b/ledger/sounds/play-wav-mixdown.ts new file mode 100644 index 0000000..ac01aaa --- /dev/null +++ b/ledger/sounds/play-wav-mixdown.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// The action Play WAV of clips laid over one another by `mixdown()` when the map is built: one clip. +const kick = tone([[0, 150], [0.08, 45]], 0.2, { volume: 0.6 }); +const hat = noise(0.03, { volume: 0.2 }); +const groove = mixdown([{ clip: kick, at: 0 }, { clip: hat, at: 0.25 }, { clip: kick, at: 0.5 }]); +program(() => { + playWav(groove, 0); +}); diff --git a/ledger/sounds/play-wav-noise.ts b/ledger/sounds/play-wav-noise.ts new file mode 100644 index 0000000..7cda92f --- /dev/null +++ b/ledger/sounds/play-wav-noise.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The action Play WAV of a clip made by `noise()` when the map is built: a burst. +const burst = noise(0.15, { volume: 0.4 }).fade(0, 0.1); +program(() => { + playWav(burst, 0); +}); diff --git a/ledger/sounds/play-wav-pluck.ts b/ledger/sounds/play-wav-pluck.ts new file mode 100644 index 0000000..f0fcc8d --- /dev/null +++ b/ledger/sounds/play-wav-pluck.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The action Play WAV of a clip made by `pluck()` when the map is built: a plucked string. +const harp = pluck("G3", 0.8, { brightness: 0.6 }); +program(() => { + playWav(harp, 0); +}); diff --git a/ledger/sounds/play-wav-together.ts b/ledger/sounds/play-wav-together.ts new file mode 100644 index 0000000..3dde4dd --- /dev/null +++ b/ledger/sounds/play-wav-together.ts @@ -0,0 +1,9 @@ +// ledger: step 5, takes +// The action Play WAV of clips the game plays together, turned down by `together()` when the map is built +// so that they never add up past the peak. +const [low, high] = together([{ clip: tone("C4", 0.5, { volume: 0.8 }), at: 0 }, { clip: tone("G4", 0.5, { volume: 0.8 }), at: 0.25 }]); +program(() => { + playWav(low, 0); + sleep(frames(6)); + playWav(high, 0); +}); diff --git a/ledger/sounds/play-wav-tone.ts b/ledger/sounds/play-wav-tone.ts new file mode 100644 index 0000000..61626c1 --- /dev/null +++ b/ledger/sounds/play-wav-tone.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The action Play WAV of a clip made by `tone()` when the map is built: a beep. +const beep = tone("A5", 0.1, { wave: "square", volume: 0.3 }).fade(0, 0.05); +program(() => { + playWav(beep, 0); +}); diff --git a/ledger/sounds/play-wav-with-silence.ts b/ledger/sounds/play-wav-with-silence.ts new file mode 100644 index 0000000..9c14886 --- /dev/null +++ b/ledger/sounds/play-wav-with-silence.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// The action Play WAV of two beeps a gap apart, the gap made by `silence()` when the map is built. +const beep = tone("C5", 0.08, { volume: 0.3 }); +const twice = beep.then(silence(0.1), beep); +program(() => { + playWav(twice, 0); +}); diff --git a/ledger/sounds/tone-made-when-built.ts b/ledger/sounds/tone-made-when-built.ts new file mode 100644 index 0000000..7d29e33 --- /dev/null +++ b/ledger/sounds/tone-made-when-built.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// `tone()` makes a clip when the map is built: its length and loudness are numbers the program has as +// constants. Nothing of it is played. +const beep = tone("A4", 0.25, { volume: 0.5 }); +program(() => { + let ms = Math.round(beep.seconds * 1000); + print(`${ms} ms`); +}); diff --git a/ledger/sounds/transmission-with-a-clip.ts b/ledger/sounds/transmission-with-a-clip.ts new file mode 100644 index 0000000..6f28902 --- /dev/null +++ b/ledger/sounds/transmission-with-a-clip.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// The action Transmission with a clip made when the map is built as its sound. +const chirp = tone([[0, 800], [0.2, 1600]], 0.2, { volume: 0.3 }); +program(() => { + transmission("Incoming.", units.TerranMarine, locations.Anywhere, "add", 0, chirp, 200); +}); diff --git a/ledger/tasks/async-arrow-awaited.ts b/ledger/tasks/async-arrow-awaited.ts new file mode 100644 index 0000000..e9f650d --- /dev/null +++ b/ledger/tasks/async-arrow-awaited.ts @@ -0,0 +1,10 @@ +// ledger: step 1, takes +// An async arrow function called and awaited where it is written: `await (async () => { … })()`. +program(async () => { + let n = 0; + await (async () => { + await sleep(frames(2)); + n = 4; + })(); + print(`${n}`); +}); diff --git a/ledger/tasks/async-function-awaited.ts b/ledger/tasks/async-function-awaited.ts new file mode 100644 index 0000000..4299ef8 --- /dev/null +++ b/ledger/tasks/async-function-awaited.ts @@ -0,0 +1,9 @@ +// ledger: step 1, takes +// An async function, awaited: it waits where it sleeps, and the program goes on after it. +async function pause(n: number) { + await sleep(frames(n)); +} +program(async () => { + await pause(3); + print("after"); +}); diff --git a/ledger/tasks/async-function-result.ts b/ledger/tasks/async-function-result.ts new file mode 100644 index 0000000..07af9c6 --- /dev/null +++ b/ledger/tasks/async-function-result.ts @@ -0,0 +1,10 @@ +// ledger: step 1, takes +// An async function that gives back a number once it has waited: `const v = await slow(2)`. +async function slow(n: number): Promise { + await sleep(frames(n)); + return n + 1; +} +program(async () => { + const v = await slow(2); + print(`${v}`); +}); diff --git a/ledger/tasks/async-program.ts b/ledger/tasks/async-program.ts new file mode 100644 index 0000000..ca4bfad --- /dev/null +++ b/ledger/tasks/async-program.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A program written as an async function, that waits with `await sleep(…)`. +program(async () => { + print("now"); + await sleep(frames(3)); + print("later"); +}); diff --git a/ledger/tasks/cutscene-camera-follow.ts b/ledger/tasks/cutscene-camera-follow.ts new file mode 100644 index 0000000..9772db7 --- /dev/null +++ b/ledger/tasks/cutscene-camera-follow.ts @@ -0,0 +1,10 @@ +// ledger: step 10, takes +// trigscript/cutscene: a camera that keeps a unit in the middle of the screen for so many frames: `await cam.follow(u, time)`. +import { Camera, SECOND } from "trigscript/cutscene"; +program(async () => { + const u = first({ owner: P1 }); + if (u) { + const cam = new Camera(u.x, u.y); + await cam.follow(u, 2 * SECOND); + } +}); diff --git a/ledger/tasks/cutscene-camera-pan-ease.ts b/ledger/tasks/cutscene-camera-pan-ease.ts new file mode 100644 index 0000000..ab8c93d --- /dev/null +++ b/ledger/tasks/cutscene-camera-pan-ease.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// trigscript/cutscene: a camera's glide that starts slow, ends slow or goes past and back: `cam.pan(x, y, time, "back")`. +import { Camera } from "trigscript/cutscene"; +program(async () => { + const cam = new Camera(640, 480); + await cam.pan(1600, 400, 48, "back"); +}); diff --git a/ledger/tasks/cutscene-camera-pan.ts b/ledger/tasks/cutscene-camera-pan.ts new file mode 100644 index 0000000..1d2b904 --- /dev/null +++ b/ledger/tasks/cutscene-camera-pan.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// trigscript/cutscene: a camera that glides to a point over so many frames: `await cam.pan(x, y, time)`. +import { Camera, SECOND } from "trigscript/cutscene"; +program(async () => { + const cam = new Camera(640, 480); + await cam.pan(1600, 400, 2 * SECOND); +}); diff --git a/ledger/tasks/cutscene-camera-shake.ts b/ledger/tasks/cutscene-camera-shake.ts new file mode 100644 index 0000000..8cf30a0 --- /dev/null +++ b/ledger/tasks/cutscene-camera-shake.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// trigscript/cutscene: a camera that shakes for so many frames and settles where it was: `await cam.shake(12)`. +import { Camera } from "trigscript/cutscene"; +program(async () => { + const cam = new Camera(640, 480); + await cam.shake(12); +}); diff --git a/ledger/tasks/cutscene-camera.ts b/ledger/tasks/cutscene-camera.ts new file mode 100644 index 0000000..8b70e8a --- /dev/null +++ b/ledger/tasks/cutscene-camera.ts @@ -0,0 +1,8 @@ +// ledger: step 5, takes +// trigscript/cutscene: a `Camera`, which looks at a point of the map as soon as it is made, and at another by `look(x, y)`. +import { Camera } from "trigscript/cutscene"; +program(async () => { + const cam = new Camera(640, 480); + await sleep(frames(24)); + cam.look(1024, 512); +}); diff --git a/ledger/tasks/cutscene-charge.ts b/ledger/tasks/cutscene-charge.ts new file mode 100644 index 0000000..8554f11 --- /dev/null +++ b/ledger/tasks/cutscene-charge.ts @@ -0,0 +1,9 @@ +// ledger: step 10, takes +// trigscript/cutscene: a unit sent to a point by an Attack order, fighting on the way: `await charge(u, x, y)`. +import { charge } from "trigscript/cutscene"; +program(async () => { + const u = first({ owner: P1 }); + if (u) { + await charge(u, 1200, 640); + } +}); diff --git a/ledger/tasks/cutscene-line.ts b/ledger/tasks/cutscene-line.ts new file mode 100644 index 0000000..e0edb20 --- /dev/null +++ b/ledger/tasks/cutscene-line.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// trigscript/cutscene: a line of a scene, the speaker's portrait talking and the words in the middle of the screen: `await line(speaker, text)`. +import { line, SECOND } from "trigscript/cutscene"; +program(async () => { + await line(units.JimRaynorMarine, "Something's moving out there.", 3 * SECOND); +}); diff --git a/ledger/tasks/cutscene-play.ts b/ledger/tasks/cutscene-play.ts new file mode 100644 index 0000000..ce9636c --- /dev/null +++ b/ledger/tasks/cutscene-play.ts @@ -0,0 +1,11 @@ +// ledger: step 12, takes +// trigscript/cutscene: a scene started as a task, which the player may skip with Escape: `await play(P1, start(scene()))`. +import { play } from "trigscript/cutscene"; +async function scene() { + await sleep(frames(48)); + print("the end"); +} +program(async () => { + const skipped = await play(P1, start(scene())); + if (skipped) print("skipped"); +}); diff --git a/ledger/tasks/cutscene-second.ts b/ledger/tasks/cutscene-second.ts new file mode 100644 index 0000000..897b731 --- /dev/null +++ b/ledger/tasks/cutscene-second.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// trigscript/cutscene: `SECOND`, the frames of a second of game time (24), for a scene's lengths. +import { SECOND } from "trigscript/cutscene"; +program(async () => { + await sleep(frames(3 * SECOND)); + print("three seconds"); +}); diff --git a/ledger/tasks/cutscene-walk-all.ts b/ledger/tasks/cutscene-walk-all.ts new file mode 100644 index 0000000..9c0b3eb --- /dev/null +++ b/ledger/tasks/cutscene-walk-all.ts @@ -0,0 +1,8 @@ +// ledger: step 10, takes +// trigscript/cutscene: a group of units sent to a point together, waited for until each is there: `await walkAll(group, x, y)`. +import { walkAll } from "trigscript/cutscene"; +program(async () => { + const group = [first({ owner: P1, type: units.TerranMarine })!, first({ owner: P1, type: units.TerranFirebat })!]; + const there = await walkAll(group, 1200, 640); + print(`${there}`); +}); diff --git a/ledger/tasks/cutscene-walk.ts b/ledger/tasks/cutscene-walk.ts new file mode 100644 index 0000000..0c26f79 --- /dev/null +++ b/ledger/tasks/cutscene-walk.ts @@ -0,0 +1,10 @@ +// ledger: step 10, takes +// trigscript/cutscene: a unit sent to a point by a Move order, waited for until it is there: `await walk(u, x, y)`. +import { walk } from "trigscript/cutscene"; +program(async () => { + const u = first({ owner: P1 }); + if (u) { + const there = await walk(u, 1200, 640); + if (there) print("arrived"); + } +}); diff --git a/ledger/tasks/generator-as-a-task.ts b/ledger/tasks/generator-as-a-task.ts new file mode 100644 index 0000000..bbdb2a7 --- /dev/null +++ b/ledger/tasks/generator-as-a-task.ts @@ -0,0 +1,16 @@ +// ledger: step 11, takes +// A generator walked inside a task started beside the program. +function* steps() { + yield 1; + yield 2; + yield 3; +} +async function walkSteps() { + for (const s of steps()) { + print(`step ${s}`); + await sleep(frames(1)); + } +} +program(async () => { + start(walkSteps()); +}); diff --git a/ledger/tasks/generator-for-await.ts b/ledger/tasks/generator-for-await.ts new file mode 100644 index 0000000..0198844 --- /dev/null +++ b/ledger/tasks/generator-for-await.ts @@ -0,0 +1,13 @@ +// ledger: step 1, takes +// An async generator walked by `for await`, waiting between its values. +async function* slowly(n: number) { + let i = 0; + while (i < n) { + await sleep(frames(2)); + yield i; + i += 1; + } +} +program(async () => { + for await (const k of slowly(3)) print(`${k}`); +}); diff --git a/ledger/tasks/generator-for-of.ts b/ledger/tasks/generator-for-of.ts new file mode 100644 index 0000000..a43a19f --- /dev/null +++ b/ledger/tasks/generator-for-of.ts @@ -0,0 +1,14 @@ +// ledger: step 1, takes +// A generator walked by `for…of`, the loop's body put where each `yield` is. +function* upTo(n: number) { + let i = 0; + while (i < n) { + yield i; + i += 1; + } +} +program(() => { + let s = 0; + for (const x of upTo(4)) s += x; + print(`${s}`); +}); diff --git a/ledger/tasks/next-frame.ts b/ledger/tasks/next-frame.ts new file mode 100644 index 0000000..55b54b6 --- /dev/null +++ b/ledger/tasks/next-frame.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// A wait for the next frame: `await nextFrame()`, the same as one frame's sleep. +program(async () => { + let n = 0; + while (n < 3) { + n += 1; + await nextFrame(); + } + print(`${n}`); +}); diff --git a/ledger/tasks/promise-all-values.ts b/ledger/tasks/promise-all-values.ts new file mode 100644 index 0000000..bb5d1c1 --- /dev/null +++ b/ledger/tasks/promise-all-values.ts @@ -0,0 +1,10 @@ +// ledger: step 11, takes +// Each side's value from a `Promise.all`: `const [a, b] = await Promise.all([…])`. +async function slow(n: number): Promise { + await sleep(frames(n)); + return n * 2; +} +program(async () => { + const [a, b] = await Promise.all([slow(2), slow(3)]); + print(`${a} ${b}`); +}); diff --git a/ledger/tasks/promise-all.ts b/ledger/tasks/promise-all.ts new file mode 100644 index 0000000..f4fd4f2 --- /dev/null +++ b/ledger/tasks/promise-all.ts @@ -0,0 +1,12 @@ +// ledger: step 11, takes +// Two waits side by side, the program going on when both have ended: `await Promise.all([…])`. +program(async () => { + await Promise.all([ + (async () => { + await sleep(frames(3)); + print("a"); + })(), + sleep(frames(5)), + ]); + print("both"); +}); diff --git a/ledger/tasks/promise-any.ts b/ledger/tasks/promise-any.ts new file mode 100644 index 0000000..7bbfcd4 --- /dev/null +++ b/ledger/tasks/promise-any.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// `Promise.any`, the same as a race (nothing in a program fails): `await Promise.any([…])`. +program(async () => { + let n = 0; + const ok = await Promise.any([until(() => n >= 9).then(() => false), sleep(frames(2)).then(() => true)]); + if (ok) print("held"); +}); diff --git a/ledger/tasks/promise-race.ts b/ledger/tasks/promise-race.ts new file mode 100644 index 0000000..3e450e9 --- /dev/null +++ b/ledger/tasks/promise-race.ts @@ -0,0 +1,10 @@ +// ledger: step 11, takes +// Two waits side by side, the program going on when the first ends: `await Promise.race([…])`. +async function wave(n: number): Promise { + await sleep(frames(n)); + return n; +} +program(async () => { + const v = await Promise.race([wave(5), sleep(frames(3)).then(() => 0)]); + print(`${v}`); +}); diff --git a/ledger/tasks/sleep-in-a-loop.ts b/ledger/tasks/sleep-in-a-loop.ts new file mode 100644 index 0000000..0e98899 --- /dev/null +++ b/ledger/tasks/sleep-in-a-loop.ts @@ -0,0 +1,10 @@ +// ledger: step 0, takes +// A game loop: a wait of one frame at the end of each turn, `while (…) { …; await sleep(frames(1)); }`. +program(async () => { + let n = 0; + while (n < 10) { + n += 1; + await sleep(frames(1)); + } + print(`${n}`); +}); diff --git a/ledger/tasks/sleep-not-awaited.ts b/ledger/tasks/sleep-not-awaited.ts new file mode 100644 index 0000000..a11fb8e --- /dev/null +++ b/ledger/tasks/sleep-not-awaited.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// A wait of so many frames on its own, in a program that is not async: `sleep(frames(3))`. +program(() => { + print("now"); + sleep(frames(3)); + print("later"); +}); diff --git a/ledger/tasks/sleep-of-a-value.ts b/ledger/tasks/sleep-of-a-value.ts new file mode 100644 index 0000000..12ef924 --- /dev/null +++ b/ledger/tasks/sleep-of-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 0, takes +// A wait whose length the program worked out while the game runs: `await sleep(frames(n))`. +program(async () => { + let n = 1; + while (n < 5) n += 2; + await sleep(frames(n)); + print("later"); +}); diff --git a/ledger/tasks/sleep-seconds.ts b/ledger/tasks/sleep-seconds.ts new file mode 100644 index 0000000..56626c1 --- /dev/null +++ b/ledger/tasks/sleep-seconds.ts @@ -0,0 +1,6 @@ +// ledger: step 0, takes +// A wait of so many seconds of game time, 24 frames each: `await sleep(seconds(2))`. +program(async () => { + await sleep(seconds(2)); + print("two seconds"); +}); diff --git a/ledger/tasks/start-arrow.ts b/ledger/tasks/start-arrow.ts new file mode 100644 index 0000000..4d8e3b4 --- /dev/null +++ b/ledger/tasks/start-arrow.ts @@ -0,0 +1,9 @@ +// ledger: step 11, takes +// A task written where it is started: `start(async () => { … })`. +program(async () => { + start(async () => { + await sleep(frames(5)); + print("later"); + }); + print("now"); +}); diff --git a/ledger/tasks/start-call.ts b/ledger/tasks/start-call.ts new file mode 100644 index 0000000..9cfd961 --- /dev/null +++ b/ledger/tasks/start-call.ts @@ -0,0 +1,14 @@ +// ledger: step 11, takes +// A task started beside the program, the program going on at once: `start(tick(3))`. +async function tick(n: number) { + let i = 0; + while (i < n) { + print(`tick ${i}`); + await sleep(frames(2)); + i += 1; + } +} +program(async () => { + start(tick(3)); + print("started"); +}); diff --git a/ledger/tasks/start-max.ts b/ledger/tasks/start-max.ts new file mode 100644 index 0000000..2387e1e --- /dev/null +++ b/ledger/tasks/start-max.ts @@ -0,0 +1,13 @@ +// ledger: step 11, takes +// A start that lets so many of its tasks run at once: `start(wave(n), { max: 4 })`. +async function wave(n: number) { + await sleep(frames(n)); + print(`wave ${n}`); +} +program(async () => { + let n = 1; + while (n <= 3) { + start(wave(n), { max: 4 }); + n += 1; + } +}); diff --git a/ledger/tasks/start-void.ts b/ledger/tasks/start-void.ts new file mode 100644 index 0000000..e1a9373 --- /dev/null +++ b/ledger/tasks/start-void.ts @@ -0,0 +1,10 @@ +// ledger: step 11, takes +// A task started by a call nothing waits for: `void later()`. +async function later() { + await sleep(frames(2)); + print("later"); +} +program(async () => { + void later(); + print("now"); +}); diff --git a/ledger/tasks/task-awaited.ts b/ledger/tasks/task-awaited.ts new file mode 100644 index 0000000..5562757 --- /dev/null +++ b/ledger/tasks/task-awaited.ts @@ -0,0 +1,11 @@ +// ledger: step 11, takes +// A task waited for: `await t`, the program going on in the frame the task ends. +async function short() { + await sleep(frames(3)); +} +program(async () => { + const t = start(short()); + print("started"); + await t; + print("ended"); +}); diff --git a/ledger/tasks/task-cancel.ts b/ledger/tasks/task-cancel.ts new file mode 100644 index 0000000..c784379 --- /dev/null +++ b/ledger/tasks/task-cancel.ts @@ -0,0 +1,10 @@ +// ledger: step 11, takes +// A task stopped where it is: `t.cancel()`. +async function forever() { + while (true) await sleep(frames(1)); +} +program(async () => { + const t = start(forever()); + await sleep(frames(10)); + t.cancel(); +}); diff --git a/ledger/tasks/task-error-awaited.ts b/ledger/tasks/task-error-awaited.ts new file mode 100644 index 0000000..0bbc5ec --- /dev/null +++ b/ledger/tasks/task-error-awaited.ts @@ -0,0 +1,15 @@ +// ledger: step 11, takes +// An error a task ends on, thrown again where the task is awaited and caught there: `try { await t; } catch (e) { … }`. +async function wave(n: number) { + await sleep(frames(2)); + if (n == 2) throw new Error("lost"); +} +program(async () => { + const t = start(wave(2)); + try { + await t; + print("done"); + } catch (e) { + print("the wave was lost"); + } +}); diff --git a/ledger/tasks/task-error-not-awaited.ts b/ledger/tasks/task-error-not-awaited.ts new file mode 100644 index 0000000..33f7baf --- /dev/null +++ b/ledger/tasks/task-error-not-awaited.ts @@ -0,0 +1,11 @@ +// ledger: step 11, takes +// An error a task ends on that nothing awaits: the task ends and the game says so, and the program goes on. +async function worker() { + await sleep(frames(1)); + throw new Error("worker failed"); +} +program(async () => { + start(worker()); + await sleep(frames(3)); + print("still here"); +}); diff --git a/ledger/tasks/task-outlives-program.ts b/ledger/tasks/task-outlives-program.ts new file mode 100644 index 0000000..f7bc6f9 --- /dev/null +++ b/ledger/tasks/task-outlives-program.ts @@ -0,0 +1,9 @@ +// ledger: step 11, takes +// A task that runs on after the program has ended. +program(async () => { + start(async () => { + await sleep(frames(5)); + print("later"); + }); + print("end"); +}); diff --git a/ledger/tasks/task-running.ts b/ledger/tasks/task-running.ts new file mode 100644 index 0000000..e2b96fb --- /dev/null +++ b/ledger/tasks/task-running.ts @@ -0,0 +1,14 @@ +// ledger: step 11, takes +// Whether a task still runs: `t.running`, true until it ends or is cancelled. +async function short() { + await sleep(frames(3)); +} +program(async () => { + const t = start(short()); + let n = 0; + while (t.running) { + n += 1; + await nextFrame(); + } + print(`${n}`); +}); diff --git a/ledger/tasks/task-starts-a-task.ts b/ledger/tasks/task-starts-a-task.ts new file mode 100644 index 0000000..9d7307d --- /dev/null +++ b/ledger/tasks/task-starts-a-task.ts @@ -0,0 +1,13 @@ +// ledger: step 11, takes +// A task that starts a task of its own. +async function inner(n: number) { + await sleep(frames(n)); + print(`inner ${n}`); +} +async function outer() { + await sleep(frames(1)); + start(inner(2)); +} +program(async () => { + start(outer()); +}); diff --git a/ledger/tasks/then-as-a-value.ts b/ledger/tasks/then-as-a-value.ts new file mode 100644 index 0000000..b871df7 --- /dev/null +++ b/ledger/tasks/then-as-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// `await sleep(…).then(() => 7)` as a value: the wait, then what the function gives. +program(async () => { + let n = 0; + n = await sleep(frames(1)).then(() => 7); + print(`${n}`); +}); diff --git a/ledger/tasks/then.ts b/ledger/tasks/then.ts new file mode 100644 index 0000000..7e146e1 --- /dev/null +++ b/ledger/tasks/then.ts @@ -0,0 +1,11 @@ +// ledger: step 1, takes +// What a promise gives, handed to a function by `.then((k) => …)` where it is awaited. +async function slow(n: number): Promise { + await sleep(frames(n)); + return n + 1; +} +program(async () => { + await slow(2).then((k) => { + print(`then ${k}`); + }); +}); diff --git a/ledger/tasks/timers-after.ts b/ledger/tasks/timers-after.ts new file mode 100644 index 0000000..5b76bfe --- /dev/null +++ b/ledger/tasks/timers-after.ts @@ -0,0 +1,8 @@ +// ledger: step 11, takes +// trigscript/timers: `after(delay, fn)`, a function run once, so many frames from now, in a task beside the program. +import { after, SECOND } from "trigscript/timers"; +program(async () => { + after(3 * SECOND, () => { + print("Go!"); + }); +}); diff --git a/ledger/tasks/timers-cooldown-ready.ts b/ledger/tasks/timers-cooldown-ready.ts new file mode 100644 index 0000000..cabbc17 --- /dev/null +++ b/ledger/tasks/timers-cooldown-ready.ts @@ -0,0 +1,11 @@ +// ledger: step 6, takes +// trigscript/timers: a `Cooldown` asked whether it is ready, how long until it is, and made ready again: `ready()`, `left()`, `reset()`. +import { Cooldown } from "trigscript/timers"; +program(async () => { + const blink = new Cooldown(24); + blink.use(); + await sleep(frames(5)); + if (!blink.ready()) print(`${blink.left()}`); + blink.reset(); + if (blink.ready()) print("ready"); +}); diff --git a/ledger/tasks/timers-cooldown.ts b/ledger/tasks/timers-cooldown.ts new file mode 100644 index 0000000..94d2938 --- /dev/null +++ b/ledger/tasks/timers-cooldown.ts @@ -0,0 +1,14 @@ +// ledger: step 6, takes +// trigscript/timers: a `Cooldown`, something that can be done once every so many frames: `shot.use()` is true when it was ready. +import { Cooldown } from "trigscript/timers"; +program(async () => { + const shot = new Cooldown(12); + let fired = 0; + let i = 0; + while (i < 48) { + if (shot.use()) fired += 1; + await nextFrame(); + i += 1; + } + print(`${fired}`); +}); diff --git a/ledger/tasks/timers-countdown-changed.ts b/ledger/tasks/timers-countdown-changed.ts new file mode 100644 index 0000000..b89b164 --- /dev/null +++ b/ledger/tasks/timers-countdown-changed.ts @@ -0,0 +1,10 @@ +// ledger: step 6, takes +// trigscript/timers: a `Countdown` given more time, and started again: `add(frames)`, `restart(length)`. +import { Countdown } from "trigscript/timers"; +program(async () => { + const fuse = new Countdown(24); + fuse.add(12); + await sleep(frames(30)); + if (!fuse.done()) fuse.restart(24); + print(`${fuse.left()}`); +}); diff --git a/ledger/tasks/timers-countdown-until.ts b/ledger/tasks/timers-countdown-until.ts new file mode 100644 index 0000000..48d533d --- /dev/null +++ b/ledger/tasks/timers-countdown-until.ts @@ -0,0 +1,8 @@ +// ledger: step 6, takes +// trigscript/timers: a wait until a `Countdown` has run out: `await until(() => round.done())`. +import { Countdown, SECOND } from "trigscript/timers"; +program(async () => { + const round = new Countdown(3 * SECOND); + await until(() => round.done()); + print("time"); +}); diff --git a/ledger/tasks/timers-countdown.ts b/ledger/tasks/timers-countdown.ts new file mode 100644 index 0000000..31be7dd --- /dev/null +++ b/ledger/tasks/timers-countdown.ts @@ -0,0 +1,11 @@ +// ledger: step 6, takes +// trigscript/timers: a `Countdown`, a length of time that runs down while the game runs: `left()` and `done()`. +import { Countdown } from "trigscript/timers"; +program(async () => { + const round = new Countdown(48); + while (!round.done()) { + print(`${round.left()}`); + await sleep(frames(12)); + } + print("time"); +}); diff --git a/ledger/tasks/timers-every-cancelled.ts b/ledger/tasks/timers-every-cancelled.ts new file mode 100644 index 0000000..e55d5b6 --- /dev/null +++ b/ledger/tasks/timers-every-cancelled.ts @@ -0,0 +1,10 @@ +// ledger: step 11, takes +// trigscript/timers: an `every` stopped by the task it gives back: `const t = every(…); t.cancel()`. +import { every, SECOND } from "trigscript/timers"; +program(async () => { + const t = every(SECOND, (n) => { + print(`${n}`); + }); + await sleep(frames(5 * SECOND)); + t.cancel(); +}); diff --git a/ledger/tasks/timers-every-times.ts b/ledger/tasks/timers-every-times.ts new file mode 100644 index 0000000..4fe28fb --- /dev/null +++ b/ledger/tasks/timers-every-times.ts @@ -0,0 +1,8 @@ +// ledger: step 11, takes +// trigscript/timers: `every(period, fn, times)`, a function run so many times, so many frames apart. +import { every } from "trigscript/timers"; +program(async () => { + every(12, (n) => { + print(`${n}`); + }, 3); +}); diff --git a/ledger/tasks/timers-every.ts b/ledger/tasks/timers-every.ts new file mode 100644 index 0000000..c31466a --- /dev/null +++ b/ledger/tasks/timers-every.ts @@ -0,0 +1,8 @@ +// ledger: step 11, takes +// trigscript/timers: `every(period, fn)`, a function run every so many frames in a task beside the program, handed how many times it has run. +import { every, SECOND } from "trigscript/timers"; +program(async () => { + every(SECOND, (n) => { + print(`${n}`); + }); +}); diff --git a/ledger/tasks/timers-now.ts b/ledger/tasks/timers-now.ts new file mode 100644 index 0000000..fb8c895 --- /dev/null +++ b/ledger/tasks/timers-now.ts @@ -0,0 +1,6 @@ +// ledger: step 6, takes +// trigscript/timers: `now()`, the frames of game time since the game began. +import { now } from "trigscript/timers"; +program(() => { + print(`${now()}`); +}); diff --git a/ledger/tasks/timers-over.ts b/ledger/tasks/timers-over.ts new file mode 100644 index 0000000..8f940c0 --- /dev/null +++ b/ledger/tasks/timers-over.ts @@ -0,0 +1,14 @@ +// ledger: step 3, takes +// trigscript/timers: `for await (const x of over(list, { per: 2 }))`, a loop over an array spread over frames, so many elements a frame. +import { over } from "trigscript/timers"; +program(async () => { + const xs = [1, 2, 3, 4, 5]; + let k = 1; + while (k < 4) { + xs[k] = k * 4; + k += 1; + } + let s = 0; + for await (const x of over(xs, { per: 2 })) s += x; + print(`${s}`); +}); diff --git a/ledger/tasks/timers-second.ts b/ledger/tasks/timers-second.ts new file mode 100644 index 0000000..164a940 --- /dev/null +++ b/ledger/tasks/timers-second.ts @@ -0,0 +1,7 @@ +// ledger: step 0, takes +// trigscript/timers: `SECOND`, the frames of a second of game time (24), for a wait counted in frames. +import { SECOND } from "trigscript/timers"; +program(async () => { + await sleep(frames(2 * SECOND)); + print("two seconds"); +}); diff --git a/ledger/tasks/timers-spread.ts b/ledger/tasks/timers-spread.ts new file mode 100644 index 0000000..950aa93 --- /dev/null +++ b/ledger/tasks/timers-spread.ts @@ -0,0 +1,10 @@ +// ledger: step 1, takes +// trigscript/timers: `for await (const i of spread(count, { per: 4 }))`, a count from 0 spread over frames, so many a frame. +import { spread } from "trigscript/timers"; +program(async () => { + let count = 2; + while (count < 10) count += 3; + let s = 0; + for await (const i of spread(count, { per: 4 })) s += i; + print(`${s}`); +}); diff --git a/ledger/tasks/timers-stopwatch.ts b/ledger/tasks/timers-stopwatch.ts new file mode 100644 index 0000000..42388bd --- /dev/null +++ b/ledger/tasks/timers-stopwatch.ts @@ -0,0 +1,9 @@ +// ledger: step 6, takes +// trigscript/timers: a `Stopwatch`, the frames since it was made or reset: `elapsed()`, `reset()`. +import { Stopwatch } from "trigscript/timers"; +program(async () => { + const lap = new Stopwatch(); + await sleep(frames(10)); + print(`${lap.elapsed()}`); + lap.reset(); +}); diff --git a/ledger/tasks/until-condition.ts b/ledger/tasks/until-condition.ts new file mode 100644 index 0000000..9d32d52 --- /dev/null +++ b/ledger/tasks/until-condition.ts @@ -0,0 +1,6 @@ +// ledger: step 5, takes +// A wait until a condition of the game holds, asked every frame: `await until(elapsedTime(">=", 5))`. +program(async () => { + await until(elapsedTime(">=", 5)); + print("five seconds"); +}); diff --git a/ledger/tasks/until-function.ts b/ledger/tasks/until-function.ts new file mode 100644 index 0000000..93d4b13 --- /dev/null +++ b/ledger/tasks/until-function.ts @@ -0,0 +1,10 @@ +// ledger: step 1, takes +// A wait until a function says true, asked once now and once every frame: `await until(() => n >= 3)`. +program(async () => { + let n = 0; + await until(() => { + n += 1; + return n >= 3; + }); + print(`${n}`); +}); diff --git a/ledger/texts/boolean-as-a-text.ts b/ledger/texts/boolean-as-a-text.ts new file mode 100644 index 0000000..b702c45 --- /dev/null +++ b/ledger/texts/boolean-as-a-text.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// A boolean of the program as a text, true or false: `` `${done}` ``. +program(() => { + let n = 0; + while (n < 12) n += 5; + const done = n > 10; + print(`${done}`); +}); diff --git a/ledger/texts/character-code.ts b/ledger/texts/character-code.ts new file mode 100644 index 0000000..16d1e67 --- /dev/null +++ b/ledger/texts/character-code.ts @@ -0,0 +1,7 @@ +// ledger: step 8, takes +// The code of a character of a text, at a place that changes while the game runs: `"Marine".charCodeAt(i)`. +program(() => { + let i = 0; + while (i < 3) i += 2; + print(`${"Marine".charCodeAt(i)}`); +}); diff --git a/ledger/texts/jsx-bar.tsx b/ledger/texts/jsx-bar.tsx new file mode 100644 index 0000000..cce480f --- /dev/null +++ b/ledger/texts/jsx-bar.tsx @@ -0,0 +1,11 @@ +// ledger: step 8, takes +// A bar of squares in a line of JSX, as long as a value of the program: ``. +program(() => { + let hp = 0; + while (hp < 60) hp += 25; + print( + + Base + , + ); +}); diff --git a/ledger/texts/jsx-in-a-program.tsx b/ledger/texts/jsx-in-a-program.tsx new file mode 100644 index 0000000..1c39782 --- /dev/null +++ b/ledger/texts/jsx-in-a-program.tsx @@ -0,0 +1,6 @@ +// ledger: step 8, takes +// A line of JSX written in the program, every part of it known when the map is built: +// `print(Wave cleared)`. +program(() => { + print(Wave cleared); +}); diff --git a/ledger/texts/jsx-known-when-built.tsx b/ledger/texts/jsx-known-when-built.tsx new file mode 100644 index 0000000..de37cba --- /dev/null +++ b/ledger/texts/jsx-known-when-built.tsx @@ -0,0 +1,11 @@ +// ledger: step 0, takes +// A line of JSX written outside the program, worked out when the map is built, with a colour by name and a cell in one +// of `textColors`: `const title = …`, then `print(title)`. +const title = ( + + Score + +); +program(() => { + print(title); +}); diff --git a/ledger/texts/jsx-with-a-number.tsx b/ledger/texts/jsx-with-a-number.tsx new file mode 100644 index 0000000..7d09ed6 --- /dev/null +++ b/ledger/texts/jsx-with-a-number.tsx @@ -0,0 +1,7 @@ +// ledger: step 8, takes +// A line of JSX with a number of the program in it, put together while the game runs: `Wave {n}`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print(Wave {n}); +}); diff --git a/ledger/texts/number-as-a-text.ts b/ledger/texts/number-as-a-text.ts new file mode 100644 index 0000000..1afd5c5 --- /dev/null +++ b/ledger/texts/number-as-a-text.ts @@ -0,0 +1,10 @@ +// ledger: step 8, takes +// A number of the program made a text: `String(n)` and `n.toString()`, kept as a text. +program(() => { + let n = 0; + while (n < 12) n += 5; + const a = String(n); + const b = n.toString(); + print(a); + print(b); +}); diff --git a/ledger/texts/parse-int.ts b/ledger/texts/parse-int.ts new file mode 100644 index 0000000..e1a5e9d --- /dev/null +++ b/ledger/texts/parse-int.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// The whole number written at the start of a text: `parseInt(s)`, and `isNaN(parseInt(s))` for whether there is one. +program(() => { + let n = 0; + while (n < 12) n += 5; + if (isNaN(parseInt(n > 10 ? " 42 ore" : "none"))) print("no number"); + else print(`${parseInt(n > 10 ? " 42 ore" : "none")}`); +}); diff --git a/ledger/texts/player-colour-as-a-value.ts b/ledger/texts/player-colour-as-a-value.ts new file mode 100644 index 0000000..3352780 --- /dev/null +++ b/ledger/texts/player-colour-as-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// A player's name and colour kept in a text and looked into: the colour is one character, the code the game fills in; +// the name is as long as the player's. +program(() => { + const c = `${color(P1)}`; + const who = `${name(P1)}!`; + print(`${c.length} ${c.charCodeAt(0) > 1} ${who.length > 1} ${who.endsWith("!")}`); +}); diff --git a/ledger/texts/player-colour-set.ts b/ledger/texts/player-colour-set.ts new file mode 100644 index 0000000..5cf0a2e --- /dev/null +++ b/ledger/texts/player-colour-set.ts @@ -0,0 +1,7 @@ +// ledger: step 6, takes +// A player's colour changed while the game runs, to one of `colors`: `stats(P2).color = colors.teal`. +program(() => { + let n = 0; + while (n < 12) n += 5; + if (n > 10) stats(P2).color = colors.teal; +}); diff --git a/ledger/texts/print-a-colour-from-a-value.tsx b/ledger/texts/print-a-colour-from-a-value.tsx new file mode 100644 index 0000000..f586b1f --- /dev/null +++ b/ledger/texts/print-a-colour-from-a-value.tsx @@ -0,0 +1,13 @@ +// ledger: step 8, takes +// A colour of the game's texts chosen while the game runs, in a printed line: ``, `c` one of +// `textColors`. +program(() => { + let n = 0; + while (n < 12) n += 5; + const c = n > 10 ? textColors.green : textColors.red; + print( + + Health + , + ); +}); diff --git a/ledger/texts/print-a-fixed-number.ts b/ledger/texts/print-a-fixed-number.ts new file mode 100644 index 0000000..563c628 --- /dev/null +++ b/ledger/texts/print-a-fixed-number.ts @@ -0,0 +1,7 @@ +// ledger: step 4, takes +// A number with a fraction in a printed text, as a decimal of up to four places: `` print(`${x}`) `` of 2.75. +program(() => { + let x = 0.5; + while (x < 2.5) x += 0.75; + print(`${x}`); +}); diff --git a/ledger/texts/print-a-number-below-zero.ts b/ledger/texts/print-a-number-below-zero.ts new file mode 100644 index 0000000..7f48f77 --- /dev/null +++ b/ledger/texts/print-a-number-below-zero.ts @@ -0,0 +1,7 @@ +// ledger: step 2, takes +// A number below zero in a printed text, with its minus sign: `` print(`${n}`) `` of −7. +program(() => { + let n = 5; + while (n > -5) n -= 4; + print(`n=${n}`); +}); diff --git a/ledger/texts/print-a-player-colour.ts b/ledger/texts/print-a-player-colour.ts new file mode 100644 index 0000000..f6be77d --- /dev/null +++ b/ledger/texts/print-a-player-colour.ts @@ -0,0 +1,5 @@ +// ledger: step 8, takes +// A player's colour in a printed text, so that what follows is in it: `` print(`${color(P2)}Blue team`) ``. +program(() => { + print(`${color(P2)}Blue team`); +}); diff --git a/ledger/texts/print-a-player-name.ts b/ledger/texts/print-a-player-name.ts new file mode 100644 index 0000000..29cc814 --- /dev/null +++ b/ledger/texts/print-a-player-name.ts @@ -0,0 +1,5 @@ +// ledger: step 8, takes +// A player's name in a printed text, filled in by the game: `` print(`${name(P2)} joined`) ``. +program(() => { + print(`${name(P2)} joined`); +}); diff --git a/ledger/texts/print-a-u32.ts b/ledger/texts/print-a-u32.ts new file mode 100644 index 0000000..327a7d7 --- /dev/null +++ b/ledger/texts/print-a-u32.ts @@ -0,0 +1,8 @@ +// ledger: step 2, takes +// A `u32` in a printed text, read from 0 up: `u32(-1)` prints 4294967295. +program(() => { + let a = 0; + while (a > -1) a -= 1; + const x: u32 = u32(a); + print(`${x}`); +}); diff --git a/ledger/texts/print-a-whole-number.ts b/ledger/texts/print-a-whole-number.ts new file mode 100644 index 0000000..b28aa93 --- /dev/null +++ b/ledger/texts/print-a-whole-number.ts @@ -0,0 +1,7 @@ +// ledger: step 2, takes +// A text with a whole number of the program in it, written out in digits: `` print(`Wave ${wave}`) ``. +program(() => { + let wave = 0; + while (wave < 12) wave += 5; + print(`Wave ${wave} of 20`); +}); diff --git a/ledger/texts/print-at-the-centre.ts b/ledger/texts/print-at-the-centre.ts new file mode 100644 index 0000000..a4c8a9c --- /dev/null +++ b/ledger/texts/print-at-the-centre.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// A text shown in the middle of the screen, where the game's own messages appear: `print(…, { position: "center" })`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print(`Wave ${n}`, { position: "center" }); +}); diff --git a/ledger/texts/print-known-text.ts b/ledger/texts/print-known-text.ts new file mode 100644 index 0000000..343dc64 --- /dev/null +++ b/ledger/texts/print-known-text.ts @@ -0,0 +1,5 @@ +// ledger: step 0, takes +// A text written in the script shown to the program's owner: `print("Wave cleared")`. +program(() => { + print("Wave cleared"); +}); diff --git a/ledger/texts/print-several-numbers.ts b/ledger/texts/print-several-numbers.ts new file mode 100644 index 0000000..77f1138 --- /dev/null +++ b/ledger/texts/print-several-numbers.ts @@ -0,0 +1,11 @@ +// ledger: step 2, takes +// A text with several numbers of the program in it: `` print(`${a} + ${b} = ${a + b}`) ``. +program(() => { + let a = 0; + let b = 100; + while (a < 12) { + a += 5; + b -= 7; + } + print(`${a} + ${b} = ${a + b}`); +}); diff --git a/ledger/texts/print-the-largest-and-smallest-numbers.ts b/ledger/texts/print-the-largest-and-smallest-numbers.ts new file mode 100644 index 0000000..a43c2a6 --- /dev/null +++ b/ledger/texts/print-the-largest-and-smallest-numbers.ts @@ -0,0 +1,11 @@ +// ledger: step 2, takes +// The largest and the smallest numbers a variable holds, printed: 2 147 483 647 and −2 147 483 648. +program(() => { + let top = 2147483600; + let bottom = -2147483600; + while (top < 2147483647) { + top += 47; + bottom -= 48; + } + print(`${top} ${bottom}`); +}); diff --git a/ledger/texts/print-to-all-players.ts b/ledger/texts/print-to-all-players.ts new file mode 100644 index 0000000..acccb36 --- /dev/null +++ b/ledger/texts/print-to-all-players.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// A text shown to every player: `print(…, { to: AllPlayers })`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print(`Wave ${n}`, { to: AllPlayers }); +}); diff --git a/ledger/texts/print-to-another-player.ts b/ledger/texts/print-to-another-player.ts new file mode 100644 index 0000000..39edb86 --- /dev/null +++ b/ledger/texts/print-to-another-player.ts @@ -0,0 +1,7 @@ +// ledger: step 5, takes +// A text shown to another player than the program's owner: `print(…, { to: P2 })`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print(`${n} left`, { to: P2 }); +}); diff --git a/ledger/texts/template-as-a-value.ts b/ledger/texts/template-as-a-value.ts new file mode 100644 index 0000000..4845a41 --- /dev/null +++ b/ledger/texts/template-as-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// A template put together while the game runs and used as a text, not printed at once: `` `${n}` === "15" ``. +program(() => { + let n = 0; + while (n < 12) n += 5; + if (`${n}` === "15") print("fifteen"); + else print("not fifteen"); +}); diff --git a/ledger/texts/text-array-join.ts b/ledger/texts/text-array-join.ts new file mode 100644 index 0000000..b9b5c75 --- /dev/null +++ b/ledger/texts/text-array-join.ts @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// The texts of an array of texts as one, with a separator between them: `names.join(", ")`. +program(() => { + const names = ["Raynor", "Kerrigan"]; + let n = 0; + while (n < 12) n += 5; + if (n > 10) names[1] = "Tassadar"; + print(names.join(", ")); +}); diff --git a/ledger/texts/text-array-that-grows.ts b/ledger/texts/text-array-that-grows.ts new file mode 100644 index 0000000..f6ce836 --- /dev/null +++ b/ledger/texts/text-array-that-grows.ts @@ -0,0 +1,11 @@ +// ledger: step 9, takes +// An array of texts that grows: `log.push("wave")`. +program(() => { + const log: string[] = []; + let n = 0; + while (n < 12) { + log.push("wave"); + n += 5; + } + print(`${log.length}`); +}); diff --git a/ledger/texts/text-array.ts b/ledger/texts/text-array.ts new file mode 100644 index 0000000..da41289 --- /dev/null +++ b/ledger/texts/text-array.ts @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// An array of texts of the program, stored and read at a place that changes while the game runs: `names[i] = "Medic"`. +program(() => { + const names = ["Marine", "Marine", "Marine"]; + let i = 0; + while (i < 2) i += 1; + names[i] = "Medic"; + print(names[i]); +}); diff --git a/ledger/texts/text-character-at.ts b/ledger/texts/text-character-at.ts new file mode 100644 index 0000000..1781047 --- /dev/null +++ b/ledger/texts/text-character-at.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// One character of a text, at a place that changes while the game runs: `s[i]`, `s.charAt(i)`, `s.at(-1)`. +program(() => { + let i = 0; + while (i < 6) i += 4; + const s = "Terran Marine"; + print(`${s[i]}${s.charAt(i + 1)}${s.at(-1)}`); +}); diff --git a/ledger/texts/text-chosen-by-a-condition.ts b/ledger/texts/text-chosen-by-a-condition.ts new file mode 100644 index 0000000..8651ac1 --- /dev/null +++ b/ledger/texts/text-chosen-by-a-condition.ts @@ -0,0 +1,7 @@ +// ledger: step 8, takes +// One text or another, by a test while the game runs: `print(n > 10 ? "Medic" : "Marine")`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print(n > 10 ? "Medic" : "Marine"); +}); diff --git a/ledger/texts/text-chosen-in-an-action.ts b/ledger/texts/text-chosen-in-an-action.ts new file mode 100644 index 0000000..697523e --- /dev/null +++ b/ledger/texts/text-chosen-in-an-action.ts @@ -0,0 +1,7 @@ +// ledger: step 8, takes +// A text chosen while the game runs as an action's text: `transmission(n > 10 ? "Report" : "Hold", …)`. +program(() => { + let n = 0; + while (n < 12) n += 5; + transmission(n > 10 ? "Report" : "Hold", units.TerranMarine, locations.Anywhere, "add", 0, "", 500); +}); diff --git a/ledger/texts/text-for-of.ts b/ledger/texts/text-for-of.ts new file mode 100644 index 0000000..ee10579 --- /dev/null +++ b/ledger/texts/text-for-of.ts @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// A loop over the characters of a text: `for (const ch of s)`. +program(() => { + let n = 0; + while (n < 12) n += 5; + let vowels = 0; + for (const ch of n > 10 ? "Medic" : "Marine") if (ch === "a" || ch === "e" || ch === "i") vowels += 1; + print(`${vowels}`); +}); diff --git a/ledger/texts/text-function.ts b/ledger/texts/text-function.ts new file mode 100644 index 0000000..3956624 --- /dev/null +++ b/ledger/texts/text-function.ts @@ -0,0 +1,10 @@ +// ledger: step 8, takes +// A function of the script that takes a text and gives one back: `label(name, n)`. +function label(name: string, n: number): string { + return `${name} x${n}`; +} +program(() => { + let n = 0; + while (n < 12) n += 5; + print(label(n > 10 ? "Medic" : "Marine", n)); +}); diff --git a/ledger/texts/text-in-a-printed-template.ts b/ledger/texts/text-in-a-printed-template.ts new file mode 100644 index 0000000..0c0335c --- /dev/null +++ b/ledger/texts/text-in-a-printed-template.ts @@ -0,0 +1,7 @@ +// ledger: step 8, takes +// A text of the program in a printed text: `` print(`${n > 10 ? "Medic" : "Marine"} ready`) ``. +program(() => { + let n = 0; + while (n < 12) n += 5; + print(`${n > 10 ? "Medic" : "Marine"} ready`); +}); diff --git a/ledger/texts/text-in-a-record.ts b/ledger/texts/text-in-a-record.ts new file mode 100644 index 0000000..2a3d9c4 --- /dev/null +++ b/ledger/texts/text-in-a-record.ts @@ -0,0 +1,12 @@ +// ledger: step 8, takes +// A text that is a field of each row of an array of records, written while the game runs: `heroes[i].name = "…"`. +program(() => { + const heroes = [ + { name: "Raynor", hp: 5 }, + { name: "Kerrigan", hp: 7 }, + ]; + let i = 0; + while (i < 1) i += 1; + heroes[i].name = "Tassadar"; + print(heroes[i].name); +}); diff --git a/ledger/texts/text-includes.ts b/ledger/texts/text-includes.ts new file mode 100644 index 0000000..7431566 --- /dev/null +++ b/ledger/texts/text-includes.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// Whether a text of the program has another in it: `s.includes("ed")`. +program(() => { + let n = 0; + while (n < 12) n += 5; + if ((n > 10 ? "Medic" : "Marine").includes("ed")) print("has it"); + else print("has it not"); +}); diff --git a/ledger/texts/text-index-of.ts b/ledger/texts/text-index-of.ts new file mode 100644 index 0000000..9f913ba --- /dev/null +++ b/ledger/texts/text-index-of.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// Where a text is found in another, from a place that changes while the game runs, −1 when it is not: +// `"abcabc".indexOf("c", i)`, and `lastIndexOf`. +program(() => { + let i = 0; + while (i < 3) i += 2; + print(`${"abcabc".indexOf("c", i)} ${"abcabc".lastIndexOf("a")}`); +}); diff --git a/ledger/texts/text-is-empty.ts b/ledger/texts/text-is-empty.ts new file mode 100644 index 0000000..8c5173d --- /dev/null +++ b/ledger/texts/text-is-empty.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// Whether a text is empty, by testing it: `if (s)`. +program(() => { + let n = 0; + while (n < 12) n += 5; + if (n > 10 ? "" : "x") print("not empty"); + else print("empty"); +}); diff --git a/ledger/texts/text-length.ts b/ledger/texts/text-length.ts new file mode 100644 index 0000000..de559a5 --- /dev/null +++ b/ledger/texts/text-length.ts @@ -0,0 +1,7 @@ +// ledger: step 8, takes +// How many characters a text of the program has: `.length`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print(`${(n > 10 ? "Medic" : "Marine").length}`); +}); diff --git a/ledger/texts/text-list-read-at-a-value.ts b/ledger/texts/text-list-read-at-a-value.ts new file mode 100644 index 0000000..c42b3a8 --- /dev/null +++ b/ledger/texts/text-list-read-at-a-value.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// A list of texts written in the script, looked up at a value that changes while the game runs: `NAMES[i]`. +const NAMES = ["Raynor", "Kerrigan", "Tassadar"]; +program(() => { + let i = 0; + while (i < 2) i += 1; + print(NAMES[i]); +}); diff --git a/ledger/texts/text-of-many-in-a-print.ts b/ledger/texts/text-of-many-in-a-print.ts new file mode 100644 index 0000000..688a776 --- /dev/null +++ b/ledger/texts/text-of-many-in-a-print.ts @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// Two texts of many each, chosen while the game runs, in one printed text: `` `${NAMES[i]} and ${NAMES[j]}` ``. +const NAMES = ["Raynor", "Kerrigan", "Tassadar", "Zeratul", "Artanis"]; +program(() => { + let i = 0; + while (i < 3) i += 1; + let j = 4 - i; + print(`${NAMES[i]} and ${NAMES[j]}`); +}); diff --git a/ledger/texts/text-pad.ts b/ledger/texts/text-pad.ts new file mode 100644 index 0000000..b92782b --- /dev/null +++ b/ledger/texts/text-pad.ts @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// A text made as wide as a number of characters, filled at the start or the end: `String(n).padStart(4, "0")`, +// `padEnd`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print(String(n).padStart(4, "0")); + print("Wave".padEnd(n - 7, ".")); +}); diff --git a/ledger/texts/text-repeat.ts b/ledger/texts/text-repeat.ts new file mode 100644 index 0000000..22541df --- /dev/null +++ b/ledger/texts/text-repeat.ts @@ -0,0 +1,7 @@ +// ledger: step 8, takes +// A text over again a number of times that changes while the game runs: `"■".repeat(n)`. +program(() => { + let n = 0; + while (n < 6) n += 2; + print("■".repeat(n)); +}); diff --git a/ledger/texts/text-replace.ts b/ledger/texts/text-replace.ts new file mode 100644 index 0000000..7b8a9ec --- /dev/null +++ b/ledger/texts/text-replace.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// A text with the first of another in it replaced, or every one: `replace("a", "o")`, `replaceAll("a", "o")`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print((n > 10 ? "banana" : "papaya").replace("a", "o")); + print((n > 10 ? "banana" : "papaya").replaceAll("a", "o")); +}); diff --git a/ledger/texts/text-slice.ts b/ledger/texts/text-slice.ts new file mode 100644 index 0000000..fc561a4 --- /dev/null +++ b/ledger/texts/text-slice.ts @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// Part of a text, between places that change while the game runs: `"Terran Marine".slice(i, i + 4)`, and +// `substring`. +program(() => { + let i = 0; + while (i < 6) i += 3; + print("Terran Marine".slice(i, i + 4)); + print("Terran Marine".substring(i + 1)); +}); diff --git a/ledger/texts/text-split.ts b/ledger/texts/text-split.ts new file mode 100644 index 0000000..5478871 --- /dev/null +++ b/ledger/texts/text-split.ts @@ -0,0 +1,8 @@ +// ledger: step 9, takes +// A text cut at a separator into an array of texts: `"a,b,c".split(",")`. +program(() => { + let n = 0; + while (n < 12) n += 5; + const parts = (n > 10 ? "1,2,3" : "4,5").split(","); + print(`${parts.length}`); +}); diff --git a/ledger/texts/text-starts-and-ends-with.ts b/ledger/texts/text-starts-and-ends-with.ts new file mode 100644 index 0000000..ff0c1b2 --- /dev/null +++ b/ledger/texts/text-starts-and-ends-with.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// Whether a text of the program begins or ends with another: `startsWith`, `endsWith`. +program(() => { + let n = 0; + while (n < 12) n += 5; + if ((n > 10 ? "Medic" : "Marine").startsWith("Me")) print("starts so"); + if ((n > 10 ? "Medic" : "Marine").endsWith("ine")) print("ends so"); +}); diff --git a/ledger/texts/text-switch.ts b/ledger/texts/text-switch.ts new file mode 100644 index 0000000..229edc1 --- /dev/null +++ b/ledger/texts/text-switch.ts @@ -0,0 +1,16 @@ +// ledger: step 8, takes +// A `switch` over a text, its cases texts: `switch (unit) { case "Marine": … }`. +program(() => { + let n = 0; + while (n < 12) n += 5; + switch (n > 10 ? "Medic" : "Marine") { + case "Marine": + print("50 ore"); + break; + case "Medic": + print("75 ore"); + break; + default: + print("?"); + } +}); diff --git a/ledger/texts/text-trim.ts b/ledger/texts/text-trim.ts new file mode 100644 index 0000000..7027b3d --- /dev/null +++ b/ledger/texts/text-trim.ts @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// A text without the spaces at its ends: `trim()`, `trimStart()`, `trimEnd()`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print((n > 10 ? " go " : " stop ").trim()); + print((n > 10 ? " go" : " stop").trimStart()); + print((n > 10 ? "go " : "stop ").trimEnd()); +}); diff --git a/ledger/texts/text-upper-and-lower-case.ts b/ledger/texts/text-upper-and-lower-case.ts new file mode 100644 index 0000000..45ed4ea --- /dev/null +++ b/ledger/texts/text-upper-and-lower-case.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// A text in capitals or in small letters, A to Z only: `toUpperCase()`, `toLowerCase()`. +program(() => { + let n = 0; + while (n < 12) n += 5; + print((n > 10 ? "Medic" : "Marine").toUpperCase()); + print((n > 10 ? "Medic" : "Marine").toLowerCase()); +}); diff --git a/ledger/texts/text-variable-from-another.ts b/ledger/texts/text-variable-from-another.ts new file mode 100644 index 0000000..8d1e4c6 --- /dev/null +++ b/ledger/texts/text-variable-from-another.ts @@ -0,0 +1,11 @@ +// ledger: step 8, takes +// A text variable given another's text, both only ever holding texts the script wrote: `let b = a`, then compared. +program(() => { + let n = 0; + while (n < 12) n += 5; + let a = "north"; + if (n > 10) a = "south"; + let b = "east"; + if (n > 3) b = a; + if (a === b) print(`${b} both`); +}); diff --git a/ledger/texts/text-variable.ts b/ledger/texts/text-variable.ts new file mode 100644 index 0000000..9c0614c --- /dev/null +++ b/ledger/texts/text-variable.ts @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// A text kept in a variable and changed while the game runs: `let unit = "Marine"`, then `unit = "Medic"`. +program(() => { + let unit = "Marine"; + let n = 0; + while (n < 12) n += 5; + if (n > 10) unit = "Medic"; + print(unit); +}); diff --git a/ledger/texts/texts-compared.ts b/ledger/texts/texts-compared.ts new file mode 100644 index 0000000..259f1dc --- /dev/null +++ b/ledger/texts/texts-compared.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// Two texts compared while the game runs: `===`, `!==`, and `<` in the order of their characters. +program(() => { + let n = 0; + while (n < 12) n += 5; + if ((n > 10 ? "Medic" : "Marine") < "Mf") print("before"); + else print("after"); +}); diff --git a/ledger/texts/ui-clock.ts b/ledger/texts/ui-clock.ts new file mode 100644 index 0000000..fe0c6b3 --- /dev/null +++ b/ledger/texts/ui-clock.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// Frames of game time as minutes and seconds, from `trigscript/ui`: `clock(frames)` ("1:05"). +import { clock } from "trigscript/ui"; +program(() => { + let frames = 0; + while (frames < 1500) frames += 24; + print(`Time ${clock(frames)}`); +}); diff --git a/ledger/texts/ui-dialog.tsx b/ledger/texts/ui-dialog.tsx new file mode 100644 index 0000000..8cc9abc --- /dev/null +++ b/ledger/texts/ui-dialog.tsx @@ -0,0 +1,9 @@ +// ledger: step 12, takes +// Someone speaking to a player, the words typed in, closed with Space, from `trigscript/ui`: +// `await say(P1, talk, "Raynor", "…")` and ``. +import { DialogBox, say } from "trigscript/ui"; +program(async () => { + const talk = { speaker: "", text: "", open: false }; + ui.lines(P1, () => ); + await say(P1, talk, "Raynor", "Hold the bridge until dawn."); +}); diff --git a/ledger/texts/ui-lines.tsx b/ledger/texts/ui-lines.tsx new file mode 100644 index 0000000..c149644 --- /dev/null +++ b/ledger/texts/ui-lines.tsx @@ -0,0 +1,10 @@ +// ledger: step 11, takes +// A text kept in a player's text lines and shown again as the program's variables change: `ui.lines(P1, () => …)`. +program(async () => { + let score = 0; + ui.lines(P1, () => Score {score}); + while (score < 30) { + await sleep(frames(1)); + score += 10; + } +}); diff --git a/ledger/texts/ui-log.tsx b/ledger/texts/ui-log.tsx new file mode 100644 index 0000000..e03698d --- /dev/null +++ b/ledger/texts/ui-log.tsx @@ -0,0 +1,13 @@ +// ledger: step 9, takes +// A log that keeps its last lines, shown a line each, from `trigscript/ui`: `log(lines, text)` and +// ``. +import { LogLines, log } from "trigscript/ui"; +program(async () => { + const lines: string[] = []; + let wave = 0; + while (wave < 8) { + wave += 1; + log(lines, wave > 4 ? "a wave" : "a quiet wave", 3); + } + print(); +}); diff --git a/ledger/texts/ui-menu.tsx b/ledger/texts/ui-menu.tsx new file mode 100644 index 0000000..9b8f6f1 --- /dev/null +++ b/ledger/texts/ui-menu.tsx @@ -0,0 +1,11 @@ +// ledger: step 12, takes +// A menu a player chooses from with W, S and Space, shown in the text lines, from `trigscript/ui`: +// `await choose(P1, ITEMS.length, shop)` and ``. +import { MenuList, choose } from "trigscript/ui"; +const ITEMS = ["Marine (50)", "Medic (75)", "Leave"]; +program(async () => { + const shop = { selected: 0, open: false }; + ui.lines(P1, () => <>{shop.open && }); + const picked = await choose(P1, ITEMS.length, shop); + print(`Picked ${picked}`); +}); diff --git a/ledger/texts/ui-meter.tsx b/ledger/texts/ui-meter.tsx new file mode 100644 index 0000000..4916635 --- /dev/null +++ b/ledger/texts/ui-meter.tsx @@ -0,0 +1,9 @@ +// ledger: step 8, takes +// A labelled bar, green, yellow or red by how full it is, from `trigscript/ui`: +// ``. +import { Meter } from "trigscript/ui"; +program(async () => { + let hp = 0; + while (hp < 900) hp += 400; + print(); +}); diff --git a/ledger/texts/ui-panel.tsx b/ledger/texts/ui-panel.tsx new file mode 100644 index 0000000..4e4610a --- /dev/null +++ b/ledger/texts/ui-panel.tsx @@ -0,0 +1,12 @@ +// ledger: step 8, takes +// A title over the lines inside it, from `trigscript/ui`: `…`. +import { Panel } from "trigscript/ui"; +program(async () => { + let wave = 0; + while (wave < 3) wave += 1; + print( + + Hold the bridge + , + ); +}); diff --git a/ledger/texts/ui-thousands.ts b/ledger/texts/ui-thousands.ts new file mode 100644 index 0000000..40dfeb7 --- /dev/null +++ b/ledger/texts/ui-thousands.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// A whole number with its thousands marked, from `trigscript/ui`: `thousands(n)` ("12,345"). +import { thousands } from "trigscript/ui"; +program(() => { + let n = 0; + while (n < 12000) n += 4115; + print(`Ore ${thousands(n)}`); +}); diff --git a/ledger/texts/ui-typewriter.ts b/ledger/texts/ui-typewriter.ts new file mode 100644 index 0000000..45255ed --- /dev/null +++ b/ledger/texts/ui-typewriter.ts @@ -0,0 +1,8 @@ +// ledger: step 8, takes +// A text put into a record a character at a time, from `trigscript/ui`: `await typewriter(box, "…")`. +import { typewriter } from "trigscript/ui"; +program(async () => { + const box = { text: "" }; + await typewriter(box, "Incoming!"); + print(box.text); +}); diff --git a/ledger/triggers/comment.ts b/ledger/triggers/comment.ts new file mode 100644 index 0000000..50b5fab --- /dev/null +++ b/ledger/triggers/comment.ts @@ -0,0 +1,6 @@ +// ledger: step 13, takes +// The action Comment: a trigger's name in StarEdit's list, which does nothing in the game. +trigger(P1, [always()], [comment("Starting minerals"), setResources(P1, "set", 50, "ore")]); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/disable-debug-mode.ts b/ledger/triggers/disable-debug-mode.ts new file mode 100644 index 0000000..42b621e --- /dev/null +++ b/ledger/triggers/disable-debug-mode.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Disable Debug Mode, inside a program. +program(() => { + disableDebugMode(); +}); diff --git a/ledger/triggers/disabled-action.ts b/ledger/triggers/disabled-action.ts new file mode 100644 index 0000000..fa562b8 --- /dev/null +++ b/ledger/triggers/disabled-action.ts @@ -0,0 +1,6 @@ +// ledger: step 13, takes +// An action kept in a trigger but switched off, as StarEdit can: `disabled(…)`. +trigger(P1, [always()], [displayText("Shown."), disabled(defeat())]); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/disabled-condition.ts b/ledger/triggers/disabled-condition.ts new file mode 100644 index 0000000..eb415cd --- /dev/null +++ b/ledger/triggers/disabled-condition.ts @@ -0,0 +1,6 @@ +// ledger: step 13, takes +// A condition kept in a trigger but switched off, as StarEdit can: `disabled(…)`. +trigger(P1, [always(), disabled(never())], [displayText("Fires anyway.")]); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/enable-debug-mode.ts b/ledger/triggers/enable-debug-mode.ts new file mode 100644 index 0000000..720c7c6 --- /dev/null +++ b/ledger/triggers/enable-debug-mode.ts @@ -0,0 +1,5 @@ +// ledger: step 5, takes +// The action Enable Debug Mode, inside a program. +program(() => { + enableDebugMode(); +}); diff --git a/ledger/triggers/hyper-triggers.ts b/ledger/triggers/hyper-triggers.ts new file mode 100644 index 0000000..6754535 --- /dev/null +++ b/ledger/triggers/hyper-triggers.ts @@ -0,0 +1,7 @@ +// ledger: step 13, takes +// `hyperTriggers()`: preserved triggers of Wait(0)s that make the game go through the trigger list every +// 2 frames instead of every 31. +hyperTriggers(P8); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/not-in-a-trigger.ts b/ledger/triggers/not-in-a-trigger.ts new file mode 100644 index 0000000..d024f37 --- /dev/null +++ b/ledger/triggers/not-in-a-trigger.ts @@ -0,0 +1,7 @@ +// ledger: step 13, takes +// A condition turned around by `not(…)` in a trigger's conditions: a comparison flips, a switch test flips, +// always becomes never. +trigger(P1, [not(bring(P1, units.AnyUnit, locations.Anywhere, ">=", 1)), not(switchIs(switches.Switch1, "set"))], [defeat()]); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/preserve-trigger.ts b/ledger/triggers/preserve-trigger.ts new file mode 100644 index 0000000..3669f13 --- /dev/null +++ b/ledger/triggers/preserve-trigger.ts @@ -0,0 +1,6 @@ +// ledger: step 13, takes +// The action Preserve Trigger in a trigger's actions: `preserveTrigger()`. +trigger(P1, [always()], [setResources(P1, "add", 1, "ore"), preserveTrigger()]); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/preserve.ts b/ledger/triggers/preserve.ts new file mode 100644 index 0000000..541c1da --- /dev/null +++ b/ledger/triggers/preserve.ts @@ -0,0 +1,6 @@ +// ledger: step 13, takes +// The action Preserve Trigger in a trigger's actions, by its short name: `preserve()`. +trigger(P1, [always()], [setResources(P1, "add", 1, "ore"), preserve()]); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/trigger-disabled-option.ts b/ledger/triggers/trigger-disabled-option.ts new file mode 100644 index 0000000..b4f3007 --- /dev/null +++ b/ledger/triggers/trigger-disabled-option.ts @@ -0,0 +1,6 @@ +// ledger: step 13, takes +// A trigger kept in the map but switched off: `{ disabled: true }`. +trigger(P1, [always()], [victory()], { disabled: true }); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/trigger-execution-flags.ts b/ledger/triggers/trigger-execution-flags.ts new file mode 100644 index 0000000..98aadcc --- /dev/null +++ b/ledger/triggers/trigger-execution-flags.ts @@ -0,0 +1,7 @@ +// ledger: step 13, takes +// A trigger's other execution flags: ignore the game's end, ignore Display Text, conditions met, +// paused, Wait skipping off, and raw flags ORed in. +trigger(P1, [always()], [displayText("Still here.")], { ignoreGameEnd: true, ignoreDisplay: true, conditionsMet: false, paused: false, waitSkipDisabled: true, flags: 0 }); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/trigger-for-several-players.ts b/ledger/triggers/trigger-for-several-players.ts new file mode 100644 index 0000000..0addcb1 --- /dev/null +++ b/ledger/triggers/trigger-for-several-players.ts @@ -0,0 +1,6 @@ +// ledger: step 13, takes +// A trigger for several players at once: `trigger([P1, P2], …)`. +trigger([P1, P2], [elapsedTime(">=", 10)], [displayText("Ten seconds in.")]); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/trigger-nested-lists.ts b/ledger/triggers/trigger-nested-lists.ts new file mode 100644 index 0000000..09002d5 --- /dev/null +++ b/ledger/triggers/trigger-nested-lists.ts @@ -0,0 +1,8 @@ +// ledger: step 13, takes +// A trigger's conditions and actions as nested lists, `null` entries left out: what a helper puts together. +const hard: boolean = false; +const reward = [setResources(P1, "add", 100, "ore"), hard ? setResources(P1, "subtract", 50, "gas") : null]; +trigger(P1, [always(), [switchIs(switches.Switch1, "set")]], [reward, displayText("Bonus.")]); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/trigger-preserved-option.ts b/ledger/triggers/trigger-preserved-option.ts new file mode 100644 index 0000000..6764444 --- /dev/null +++ b/ledger/triggers/trigger-preserved-option.ts @@ -0,0 +1,6 @@ +// ledger: step 13, takes +// A trigger that runs again every time its conditions hold: `{ preserve: true }`. +trigger(P1, [always()], [setResources(P1, "add", 1, "ore")], { preserve: true }); +program(() => { + print("started"); +}); diff --git a/ledger/triggers/trigger.ts b/ledger/triggers/trigger.ts new file mode 100644 index 0000000..6341ee9 --- /dev/null +++ b/ledger/triggers/trigger.ts @@ -0,0 +1,6 @@ +// ledger: step 13, takes +// A trigger of the map's own, beside a program: `trigger(players, conditions, actions)`. +trigger(P1, [bring(P1, units.TerranMarine, locations.Anywhere, ">=", 1)], [displayText("A Marine is here."), victory()]); +program(() => { + print("started"); +}); diff --git a/ledger/units/all-units-for-of.ts b/ledger/units/all-units-for-of.ts new file mode 100644 index 0000000..fdb924b --- /dev/null +++ b/ledger/units/all-units-for-of.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over every unit on the map: `for (const u of allUnits())`. +program(() => { + let n = 0; + for (const u of allUnits()) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/first-of-a-set.ts b/ledger/units/first-of-a-set.ts new file mode 100644 index 0000000..98885ad --- /dev/null +++ b/ledger/units/first-of-a-set.ts @@ -0,0 +1,9 @@ +// ledger: step 10, takes +// `first(unitsAt(…))`: a set of units as a pick's filter; a pick's method and field through `?.`. +program(() => { + const u = first(unitsAt(locations.Anywhere, { owner: P1, type: units.TerranMarine })); + let hp = 0; + hp = u?.hp ?? 0; + first({ owner: P2 })?.order("move", locations.Anywhere); + print(`${hp}`); +}); diff --git a/ledger/units/on-created-at-the-top.ts b/ledger/units/on-created-at-the-top.ts new file mode 100644 index 0000000..21053f1 --- /dev/null +++ b/ledger/units/on-created-at-the-top.ts @@ -0,0 +1,5 @@ +// ledger: step 11, takes +// A listener written at the top of a script, not in a program: it listens from the start of the game. +on.created(units.ZergZergling, (u) => { + u.energy = 50; +}); diff --git a/ledger/units/on-created-filter.ts b/ledger/units/on-created-filter.ts new file mode 100644 index 0000000..f61a98f --- /dev/null +++ b/ledger/units/on-created-filter.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// The units that come onto the map that a filter matches: `on.created({ owner: P2 }, (u) => …)`. +program(async () => { + on.created({ owner: P2 }, (u) => { + u.invincible = true; + }); +}); diff --git a/ledger/units/on-created-type.ts b/ledger/units/on-created-type.ts new file mode 100644 index 0000000..cba962a --- /dev/null +++ b/ledger/units/on-created-type.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// The units of one type that come onto the map: `on.created(units.ZergZergling, (u) => …)`. +program(async () => { + on.created(units.ZergZergling, (u) => { + u.hp = 10; + }); +}); diff --git a/ledger/units/on-created.ts b/ledger/units/on-created.ts new file mode 100644 index 0000000..49c4aad --- /dev/null +++ b/ledger/units/on-created.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// Every unit that comes onto the map, handed to a function in the next frame: `on.created((u) => …)`. +program(async () => { + on.created((u) => { + u.energy = 50; + }); +}); diff --git a/ledger/units/on-died-filter.ts b/ledger/units/on-died-filter.ts new file mode 100644 index 0000000..acadfcc --- /dev/null +++ b/ledger/units/on-died-filter.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// The units that die that a filter matches, as they died: `on.died({ owner: P8 }, (u) => …)`. +program(async () => { + on.died({ owner: P8 }, (u) => { + if (u.type == units.ZergZergling) print("a zergling"); + }); +}); diff --git a/ledger/units/on-died.ts b/ledger/units/on-died.ts new file mode 100644 index 0000000..4932c0f --- /dev/null +++ b/ledger/units/on-died.ts @@ -0,0 +1,9 @@ +// ledger: step 11, takes +// Every unit that dies or is removed, handed to a function as it was: `on.died((u) => …)`. +program(async () => { + let n = 0; + on.died((u) => { + n += 1; + print(`${n}`); + }); +}); diff --git a/ledger/units/on-event-async.ts b/ledger/units/on-event-async.ts new file mode 100644 index 0000000..312640d --- /dev/null +++ b/ledger/units/on-event-async.ts @@ -0,0 +1,8 @@ +// ledger: step 11, takes +// An event's function that waits, started as a task each time: `on.died(…, async (u) => { await sleep(…); … })`. +program(async () => { + on.died({ owner: P8 }, async (u) => { + await sleep(seconds(2)); + print("back"); + }, { max: 4 }); +}); diff --git a/ledger/units/on-event-stopped.ts b/ledger/units/on-event-stopped.ts new file mode 100644 index 0000000..8dd8b37 --- /dev/null +++ b/ledger/units/on-event-stopped.ts @@ -0,0 +1,9 @@ +// ledger: step 11, takes +// An event's listening stopped by the task it gives back: `const t = on.created(…); t.cancel()`. +program(async () => { + const t = on.created((u) => { + u.kill(); + }); + await sleep(seconds(10)); + t.cancel(); +}); diff --git a/ledger/units/on-killed-by.ts b/ledger/units/on-killed-by.ts new file mode 100644 index 0000000..e1a1952 --- /dev/null +++ b/ledger/units/on-killed-by.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// The units one player kills of another's: `on.killed({ owner: P8, by: P1 }, (u, by) => …)`. +program(async () => { + on.killed({ owner: P8, by: P1 }, (u, by) => { + setResources(by, "add", 25, "ore"); + }); +}); diff --git a/ledger/units/on-killed.ts b/ledger/units/on-killed.ts new file mode 100644 index 0000000..a211ce4 --- /dev/null +++ b/ledger/units/on-killed.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// Every unit killed in a fight, with the player whose attack killed it: `on.killed((u, by) => …)`. +program(async () => { + on.killed((u, by) => { + if (by == P1) print("P1 got one"); + }); +}); diff --git a/ledger/units/on-morphed-type.ts b/ledger/units/on-morphed-type.ts new file mode 100644 index 0000000..831a21f --- /dev/null +++ b/ledger/units/on-morphed-type.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// The units that turn into one type: `on.morphed(units.TerranSiegeTankSiegeMode, (u) => …)`. +program(async () => { + on.morphed(units.TerranSiegeTankSiegeMode, (u) => { + print("sieged"); + }); +}); diff --git a/ledger/units/on-morphed.ts b/ledger/units/on-morphed.ts new file mode 100644 index 0000000..d6b05b7 --- /dev/null +++ b/ledger/units/on-morphed.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// A unit that turns into another type and stays the same unit, with the type it was: `on.morphed((u, was) => …)`. +program(async () => { + on.morphed((u, was) => { + if (was == units.ZergLarva) print("hatched"); + }); +}); diff --git a/ledger/units/on-order-of-one.ts b/ledger/units/on-order-of-one.ts new file mode 100644 index 0000000..52e0ed0 --- /dev/null +++ b/ledger/units/on-order-of-one.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// The units of a type that take up one order: `on.order(units.TerranMarine, orders.Move, (u) => …)`. +program(async () => { + on.order(units.TerranMarine, orders.Move, (u) => { + u.stim = 50; + }); +}); diff --git a/ledger/units/on-order.ts b/ledger/units/on-order.ts new file mode 100644 index 0000000..55539e6 --- /dev/null +++ b/ledger/units/on-order.ts @@ -0,0 +1,7 @@ +// ledger: step 11, takes +// Every unit that takes up a new order, with the order and the one it had: `on.order((u, order, was) => …)`. +program(async () => { + on.order((u, order, was) => { + if (order == orders.Move) print("moving"); + }); +}); diff --git a/ledger/units/pick-first-filter.ts b/ledger/units/pick-first-filter.ts new file mode 100644 index 0000000..c1b465d --- /dev/null +++ b/ledger/units/pick-first-filter.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// The first unit a filter matches: `first({ owner: P2, type: units.ZergZergling })`. +program(() => { + first({ owner: P2, type: units.ZergZergling })?.kill(); +}); diff --git a/ledger/units/pick-first.ts b/ledger/units/pick-first.ts new file mode 100644 index 0000000..86e815e --- /dev/null +++ b/ledger/units/pick-first.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// The first unit on the map, whatever it is: `first()`. +program(() => { + first()?.kill(); +}); diff --git a/ledger/units/pick-nearest-filter.ts b/ledger/units/pick-nearest-filter.ts new file mode 100644 index 0000000..07703bd --- /dev/null +++ b/ledger/units/pick-nearest-filter.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// The nearest unit of any type that one player owns: `nearest(units.AnyUnit, there, { owner: P2 })`. +program(() => { + nearest(units.AnyUnit, locations.Anywhere, { owner: P2 })?.kill(); +}); diff --git a/ledger/units/pick-nearest.ts b/ledger/units/pick-nearest.ts new file mode 100644 index 0000000..a45590c --- /dev/null +++ b/ledger/units/pick-nearest.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// The unit of a type nearest the centre of a location: `nearest(units.TerranMarine, there)`. +program(() => { + nearest(units.TerranMarine, locations.Anywhere)?.kill(); +}); diff --git a/ledger/units/pick-random-unit-filter.ts b/ledger/units/pick-random-unit-filter.ts new file mode 100644 index 0000000..93f3fa9 --- /dev/null +++ b/ledger/units/pick-random-unit-filter.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit picked at random among those a filter matches: `randomUnit({ owner: P2 })`. +program(() => { + randomUnit({ owner: P2 })?.kill(); +}); diff --git a/ledger/units/pick-random-unit.ts b/ledger/units/pick-random-unit.ts new file mode 100644 index 0000000..d17d83f --- /dev/null +++ b/ledger/units/pick-random-unit.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit picked at random by the game: `randomUnit()`. +program(() => { + randomUnit()?.kill(); +}); diff --git a/ledger/units/select-one.ts b/ledger/units/select-one.ts new file mode 100644 index 0000000..56ba7d9 --- /dev/null +++ b/ledger/units/select-one.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// What a player has selected made one unit, as if they had clicked it: `select(P1, u)`. +program(() => { + select(P1, first({ owner: P1, type: units.TerranMarine })!); +}); diff --git a/ledger/units/select-several.ts b/ledger/units/select-several.ts new file mode 100644 index 0000000..218cd05 --- /dev/null +++ b/ledger/units/select-several.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// What a player has selected made several units at once: `select(P1, [a, b])`. +program(() => { + select(P1, [first({ owner: P1, type: units.TerranMarine })!, first({ owner: P1, type: units.TerranSCV })!]); +}); diff --git a/ledger/units/unit-array.ts b/ledger/units/unit-array.ts new file mode 100644 index 0000000..0ceb9e3 --- /dev/null +++ b/ledger/units/unit-array.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// Units kept in a fixed-length array: `const pair: (Unit | null)[] = [a, b]`. +program(() => { + const pair: (Unit | null)[] = [first({ owner: P1 }), first({ owner: P2 })]; + for (const u of pair) if (u) u.heal(5); +}); diff --git a/ledger/units/unit-assigned.ts b/ledger/units/unit-assigned.ts new file mode 100644 index 0000000..b36c63d --- /dev/null +++ b/ledger/units/unit-assigned.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A unit variable given another unit: `u = first(…)`. +program(() => { + let u = first({ owner: P1 }); + if (!u) u = first({ owner: P2 }); + if (u) u.kill(); +}); diff --git a/ledger/units/unit-build-time-read.ts b/ledger/units/unit-build-time-read.ts new file mode 100644 index 0000000..17124e5 --- /dev/null +++ b/ledger/units/unit-build-time-read.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// Frames left of a unit's or a building's making: `u.buildTime`, 0 once it is complete. +program(() => { + const cc = first({ type: units.TerranCommandCenter }); + if (cc && cc.buildTime > 0) print(`${cc.buildTime} to go`); +}); diff --git a/ledger/units/unit-build-time-written.ts b/ledger/units/unit-build-time-written.ts new file mode 100644 index 0000000..a19ac28 --- /dev/null +++ b/ledger/units/unit-build-time-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's remaining build time, written: `u.buildTime = 24` finishes it in a second. +program(() => { + for (const u of unitsOf(P1, { type: units.Buildings })) if (u.buildTime > 24) u.buildTime = 24; +}); diff --git a/ledger/units/unit-burrowed-read.ts b/ledger/units/unit-burrowed-read.ts new file mode 100644 index 0000000..3810050 --- /dev/null +++ b/ledger/units/unit-burrowed-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether a unit is burrowed: `u.burrowed`, here straight off a pick (`first(…)`). +program(() => { + if (first({ owner: P1 })?.burrowed) print("burrowed"); +}); diff --git a/ledger/units/unit-cloaked-read.ts b/ledger/units/unit-cloaked-read.ts new file mode 100644 index 0000000..4cf0c2e --- /dev/null +++ b/ledger/units/unit-cloaked-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether a unit is cloaked: `u.cloaked`, here straight off a pick (`first(…)`). +program(() => { + if (first({ owner: P1 })?.cloaked) print("cloaked"); +}); diff --git a/ledger/units/unit-cooldown-read.ts b/ledger/units/unit-cooldown-read.ts new file mode 100644 index 0000000..68683ab --- /dev/null +++ b/ledger/units/unit-cooldown-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Frames until a unit can attack again, read: `u.cooldown`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.cooldown}`); +}); diff --git a/ledger/units/unit-cooldown-written.ts b/ledger/units/unit-cooldown-written.ts new file mode 100644 index 0000000..fc4f53b --- /dev/null +++ b/ledger/units/unit-cooldown-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's weapon cooldown, written, holding its fire that many frames: `u.cooldown = 48`. +program(() => { + first({ owner: P1 })!.cooldown = 48; +}); diff --git a/ledger/units/unit-damage-by-a-value.ts b/ledger/units/unit-damage-by-a-value.ts new file mode 100644 index 0000000..02db570 --- /dev/null +++ b/ledger/units/unit-damage-by-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A unit damaged by an amount the program worked out: `u.damage(n)`. +program(() => { + let n = 3; + while (n < 20) n += 5; + first({ owner: P1 })?.damage(n); +}); diff --git a/ledger/units/unit-damage-percent.ts b/ledger/units/unit-damage-percent.ts new file mode 100644 index 0000000..3b7365a --- /dev/null +++ b/ledger/units/unit-damage-percent.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's hit points taken down by a share of its type's most: `u.damage({ percent: 50 })`. +program(() => { + first({ owner: P1 })?.damage({ percent: 50 }); +}); diff --git a/ledger/units/unit-damage.ts b/ledger/units/unit-damage.ts new file mode 100644 index 0000000..d955a23 --- /dev/null +++ b/ledger/units/unit-damage.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's hit points taken down by so many: `u.damage(20)`. +program(() => { + first({ owner: P1 })?.damage(20); +}); diff --git a/ledger/units/unit-energy-read.ts b/ledger/units/unit-energy-read.ts new file mode 100644 index 0000000..a442184 --- /dev/null +++ b/ledger/units/unit-energy-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's energy, read: `u.energy`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.energy}`); +}); diff --git a/ledger/units/unit-energy-written.ts b/ledger/units/unit-energy-written.ts new file mode 100644 index 0000000..32bdc54 --- /dev/null +++ b/ledger/units/unit-energy-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's energy, written: `u.energy = 200`. +program(() => { + first({ owner: P1 })!.energy = 200; +}); diff --git a/ledger/units/unit-ensnare-read.ts b/ledger/units/unit-ensnare-read.ts new file mode 100644 index 0000000..f4d9750 --- /dev/null +++ b/ledger/units/unit-ensnare-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Frames of Ensnare left on a unit, read: `u.ensnare`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.ensnare}`); +}); diff --git a/ledger/units/unit-ensnare-written.ts b/ledger/units/unit-ensnare-written.ts new file mode 100644 index 0000000..d3d928d --- /dev/null +++ b/ledger/units/unit-ensnare-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Ensnare put on a unit by writing its frames: `u.ensnare = 100`. +program(() => { + first({ owner: P1 })!.ensnare = 100; +}); diff --git a/ledger/units/unit-give.ts b/ledger/units/unit-give.ts new file mode 100644 index 0000000..292a249 --- /dev/null +++ b/ledger/units/unit-give.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit handed to another player: `u.give(P2)`. +program(() => { + first({ owner: P1 })?.give(P2); +}); diff --git a/ledger/units/unit-hallucinated-read.ts b/ledger/units/unit-hallucinated-read.ts new file mode 100644 index 0000000..b2dff91 --- /dev/null +++ b/ledger/units/unit-hallucinated-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether a unit is a hallucination: `u.hallucinated`, here straight off a pick (`first(…)`). +program(() => { + if (first({ owner: P1 })?.hallucinated) print("not real"); +}); diff --git a/ledger/units/unit-hallucinated-written.ts b/ledger/units/unit-hallucinated-written.ts new file mode 100644 index 0000000..45cfecc --- /dev/null +++ b/ledger/units/unit-hallucinated-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit made a hallucination by a write: `u.hallucinated = true` (double damage, gone after a while). +program(() => { + first({ owner: P2 })!.hallucinated = true; +}); diff --git a/ledger/units/unit-has-target-read.ts b/ledger/units/unit-has-target-read.ts new file mode 100644 index 0000000..1f7ba34 --- /dev/null +++ b/ledger/units/unit-has-target-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether a unit's order is aimed at another unit: `u.hasTarget`. +program(() => { + if (first({ owner: P1 })?.hasTarget) print("busy with something"); +}); diff --git a/ledger/units/unit-heal-percent.ts b/ledger/units/unit-heal-percent.ts new file mode 100644 index 0000000..7399ea2 --- /dev/null +++ b/ledger/units/unit-heal-percent.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit healed by a share of its type's most: `u.heal({ percent: 25 })`. +program(() => { + first({ owner: P1 })?.heal({ percent: 25 }); +}); diff --git a/ledger/units/unit-heal.ts b/ledger/units/unit-heal.ts new file mode 100644 index 0000000..1890e3b --- /dev/null +++ b/ledger/units/unit-heal.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's hit points given back, up to its type's most: `u.heal(20)`. +program(() => { + first({ owner: P1 })?.heal(20); +}); diff --git a/ledger/units/unit-hp-added-to.ts b/ledger/units/unit-hp-added-to.ts new file mode 100644 index 0000000..7fba9e7 --- /dev/null +++ b/ledger/units/unit-hp-added-to.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's hit points read and written in one: `u.hp += 10`. +program(() => { + first({ owner: P1 })!.hp += 10; +}); diff --git a/ledger/units/unit-hp-from-a-value.ts b/ledger/units/unit-hp-from-a-value.ts new file mode 100644 index 0000000..42f3aea --- /dev/null +++ b/ledger/units/unit-hp-from-a-value.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A unit's hit points written from a value of the program: `u.hp = n`. +program(() => { + let n = 5; + while (n < 40) n += 7; + first({ owner: P1 })!.hp = n; +}); diff --git a/ledger/units/unit-hp-read.ts b/ledger/units/unit-hp-read.ts new file mode 100644 index 0000000..e184d13 --- /dev/null +++ b/ledger/units/unit-hp-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's hit points, read: `u.hp`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.hp}`); +}); diff --git a/ledger/units/unit-hp-written.ts b/ledger/units/unit-hp-written.ts new file mode 100644 index 0000000..4640d1a --- /dev/null +++ b/ledger/units/unit-hp-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's hit points, written: `u.hp = 10`. +program(() => { + first({ owner: P1 })!.hp = 10; +}); diff --git a/ledger/units/unit-in-a-text.ts b/ledger/units/unit-in-a-text.ts new file mode 100644 index 0000000..109d9ca --- /dev/null +++ b/ledger/units/unit-in-a-text.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// A unit's numbers in a printed text: `${u.hp} of ${u.maxHp}`. +program(() => { + const u = first({ owner: P1 }); + if (u) print(`${u.hp} of ${u.maxHp}`); +}); diff --git a/ledger/units/unit-in-transport-read.ts b/ledger/units/unit-in-transport-read.ts new file mode 100644 index 0000000..0033bff --- /dev/null +++ b/ledger/units/unit-in-transport-read.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// Whether a unit rides in a transport or a bunker: `u.inTransport`. +program(() => { + let riding = 0; + for (const u of unitsOf(P1)) if (u.inTransport) riding += 1; + print(`${riding} aboard`); +}); diff --git a/ledger/units/unit-invincible-read.ts b/ledger/units/unit-invincible-read.ts new file mode 100644 index 0000000..38b13b7 --- /dev/null +++ b/ledger/units/unit-invincible-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether a unit cannot be hurt, read: `u.invincible`, here straight off a pick (`first(…)`). +program(() => { + if (first({ owner: P1 })?.invincible) print("invincible"); +}); diff --git a/ledger/units/unit-invincible-written.ts b/ledger/units/unit-invincible-written.ts new file mode 100644 index 0000000..1df97b2 --- /dev/null +++ b/ledger/units/unit-invincible-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit made invincible: `u.invincible = true`. +program(() => { + first({ owner: P1 })!.invincible = true; +}); diff --git a/ledger/units/unit-irradiate-read.ts b/ledger/units/unit-irradiate-read.ts new file mode 100644 index 0000000..f5c0bd4 --- /dev/null +++ b/ledger/units/unit-irradiate-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Frames of Irradiate left on a unit, read: `u.irradiate`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.irradiate}`); +}); diff --git a/ledger/units/unit-irradiate-written.ts b/ledger/units/unit-irradiate-written.ts new file mode 100644 index 0000000..7f03342 --- /dev/null +++ b/ledger/units/unit-irradiate-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Irradiate put on a unit by writing its frames: `u.irradiate = 100`. +program(() => { + first({ owner: P1 })!.irradiate = 100; +}); diff --git a/ledger/units/unit-kept-across-a-sleep.ts b/ledger/units/unit-kept-across-a-sleep.ts new file mode 100644 index 0000000..0bd9f73 --- /dev/null +++ b/ledger/units/unit-kept-across-a-sleep.ts @@ -0,0 +1,8 @@ +// ledger: step 10, takes +// A unit kept across a sleep, checked again before it is used: gone, it reads as none. +program(() => { + const u = first({ owner: P1 }); + sleep(frames(24)); + if (u) u.heal(10); + else print("gone"); +}); diff --git a/ledger/units/unit-kept-in-a-variable.ts b/ledger/units/unit-kept-in-a-variable.ts new file mode 100644 index 0000000..bb33491 --- /dev/null +++ b/ledger/units/unit-kept-in-a-variable.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// A unit kept in a variable, and told something there: `const u = first(…); if (u) u.kill()`. +program(() => { + const u = first({ owner: P1 }); + if (u) u.kill(); +}); diff --git a/ledger/units/unit-kill.ts b/ledger/units/unit-kill.ts new file mode 100644 index 0000000..cf17672 --- /dev/null +++ b/ledger/units/unit-kill.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit killed: `u.kill()`. +program(() => { + first({ owner: P1 })?.kill(); +}); diff --git a/ledger/units/unit-kills-read.ts b/ledger/units/unit-kills-read.ts new file mode 100644 index 0000000..b6e8989 --- /dev/null +++ b/ledger/units/unit-kills-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// How many units a unit has killed, read: `u.kills`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.kills}`); +}); diff --git a/ledger/units/unit-kills-written.ts b/ledger/units/unit-kills-written.ts new file mode 100644 index 0000000..590ce0a --- /dev/null +++ b/ledger/units/unit-kills-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's kill count, written: `u.kills = 5`. +program(() => { + first({ owner: P1 })!.kills = 5; +}); diff --git a/ledger/units/unit-locate.ts b/ledger/units/unit-locate.ts new file mode 100644 index 0000000..ea19f20 --- /dev/null +++ b/ledger/units/unit-locate.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// A location centred on a unit, so that an action can happen where the unit is: `u.locate(there)`. +const there = reserveLocation("where the unit is"); +program(() => { + first({ owner: P1 })?.locate(there); +}); diff --git a/ledger/units/unit-lockdown-read.ts b/ledger/units/unit-lockdown-read.ts new file mode 100644 index 0000000..d32be20 --- /dev/null +++ b/ledger/units/unit-lockdown-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Frames of Lockdown left on a unit, read: `u.lockdown`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.lockdown}`); +}); diff --git a/ledger/units/unit-lockdown-written.ts b/ledger/units/unit-lockdown-written.ts new file mode 100644 index 0000000..830093a --- /dev/null +++ b/ledger/units/unit-lockdown-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Lockdown put on a unit by writing its frames: `u.lockdown = 100`. +program(() => { + first({ owner: P1 })!.lockdown = 100; +}); diff --git a/ledger/units/unit-maelstrom-read.ts b/ledger/units/unit-maelstrom-read.ts new file mode 100644 index 0000000..3dfcf96 --- /dev/null +++ b/ledger/units/unit-maelstrom-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Frames of Maelstrom left on a unit, read: `u.maelstrom`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.maelstrom}`); +}); diff --git a/ledger/units/unit-maelstrom-written.ts b/ledger/units/unit-maelstrom-written.ts new file mode 100644 index 0000000..bcdce7c --- /dev/null +++ b/ledger/units/unit-maelstrom-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Maelstrom put on a unit by writing its frames: `u.maelstrom = 100`. +program(() => { + first({ owner: P1 })!.maelstrom = 100; +}); diff --git a/ledger/units/unit-matrix-read.ts b/ledger/units/unit-matrix-read.ts new file mode 100644 index 0000000..9f34a13 --- /dev/null +++ b/ledger/units/unit-matrix-read.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// Frames left of a unit's defensive matrix: `u.matrix`, 0 without one. +program(() => { + const u = first({ owner: P1 }); + if (u && u.matrix > 0) print("shielded"); +}); diff --git a/ledger/units/unit-matrix-written.ts b/ledger/units/unit-matrix-written.ts new file mode 100644 index 0000000..3104236 --- /dev/null +++ b/ledger/units/unit-matrix-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A defensive matrix given by a write: `u.matrix = 240` is 250 points of matrix for ten seconds; 0 takes it away. +program(() => { + first({ owner: P1 })!.matrix = 240; +}); diff --git a/ledger/units/unit-max-hp-read.ts b/ledger/units/unit-max-hp-read.ts new file mode 100644 index 0000000..a300c25 --- /dev/null +++ b/ledger/units/unit-max-hp-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's type's most hit points, read: `u.maxHp`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.maxHp}`); +}); diff --git a/ledger/units/unit-max-shields-read.ts b/ledger/units/unit-max-shields-read.ts new file mode 100644 index 0000000..01d57bc --- /dev/null +++ b/ledger/units/unit-max-shields-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's type's most shield points, read: `u.maxShields`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.maxShields}`); +}); diff --git a/ledger/units/unit-none.ts b/ledger/units/unit-none.ts new file mode 100644 index 0000000..7124d9d --- /dev/null +++ b/ledger/units/unit-none.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A pick compared with null: `first(…) == null` when it found none. +program(() => { + if (first({ owner: P2 }) == null) print("none"); +}); diff --git a/ledger/units/unit-null.ts b/ledger/units/unit-null.ts new file mode 100644 index 0000000..7b7f7ca --- /dev/null +++ b/ledger/units/unit-null.ts @@ -0,0 +1,8 @@ +// ledger: step 10, takes +// A variable that holds a unit or none, none to begin with: `let u: Unit | null = null`. +program(() => { + let u: Unit | null = null; + if (u) print("there"); + u = first({ owner: P1 }); + if (u) print("found"); +}); diff --git a/ledger/units/unit-order-attack.ts b/ledger/units/unit-order-attack.ts new file mode 100644 index 0000000..3ea94d7 --- /dev/null +++ b/ledger/units/unit-order-attack.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// A unit sent to attack-move, that unit alone: `u.order("attack", there)`. +program(() => { + const u = first({ owner: P1 }); + if (u) u.order("attack", locations.Anywhere); +}); diff --git a/ledger/units/unit-order-id-read.ts b/ledger/units/unit-order-id-read.ts new file mode 100644 index 0000000..a5c438a --- /dev/null +++ b/ledger/units/unit-order-id-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// What a unit is doing, compared with an order: `u.orderId == orders.Move`, here straight off a pick (`first(…)`). +program(() => { + if (first({ owner: P1 })?.orderId == orders.Move) print("moving"); +}); diff --git a/ledger/units/unit-order-move.ts b/ledger/units/unit-order-move.ts new file mode 100644 index 0000000..2d1319f --- /dev/null +++ b/ledger/units/unit-order-move.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// A unit sent somewhere by a Move order, that unit alone: `u.order("move", there)`. +program(() => { + const u = first({ owner: P1 }); + if (u) u.order("move", locations.Anywhere); +}); diff --git a/ledger/units/unit-order-patrol.ts b/ledger/units/unit-order-patrol.ts new file mode 100644 index 0000000..06f8c6f --- /dev/null +++ b/ledger/units/unit-order-patrol.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// A unit sent to patrol, that unit alone: `u.order("patrol", there)`. +program(() => { + const u = first({ owner: P1 }); + if (u) u.order("patrol", locations.Anywhere); +}); diff --git a/ledger/units/unit-owner-read.ts b/ledger/units/unit-owner-read.ts new file mode 100644 index 0000000..a3ae934 --- /dev/null +++ b/ledger/units/unit-owner-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Who owns a unit, read and compared with a player: `u.owner == P2`, here straight off a pick (`first(…)`). +program(() => { + if (first({ owner: P1 })?.owner == P2) print("given"); +}); diff --git a/ledger/units/unit-parameter.ts b/ledger/units/unit-parameter.ts new file mode 100644 index 0000000..121eebe --- /dev/null +++ b/ledger/units/unit-parameter.ts @@ -0,0 +1,9 @@ +// ledger: step 10, takes +// A function that takes a unit: `function hurt(u: Unit, n: number)`. +function hurt(u: Unit, n: number) { + u.damage(n); +} +program(() => { + const u = first({ owner: P1 }); + if (u) hurt(u, 10); +}); diff --git a/ledger/units/unit-plague-read.ts b/ledger/units/unit-plague-read.ts new file mode 100644 index 0000000..22d77b2 --- /dev/null +++ b/ledger/units/unit-plague-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Frames of Plague left on a unit, read: `u.plague`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.plague}`); +}); diff --git a/ledger/units/unit-plague-written.ts b/ledger/units/unit-plague-written.ts new file mode 100644 index 0000000..650259c --- /dev/null +++ b/ledger/units/unit-plague-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Plague put on a unit by writing its frames: `u.plague = 100`. +program(() => { + first({ owner: P1 })!.plague = 100; +}); diff --git a/ledger/units/unit-rank-read.ts b/ledger/units/unit-rank-read.ts new file mode 100644 index 0000000..8734cf9 --- /dev/null +++ b/ledger/units/unit-rank-read.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// A unit's rank, the number added to its type's: `u.rank`. +program(() => { + const u = first({ owner: P1 }); + if (u) print(`rank ${u.rank}`); +}); diff --git a/ledger/units/unit-rank-written.ts b/ledger/units/unit-rank-written.ts new file mode 100644 index 0000000..5dbfa8b --- /dev/null +++ b/ledger/units/unit-rank-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's rank, written: `u.rank = 5` (its panel shows the type's rank five up). +program(() => { + first({ owner: P1 })!.rank = 5; +}); diff --git a/ledger/units/unit-remove.ts b/ledger/units/unit-remove.ts new file mode 100644 index 0000000..c06243a --- /dev/null +++ b/ledger/units/unit-remove.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit taken off the map without a death: `u.remove()`. +program(() => { + first({ owner: P1 })?.remove(); +}); diff --git a/ledger/units/unit-resources-read.ts b/ledger/units/unit-resources-read.ts new file mode 100644 index 0000000..d54f01c --- /dev/null +++ b/ledger/units/unit-resources-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// What a mineral field or a geyser still holds, read: `u.resources`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.resources}`); +}); diff --git a/ledger/units/unit-resources-written.ts b/ledger/units/unit-resources-written.ts new file mode 100644 index 0000000..27855e2 --- /dev/null +++ b/ledger/units/unit-resources-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// What a mineral field holds, written: `u.resources = 5000`. +program(() => { + first({ type: units.MineralFieldType1 })!.resources = 5000; +}); diff --git a/ledger/units/unit-result.ts b/ledger/units/unit-result.ts new file mode 100644 index 0000000..193a388 --- /dev/null +++ b/ledger/units/unit-result.ts @@ -0,0 +1,8 @@ +// ledger: step 10, takes +// A function that gives back a unit or none: `function pick(): Unit | null`. +function pick(): Unit | null { + return first({ owner: P2, type: units.ZergZergling }); +} +program(() => { + pick()?.kill(); +}); diff --git a/ledger/units/unit-same.ts b/ledger/units/unit-same.ts new file mode 100644 index 0000000..2021a58 --- /dev/null +++ b/ledger/units/unit-same.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Two units compared: `a == b` when both name the same unit of the game. +program(() => { + if (first({ owner: P1 }) == nearest(units.AnyUnit, locations.Anywhere, { owner: P1 })) print("the same"); +}); diff --git a/ledger/units/unit-set-every.ts b/ledger/units/unit-set-every.ts new file mode 100644 index 0000000..e5efa78 --- /dev/null +++ b/ledger/units/unit-set-every.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether every unit of a set fits: `unitsOf(P1).every((u) => u.hp == u.maxHp)`. +program(() => { + if (unitsOf(P1).every((u) => u.hp == u.maxHp)) print("all well"); +}); diff --git a/ledger/units/unit-set-filter.ts b/ledger/units/unit-set-filter.ts new file mode 100644 index 0000000..3a3ddeb --- /dev/null +++ b/ledger/units/unit-set-filter.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// The units of a set that fit, kept in an array: `unitsOf(P1).filter((u) => u.hp < 20)`. +program(() => { + const weak = unitsOf(P1).filter((u) => u.hp < 20); + for (const u of weak) if (u) u.heal(10); +}); diff --git a/ledger/units/unit-set-find.ts b/ledger/units/unit-set-find.ts new file mode 100644 index 0000000..35d1e0f --- /dev/null +++ b/ledger/units/unit-set-find.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// The first unit of a set that fits, or none: `unitsOf(P1).find((u) => u.energy >= 75)`. +program(() => { + const caster = unitsOf(P1).find((u) => u.energy >= 75); + if (caster) caster.energy = 0; +}); diff --git a/ledger/units/unit-set-for-each.ts b/ledger/units/unit-set-for-each.ts new file mode 100644 index 0000000..70bf3c2 --- /dev/null +++ b/ledger/units/unit-set-for-each.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Every unit of a set handed to a function: `unitsOf(P1).forEach((u) => u.heal(10))`. +program(() => { + unitsOf(P1).forEach((u) => u.heal(10)); +}); diff --git a/ledger/units/unit-set-map.ts b/ledger/units/unit-set-map.ts new file mode 100644 index 0000000..34f6a7a --- /dev/null +++ b/ledger/units/unit-set-map.ts @@ -0,0 +1,8 @@ +// ledger: step 10, takes +// A number from each unit of a set, in an array: `unitsOf(P1).map((u) => u.hp)`. +program(() => { + const hps = unitsOf(P1).map((u) => u.hp); + let total = 0; + for (const h of hps) total += h; + print(`${total}`); +}); diff --git a/ledger/units/unit-set-reduce.ts b/ledger/units/unit-set-reduce.ts new file mode 100644 index 0000000..0141e9d --- /dev/null +++ b/ledger/units/unit-set-reduce.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// A set of units folded into one number: `unitsOf(P1).reduce((sum, u) => sum + u.hp, 0)`. +program(() => { + const total = unitsOf(P1).reduce((sum, u) => sum + u.hp, 0); + print(`${total}`); +}); diff --git a/ledger/units/unit-set-some.ts b/ledger/units/unit-set-some.ts new file mode 100644 index 0000000..81dbe80 --- /dev/null +++ b/ledger/units/unit-set-some.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether any unit of a set fits: `unitsOf(P1).some((u) => u.hp < 20)`. +program(() => { + if (unitsOf(P1).some((u) => u.hp < 20)) print("one is hurt"); +}); diff --git a/ledger/units/unit-shields-read.ts b/ledger/units/unit-shields-read.ts new file mode 100644 index 0000000..6170f73 --- /dev/null +++ b/ledger/units/unit-shields-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's shield points, read: `u.shields`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.shields}`); +}); diff --git a/ledger/units/unit-shields-written.ts b/ledger/units/unit-shields-written.ts new file mode 100644 index 0000000..842cc7b --- /dev/null +++ b/ledger/units/unit-shields-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's shield points, written: `u.shields = 0`. +program(() => { + first({ owner: P1 })!.shields = 0; +}); diff --git a/ledger/units/unit-speed-read.ts b/ledger/units/unit-speed-read.ts new file mode 100644 index 0000000..fef0f95 --- /dev/null +++ b/ledger/units/unit-speed-read.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// How fast a unit is moving now, in 256ths of a pixel a frame: `u.speed`, 0 standing still. +program(() => { + const u = first({ owner: P1 }); + if (u && u.speed > 0) print(`moving at ${u.speed / 256}`); +}); diff --git a/ledger/units/unit-speed-upgrade-read.ts b/ledger/units/unit-speed-upgrade-read.ts new file mode 100644 index 0000000..519fa4d --- /dev/null +++ b/ledger/units/unit-speed-upgrade-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether a unit moves at its upgraded speed: `u.speedUpgrade`. +program(() => { + if (first({ owner: P1 })?.speedUpgrade) print("boosted"); +}); diff --git a/ledger/units/unit-speed-upgrade-written.ts b/ledger/units/unit-speed-upgrade-written.ts new file mode 100644 index 0000000..f29d7b8 --- /dev/null +++ b/ledger/units/unit-speed-upgrade-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit's speed upgrade, switched on by a write: `u.speedUpgrade = true`, without the upgrade. +program(() => { + for (const u of unitsOf(P1, { type: units.ZergZergling })) u.speedUpgrade = true; +}); diff --git a/ledger/units/unit-stasis-read.ts b/ledger/units/unit-stasis-read.ts new file mode 100644 index 0000000..a61ff34 --- /dev/null +++ b/ledger/units/unit-stasis-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Frames of Stasis Field left on a unit, read: `u.stasis`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.stasis}`); +}); diff --git a/ledger/units/unit-stasis-written.ts b/ledger/units/unit-stasis-written.ts new file mode 100644 index 0000000..fe5ad33 --- /dev/null +++ b/ledger/units/unit-stasis-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A unit held in Stasis Field by writing its frames: `u.stasis = 100`. +program(() => { + first({ owner: P1 })!.stasis = 100; +}); diff --git a/ledger/units/unit-stim-read.ts b/ledger/units/unit-stim-read.ts new file mode 100644 index 0000000..958f8cd --- /dev/null +++ b/ledger/units/unit-stim-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Frames of stim left on a unit, read: `u.stim`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.stim}`); +}); diff --git a/ledger/units/unit-stim-written.ts b/ledger/units/unit-stim-written.ts new file mode 100644 index 0000000..4173566 --- /dev/null +++ b/ledger/units/unit-stim-written.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Stim started on a unit by writing its frames: `u.stim = 100`. +program(() => { + first({ owner: P1 })!.stim = 100; +}); diff --git a/ledger/units/unit-there.ts b/ledger/units/unit-there.ts new file mode 100644 index 0000000..1563eae --- /dev/null +++ b/ledger/units/unit-there.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether a pick found a unit that is still on the map: `if (first(…))`. +program(() => { + if (first({ owner: P1, type: units.TerranMarine })) print("a marine"); +}); diff --git a/ledger/units/unit-type-read.ts b/ledger/units/unit-type-read.ts new file mode 100644 index 0000000..cf4e8cd --- /dev/null +++ b/ledger/units/unit-type-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// What a unit is, compared with a type: `u.type == units.TerranMarine`, here straight off a pick (`first(…)`). +program(() => { + if (first({ owner: P1 })?.type == units.TerranMarine) print("a marine"); +}); diff --git a/ledger/units/unit-under-attack-read.ts b/ledger/units/unit-under-attack-read.ts new file mode 100644 index 0000000..fe94332 --- /dev/null +++ b/ledger/units/unit-under-attack-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Whether something hit a unit in the last second or so: `u.underAttack`, here straight off a pick (`first(…)`). +program(() => { + if (first({ owner: P1 })?.underAttack) print("hit"); +}); diff --git a/ledger/units/unit-verb-if-there.ts b/ledger/units/unit-verb-if-there.ts new file mode 100644 index 0000000..244c505 --- /dev/null +++ b/ledger/units/unit-verb-if-there.ts @@ -0,0 +1,6 @@ +// ledger: step 10, takes +// A verb on a unit kept in a variable that may be none: `u?.kill()`. +program(() => { + const u = first({ owner: P1 }); + u?.kill(); +}); diff --git a/ledger/units/unit-x-read.ts b/ledger/units/unit-x-read.ts new file mode 100644 index 0000000..91b8793 --- /dev/null +++ b/ledger/units/unit-x-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Where a unit is across the map, in pixels: `u.x`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.x}`); +}); diff --git a/ledger/units/unit-y-read.ts b/ledger/units/unit-y-read.ts new file mode 100644 index 0000000..aec2d88 --- /dev/null +++ b/ledger/units/unit-y-read.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// Where a unit is down the map, in pixels: `u.y`, here straight off a pick (`first(…)`). +program(() => { + print(`${first({ owner: P1 })!.y}`); +}); diff --git a/ledger/units/units-at-for-of.ts b/ledger/units/units-at-for-of.ts new file mode 100644 index 0000000..0399201 --- /dev/null +++ b/ledger/units/units-at-for-of.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over the units at a location: `for (const u of unitsAt(…))`. +program(() => { + let n = 0; + for (const u of unitsAt(locations.Anywhere)) if (u.hp > 0) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/units-filter-all-three.ts b/ledger/units/units-filter-all-three.ts new file mode 100644 index 0000000..4eafe46 --- /dev/null +++ b/ledger/units/units-filter-all-three.ts @@ -0,0 +1,8 @@ +// ledger: step 10, takes +// A loop over a player's units of one type inside a location: `unitsAt(there, { owner: P1, type: units.TerranMarine })`. +const there = reserveLocation("where to count"); +program(() => { + let n = 0; + for (const u of unitsAt(there, { owner: P1, type: units.TerranMarine })) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/units-filter-at.ts b/ledger/units/units-filter-at.ts new file mode 100644 index 0000000..a4daa13 --- /dev/null +++ b/ledger/units/units-filter-at.ts @@ -0,0 +1,8 @@ +// ledger: step 10, takes +// A loop over the units inside a location by the filter's `at`: `allUnits({ at: there })`. +const there = reserveLocation("where to count"); +program(() => { + let n = 0; + for (const u of allUnits({ at: there })) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/units-filter-buildings.ts b/ledger/units/units-filter-buildings.ts new file mode 100644 index 0000000..d920dc7 --- /dev/null +++ b/ledger/units/units-filter-buildings.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over a player's buildings: `unitsOf(P1, { type: units.Buildings })`. +program(() => { + let n = 0; + for (const u of unitsOf(P1, { type: units.Buildings })) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/units-filter-factories.ts b/ledger/units/units-filter-factories.ts new file mode 100644 index 0000000..61f23f7 --- /dev/null +++ b/ledger/units/units-filter-factories.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over a player's factories: `unitsOf(P1, { type: units.Factories })`. +program(() => { + let n = 0; + for (const u of unitsOf(P1, { type: units.Factories })) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/units-filter-men.ts b/ledger/units/units-filter-men.ts new file mode 100644 index 0000000..1387ee0 --- /dev/null +++ b/ledger/units/units-filter-men.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over a group of types, the game's own: `unitsOf(P1, { type: units.Men })`. +program(() => { + let n = 0; + for (const u of unitsOf(P1, { type: units.Men })) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/units-filter-owner.ts b/ledger/units/units-filter-owner.ts new file mode 100644 index 0000000..95028b2 --- /dev/null +++ b/ledger/units/units-filter-owner.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over one player's units by the filter's owner: `allUnits({ owner: P2 })`. +program(() => { + let n = 0; + for (const u of allUnits({ owner: P2 })) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/units-filter-type.ts b/ledger/units/units-filter-type.ts new file mode 100644 index 0000000..a1dc8ab --- /dev/null +++ b/ledger/units/units-filter-type.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over the units of one type: `allUnits({ type: units.TerranMarine })`. +program(() => { + let n = 0; + for (const u of allUnits({ type: units.TerranMarine })) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/units-loop-break.ts b/ledger/units/units-loop-break.ts new file mode 100644 index 0000000..9a34d9a --- /dev/null +++ b/ledger/units/units-loop-break.ts @@ -0,0 +1,12 @@ +// ledger: step 10, takes +// A loop over units left early: `break` at the first unit that is hurt. +program(() => { + let found = false; + for (const u of unitsOf(P1)) { + if (u.hp < u.maxHp) { + found = true; + break; + } + } + if (found) print("hurt"); +}); diff --git a/ledger/units/units-loop-continue.ts b/ledger/units/units-loop-continue.ts new file mode 100644 index 0000000..4d14dcb --- /dev/null +++ b/ledger/units/units-loop-continue.ts @@ -0,0 +1,10 @@ +// ledger: step 10, takes +// A loop over units that passes some by: `continue` for a burrowed unit. +program(() => { + let n = 0; + for (const u of allUnits()) { + if (u.burrowed) continue; + n += 1; + } + print(`${n}`); +}); diff --git a/ledger/units/units-loop-writes.ts b/ledger/units/units-loop-writes.ts new file mode 100644 index 0000000..1523386 --- /dev/null +++ b/ledger/units/units-loop-writes.ts @@ -0,0 +1,5 @@ +// ledger: step 10, takes +// A loop over units that writes to each: `for (const u of unitsOf(P2)) u.hp = u.maxHp / 2`. +program(() => { + for (const u of unitsOf(P2)) u.hp = u.maxHp / 2; +}); diff --git a/ledger/units/units-of-current-player.ts b/ledger/units/units-of-current-player.ts new file mode 100644 index 0000000..054bcfd --- /dev/null +++ b/ledger/units/units-of-current-player.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over the units of the player the program runs as: `unitsOf(CurrentPlayer)`. +program(() => { + let n = 0; + for (const u of unitsOf(CurrentPlayer)) n += 1; + print(`${n}`); +}, { owner: P2 }); diff --git a/ledger/units/units-of-for-of.ts b/ledger/units/units-of-for-of.ts new file mode 100644 index 0000000..7da17aa --- /dev/null +++ b/ledger/units/units-of-for-of.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over a player's units: `for (const u of unitsOf(P1))`. +program(() => { + let n = 0; + for (const u of unitsOf(P1)) n += 1; + print(`${n}`); +}); diff --git a/ledger/units/units-of-neutral.ts b/ledger/units/units-of-neutral.ts new file mode 100644 index 0000000..68a8da1 --- /dev/null +++ b/ledger/units/units-of-neutral.ts @@ -0,0 +1,7 @@ +// ledger: step 10, takes +// A loop over the neutral player's units, the critters and the mineral fields: `unitsOf(P12)`. +program(() => { + let n = 0; + for (const u of unitsOf(P12)) n += 1; + print(`${n}`); +}); diff --git a/monaco.ts b/monaco.ts index fd126c4..bfca1c0 100644 --- a/monaco.ts +++ b/monaco.ts @@ -17,8 +17,11 @@ */ import type * as Monaco from "monaco-editor"; import { DECLARATIONS_FILE } from "./compiler/declarations"; +import { MODULE_NAME } from "./compiler/api"; +import { STDLIB, STDLIB_EXT } from "./compiler/stdlib"; import type { LineHint, ScriptDiagnostic, ScriptFiles, SourceRange, VariableInfo } from "./compiler/compiler"; import { describeVariable, normalizePath } from "./compiler/compiler"; +import { isMediaFile } from "./compiler/media"; import { findReferences } from "./refs"; import { english, type Translate } from "./i18n"; @@ -153,9 +156,16 @@ export function loadMonaco(base: string = DEFAULT_DIST): Promise { return loading; } -/** Point the language service at a fresh declaration file (the map's names changed). */ +/** + * Point the language service at a fresh declaration file (the map's names changed), and at the standard library's + * modules, where `import { fog } from "trigscript/fog"` finds them by the bundler's rules — so a hover and go to + * definition show the library's own TrigScript. + */ export function setDeclarations(monaco: MonacoApi, content: string) { - tsLanguage(monaco).typescriptDefaults.setExtraLibs([{ content, filePath: `file:///${DECLARATIONS_FILE}` }]); + tsLanguage(monaco).typescriptDefaults.setExtraLibs([ + { content, filePath: `file:///${DECLARATIONS_FILE}` }, + ...Object.entries(STDLIB).map(([name, source]) => ({ content: source, filePath: `file:///node_modules/${MODULE_NAME}/${name}${STDLIB_EXT[name] ?? ".ts"}` })), + ]); } export const fileUri = (monaco: MonacoApi, path: string) => monaco.Uri.parse(`file:///${normalizePath(path)}`); @@ -324,8 +334,14 @@ export function setMapRefs(monaco: MonacoApi, refs: () => MapRefs | null) { }); } -/** A line worth a warning that is no fault of the script: a test that fails, a `test.only` left in. */ -export interface ScriptWarning { file: string; line: number; message: string } +/** A line worth a warning that is no fault of the script: a test that fails, a `test.only` left in, a program built through eudplib. */ +export interface ScriptWarning { + file: string; + line: number; + message: string; + /** Who says so: the tests (unset), or the build — what the Problems view writes in its column. */ + source?: "build"; +} /** The compiler's own diagnostics, drawn under the TypeScript ones, per file; `warnings` in the colour of a warning. */ export function setCompilerMarkers(monaco: MonacoApi, files: ScriptFiles, diagnostics: ScriptDiagnostic[], warnings: ScriptWarning[] = []) { @@ -390,6 +406,11 @@ export interface ScriptEditor { insert(text: string): void; /** The file and line the cursor is on. */ cursor(): { file: string; line: number }; + /** + * The debugger's marks (TrigScript 4, A5): breakpoints in the margin, the line about to run and the test's line + * lit; `onBreakpoint` (of `createScriptEditor`) hears a click in the margin where no test's mark is. + */ + setDebug(marks: { breakpoints: { file: string; line: number }[]; at: { file: string; line: number } | null; test: { file: string; line: number } | null }): void; dispose(): void; } @@ -411,7 +432,7 @@ export function releaseScriptEditor(monaco: MonacoApi): void { defaults.setCompilerOptions(defaults.getCompilerOptions()); } -export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: ScriptFiles, active: string, onChange: (path: string, text: string) => void, onMark?: (id: string) => void): ScriptEditor { +export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: ScriptFiles, active: string, onChange: (path: string, text: string) => void, onMark?: (id: string) => void, onBreakpoint?: (file: string, line: number) => void): ScriptEditor { disposeModels(monaco); const models = new Map(); const subs = new Map(); @@ -419,6 +440,8 @@ export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: const decorations = new Map(); /** Per file, the ids of the tests' decorations. */ const testDecorations = new Map(); + /** Per file, the ids of the debugger's. */ + const debugDecorations = new Map(); /** The marks as last set, to find the one a click in the margin is on. */ let testMarks: TestMark[] = []; /** Per file, the view state (cursor, scroll) to restore when it is shown again. */ @@ -429,7 +452,13 @@ export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: subs.set(path, model.onDidChangeContent(() => onChange(path, model.getValue()))); return model; }; - for (const [path, text] of Object.entries(files)) make(normalizePath(path), text); + // A video (`compiler/media.ts`) is a file of the script but no text to edit: kept beside the models, never one — + // Monaco's TypeScript would read every model as TypeScript. + const media = new Map(); + for (const [path, text] of Object.entries(files)) { + if (isMediaFile(path)) media.set(normalizePath(path), text); + else make(normalizePath(path), text); + } let current = normalizePath(active); if (!models.has(current)) current = [...models.keys()][0]; const editor = monaco.editor.create(host, { @@ -442,6 +471,8 @@ export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: minimap: { enabled: false }, // Where a test's mark goes; it stays narrow for a script without any. glyphMargin: true, + // (Room for the debugger's arrow beside the line numbers.) + lineDecorationsWidth: 16, scrollBeyondLastLine: false, renderLineHighlight: "line", tabSize: 2, @@ -458,6 +489,7 @@ export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: const line = e.target.position?.lineNumber; const mark = testMarks.find((m) => normalizePath(m.file) === current && m.line === line); if (mark) onMark?.(mark.id); + else if (line) onBreakpoint?.(current, line); }); const show = (path: string) => { const p = normalizePath(path); @@ -473,16 +505,18 @@ export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: editor, active: () => current, show, - files: () => Object.fromEntries([...models].map(([p, m]) => [p, m.getValue()])), + files: () => ({ ...Object.fromEntries(media), ...Object.fromEntries([...models].map(([p, m]) => [p, m.getValue()])) }), add(path, text) { const p = normalizePath(path); - if (models.has(p)) return; + if (models.has(p) || media.has(p)) return; + if (isMediaFile(p)) { media.set(p, text); onChange(p, text); return; } make(p, text); onChange(p, text); show(p); }, remove(path) { const p = normalizePath(path); + if (media.delete(p)) return; const model = models.get(p); if (!model) return; if (p === current) { const other = [...models.keys()].find((k) => k !== p); if (other) show(other); } @@ -492,11 +526,14 @@ export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: views.delete(p); decorations.delete(p); testDecorations.delete(p); + debugDecorations.delete(p); model.dispose(); }, rename(from, to) { const a = normalizePath(from); const b = normalizePath(to); + const moving = media.get(a); + if (moving !== undefined) { if (!media.has(b) && !models.has(b)) { media.delete(a); media.set(b, moving); } return; } const model = models.get(a); if (!model || models.has(b)) return; const text = model.getValue(); @@ -508,6 +545,7 @@ export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: views.delete(a); decorations.delete(a); testDecorations.delete(a); + debugDecorations.delete(a); if (wasCurrent) editor.setModel(null); model.dispose(); const next = make(b, text); @@ -515,6 +553,7 @@ export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: if (wasCurrent) { current = b; editor.setModel(next); if (view) editor.restoreViewState(view); } }, set(path, text) { + if (media.has(normalizePath(path))) { media.set(normalizePath(path), text); return; } const model = models.get(normalizePath(path)); if (model && model.getValue() !== text) model.setValue(text); }, @@ -553,6 +592,19 @@ export function createScriptEditor(monaco: MonacoApi, host: HTMLElement, files: decorations.set(p, model.deltaDecorations(decorations.get(p) ?? [], next)); } }, + setDebug(d) { + const here = (p: { file: string; line: number } | null, path: string, lines: number) => p && normalizePath(p.file) === path && p.line >= 1 && p.line <= lines; + for (const [p, model] of models) { + const lines = model.getLineCount(); + const next: Monaco.editor.IModelDeltaDecoration[] = d.breakpoints.filter((b) => here(b, p, lines)).map((b) => ({ + range: new monaco.Range(b.line, 1, b.line, 1), + options: { glyphMarginClassName: "trigscript-bp", glyphMarginHoverMessage: { value: tr("Breakpoint: the debugger's Continue stops before this line runs (a test's line: at the end of the frame it asks for). Click to take it away.") }, stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges }, + })); + if (d.test && here(d.test, p, d.test.line) && d.test.line <= lines && !(d.at && normalizePath(d.at.file) === p && d.at.line === d.test.line)) next.push({ range: new monaco.Range(d.test.line, 1, d.test.line, 1), options: { isWholeLine: true, className: "trigscript-debug-test", linesDecorationsClassName: "trigscript-debug-test-arrow" } }); + if (d.at && here(d.at, p, lines)) next.push({ range: new monaco.Range(d.at.line, 1, d.at.line, 1), options: { isWholeLine: true, className: "trigscript-debug-line", linesDecorationsClassName: "trigscript-debug-arrow" } }); + debugDecorations.set(p, model.deltaDecorations(debugDecorations.get(p) ?? [], next)); + } + }, dispose() { for (const s of subs.values()) s.dispose(); editor.dispose(); diff --git a/notesImport.ts b/notesImport.ts new file mode 100644 index 0000000..7afab9f --- /dev/null +++ b/notesImport.ts @@ -0,0 +1,178 @@ +/** + * Import Video… for a MIDI file, and the notes viewer. A `.mid` the person picks is read here once and written as a + * `.notes` file of the script (`compiler/notes.ts`), which the build reads the same on every computer; `music.midi` + * makes a song of it. Clicking a `.notes` shows its notes on a roll, a colour a track, and plays them roughly — a + * sine a note — to hear that they are the right ones. + */ +import type { PluginApi } from "@scm-js/plugin-api"; +import type { Translate } from "./i18n"; +import { readMidi, readNotes, writeNotes, type NotesData } from "./compiler/notes"; + +export const MIDI_FILES = ".mid,.midi,audio/midi,audio/x-midi"; +export const isMidiSource = (f: File): boolean => /\.midi?$/i.test(f.name) || f.type === "audio/midi" || f.type === "audio/x-midi"; + +const COLORS = ["#9cc8ff", "#ffd75e", "#8ee08e", "#ff9c9c", "#d4a8ff", "#7fe0d8", "#ffb86b", "#c8c8c8"]; +const ROLL_STYLE = "width: 100%; max-width: 640px; height: 160px; display: block; margin: 0 auto 8px; background: #222;"; + +/** The notes on a roll: time across, pitch up, a colour a track (those `shown` says), a mark at `mark` beats. */ +function drawRoll(canvas: HTMLCanvasElement, data: NotesData, shown: (k: number) => boolean, mark = -1): void { + const w = (canvas.width = 640), h = (canvas.height = 160), ctx = canvas.getContext("2d")!; + ctx.fillStyle = "#222"; + ctx.fillRect(0, 0, w, h); + const all = data.tracks.flatMap((t, k) => (shown(k) ? t.notes : [])); + if (all.length === 0) return; + const end = Math.max(...all.map((n) => n.at + n.beats)), lo = Math.min(...all.map((n) => n.pitch)), hi = Math.max(...all.map((n) => n.pitch)); + const x = (beat: number) => (beat / end) * w, row = h / (hi - lo + 1); + data.tracks.forEach((t, k) => { + if (!shown(k)) return; + ctx.fillStyle = COLORS[k % COLORS.length]; + for (const n of t.notes) ctx.fillRect(x(n.at), h - (n.pitch - lo + 1) * row, Math.max(1, x(n.beats) - 0.5), Math.max(1, row - 0.5)); + }); + if (mark >= 0) { ctx.fillStyle = "#fff"; ctx.fillRect(Math.round(x(mark)), 0, 1, h); } +} + +/** A rough player: each note a sine with a short fade, the drums a click; the roll's mark follows. */ +function player(data: NotesData, shown: (k: number) => boolean, canvas: HTMLCanvasElement) { + let ctx: AudioContext | null = null, raf = 0, started = 0, playing = false; + const spb = 60 / data.bpm; + const stop = () => { playing = false; cancelAnimationFrame(raf); void ctx?.close(); ctx = null; drawRoll(canvas, data, shown); }; + const play = () => { + stop(); + ctx = new AudioContext(); + const out = ctx.createGain(); + out.gain.value = 0.15; + out.connect(ctx.destination); + started = ctx.currentTime + 0.1; + let last = 0; + data.tracks.forEach((t, k) => { + if (!shown(k)) return; + for (const n of t.notes) { + const at = started + n.at * spb, len = Math.max(0.03, Math.min(n.beats * spb, 4)), osc = ctx!.createOscillator(), g = ctx!.createGain(); + osc.type = t.channel === 10 ? "square" : "sine"; + osc.frequency.value = t.channel === 10 ? 80 + (n.pitch % 12) * 40 : 440 * 2 ** ((n.pitch - 69) / 12); + const peak = (n.velocity / 127) * (t.channel === 10 ? 0.4 : 0.5), tail = t.channel === 10 ? 0.04 : len; + g.gain.setValueAtTime(0, at); + g.gain.linearRampToValueAtTime(peak, at + 0.005); + g.gain.setValueAtTime(peak, at + Math.max(0.006, tail - 0.03)); + g.gain.linearRampToValueAtTime(0, at + tail); + osc.connect(g).connect(out); + osc.start(at); + osc.stop(at + tail + 0.01); + last = Math.max(last, n.at + n.beats); + } + }); + playing = true; + const mine = ctx; + const tick = () => { + if (!playing || ctx !== mine) return; + const beat = (mine.currentTime - started) / spb; + if (beat > last) { stop(); return; } + drawRoll(canvas, data, shown, Math.max(0, beat)); + raf = requestAnimationFrame(tick); + }; + tick(); + }; + return { play, stop }; +} + +/** How the notes are described: tracks, notes, tempo, length. */ +function about(t: Translate, data: NotesData): string { + const n = data.tracks.reduce((s, tr) => s + tr.notes.length, 0); + const end = Math.max(0, ...data.tracks.flatMap((tr) => tr.notes.map((x) => x.at + x.beats))); + const seconds = Math.round((end * 60) / data.bpm); + return t("{tracks} tracks, {notes} notes, {bpm} beats a minute ({time}).", { tracks: data.tracks.length, notes: n.toLocaleString(), bpm: Math.round(data.bpm * 100) / 100, time: `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}` }) + + (data.tempoChanges ? " " + t("The tempo changes later in the file; a song plays at the first.") : ""); +} + +/** The tracks listed: a colour, the name, the channel, how many notes. */ +function trackList(api: PluginApi, t: Translate, data: NotesData): HTMLElement { + const el = api.ui.el; + const styled = (e: E, style: string) => { e.setAttribute("style", style); return e; }; + return styled(el("ul", {}, + ...data.tracks.map((tr, k) => el("li", {}, + styled(el("span", {}), `display: inline-block; width: 10px; height: 10px; margin-right: 6px; background: ${COLORS[k % COLORS.length]};`), + tr.channel === 10 + ? t("{name} — the drums, {n} notes", { name: tr.name, n: tr.notes.length }) + : t("{name} — channel {channel}, instrument {program}, {n} notes", { name: tr.name, channel: tr.channel, program: tr.program, n: tr.notes.length })))), + "list-style: none; padding: 0; margin: 0 0 8px; font-size: 12px; max-height: 140px; overflow: auto;"); +} + +/** + * Import a MIDI file: its tracks, a listen, the name; the `.notes` file it becomes, or null when the person cancelled + * or it could not be read. `taken` says whether a path is in use. + */ +export async function importNotes(api: PluginApi, t: Translate, file: File, taken: (path: string) => boolean, stem: string): Promise<{ path: string; text: string } | null> { + const w = api.ui.widgets, el = api.ui.el; + let data: NotesData; + try { data = readMidi(new Uint8Array(await file.arrayBuffer()), file.name); } catch (err) { + await api.ui.alert(t("{name} could not be opened: {message}", { name: file.name, message: (err as Error).message })); + return null; + } + const canvas = el("canvas", {}) as HTMLCanvasElement; + canvas.setAttribute("style", ROLL_STYLE); + const status = w.hint(""); + const name = w.text({ value: `${stem}.notes` }); + const play = player(data, () => true, canvas); + let result: { path: string; text: string } | null = null; + await new Promise((closed) => { + api.ui.dialog({ + title: t("Import MIDI"), + size: "md", + mount(body) { + body.append( + canvas, + w.row(w.button(t("Play"), { onClick: () => play.play() }), w.button(t("Stop"), { onClick: () => play.stop() })), + trackList(api, t, data), + w.form([{ label: t("File"), field: name }]), + w.hint(about(t, data)), + w.hint(t("The notes are kept, not the sounds: music.midi(notes, { Piano: keys }) plays each track you name with an instrument of the music library.")), + status, + ); + drawRoll(canvas, data, () => true); + return () => { play.stop(); closed(); }; + }, + buttons: [ + { label: t("Cancel") }, + { + label: t("Import"), primary: true, + run: () => { + let path = name.value.trim().replace(/\\/g, "/").replace(/^\/+/, ""); + if (!path.toLowerCase().endsWith(".notes")) path += ".notes"; + if (!/^[A-Za-z0-9_\-./]+$/.test(path) || path.split("/").some((p) => p === "." || p === ".." || p === "")) { status.textContent = t("A file name is letters, digits, _ - and ., folders with /."); return false; } + if (taken(path)) { status.textContent = t("There is already a {name}.", { name: path }); return false; } + result = { path, text: writeNotes(data) }; + return true; + }, + }, + ], + }); + }); + return result; +} + +/** A `.notes` file of the script, shown: its roll, its tracks, played roughly, and how a script uses it. */ +export function showNotes(api: PluginApi, t: Translate, path: string, text: string): void { + let data: NotesData; + try { data = readNotes(text); } catch (err) { void api.ui.alert(`${path}: ${(err as Error).message}`); return; } + const w = api.ui.widgets, el = api.ui.el; + const canvas = el("canvas", {}) as HTMLCanvasElement; + canvas.setAttribute("style", ROLL_STYLE); + const play = player(data, () => true, canvas); + const stem = (path.split("/").pop() ?? path).replace(/\.notes$/i, ""); + const name = stem.replace(/[^A-Za-z0-9]+(.)?/g, (_m, c: string | undefined) => (c ? c.toUpperCase() : "")).replace(/^[^A-Za-z_]+/, "") || "notes"; + api.ui.dialog({ + title: path, + size: "md", + mount(body) { + body.append( + canvas, + w.row(w.button(t("Play"), { onClick: () => play.play() }), w.button(t("Stop"), { onClick: () => play.stop() })), + trackList(api, t, data), + w.hint(about(t, data) + (data.source ? ` · ${data.source}` : "")), + w.hint(t("Import it with import {name} from \"./{file}\", make a song of it with music.midi({name}, { \"{track}\": music.presets.keys() }).", { name, file: path.split("/").pop() ?? path, track: data.tracks[0]?.name ?? "Track 1" })), + ); + drawRoll(canvas, data, () => true); + return () => play.stop(); + }, + }); +} diff --git a/package-lock.json b/package-lock.json index 026d4b2..f3882fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,22 +1,23 @@ { "name": "scmjs-plugin-trigscript", - "version": "3.12.0", + "version": "4.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "scmjs-plugin-trigscript", - "version": "3.12.0", + "version": "4.0.0", "license": "MIT", "devDependencies": { - "@scm-js/plugin-api": "^1.28.0", + "@scm-js/plugin-api": "^1.61.0", "@types/node": "^24.13.3", "dts-bundle-generator": "^9.5.1", "esbuild": "^0.28.2", "monaco-editor": "^0.56.0", "mopaq": "^1.3.0", "typescript": "~6.0.3", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "wasm-media-encoders": "^0.7.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -741,9 +742,9 @@ "license": "MIT" }, "node_modules/@scm-js/plugin-api": { - "version": "1.28.0", - "resolved": "https://registry.npmjs.org/@scm-js/plugin-api/-/plugin-api-1.28.0.tgz", - "integrity": "sha512-bQ4rk6QLqv/wS/nvG8d2JntWl4R43sLsZ+yAMLg7NenfnJtszgzf1Pj9xyXtu0XRZnYGYtF5Shuqq92R3MgnQg==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/@scm-js/plugin-api/-/plugin-api-1.61.0.tgz", + "integrity": "sha512-a0FftMzMkHsD8EkGtiunT0ZyCYjbsY2Gqf4Bw8SFovWX1R0ixXKaUA4rsD7BiLXiLlqLknKEsINQ0hp2DLgTSw==", "dev": true, "license": "MIT" }, @@ -754,6 +755,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@swc/helpers": { + "version": "0.5.23", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz", + "integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1728,6 +1739,13 @@ "node": ">=14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1918,6 +1936,19 @@ } } }, + "node_modules/wasm-media-encoders": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/wasm-media-encoders/-/wasm-media-encoders-0.7.0.tgz", + "integrity": "sha512-Sp4wUasgxOK/IFfNhpon6LQQgYGwtpxyV4isjGIe1rvhnJL3w2KYr4f+CdqDNtGPDcgCDRY+uBanVfSx6Si0WQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@swc/helpers": "^0.5.10" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", diff --git a/package.json b/package.json index 573b321..492e039 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scmjs-plugin-trigscript", - "version": "3.12.0", + "version": "4.0.0", "private": true, "description": "TrigScript — TypeScript that builds into triggers; a plugin for the scmJS StarCraft map editor.", "author": "Jeany", @@ -14,21 +14,22 @@ "./package.json": "./package.json" }, "scripts": { - "build": "esbuild plugin.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/plugin.js --banner:js=\"// Generated by npm run build - do not edit; the source is plugin.ts.\" && esbuild compiler/entry.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/compiler.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/entry.ts. The compile worker imports this when the plugin is compiled into the editor.\" && esbuild compiler/testingEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --external:node:* --outfile=dist/testing.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/testingEntry.ts. The plugin never loads this: it is what another repository's tests import to run triggers.\" && dts-bundle-generator --silent --no-banner --project tsconfig.json -o dist/testing.d.ts compiler/testingEntry.ts", + "build": "esbuild plugin.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/plugin.js --banner:js=\"// Generated by npm run build - do not edit; the source is plugin.ts.\" && esbuild compiler/entry.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/compiler.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/entry.ts. The compile worker imports this when the plugin is compiled into the editor.\" && esbuild compiler/testingEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --external:node:* --outfile=dist/testing.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/testingEntry.ts. The plugin never loads this: it is what another repository's tests import to run triggers.\" && dts-bundle-generator --silent --no-banner --project tsconfig.json -o dist/testing.d.ts compiler/testingEntry.ts && esbuild compiler/gameTestEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --outfile=dist/gametest.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/gameTestEntry.ts. The plugin hands its text to a host of the lifted game, which runs a script's tests beside the game.\" && node -e \"const fs=require('fs');fs.writeFileSync('dist/allowlist.json',JSON.stringify(JSON.parse(fs.readFileSync('data/allowlist.json','utf8'))))\" && node scripts/copy-vorbis.mjs", "dev": "npm run build -- --watch", "typecheck": "tsc -p tsconfig.json", "test": "vitest run", "bundle": "node bundle/build.mjs", - "embed": "node scripts/embed-python.mts" + "embed": "node scripts/embed-stdlib.mts" }, "devDependencies": { - "@scm-js/plugin-api": "^1.28.0", + "@scm-js/plugin-api": "^1.61.0", "@types/node": "^24.13.3", "dts-bundle-generator": "^9.5.1", "esbuild": "^0.28.2", "monaco-editor": "^0.56.0", "mopaq": "^1.3.0", "typescript": "~6.0.3", - "vitest": "^4.1.11" + "vitest": "^4.1.11", + "wasm-media-encoders": "^0.7.0" } } diff --git a/plugin.json b/plugin.json index 579b83d..aaada59 100644 --- a/plugin.json +++ b/plugin.json @@ -1,15 +1,12 @@ { "name": "TrigScript", "id": "trigscript", - "version": "3.12.0", - "description": "TypeScript kept inside the map. Ordinary TypeScript — files, imports, helpers, loops — runs when the script is applied and records one trigger per trigger() call: ordinary triggers that play on every version. Code inside program(() => { … }) runs in the game — variables, loops, functions, sleep — and is built into the map by the eudplib plugin when you save; a map with a program needs StarCraft: Remastered. Checked as you type against the map's own names, with a built-in simulator.", + "version": "4.0.0", + "description": "TypeScript kept inside the map. Ordinary TypeScript — files, imports, helpers, loops — runs when the script is applied and records one trigger per trigger() call: ordinary triggers that play on every version. Code inside program(() => { … }) runs in the game — variables, loops, functions, sleep — and is built into the map by TrigScript itself when you save; a map with a program needs StarCraft: Remastered. Checked as you type against the map's own names, with a built-in simulator.", "author": "Jeany", "homepage": "https://github.com/scm-js/plugin-trigscript", "entry": "plugin.ts", "build": "dist/plugin.js", "icon": "icon.svg", - "api": 1, - "requires": [ - "github:scm-js/plugin-eudplib" - ] + "api": 1 } diff --git a/plugin.ts b/plugin.ts index b55dc27..cd1a60c 100644 --- a/plugin.ts +++ b/plugin.ts @@ -1,6 +1,6 @@ /** * TrigScript: TypeScript kept as files inside the map. Its `trigger()` calls become a block - * of the map's trigger list; its `program()`s are built into the saved map by the eudplib + * of the map's trigger list; its `program()`s are built into the saved map by TrigScript's own * plugin. This is the activation: the editor under Triggers, a claim on the generated block * so the editor's own trigger editors show it badged and locked, the part it takes in * saving (`ScriptService.attach`), and the commands other plugins call @@ -12,6 +12,7 @@ import type { TriggerRecord } from "./vendor/triggers"; import { openScriptEditor, relabelScriptEditor } from "./editor"; import { msg } from "./i18n"; import { KO } from "./ko"; +import { VERSION } from "./version"; import { ScriptService, type ScriptInput } from "./service"; export default function activate(api: PluginApi) { @@ -41,9 +42,11 @@ export default function activate(api: PluginApi) { api.commands.register({ id: "simulate", title: msg("TrigScript: simulate records"), run: (triggers, cycles, options) => svc.simulate(records(triggers), Math.max(1, Math.round(Number(cycles) || 30)), { player: isRecord(options) && typeof options.player === "number" ? options.player : undefined }) }); api.commands.register({ id: "triggerAt", title: msg("TrigScript: trigger at a source line"), run: (file, line) => svc.triggerAt(str(file) ?? "main.ts", Number(line) || 0) }); - // Save, Test Map and export apply the script first and build its programs; the library's list is not the host's to sweep. + // Save, Test Map and export apply the script first and build its programs — the other plugins' parts too + // (`trigscript.build`, the service Magenta contributes its rows through; `contract.d.ts`). const attached = svc.attach(); - return () => attached.dispose(); + const provided = api.services.provide("build", svc.buildService(VERSION), { version: 1 }); + return () => { provided.dispose(); attached.dispose(); }; } const isRecord = (v: unknown): v is Record => typeof v === "object" && v !== null; diff --git a/probes/answers/korobeiniki-ab.txt b/probes/answers/korobeiniki-ab.txt new file mode 100755 index 0000000..a29a62b --- /dev/null +++ b/probes/answers/korobeiniki-ab.txt @@ -0,0 +1,32 @@ +TrigScript sounds, step 1: Korobeiniki, A then B +================================================ + +Host it as a multiplayer game, alone, with the game's music OFF. Headphones help for B. +It plays A (32 s), a pause, B (32 s), a pause, and repeats. Start it once, quit when A comes round again. +A recording is all I need; a sentence or two of what you heard is the rest. + +A: how a mixed song went in before: 22 kHz, one channel, a WAV, turned down so it does not clip. +B: the same notes made at 44 kHz, the parts spread left and right, through the limiter, as Ogg Vorbis. + (Lead a little left, the low lead and the arpeggio right, the chords split both ways, drums and bass in the middle.) + +The two files are beside this sheet too (korobeiniki-A-before.wav, korobeiniki-B-after.ogg), to compare outside the game. + +1. Does B sound better than A? Brighter / clearer top, wider? ............ +2. Anything wrong in B: crackle, clipping, distortion, a dropout? ............ +3. Does B start right when its line of text appears, or late? (The recording tells me too.) ............ +4. Any hitch in the game when B starts (it is a 559 KB Ogg)? ............ +5. B is about 2 dB louder than A at the same peak: too loud next to the game's own sounds? ............ + +The instruments are still the plain ones (square, saw, triangle, noise drums); making them sound good is step 2. +This round is about the format, the stereo and the limiter. + +Measured from the recording (StarCraft 2026.10.19 - 13.59.06.34.mp4), 2026-10-19 +----------------------------------------------------------------------------- +- A started at 6.771 s, B at 41.715 s, A again at 76.659 s: 34.944 s apart both times, the 832 frames the script + waits to the millisecond. The Ogg starts with the same timing as the WAV, and B cost the game no time (no hitch). +- B as heard against the file the build wrote: the same within 31.9 dB over the whole 32 s (what the recording's + own AAC allows; A, a WAV, came back at 24.5), the worst 100 ms still 19.5 dB: no dropout anywhere. +- Left and right kept: the recording's L−R follows the file's at 0.996. +- No clipping: the loudest peaks came back at 0.97 of full scale. The whole is heard 1.08× the file (0.7 dB), the + peaks only 1.00–1.05×: something after the game rounds the very top off a little, gently. +- Q1 and Q5 (does it sound better; too loud?) are for your ears. diff --git a/probes/answers/korobeiniki-band-bars.txt b/probes/answers/korobeiniki-band-bars.txt new file mode 100755 index 0000000..d40ceca --- /dev/null +++ b/probes/answers/korobeiniki-band-bars.txt @@ -0,0 +1,30 @@ +TrigScript sounds, step 4: Korobeiniki, steered by the game +=========================================================== + +(Second build, 18:03: the bass now as loud in the game as it was mixed, and the Space crash turned down with the +music so it can never push it to clipping. The first build's recording went within 0.2% of full scale once.) + +Host it as a multiplayer game, alone, with the game's music OFF. Headphones help. Record it. + +This is step 3's B cut into 160 bars (1.68 s each) in four layers: drums, bass, harmony (pad, arpeggio, piano), +melody. The game plays one bar of each layer at every bar line, as the program says. Nothing is one long song any +more: each bar is its own sound, starting on its frame, and the bars overlap so nothing is cut off. + +1. FIRST, let it play once all the way through WITHOUT touching anything (about 70 s, until "intro" shows again). + I will compare that pass against the song bar by bar, to measure how evenly the game starts the bars. + +2. THEN play with it: + 1 2 3 4 5 6 7 jump to a section at the next bar line: intro, verse, build, drop, break, last verse, outro + D B H M drums, bass, harmony, melody: off / on from the next bar line + L loop: a section plays again when it ends (press again: on through the song) + Space a crash on the next bar line + Try: loop the verse (2, L), drop out the drums and bring them back, jump to the drop from anywhere (4), + hit Space a few times, jump to the break mid-phrase (5), and let the outro end it (7, L off). + +What to listen for: +1. The first pass: does it sound like step 3's B, or can you hear the bars — a stutter, a flam, a click, a gap? ............ +2. Jumps: does a jump land cleanly on the bar line? Does the old section's tail ring into the new one nicely? ............ +3. Layers on and off: clean? Does the mix still sound full with one off? ............ +4. How quick it feels: a jump waits for the next bar line (up to 1.7 s). Too slow for a game? ............ +5. It is about 4 dB quieter than B was: the game adds the bars up itself, with no limiter, so they are turned down + to leave room. Too quiet? ............ diff --git a/probes/answers/korobeiniki-band.txt b/probes/answers/korobeiniki-band.txt new file mode 100755 index 0000000..7deef99 --- /dev/null +++ b/probes/answers/korobeiniki-band.txt @@ -0,0 +1,13 @@ +TrigScript sounds, step 4 again: Korobeiniki in half bars +========================================================= + +The same as trigscript-korobeiniki-band.scx, cut every half bar instead of every bar, for your "waiting for the next +bar line took a bit too long": a jump or a layer change now waits at most 0.84 s (it was 1.68), and Space's crash +plays on the next beat (at most 0.42 s). The cost: 7.5 MB instead of 5.6, and up to 20 sounds at once of the game's 32 +(it was 13). Host it as a multiplayer game, alone, game music OFF, and record it if you like. + +Keys as before: 1-7 a section, D B H M a layer, L loop, Space a crash. + +1. Does a change feel quick enough now for "something important happens"? ............ +2. Can you hear the half-bar seams anywhere (a stutter, a flam)? ............ +3. Half bars (quicker, bigger map) or whole bars (slower, smaller), for the showpiece? ............ diff --git a/probes/answers/korobeiniki-form.txt b/probes/answers/korobeiniki-form.txt new file mode 100755 index 0000000..f04619a --- /dev/null +++ b/probes/answers/korobeiniki-form.txt @@ -0,0 +1,29 @@ +TrigScript sounds, step 3: Korobeiniki as a song of sections +============================================================ + +Host it as a multiplayer game, alone, with the game's music OFF. Headphones help. +It plays A (63 s: step 2's B, exactly the file you heard last time), a pause, then B (about 73 s), and repeats. +Start it once and quit when A comes round again. A recording is all I need, plus what your ears say. +B is beside this sheet as korobeiniki-step3.ogg too, to play outside the game. + +B, section by section (143 beats a minute, a bar 1.68 s): + 0:00 intro, 4 bars: the pad and the plucked arpeggio behind a filter that opens; hats creep in; a reversed crash + 0:07 verse, 8 bars: the melody on the warm lead, a crash on the first beat, the groove, held back a little + 0:20 build, 4 bars: the bell plays the first phrase, a riser, the snare roll speeding up and getting louder, + the filters opening, half a bar of silence before... + 0:27 drop, 8 bars: the supersaw melody and the bell, four on the floor, an impact, the pad, bass and arpeggio + pumping with the beat + 0:40 break, 4 bars: electric piano, the bell plays the second phrase, the kick comes back, a short riser + 0:47 last verse, a tone up (B minor): the supersaw, the bell, the acid line, pumping + 1:00 outro: the last chord, an impact, the pad closing down into the reverb + +What to listen for: +1. Does the shape work: does the build build, does the drop hit, does the key change lift? ............ +2. The drums: better now (they are about 2 dB quieter against the rest than in A)? Still too much? ............ +3. The riser, the impacts, the reversed crash, the pumping: good, cheesy, too much? ............ +4. Anything wrong: crackle, clipping, a sound that cuts off, a hitch in the game? ............ + +What is new (step 3), all for writing songs, not new sounds: a song made of sections that fill parts by name and +repeat a pattern to fill themselves; a section changed for its second time round; chord names (Am, E, Dm7, C/E) and +templates that play a chord's notes by number, with voice leading; transpose, stretch, accents ("@0.5" on a step), +swing, effects and volumes for one section only (the filter sweeps, the pumping), risers, impacts and a crash. diff --git a/probes/answers/korobeiniki.txt b/probes/answers/korobeiniki.txt new file mode 100755 index 0000000..757d46b --- /dev/null +++ b/probes/answers/korobeiniki.txt @@ -0,0 +1,26 @@ +TrigScript sounds, step 2: Korobeiniki, arranged +================================================ + +Host it as a multiplayer game, alone, with the game's music OFF. Headphones help. +It plays A (32 s, step 1: plain waves, the notes you heard last time), a pause, then B (about 63 s), and repeats. +Start it once and quit when A comes round again. A recording is all I need, plus what your ears say. +B is beside this sheet as korobeiniki-step2.ogg too, to play outside the game. + +B, bar by bar (143 beats a minute, a bar 1.68 s): + 0:00 intro, 4 bars: a slow pad and a plucked arpeggio; hats come in, then the kick and a snare build + 0:07 A1, 8 bars: the melody on a warm filtered lead with a dotted echo; bass, drums + 0:20 A2, 8 bars: the melody on seven detuned saws, a bell an octave up, the pluck arpeggio, claps and open hats + 0:34 break, 4 bars: electric piano (FM), the bell, the bass holding, no drums + 0:40 A3, 8 bars: everything, with an acid line under the melody; a drum fill at the end + 0:54 the last chord, ringing out into the reverb + +What to listen for: +1. Does B sound like real produced music now, or still like a toy? What sounds best, what sounds worst? ............ +2. The mix: is anything too loud or buried (melody, bass, kick, hats, bell, acid)? ............ +3. The reverb and echoes: too much, too little, about right? ............ +4. Anything wrong: crackle, clipping, a sound that cuts off, a hitch in the game? ............ +5. Which instruments would you want more of for the falling-block showpiece? ............ + +What is new (step 2): filters that open and close with each note, detuned stacks of voices (supersaw, pad), FM (the +bell and the electric piano), plucked strings, a synthesized drum kit (kick, snare, clap, hats, tom, rim), drive, +echo, chorus, a plate reverb shared by the tracks, a compressor on the drums, the bass and the whole mix. diff --git a/probes/bad-apple.ts b/probes/bad-apple.ts new file mode 100644 index 0000000..56c1f3f --- /dev/null +++ b/probes/bad-apple.ts @@ -0,0 +1,35 @@ +// Bad Apple!! in the fog of war (TrigScript 4, S4 in TrigScript: B8). The video is imported like a file of the +// script — a .video, the frames the editor decoded — and the fog library plays it on the minimap a frame of it every +// frame of the game, with the song from the map's archive. Host it as a multiplayer game, alone, with Classic graphics. +// +// The video and the song are not ours and never committed. Made with: +// npx tsx scripts/import-video.mts