From 8b1a3a64c7e3cad05be3bf1c9dc33536a64e5b1f Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 23 Oct 2026 14:27:33 -0600 Subject: [PATCH] =?UTF-8?q?Step=2014:=20the=20build=20in=20the=20editor=20?= =?UTF-8?q?=E2=80=94=20TrigScript=20builds=20the=20map=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The map of our own is what Save, Test Map and an export write: the plugin's own build step (buildOwn, compiler/backend/build.ts in the compile worker) lays the programs out into the scenario the host hands it, on its bytes alone (chk.ts), with eudplib's fixes to the map (fixes.ts) and the script's settings; a refusal is a warning on the line while the script is typed (CompileResult.ownRefusal) and goes into the manifest, which decides the changeover: TrigScript's step where it takes the script, the eudplib contribution elsewhere and where Magenta shares the map. build-own.mts builds through the same function; the step-13 probe prints the same lines either way in the lifted game, and the editor was driven headlessly. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- README.md | 39 +++--- compile.ts | 56 +++++++-- compiler/backend/build.ts | 167 +++++++++++++++++++++++++ compiler/backend/chk.ts | 251 ++++++++++++++++++++++++++++++++++++++ compiler/backend/emit.ts | 7 +- compiler/backend/fixes.ts | 104 ++++++++++++++++ compiler/backend/own.ts | 14 ++- compiler/compiler.ts | 34 +++++- compiler/entry.ts | 2 + docs/v4-plan.md | 67 +++++++++- editor.ts | 51 +++++--- ko.ts | 15 ++- monaco.ts | 10 +- package-lock.json | 8 +- package.json | 2 +- plugin.json | 2 +- script.ts | 9 +- scripts/build-own.mts | 133 ++++---------------- service.ts | 121 +++++++++++++++--- tests/own-build.test.ts | 217 ++++++++++++++++++++++++++++++++ tests/service.test.ts | 140 +++++++++++++++++++-- 21 files changed, 1253 insertions(+), 196 deletions(-) create mode 100644 compiler/backend/build.ts create mode 100644 compiler/backend/chk.ts create mode 100644 compiler/backend/fixes.ts create mode 100644 tests/own-build.test.ts diff --git a/README.md b/README.md index 7aa1b97..c19d6c3 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ 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 and `sleep`. A program is built into the +map by TrigScript itself when you save — laid out as the game's own triggers, in under a +second, with nothing else to install — and a map with a program in it needs **StarCraft: +Remastered**. What TrigScript's own build does not take yet is built through +[eudplib](https://github.com/scm-js/plugin-eudplib), the compiler behind euddraft, and +Problems says so on the line (see [Use](#use), on the status bar). 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 @@ -54,7 +56,7 @@ is, with the same keys where it has one: | **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. | +| **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, who builds them (**built by TrigScript**, or the eudplib plugin and its state), 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 @@ -68,7 +70,7 @@ There is no build step to remember. **Saving the map applies the script**, and s 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 +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. @@ -355,13 +357,16 @@ world has one player; `test(name, { as: P2 }, (sim) => { … })` says which. - 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, +A script with programs shows, at the right of the status bar, who builds them. **built by +TrigScript** is the usual case: the plugin lays the programs out into the map itself, in +under a second, with nothing to download. A script with something TrigScript's own build +does not take yet — Problems says what, as a warning on the line — goes through the eudplib +plugin instead, and the status bar then shows whether that plugin is running and whether +its runtime is on this machine: Pyodide, a Python for the browser, and eudplib, downloaded +once, about 15 MB, after asking, the first time such a map is saved (the desktop app and +the container image carry it). Either way 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 — 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. @@ -1675,7 +1680,7 @@ 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, wherever the last apply found nothing a map of our own refuses (the manifest's `ownRefusal`) and Magenta's triggers are not in the map; otherwise 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. | | `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. | @@ -1684,12 +1689,12 @@ The layout: | `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 `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; `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); `python/trigscript.py` is the other way, the euddraft plugin that lowers the IR to eudplib for what the own build does not take yet, 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 other lowering: the euddraft plugin that turns the IR into eudplib code, handed to the eudplib plugin with a build of what TrigScript's own build does not take yet. `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. | | `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/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; `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 — the own build step, and the contribution to eudplib's — 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); `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 diff --git a/compile.ts b/compile.ts index f1f46e0..57f0168 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"; @@ -76,6 +77,8 @@ export interface CompileInput { 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; } interface CompileRequest extends CompileInput { @@ -85,9 +88,17 @@ interface CompileRequest extends CompileInput { 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; @@ -98,20 +109,26 @@ importScripts(${JSON.stringify(TS_URL)}); let loading = null; let lib = null; self.onmessage = async (e) => { - const { id, moduleUrl, libUrl, vorbisUrl, files, names, tests } = e.data; + const { id, moduleUrl, libUrl, vorbisUrl, files, names, tests, own, 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(); } // 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) }); + const result = mod.compileScript(self.ts, files, names, { lib, tests, sounds: Boolean(vorbisUrl), own }); // 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; @@ -140,7 +157,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 @@ -222,7 +240,7 @@ function timeOut(id: number, ms: number) { worker?.terminate(); worker = null; rejectAll("The compile was stopped."); - p.reject(new Error(`The script did not finish in ${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. */ @@ -267,19 +285,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) => { const ms = input.sounds ? BUILD_TIMEOUT_MS : COMPILE_TIMEOUT_MS; - pending.set(id, { resolve, reject, timer: setTimeout(() => timeOut(id, ms), ms) }); - 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) } : {}) }; + 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 } : {}) }; 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/backend/build.ts b/compiler/backend/build.ts new file mode 100644 index 0000000..c55fe66 --- /dev/null +++ b/compiler/backend/build.ts @@ -0,0 +1,167 @@ +/** + * 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 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; +} + +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; + 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; +} + +export interface OwnBuild { + chk: Uint8Array; + /** What was done, for the Output panel: the fixes, the input, the counts. */ + lines: string[]; + stats: OwnBuildStats; +} + +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, + 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, + }; + 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` : ""}), start-up ${stats.startUpTriggers} triggers, ${stats.stringsAfter - stats.stringsBefore} strings added`); + return { chk: s.bytes(), lines, stats }; +} + +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..6f15677 --- /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 + 16, view.getUint16(at + 16, 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 index 671392d..28b1f33 100644 --- a/compiler/backend/emit.ts +++ b/compiler/backend/emit.ts @@ -6257,6 +6257,9 @@ function plainFns(x: unknown): unknown { /** How often the emitter did what is worth counting, for its tests: loops with values computed once on the way in. */ export const emitterCounts = { hoisted: 0, reciprocals: 0, jumps: 0, 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, for a map of our own (no Python side, no eudplib): every statement * must be the emitter's — a print of a known text is the game's Display Text — with nothing left to an island, no @@ -6331,11 +6334,11 @@ export function emitBody(body: Stmt[], decls: Map, key: string, // all players the front end adds, say, after a closure it cannot keep.) const refused = [...scope.islands.keys()]; 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`}${first.at ? `, line ${first.at.line}` : ""}`; + return `${scope.whyOf.get(first)?.why || `a ${first.kind} it does not lower`}${refusedAt(first.at)}`; } if (scope.calls.size) { const [s, call] = [...scope.calls][0]; - return `a ${call.fn.fn.startsWith("fdiv") ? "quotient" : "product"} of fixed numbers${s.at ? `, line ${s.at.line}` : ""}`; + return `a ${call.fn.fn.startsWith("fdiv") ? "quotient" : "product"} of fixed numbers${refusedAt(s.at)}`; } const make = (forced: Set, unguarded = new Set()): { e: Emitter; g: Graph | string } => { const e = new Emitter(key, scope); 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/own.ts b/compiler/backend/own.ts index cb0cae2..4559ad2 100644 --- a/compiler/backend/own.ts +++ b/compiler/backend/own.ts @@ -87,6 +87,14 @@ function ownTrigger(record: TriggerRecord, players: MapPlayers, string: (i: numb }; } +/** 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 }; @@ -106,7 +114,7 @@ function ownerSlots(program: Program, players: MapPlayers): number[] | string { * `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 }): Own | string { +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 @@ -132,7 +140,7 @@ export function emitOwn(r: Pick & 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))))); + 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); @@ -159,7 +167,7 @@ export function emitOwn(r: Pick & 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, undefined, player, players, program.errors, runs.length === firstRun ? program.pass : undefined, runs.length === firstRun ? program.after : undefined); + 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; diff --git a/compiler/compiler.ts b/compiler/compiler.ts index 538c543..9030ae6 100644 --- a/compiler/compiler.ts +++ b/compiler/compiler.ts @@ -28,7 +28,8 @@ import { checkProgram } from "./eud"; import { checkLocal } from "./local"; import { typeNumbers } from "./numbers"; import { rangeNumbers } from "./ranges"; -import { emitGraphs } from "./backend/emit"; +import { emitGraphs, type MapPlayers } from "./backend/emit"; +import { emitOwn } from "./backend/own"; import { markRecursion, settleRecursion } from "./recursion"; import { inputPlan, inputsOf, numberSyncSlots, type InputPlan } from "./input"; import { declarations, programDeclarations, type Program } from "./ir"; @@ -173,10 +174,24 @@ export interface CompileResult { * 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; @@ -193,6 +208,11 @@ export interface CompileOptions { * 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; } export { LowerError }; @@ -557,7 +577,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, sounds: [...collector.sounds.values()], variables, programs, buildTime, hints, tests, ...(registry.empty ? {} : { modules: [...linked] }) }); + // 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/entry.ts b/compiler/entry.ts index 2a57307..88a34be 100644 --- a/compiler/entry.ts +++ b/compiler/entry.ts @@ -11,6 +11,8 @@ 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/docs/v4-plan.md b/docs/v4-plan.md index 0eced92..8bb90d9 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -3578,7 +3578,7 @@ found what they left out; where a step moved, its old number is given. 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). A build step of the plugin's own for Save, Test Map and export; the CHK and the +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 @@ -3863,6 +3863,8 @@ probes and seven measurement probes. No showpiece does. 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 @@ -3974,6 +3976,16 @@ the ledger is the list it can be written again from at 4.0. (`tests/events.test.ts`). A raw number is a second signature, the action's own. *Open:* +- Step 14's leftovers (2026-10-23). *Magenta.* A map with Magenta's member goes through eudplib with TrigScript's + programs in its build; Magenta on the own layout, or two payloads behind each other, is for later. *Protection.* + The own build scrambles nothing (eudplib's MRGN, shuffle and fake ISOM): the user's call. *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). - `new P(4).x` written in a program's expression is "A value must be known when the script is built"; the same through a function (`function made(x) { return new P(x).x; }`) is taken. - `displayText(text, always)` made a print loses its flag — to check against the flags a print gives Display Text. @@ -5404,6 +5416,59 @@ eudplib's build of `own-made-texts`: 894 KB of strings in a 1.1 MB scenario, 121 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. + ## Part C — the game, pushed Each is a library over Part B, offered only after its probe. "Known" means a played probe diff --git a/editor.ts b/editor.ts index 26dcd70..474380c 100644 --- a/editor.ts +++ b/editor.ts @@ -41,7 +41,7 @@ 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"; @@ -82,7 +82,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(() => { @@ -713,9 +713,25 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa ])); }; + /** + * A script TrigScript's own build does not take is built through eudplib instead (while that is there): a warning on + * the line that says why, so that a map does not change builds without anyone seeing. + */ + 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 = library?.contribute + ? t("Built through eudplib, not by TrigScript itself: {reason}. The map is built in a few seconds instead of under one, and plays the same.", { reason }) + : t("Not built: TrigScript itself does not take this yet ({reason}), and the eudplib plugin, which would, is not running.", { 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); + 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; } @@ -738,7 +754,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa 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 })), )), )); @@ -1316,10 +1332,16 @@ 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; + // Who builds the programs into the saved map: TrigScript itself, or — where its own build does not take the + // script yet, or Magenta shares the map — the eudplib plugin, which then has to be running. + const needsLibrary = programs > 0 && !svc.buildsOwn(); + const whyNotOwn = needsLibrary ? svc.whyNotOwn() : null; const libraryOk = !!library?.contribute && library.state() !== "failed"; - libraryItem.set(!needsLibrary ? null : { + libraryItem.set(programs === 0 ? null : !needsLibrary ? { + 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: no eudplib, nothing downloaded, and the map is built in under a second"), + } : { kind: libraryOk ? undefined : "warn", icon: libraryOk ? undefined : "warning", text: !library @@ -1327,11 +1349,11 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa : !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 + 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"), + : t("The eudplib plugin builds the programs into the map when it is saved or tested; its runtime is downloaded once, the first time")}${whyNotOwn ? `\n${whyNotOwn}` : ""}`, }); 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); @@ -1355,7 +1377,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(); }; @@ -1491,7 +1513,7 @@ 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; } + if (result?.programs.length && !svc.buildsOwn() && !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); @@ -1528,9 +1550,10 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa if (!isMachineLine(e.line)) log(e.line, false); } else if (e.kind === "done") { if (!streamed && e.log) log(withoutMachineLines(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 })); + // (TrigScript's own build takes tens of milliseconds, eudplib's 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(withoutMachineLines(e.log), false); // Save goes on without the step: the file is the map and its script, with no programs in it. diff --git a/ko.ts b/ko.ts index 80bda52..38b6f83 100644 --- a/ko.ts +++ b/ko.ts @@ -30,8 +30,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": "출력 지우기", @@ -461,4 +462,14 @@ export const KO: Record = { "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() })로 곡을 만듭니다.", + "Magenta's triggers are in this map: the programs are built through eudplib, together with them": "이 맵에 Magenta의 트리거가 있습니다: 프로그램은 그 트리거와 함께 eudplib로 빌드됩니다", + "TrigScript's own build does not take the script yet: {reason}": "TrigScript 자체 빌드가 아직 이 스크립트를 받지 못합니다: {reason}", + "The map has no script.": "맵에 스크립트가 없습니다.", + "Built by TrigScript itself: {n, plural, one {# program} other {# programs}} laid out into the map, no eudplib": "TrigScript가 직접 빌드: 프로그램 {n}개를 맵에 배치, eudplib 없음", + "TrigScript's own build does not take the script: {reason}. Apply the script again to build it through eudplib.": "TrigScript 자체 빌드가 이 스크립트를 받지 못합니다: {reason}. 스크립트를 다시 적용하면 eudplib로 빌드합니다.", + "built by TrigScript": "TrigScript가 빌드", + "The programs are laid out into the map by TrigScript itself when it is saved or tested: no eudplib, nothing downloaded, and the map is built in under a second": "맵을 저장하거나 테스트할 때 TrigScript가 직접 프로그램을 맵에 배치합니다: eudplib도, 내려받을 것도 없고, 1초 안에 빌드됩니다", + "Built through eudplib, not by TrigScript itself: {reason}. The map is built in a few seconds instead of under one, and plays the same.": "TrigScript가 직접 빌드하지 않고 eudplib로 빌드합니다: {reason}. 1초 대신 몇 초가 걸리지만 똑같이 플레이됩니다.", + "Not built: TrigScript itself does not take this yet ({reason}), and the eudplib plugin, which would, is not running.": "빌드되지 않음: TrigScript가 아직 이것을 받지 못하고({reason}), 받을 수 있는 eudplib 플러그인은 실행 중이 아닙니다.", + "build": "빌드", }; diff --git a/monaco.ts b/monaco.ts index 7973150..bfca1c0 100644 --- a/monaco.ts +++ b/monaco.ts @@ -334,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[] = []) { diff --git a/package-lock.json b/package-lock.json index c2cb93b..a7a4be8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.12.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", @@ -742,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" }, diff --git a/package.json b/package.json index 9fae224..13dcca9 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "embed": "node scripts/embed-python.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", diff --git a/plugin.json b/plugin.json index 579b83d..a9fa92f 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "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.", + "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", diff --git a/script.ts b/script.ts index af84c2a..90e28f7 100644 --- a/script.ts +++ b/script.ts @@ -89,6 +89,11 @@ export interface ScriptManifest { sourceHash: string; /** `hashRecord` of each generated record, so an edited block can be taken apart; absent in a manifest from before 2.4.1. */ records?: string[]; + /** + * Why the programs are not built by TrigScript itself (`CompileResult.ownRefusal`), as of the last apply: the map + * is then built through the eudplib plugin. Absent when TrigScript builds it, or there are no programs. + */ + ownRefusal?: string; } export interface ScriptBlock { @@ -187,7 +192,7 @@ export function readManifest(extras: Extras): ScriptManifest | null { const sources = Array.isArray(m.sources) ? m.sources.map((s) => (s && typeof s === "object" && typeof s.file === "string" && typeof s.line === "number" ? { file: s.file, line: s.line } : null)) : []; const files = Array.isArray(m.files) ? m.files.filter((f): f is string => typeof f === "string") : []; const records = Array.isArray(m.records) && m.records.length === m.count && m.records.every((r) => typeof r === "string") ? m.records : undefined; - return { version: 2, ...programs, start: m.start, count: m.count, hash: m.hash, sources, files, sourceHash: typeof m.sourceHash === "string" ? m.sourceHash : "", ...(records ? { records } : {}) }; + return { version: 2, ...programs, start: m.start, count: m.count, hash: m.hash, sources, files, sourceHash: typeof m.sourceHash === "string" ? m.sourceHash : "", ...(records ? { records } : {}), ...(typeof m.ownRefusal === "string" && m.ownRefusal ? { ownRefusal: m.ownRefusal } : {}) }; } catch { return null; } @@ -384,7 +389,7 @@ export function buildScript(triggers: TriggerRecord[], extras: Extras, files: Sc } else { start = triggers.length; before = triggers.slice(); after = []; } - const manifest: ScriptManifest = { version: 2, programs: compiled.ir.length, start, count: records.length, hash: hashTriggers(records), sources: compiled.sources, files: Object.keys(files).map(normalizePath).sort(), sourceHash: hashFiles(files), records: records.map(hashRecord) }; + const manifest: ScriptManifest = { version: 2, programs: compiled.ir.length, start, count: records.length, hash: hashTriggers(records), sources: compiled.sources, files: Object.keys(files).map(normalizePath).sort(), sourceHash: hashFiles(files), records: records.map(hashRecord), ...(compiled.ir.length && compiled.ownRefusal ? { ownRefusal: compiled.ownRefusal } : {}) }; return { list: [...before, ...records, ...after], extras: withSounds(withManifest(options.keepFiles ? extras : withFiles(extras, files), manifest), compiled.sounds ?? []), block: { start, count: records.length, sources: manifest.sources }, ...(replaced ? { replaced } : {}) }; } diff --git a/scripts/build-own.mts b/scripts/build-own.mts index ee4c365..0888020 100644 --- a/scripts/build-own.mts +++ b/scripts/build-own.mts @@ -3,22 +3,22 @@ * start-up). Each program's whole body is lowered by the emitter (`compiler/backend/emit.ts`, `emitBody`) — once for * each player a per-player program runs for — laid out at fixed addresses after the strings * (`compiler/backend/layout.ts`), and handed to the game's trigger walk by a few triggers in the map's TRIG section. - * What the emitter cannot lower yet is refused, and said. + * What the emitter cannot lower yet is refused, and said. The build itself is `compiler/backend/build.ts`, the one + * the plugin's build step runs in the editor (step 14); this script prepares a probe map for it and writes the file. * * npx tsx scripts/build-own.mts probes/own-loader.ts out.scx [map.scx] * * The map is prepared as build-fixture prepares it (a base each, Player 1 the person; PEOPLE and a `// probe-players:` - * line as there; MAP_NAME the name the lobby shows), made a Remastered map (VER 206, STRx), and written through the - * editor's own code (a scm-js checkout beside this repository, or SCMJS_DIR). + * line as there; MAP_NAME the name the lobby shows), and written through the editor's own code (a scm-js checkout + * beside this repository, or SCMJS_DIR). The base is a melee map, whose stock triggers would end a Use Map Settings + * game at once: they are left out unless OWN_KEEP_TRIGGERS says otherwise. OWN_FREE= fills the payload's free + * cells with a marker. */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import ts from "typescript"; import { compileScript } from "../compiler/compiler"; -import { emitOwn } from "../compiler/backend/own"; -import type { MapPlayers } from "../compiler/backend/emit"; -import { STRINGS, layout } from "../compiler/backend/layout"; -import type { Graph } from "../compiler/backend/graph"; +import { buildOwnMap, isRefused } from "../compiler/backend/build"; import { defaultScriptNames } from "../compiler/names"; import { defaultLib } from "../bundle/lib.mjs"; import { finishSounds, vorbisEncoder } from "../compiler/vorbis"; @@ -37,14 +37,11 @@ const compiled = performance.now(); const EDITOR = resolve(process.env.SCMJS_DIR ?? join(root, "..", "scm-js")); const { loadMap, saveMap, readExtras } = await import(join(EDITOR, "src", "formats", "mpq", "scm.ts")); -const { parseScenario, serializeScenario, markDirty, setMapVersion, setScenarioName } = await import(join(EDITOR, "src", "formats", "chk", "scenario.ts")); -const { internString, patchPlayer, readPlayerSettings } = await import(join(EDITOR, "src", "editor", "settings.ts")); -const { setString } = await import(join(EDITOR, "src", "formats", "chk", "sections", "strings.ts")); +const { parseScenario, serializeScenario, markDirty, setScenarioName } = await import(join(EDITOR, "src", "formats", "chk", "scenario.ts")); +const { patchPlayer } = await import(join(EDITOR, "src", "editor", "settings.ts")); const { addUnits, applyUnitChanges, makeUnit, nextSerial } = await import(join(EDITOR, "src", "editor", "units.ts")); -const { cuwpUsage, patchCuwp } = await import(join(EDITOR, "src", "editor", "cuwp.ts")); const loaded = await loadMap(new Uint8Array(readFileSync(map))); const scn = parseScenario(loaded.chk); -setMapVersion(scn, "remastered"); // (MAP_NAME: the name the lobby shows, so that maps built on the same base can be told apart in a recording.) if (process.env.MAP_NAME) setScenarioName(scn, process.env.MAP_NAME); patchPlayer(scn, 0, { type: 6, race: 1, force: 0 }); @@ -52,33 +49,7 @@ patchPlayer(scn, 1, { type: 5, race: 0, force: 1 }); for (const slot of (process.env.PEOPLE ?? "").split(",").filter(Boolean).map(Number)) patchPlayer(scn, slot, { type: 6, race: 1, force: slot === 0 ? 0 : 1 }); const playersLine = /^\/\/ probe-players: (.+)$/m.exec(readFileSync(scriptPath, "utf8")); if (playersLine) for (const [slot, patch] of Object.entries(JSON.parse(playersLine[1]) as Record)) patchPlayer(scn, Number(slot), patch); -// The map's players as the programs need them: the slots a human or a computer plays, and their forces. -const settings = readPlayerSettings(scn) as { types: number[]; force: number[] }; -const players: MapPlayers = { slots: [0, 1, 2, 3, 4, 5, 6, 7].filter((p) => settings.types[p] === 5 || settings.types[p] === 6), forces: settings.force.slice(0, 8) }; -// The texts the programs choose between while the game runs: the map's next strings, one after another, in the order -// of their numbers (`compiler/backend/texts.ts`). -const textBase = scn.strings.strings.length; -// The players' input, when a program reads it (`compiler/backend/input.ts`): the humans, the map in pixels, a -// unit-properties slot the map does not use — written "in transit" below when the programs need carriers — and a -// carrier type the map's units and triggers never name (the Valkyrie unless they do). -const humans = [0, 1, 2, 3, 4, 5, 6, 7].filter((p) => settings.types[p] === 6); -const cuwpFree = cuwpUsage(scn) as number[]; -const cuwp = 1 + Math.max(0, ...[...Array(64).keys()].filter((i) => !cuwpFree[i])); -const used = new Set([...scn.units.map((u: { unitId: number }) => u.unitId), ...scn.triggers.flatMap((t: { conditions: { unitId: number }[]; actions: { unitId: number }[] }) => [...t.conditions.map((c) => c.unitId), ...t.actions.map((a) => a.unitId)])]); -const carrier = used.has(58) ? [...Array(228).keys()].find((t) => !used.has(t))! : 58; -// The map's own triggers go into the payload with the script's records (step 13), and the TRIG section holds the -// start-up alone. The base is a melee map, whose stock triggers would end a Use Map Settings game at once: they are -// left out unless OWN_KEEP_TRIGGERS says otherwise. -const mapTriggers = process.env.OWN_KEEP_TRIGGERS ? scn.triggers.slice() : []; -const lowered = emitOwn(r, players, textBase, { humans, width: scn.width * 32, height: scn.height * 32, cuwp, carrier, triggers: mapTriggers }); -if (typeof lowered === "string") { console.error(`the emitter cannot lower this program: ${lowered}`); process.exit(1); } -lowered.texts.forEach((text, i) => { - if (setString(scn.strings, 0, text) !== textBase + i) throw new Error("the pool of texts did not go into the map's strings one after another"); -}); -const { runs } = lowered; -const graphs: Graph[] = runs.map((run) => run.graph); -const emitted = performance.now(); -scn.triggers = []; +// A base and a few troops each, as build-fixture places them. const START_LOCATION = 214, COMMAND_CENTER = 106, HATCHERY = 131, MARINE = 0, ZERGLING = 37; const placed = []; let serial = nextSerial(scn); @@ -89,82 +60,20 @@ for (const [owner, base, troop] of [[0, COMMAND_CENTER, MARINE], [1, HATCHERY, Z for (let i = 0; i < 4; i++) placed.push(makeUnit(null, troop, owner, start.x - 48 + i * 32, start.y + 96, serial++)); } applyUnitChanges(scn, addUnits(scn, placed)); -// The texts the graph shows, in the strings first: the payload goes after them. -const texts = new Map(); -const intern = (text: string) => { if (!texts.has(text)) texts.set(text, internString(scn, text)); }; -for (const t of [...graphs.flatMap((g) => g.triggers), ...lowered.triggers]) for (const a of t.acts) { - if (a.display !== undefined) intern(a.display); - // (An action of the game's: its text and its sound file's name.) - for (const s of [a.strings?.text, a.strings?.wav]) if (s && "text" in s) intern(s.text); - if (a.amountText) intern(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 goes in it from its first dword's start on, and the string's offset is 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, as eudplib's -// buffer does). -const templates = new Map(); -for (const t of graphs.flatMap((g) => g.templates ?? [])) { - // (A print at the centre of the screen is written into the game's own line, and data is never shown: no string.) - if (t.at !== undefined || t.data) continue; - const name = `(TrigScript text ${t.id})`; - templates.set(t.id, internString(scn, name + "\r".repeat(Math.max(0, t.bytes.length + 3 - name.length)))); -} -if (lowered.input) { - // (The slot written "in transit": the sections it touched are marked dirty by the editor's code.) - patchCuwp(scn, cuwp - 1, { inTransit: true }, true); - console.log(`input: ${lowered.input.keys.length} keys, ${lowered.input.mouse ? "the mouse, " : ""}${lowered.input.syncs} sync slots; carriers of type ${carrier}, ${lowered.input.ring} a ring, ${lowered.input.rings} rings, for players ${humans.map((h) => h + 1).join(", ")}; unit-properties slot ${cuwp}; ${lowered.input.bits} bits a value`); -} -markDirty(scn, "TRIG", "UNIT", "STRx"); -const chk = serializeScenario(scn); +markDirty(scn, "UNIT"); +const chk: Uint8Array = serializeScenario(scn); +const prepared = performance.now(); -// The sections, as they are; the strings' and the triggers' put back with the payload and the start-up. -const sections: { name: string; body: Uint8Array }[] = []; -const dv = new DataView(chk.buffer, chk.byteOffset, chk.byteLength); -for (let o = 0; o + 8 <= chk.length;) { - const name = String.fromCharCode(...chk.subarray(o, o + 4)); - const len = dv.getInt32(o + 4, true); - if (len < 0 || o + 8 + len > chk.length) break; - sections.push({ name, body: chk.subarray(o + 8, o + 8 + len) }); - o += 8 + len; -} -const strx = sections.find((s) => s.name === "STRx"); -if (!strx) { console.error("the map came out with no STRx"); process.exit(1); } -const values = lowered.values; -// (STRx: the count, then a four-byte offset a string from the section's start; the section is at STRINGS in the game.) -const strings = new Uint8Array(strx.body); -const offsets = new DataView(strings.buffer); -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 + offsets.getUint32(4 * index, true) + 3) & ~3; - strings.set(t.bytes, at - STRINGS); - offsets.setUint32(4 * index, at - STRINGS, true); - templateAt.set(t.id, at); -} -const own = layout({ runs: runs.map((run) => ({ graph: run.graph, owner: run.player })), players: players.slots, triggers: lowered.triggers, strings: strings.length, text: (t) => texts.get(t)!, arrays: values, template: (id) => templates.get(id)!, templateAt, ...(process.env.OWN_FREE ? { free: Number(process.env.OWN_FREE) } : {}) }); -const withPayload = new Uint8Array(strings.length + own.payload.length); -withPayload.set(strings); -withPayload.set(own.payload, strings.length); -strx.body = withPayload; -const trig = sections.find((s) => s.name === "TRIG"); -if (trig) trig.body = own.trig; -else sections.push({ name: "TRIG", body: own.trig }); -const parts: Uint8Array[] = []; -for (const s of sections) { - const head = new Uint8Array(8); - head.set([...s.name].map((c) => c.charCodeAt(0))); - new DataView(head.buffer).setInt32(4, s.body.length, true); - parts.push(head, s.body); -} -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; } +// The build as the editor runs it: the compile's records go in as the script's (the map's trigger list has none of +// them here, where the editor's apply would have written them into it). +const built = buildOwnMap({ chk, compiled: r, dropMapTriggers: !process.env.OWN_KEEP_TRIGGERS, ...(process.env.OWN_FREE ? { free: Number(process.env.OWN_FREE) } : {}) }); +if (isRefused(built)) { console.error(`the emitter cannot lower this program: ${built.refused}`); process.exit(1); } +const emitted = performance.now(); const extras = loaded.archive ? await readExtras(loaded.archive, loaded.files) : new Map(); // The clips the script plays, in the archive (an Ogg one encoded first), as build-fixture puts them. if (r.sounds.some((s) => s.vorbis)) finishSounds(r.sounds, vorbisEncoder(readFileSync(join(root, "node_modules", "wasm-media-encoders", "wasm", "ogg.wasm")))); for (const s of r.sounds) extras.set(s.path, s.bytes!); -writeFileSync(resolve(outPath), await saveMap(out, { extras, compress: "pkware", listfile: true })); +writeFileSync(resolve(outPath), await saveMap(built.chk, { extras, compress: "pkware", listfile: true })); const done = performance.now(); -console.log(`${resolve(outPath)}: ${runs.length} run${runs.length === 1 ? "" : "s"} (${runs.map((run) => `program ${run.program + 1} as P${run.player + 1}`).join(", ")}), ${graphs.reduce((n, g) => n + g.triggers.length, 0)} triggers of the graphs, payload ${own.payload.length} bytes at 0x${own.address.toString(16)}, ${lowered.triggers.length} trigger${lowered.triggers.length === 1 ? "" : "s"} of the map's own (${lowered.triggers.reduce((n, t) => n + t.slots.length, 0)} copies${lowered.hyperTriggersLeftOut ? `, ${lowered.hyperTriggersLeftOut} hyper triggers left out` : ""}), start-up ${own.trig.length / 2400} triggers`); -console.log(`built in ${(done - started).toFixed(0)} ms (compile ${(compiled - started).toFixed(0)}, emit ${(emitted - compiled).toFixed(0)}, map ${(done - emitted).toFixed(0)})`); +for (const line of built.lines) console.log(line); +console.log(`${resolve(outPath)}: built in ${(done - started).toFixed(0)} ms (compile ${(compiled - started).toFixed(0)}, map ${(prepared - compiled).toFixed(0)}, build ${(emitted - prepared).toFixed(0)}, archive ${(done - emitted).toFixed(0)})`); diff --git a/service.ts b/service.ts index f995a0b..b21dcc3 100644 --- a/service.ts +++ b/service.ts @@ -7,16 +7,19 @@ * the editor's own trigger dialogs write. * * A script has two outputs. Its `trigger()` calls are ordinary triggers: applying writes - * them into the map's trigger list as the script's block. Its `program()`s are IR, which - * the eudplib plugin builds into the *saved file* — never into the map the user edits — - * so the service contributes them to the library's build (`attach`). Both happen by - * themselves whenever the map is saved, tested or exported: the editor runs `bringUpToDate` - * first (`buildSteps.before`), then asks the contribution. + * them into the map's trigger list as the script's block. Its `program()`s are IR, built + * into the *saved file* — never into the map the user edits — by TrigScript's own build + * step (`buildOwn`, `compiler/backend/build.ts`), or, where that does not take the script + * yet or Magenta's triggers share the map, by the eudplib plugin's build, which the service + * contributes them to (`collect`). Both happen by themselves whenever the map is saved, + * tested or exported: the editor runs `bringUpToDate` first (`buildSteps.before`), then + * the step that applies (`attach`). */ import type { PluginApi, TriggerClaimHandle } from "@scm-js/plugin-api"; import type { TriggerRecord } from "./vendor/triggers"; -import { compileInBackground, type CompileInput } from "./compile"; -import { ENTRY_FILE, type CompileResult, type ScriptDiagnostic, type ScriptFiles } from "./compiler/compiler"; +import { buildInBackground, compileInBackground, type CompileInput } from "./compile"; +import { isRefused, type OwnBuildInput, type OwnBuildOutcome } from "./compiler/backend/build"; +import { ENTRY_FILE, type CompileResult, type OwnCheck, type ScriptDiagnostic, type ScriptFiles } from "./compiler/compiler"; import { generateDeclarations } from "./compiler/declarations"; import { serializeIr } from "./compiler/eud"; import { buildPlugins } from "./compiler/input"; @@ -113,6 +116,14 @@ export interface MapNames { /** What compiles a script: `compileInBackground`, or something synchronous in a test. */ export type Compiler = (input: CompileInput, dist: string) => Promise; +/** What builds a map of our own: `buildInBackground`, or `buildOwnMap` itself in a test. */ +export type OwnBuilder = (input: OwnBuildInput, dist: string) => Promise; + +/** The member the Magenta plugin keeps its triggers in: a map with it is built through eudplib, with Magenta's, until Magenta builds on the own layout too. */ +export const MAGENTA_MEMBER = "magenta\\magenta.json"; + +/** A build of the programs by TrigScript itself, said the way the library says its own (`EudplibBuildEvent`), so the editor hears both alike. */ +export type OwnBuildEvent = EudplibBuildEvent; /** The script's members as the map holds them, by their stored names, and the WAVs its last build wrote for its clips. */ export function snapshotExtras(api: PluginApi): Map { @@ -138,6 +149,9 @@ export class ScriptService { readonly api: PluginApi; readonly claim: TriggerClaimHandle; private readonly compiler: Compiler; + private readonly builder: OwnBuilder; + /** Who hears about the builds TrigScript does itself (`onBuild`). */ + private readonly ownListeners = new Set<(event: OwnBuildEvent) => void>(); private lastManifest: Uint8Array | null | undefined; /** The last compile, for a save that comes right after an apply: the same files against the same names need no second compile. */ private lastArtifact: ScriptArtifact | null = null; @@ -146,9 +160,10 @@ export class ScriptService { /** The editor's language, for what a person reads in the editor's own trigger editors and notices; what the commands return stays English. */ private readonly t: Translate; - constructor(api: PluginApi, open: (file?: string, line?: number) => void, compiler: Compiler = compileInBackground) { + constructor(api: PluginApi, open: (file?: string, line?: number) => void, compiler: Compiler = compileInBackground, builder: OwnBuilder = buildInBackground) { this.api = api; this.compiler = compiler; + this.builder = builder; const t: Translate = api.i18n ? (text, params) => api.i18n.t(text, params) : english; this.t = t; this.claim = api.triggers.claim({ @@ -207,8 +222,9 @@ export class ScriptService { } /** - * Take part in saving: bring the map's block up to date before its bytes are produced, - * and hand the programs to the eudplib plugin's build. Dispose when the plugin goes. + * Take part in saving: bring the map's block up to date before its bytes are produced, then build the programs + * into the map — TrigScript's own build step wherever it takes the script (TrigScript 4, step 14), and the eudplib + * plugin's build where it does not, or where Magenta's triggers share the map. Dispose when the plugin goes. */ attach(): { dispose(): void } { const before = this.api.document.buildSteps?.before({ @@ -216,12 +232,17 @@ export class ScriptService { applies: () => this.api.document.isOpen() && snapshotExtras(this.api).size > 0, run: () => this.bringUpToDate(), }); + const step = this.api.document.buildSteps?.add({ + id: "build", label: LABEL, + applies: () => this.buildsOwn(), + run: (input) => this.buildOwn(input), + }); let mine: { dispose(): void } | null = null; const watch = this.watchLibrary((library) => { mine?.dispose(); - mine = library?.contribute?.({ id: "trigscript", label: LABEL, applies: () => this.hasPrograms(), collect: () => this.collect() }) ?? null; + mine = library?.contribute?.({ id: "trigscript", label: LABEL, applies: () => this.hasPrograms() && !this.buildsOwn(), collect: () => this.collect() }) ?? null; }); - return { dispose: () => { before?.dispose(); mine?.dispose(); watch.dispose(); } }; + return { dispose: () => { before?.dispose(); step?.dispose(); mine?.dispose(); watch.dispose(); } }; } /** Whether the map's script, as last applied, has programs — read off the manifest, so it costs a save nothing. */ @@ -230,6 +251,73 @@ export class ScriptService { return (readManifest(snapshotExtras(this.api))?.programs ?? 0) > 0; } + /** + * Whether TrigScript builds the programs itself: the script has programs, its last apply found nothing a map of our + * own refuses, and Magenta has no triggers in the map. Read off the manifest and the members, so a save asks for free. + */ + buildsOwn(): boolean { + return this.hasPrograms() && this.whyNotOwn() === null; + } + + /** Why the programs go through the eudplib plugin instead of TrigScript's own build, in the user's words; null when they do not. */ + whyNotOwn(): string | null { + if (!this.api.document.isOpen()) return null; + if (this.api.document.extras.get(MAGENTA_MEMBER) !== null) return this.t("Magenta's triggers are in this map: the programs are built through eudplib, together with them"); + const refusal = readManifest(snapshotExtras(this.api))?.ownRefusal; + return refusal ? this.t("TrigScript's own build does not take the script yet: {reason}", { reason: refusal }) : null; + } + + /** The open map as a map of our own is checked against while the script is typed: who plays, and its size in pixels. */ + ownCheck(): OwnCheck | null { + const scn = this.api.document.scenario(); + if (!scn) return null; + const types = scn.playerTypes.slice(0, 8); + return { + players: { slots: types.flatMap((type, slot) => (type === OWNER_HUMAN || type === OWNER_COMPUTER ? [slot] : [])), forces: scn.forces.playerForce.slice(0, 8) }, + humans: types.flatMap((type, slot) => (type === OWNER_HUMAN ? [slot] : [])), + width: scn.width * 32, + height: scn.height * 32, + }; + } + + /** + * The build step: the scenario out of the map Save made (the script applied), the programs laid out into it by + * TrigScript itself (`compiler/backend/build.ts`, in the compile worker), the scenario back — the editor lays the + * file out with the user's save options and the map's members as they are. Throws, worded for the editor's notice, + * when the script does not compile or a map of our own refuses it; the map is then saved without its programs. + */ + async buildOwn(input: { map: Uint8Array; purpose: "save" | "test" | "export"; signal: AbortSignal }): Promise { + const started = Date.now(); + const contributors = ["trigscript"]; + const lines: string[] = []; + const say = (event: OwnBuildEvent) => { for (const l of this.ownListeners) l(event); }; + const log = (line: string) => { lines.push(line); say({ kind: "log", line }); }; + say({ kind: "start", purpose: input.purpose, contributors }); + try { + const state = this.state(); + if (!state?.files) throw new Error(this.t("The map has no script.")); + const map = this.names(); + const cached = this.lastArtifact; + const guard = this.settings().testsGuardBuild; + const fresh = cached && map && cached.context === map.context && cached.document === this.documentId() && hashFiles(cached.files) === hashFiles(state.files) && (!guard || !cached.compiled.tests || cached.compiled.tests.results.length > 0); + const artifact = fresh ? cached : await this.prepare(state.files, map, guard ? { world: this.world() } : undefined); + if (!artifact.compiled.ok) throw new Error(firstFault(artifact.compiled.diagnostics)); + if (guard) this.guardTests(artifact.compiled); + const chk = await this.api.document.sections.chkOf(input.map); + const { heapCells, stackDepth } = this.settings(); + log(this.t("Built by TrigScript itself: {n, plural, one {# program} other {# programs}} laid out into the map, no eudplib", { n: artifact.compiled.ir.length })); + const out = await this.builder({ chk, compiled: { ir: artifact.compiled.ir, strings: artifact.compiled.strings, input: artifact.compiled.input, triggers: [] }, settings: { heapCells, stackDepth } }, this.dist()); + if (isRefused(out)) throw new Error(this.t("TrigScript's own build does not take the script: {reason}. Apply the script again to build it through eudplib.", { reason: out.refused })); + for (const line of out.lines) log(line); + say({ kind: "done", purpose: input.purpose, contributors, log: lines.join("\n"), chkBytes: out.chk.length, ms: Date.now() - started }); + return out.chk; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + say({ kind: "failed", purpose: input.purpose, contributors, from: "trigscript", message, log: lines.join("\n") }); + throw err; + } + } + /** * Apply the script when its files are newer than the block: what Save, Test Map and an * export do first. Throws, worded for the editor's notice, when the script does not @@ -326,11 +414,12 @@ export class ScriptService { try { return await run(); } finally { this.variableMaps--; } } - /** Hear about the library's builds (it runs them on Save, Test Map and export): the log, and where a failure points. */ + /** Hear about the builds of the programs — TrigScript's own, and the library's (both run on Save, Test Map and export): the log, and where a failure points. */ onBuild(listener: (event: EudplibBuildEvent) => void): { dispose(): void } { + this.ownListeners.add(listener); let mine: { dispose(): void } | null = null; const watch = this.watchLibrary((library) => { mine?.dispose(); mine = library?.onBuild?.(listener) ?? null; }); - return { dispose: () => { mine?.dispose(); watch.dispose(); } }; + return { dispose: () => { this.ownListeners.delete(listener); mine?.dispose(); watch.dispose(); } }; } /** The tables and the `.d.ts` for the open map — its forces, used locations, switch names, custom unit names and sounds. Null with no map. */ @@ -389,7 +478,9 @@ export class ScriptService { const files = this.filesOf(input); const document = this.documentId(); const archived = hashFiles(this.state()?.files ?? {}); - const compiled = await this.compiler({ files, names: map.names, ...(tests ? { tests } : {}), ...(sounds ? { sounds } : {}) }, this.dist()); + // (And whether TrigScript's own build takes the programs for this map: the apply writes the answer into the manifest.) + const own = this.ownCheck(); + const compiled = await this.compiler({ files, names: map.names, ...(tests ? { tests } : {}), ...(sounds ? { sounds } : {}), ...(own ? { own } : {}) }, this.dist()); const artifact = { files, compiled, document, context: map.context, archived }; this.lastArtifact = artifact; return artifact; diff --git a/tests/own-build.test.ts b/tests/own-build.test.ts new file mode 100644 index 0000000..99256bb --- /dev/null +++ b/tests/own-build.test.ts @@ -0,0 +1,217 @@ +/** + * The build in the editor (TrigScript 4, step 14): a scenario's bytes in, the map of our own out — the programs' + * payload after the strings, the start-up in TRIG, eudplib's fixes applied, the map's own triggers taken along, a + * unit-properties slot in transit when the players are read — and a refusal said as one. + */ +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { compileScript } from "../compiler/compiler"; +import { defaultScriptNames } from "../compiler/names"; +import { buildOwnMap, isRefused, type OwnBuild } from "../compiler/backend/build"; +import { Sections, freeCuwpSlot, intern, readFacts, readStrings, readTriggers, writeChunks, writeStrings } from "../compiler/backend/chk"; +import { STRINGS } from "../compiler/backend/layout"; +import { ActionType, PlayerGroup, decodeTriggers, emptyAction, emptyTrigger, encodeTriggers, type TriggerRecord } from "../vendor/triggers"; +import { defaultLib } from "../bundle/lib.mjs"; + +let lib: ReturnType | undefined; +const compile = (source: string, file = "main.ts") => { + lib ??= defaultLib(); + const r = compileScript(ts, { [file]: source }, defaultScriptNames(), { lib, emitter: false, own: { players: { slots: [0, 1], forces: [0, 1, 0, 0, 0, 0, 0, 0] }, humans: [0], width: 64 * 32, height: 64 * 32 } }); + expect(r.diagnostics).toEqual([]); + return r; +}; + +const u16 = (...v: number[]) => new Uint8Array(v.flatMap((x) => [x & 0xff, (x >> 8) & 0xff])); + +/** + * A scenario of the few sections the build reads: 64 × 64, P1 human and P2 computer in forces of their own, a string + * table (STR unless `extended`), a trigger list, a placed unit with a flag at odds with its figure, a null tile, + * a UNIx row at the defaults with a setting left in. + */ +function scenario(opts: { strings?: string[]; triggers?: TriggerRecord[]; extended?: boolean; version?: number } = {}): Uint8Array { + const strings = { entries: (opts.strings ?? ["Probe", "P1 says hi"]).map((s) => new TextEncoder().encode(s)) }; + const str = opts.extended ? writeStrings(strings).data : (() => { + const count = strings.entries.length; + const header = 2 * (1 + count); + const size = header + strings.entries.reduce((n, e) => n + e.length + 1, 0); + const data = new Uint8Array(size); + const view = new DataView(data.buffer); + view.setUint16(0, count, true); + let at = header; + strings.entries.forEach((e, i) => { view.setUint16(2 * (1 + i), at, true); data.set(e, at); at += e.length + 1; }); + return data; + })(); + const ownr = new Uint8Array(12); ownr[0] = 6; ownr[1] = 5; + const forc = new Uint8Array(20); forc[1] = 1; + const mtxm = new Uint8Array(64 * 64 * 2).fill(1); mtxm[2 * 5] = 0; mtxm[2 * 5 + 1] = 0; + // One Marine of P1: hit points 100 % with the flag set (at odds), energy flag clear with a figure left in. + const unit = new Uint8Array(36); const uv = new DataView(unit.buffer); + uv.setUint16(8, 0, true); uv.setUint16(14, 2, true); unit[16] = 0; unit[17] = 100; unit[19] = 50; + // UNIx: row 7 at the defaults (byte 1) with its hit points left in. + const unix = new Uint8Array(228 + 228 * (4 + 2 + 1 + 2 + 2 + 2 + 2)); unix[7] = 1; unix[228 + 4 * 7] = 40; + return writeChunks([ + { name: "VER ", data: u16(opts.version ?? 59) }, + { name: "DIM ", data: u16(64, 64) }, + { name: "OWNR", data: ownr }, + { name: "FORC", data: forc }, + { name: opts.extended ? "STRx" : "STR ", data: str }, + { name: "MTXM", data: mtxm }, + { name: "UNIT", data: unit }, + { name: "UNIx", data: unix }, + { name: "UPRP", data: new Uint8Array(64 * 20) }, + { name: "UPUS", data: new Uint8Array(64) }, + { name: "TRIG", data: encodeTriggers(opts.triggers ?? []) }, + ]); +} + +const PROGRAM = `program(() => { let n = 0; while (true) { n++; displayText("tick"); sleep(seconds(1)); } });`; +const WITH_VALUES = `program(() => { let n = 0; while (true) { n++; displayText(\`n is \${n}\`); sleep(seconds(1)); } });`; + +function built(source: string, chk = scenario(), more: Partial[0]> = {}): OwnBuild { + const out = buildOwnMap({ chk, compiled: compile(source), ...more }); + if (isRefused(out)) throw new Error(out.refused); + return out; +} + +describe("the build in the editor", () => { + it("lays the programs out after the strings, the start-up in TRIG, and makes the file a Remastered one", () => { + const chk = scenario(); + const out = built(PROGRAM, chk); + const s = new Sections(out.chk); + expect(readFacts(s).version).toBe(206); + expect(s.get("STR ")).toBeNull(); + const strx = s.get("STRx")!; + const table = readStrings(strx, true); + // The map's strings as they were, then the pool's texts and what the actions show. + expect(table.entries.slice(0, 2).map((e) => new TextDecoder().decode(e))).toEqual(["Probe", "P1 says hi"]); + expect(table.entries.map((e) => new TextDecoder().decode(e))).toContain("tick"); + expect(strx.length).toBe(writeStrings(table).data.length + out.stats.payloadBytes); + expect(out.stats.address).toBe(STRINGS + ((writeStrings(table).data.length + 3) & ~3)); + expect(out.stats.runs).toBe(1); + expect(out.stats.runNames).toEqual(["program 1 as P1"]); + expect(out.stats.graphTriggers).toBeGreaterThan(0); + // The start-up: a few triggers of the map's own, each once; nothing else in the list. + const trig = decodeTriggers(s.get("TRIG")!); + expect(trig.length).toBe(out.stats.startUpTriggers); + expect(trig.length).toBeGreaterThan(0); + expect(out.stats.input).toBeNull(); + // Nothing of the input: the properties slot untouched. + expect([...s.get("UPRP")!].every((b) => b === 0)).toBe(true); + // What was done, said. + expect(out.lines.join("\n")).toMatch(/a Remastered file \(VER 206, was 59\)/); + expect(out.lines.join("\n")).toMatch(/the strings written as STRx/); + expect(out.lines.at(-1)).toMatch(/^1 run \(program 1 as P1\), \d+ triggers of the programs, payload \d+ bytes at 0x[0-9a-f]+, 0 triggers of the map's own \(0 copies\), start-up \d+ triggers, \d+ strings added$/); + // The sections the build does not touch come back as they were. + const before = new Sections(chk); + for (const name of ["DIM ", "OWNR", "FORC"]) expect([...s.get(name)!]).toEqual([...before.get(name)!]); + }); + + it("applies eudplib's fixes: unit flags, settings rows at the defaults, null tiles", () => { + const out = built(PROGRAM); + const s = new Sections(out.chk); + const unit = s.get("UNIT")!; + // The hit-points flag of a unit at 100 % cleared and the figure zeroed; the energy figure with no flag zeroed. + expect(unit[14]).toBe(0); + expect(unit[17]).toBe(0); + expect(unit[19]).toBe(0); + expect(s.get("UNIx")![228 + 4 * 7]).toBe(0); + const mtxm = s.get("MTXM")!; + expect([mtxm[10], mtxm[11]]).toEqual([1, 0]); + expect(out.lines.join("\n")).toMatch(/1 placed unit with a figure's flag at odds/); + expect(out.lines.join("\n")).toMatch(/1 row of UNIx at the defaults/); + expect(out.lines.join("\n")).toMatch(/1 null tile \(0000\.00\) made 0000\.01.*at \(5, 0\)/); + }); + + it("keeps an STRx table as it is and adds to it; a template is written over its own string", () => { + const out = built(WITH_VALUES, scenario({ extended: true, strings: ["Probe", "Hello"] })); + const s = new Sections(out.chk); + expect(out.lines.join("\n")).not.toMatch(/written as STRx/); + const strx = s.get("STRx")!; + const table = readStrings(strx, true); + expect(table.entries.slice(0, 2).map((e) => new TextDecoder().decode(e))).toEqual(["Probe", "Hello"]); + // The template's string points at a dword boundary inside the section, past the header. + const view = new DataView(strx.buffer, strx.byteOffset, strx.byteLength); + const count = view.getUint32(0, true); + const offsets = [...Array(count).keys()].map((i) => view.getUint32(4 * (1 + i), true)); + const aligned = offsets.filter((o) => (STRINGS + o) % 4 === 0 && o > 4 * (1 + count)); + expect(aligned.length).toBeGreaterThan(0); + }); + + it("takes the map's own triggers along, hyper triggers left out, and marks a properties slot in transit for the players' input", () => { + const hand: TriggerRecord = { ...emptyTrigger(), players: Array.from({ length: 27 }, (_, g) => (g === PlayerGroup.AllPlayers ? 1 : 0)), actions: [{ ...emptyAction(), type: ActionType.SetResources, player: PlayerGroup.Player1, target: 5, modifier: 8, unitId: 0 }] }; + const hyper: TriggerRecord = { ...emptyTrigger(), flags: 4, players: hand.players, actions: Array.from({ length: 63 }, () => ({ ...emptyAction(), type: ActionType.Wait, time: 0 })) }; + const cuwp: TriggerRecord = { ...emptyTrigger(), players: hand.players, actions: [{ ...emptyAction(), type: ActionType.CreateUnitWithProperties, player: PlayerGroup.Player1, unitId: 0, modifier: 1, location: 1, target: 1 }] }; + const chk = scenario({ triggers: [hand, hyper, cuwp] }); + expect(freeCuwpSlot(readTriggers(new Sections(chk)))).toBe(2); + const out = built(`program(() => { while (true) { if (keyPressed(P1, "A")) displayText("A"); sleep(frames(1)); } });`, chk); + expect(out.stats.mapTriggers).toBe(2); + expect(out.stats.mapTriggerCopies).toBe(4); + expect(out.stats.hyperTriggersLeftOut).toBe(1); + expect(out.stats.input).toMatchObject({ keys: 1, mouse: false, humans: [0], cuwp: 2 }); + const s = new Sections(out.chk); + const uprp = s.get("UPRP")!; + const view = new DataView(uprp.buffer, uprp.byteOffset, uprp.byteLength); + expect(view.getUint16(20, true) & 4).toBe(4); + expect(view.getUint16(20 + 16, true) & 4).toBe(4); + expect(s.get("UPUS")![1]).toBe(1); + expect(out.lines.join("\n")).toMatch(/input: 1 keys, 0 sync slots; carriers of type 58/); + expect(out.lines.at(-1)).toMatch(/2 triggers of the map's own \(4 copies, 1 hyper triggers left out\)/); + // A probe on a melee base leaves them out. + expect(built(PROGRAM, chk, { dropMapTriggers: true }).stats.mapTriggers).toBe(0); + }); + + it("the script's own records go in when the list does not hold them yet, with their texts", () => { + const out = built(`trigger(P1, [always()], [displayText("from a record")]);\n${PROGRAM}`); + expect(out.stats.mapTriggers).toBe(1); + const table = readStrings(new Sections(out.chk).get("STRx")!, true); + expect(table.entries.map((e) => new TextDecoder().decode(e))).toContain("from a record"); + }); + + it("refuses what a map of our own does not take, in the compile too, with the line and the file", () => { + const r = compile(`program(() => { while (true) { wait(500); sleep(frames(1)); } });`); + expect(r.ownRefusal).toMatch(/^the action Wait \(.*\), line 1$/); + const out = buildOwnMap({ chk: scenario(), compiled: r }); + expect(isRefused(out) && out.refused).toBe(r.ownRefusal); + // The entry file's name is left out; another file's is said. + const other = compileScript(ts, { "main.ts": `import "./waves";`, "waves.ts": `program(() => {\n while (true) { wait(500); sleep(frames(1)); }\n});` }, defaultScriptNames(), { lib: lib!, emitter: false, own: { players: { slots: [0], forces: [0, 0, 0, 0, 0, 0, 0, 0] }, humans: [0], width: 2048, height: 2048 } }); + expect(other.diagnostics).toEqual([]); + expect(other.ownRefusal).toMatch(/^the action Wait \(.*\), line 2 of waves\.ts$/); + // No programs, or no asking: nothing said. + expect(compile(`trigger(P1, [always()], [wait(500)]);`).ownRefusal).toBeUndefined(); + expect(compileScript(ts, { "main.ts": PROGRAM }, defaultScriptNames(), { lib: lib!, emitter: false }).ownRefusal).toBeUndefined(); + // Asked for a map none of whose players is among the owners (a force with nobody in it). + const nobody = compileScript(ts, { "main.ts": `program(() => { displayText("x"); }, { owner: players.Force4 });` }, defaultScriptNames(), { lib: lib!, emitter: false, own: { players: { slots: [0, 1], forces: [0, 1, 0, 0, 0, 0, 0, 0] }, humans: [0], width: 2048, height: 2048 } }); + expect(nobody.diagnostics).toEqual([]); + expect(nobody.ownRefusal).toBe("no human or computer player of this map is among the program's owners"); + }); + + it("the settings reach the build: a stack past what the map lays out is refused, and a lower depth takes", () => { + const deep = `function fib(n: number): number { return n < 2 ? n : fib(n - 1) + fib(n - 2); }\nprogram(() => { let a: number[] = []; while (true) { a.push(fib(minerals(P1))); displayText("x"); sleep(seconds(1)); } });`; + const r = compile(deep); + const refused = buildOwnMap({ chk: scenario(), compiled: r, settings: { stackDepth: 65536 } }); + expect(isRefused(refused) ? refused.refused : "took").toMatch(/^stacks of 131072 cells \(65536 calls deep/); + const ok = buildOwnMap({ chk: scenario(), compiled: r, settings: { stackDepth: 64, heapCells: 2048 } }); + expect(isRefused(ok)).toBe(false); + // A smaller heap and a shallower stack are smaller in the payload. + const whole = buildOwnMap({ chk: scenario(), compiled: r }); + expect(!isRefused(whole) && !isRefused(ok) && ok.stats.payloadBytes < whole.stats.payloadBytes).toBe(true); + }); + + it("reads and writes the string table and the chunks faithfully", () => { + expect(readStrings(new Uint8Array(0), false).entries).toEqual([]); + const s = new Sections(scenario({ strings: ["a", "b", "a"] })); + const t = readStrings(s.get("STR ")!, false); + expect(t.entries.map((e) => new TextDecoder().decode(e))).toEqual(["a", "b", "a"]); + expect(intern(t, "b")).toBe(2); + expect(intern(t, "c")).toBe(4); + const w = writeStrings(t); + // Two strings alike share their bytes. + expect(w.offsets[0]).toBe(w.offsets[2]); + expect(readStrings(w.data, true).entries.map((e) => new TextDecoder().decode(e))).toEqual(["a", "b", "a", "c"]); + // A chunk with a negative length ends the reading; what follows is kept. + const odd = new Uint8Array([...writeChunks([{ name: "VER ", data: u16(59) }]), 73, 83, 79, 77, 0xff, 0xff, 0xff, 0xff, 1, 2, 3]); + const read = new Sections(odd); + expect(read.chunks.map((c) => c.name)).toEqual(["VER "]); + expect([...read.bytes()]).toEqual([...odd]); + }); +}); diff --git a/tests/service.test.ts b/tests/service.test.ts index 6e63c7a..3761b6b 100644 --- a/tests/service.test.ts +++ b/tests/service.test.ts @@ -1,16 +1,19 @@ /** * The service against a stand-in host: applying lands on the map the script was compiled - * for, and only there; saving applies the script first and hands its programs to the - * eudplib plugin's build. The compiler is the real one, run synchronously; the host is the - * handful of calls `prepare` and `install` make, over a trigger list and a member map - * per "document". + * for, and only there; saving applies the script first and builds its programs — by + * TrigScript's own build step, or, where that does not take the script, by handing them to + * the eudplib plugin's build. The compiler is the real one, run synchronously; the host is + * the handful of calls `prepare`, `install` and `buildOwn` make, over a trigger list and a + * member map per "document". */ import ts from "typescript"; import { describe, expect, it } from "vitest"; import type { PluginApi } from "@scm-js/plugin-api"; import { ActionType, emptyAction, emptyTrigger, type TriggerRecord } from "../vendor/triggers"; import { compileScript } from "../compiler/compiler"; -import { firstFault, positionIn, ScriptService, type Compiler } from "../service"; +import { buildOwnMap } from "../compiler/backend/build"; +import { Sections, readFacts, writeChunks } from "../compiler/backend/chk"; +import { firstFault, MAGENTA_MEMBER, positionIn, ScriptService, type Compiler } from "../service"; import { readFiles, readManifest, scriptState } from "../script"; import type { EudplibBuildEvent, EudplibContribution, EudplibService } from "../vendor/eudplib"; import { defaultLib } from "../bundle/lib.mjs"; @@ -19,14 +22,18 @@ const LIB = defaultLib(); interface Doc { id: number; triggers: TriggerRecord[]; extras: Map; strings: string[]; locations: string[] } +/** A build step as the host keeps it (`buildSteps.add`). */ +interface Step { id: string; label: string; applies: () => boolean; run: (input: { map: Uint8Array; fileName: string; purpose: "save" | "test" | "export"; signal: AbortSignal }) => Promise } + /** A host with several open maps and one in front. */ function host() { const docs: Doc[] = []; let front: Doc | null = null; const statuses: string[] = []; const doc = () => { if (!front) throw new Error("no map"); return front; }; - /** What the editor's save path holds for the plugin: the work before the bytes, and the library the service watches for. */ + /** What the editor's save path holds for the plugin: the work before the bytes, the build steps, and the library the service watches for. */ const before: { id: string; label: string; applies?: () => boolean; run: (ctx: { purpose: "save"; signal: AbortSignal }) => void | Promise }[] = []; + const steps: Step[] = []; let library: EudplibService | null = null; const watchers = new Set<(s: EudplibService | null) => void>(); const api = { @@ -34,13 +41,20 @@ function host() { isOpen: () => front !== null, id: () => front?.id ?? null, info: () => (front ? { width: 64, height: 64 } : null), + // P1 a human, P2 a computer, in forces of their own. + scenario: () => (front ? { width: 64, height: 64, playerTypes: [6, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], forces: { playerForce: [0, 1, 0, 0, 0, 0, 0, 0] }, units: [], locations: [], cuwp: null } : null), + // (A bare scenario stands for the map file here.) + sections: { chkOf: async (file: Uint8Array) => file }, extras: { list: () => [...doc().extras.keys()], get: (name: string) => doc().extras.get(name) ?? null, set: (name: string, bytes: Uint8Array) => { doc().extras.set(name, bytes); }, remove: (name: string) => { doc().extras.delete(name); }, }, - buildSteps: { before: (spec: (typeof before)[number]) => { before.push(spec); return { dispose: () => { before.splice(before.indexOf(spec), 1); } }; } }, + buildSteps: { + before: (spec: (typeof before)[number]) => { before.push(spec); return { dispose: () => { before.splice(before.indexOf(spec), 1); } }; }, + add: (spec: Step) => { steps.push(spec); return { dispose: () => { steps.splice(steps.indexOf(spec), 1); } }; }, + }, update: (_label: string, build: (tx: unknown) => void) => { const d = doc(); build({ @@ -69,6 +83,7 @@ function host() { api: api as unknown as PluginApi, statuses, before, + steps, /** The eudplib plugin arrives (or goes): what it was asked to build with is kept for the test to look at. */ provide(svc: EudplibService | null) { library = svc; for (const w of [...watchers]) w(svc); }, open(id: number, locations: string[] = []): Doc { const d: Doc = { id, triggers: [], extras: new Map(), strings: [""], locations }; docs.push(d); front = d; return d; }, @@ -80,14 +95,26 @@ function host() { /** The real compiler, at once — with a hook to run something between the compile starting and its result landing. */ function compiler(during: () => void = () => {}): Compiler { return async (input) => { - const r = compileScript(ts, input.files, input.names, { lib: LIB }); + const r = compileScript(ts, input.files, input.names, { lib: LIB, ...(input.own ? { own: input.own } : {}) }); during(); return r; }; } +/** The build of a map of our own, at once. */ +const builder = async (input: Parameters[0]) => buildOwnMap(input); + const SRC = `trigger(P1, [always()], [displayText("hi")]);`; +/** A scenario of the few sections the build reads: 64 × 64, P1 a human and P2 a computer, two strings, no triggers. */ +function chk(): Uint8Array { + const u16 = (...v: number[]) => new Uint8Array(v.flatMap((x) => [x & 0xff, (x >> 8) & 0xff])); + const ownr = new Uint8Array(12); ownr[0] = 6; ownr[1] = 5; + const forc = new Uint8Array(20); forc[1] = 1; + const str = new Uint8Array([2, 0, 6, 0, 8, 0, 65, 0, 66, 0]); + return writeChunks([{ name: "VER ", data: u16(59) }, { name: "DIM ", data: u16(64, 64) }, { name: "OWNR", data: ownr }, { name: "FORC", data: forc }, { name: "STR ", data: str }, { name: "TRIG", data: new Uint8Array(0) }]); +} + describe("service: a build lands on the map it was compiled for", () => { it("builds the map in front and stores the files", async () => { const h = host(); @@ -195,6 +222,98 @@ const ctx = { purpose: "save" as const, signal: new AbortController().signal }; describe("service: saving applies the script and builds its programs", () => { const PROGRAM = `${SRC}\nprogram(() => { let n = 0; while (true) { n++; displayText("tick"); sleep(seconds(1)); } });`; + /** A program TrigScript's own build does not take (Wait is refused on purpose): built through eudplib. */ + const THROUGH_EUDPLIB = `${SRC}\nprogram(() => { let n = 0; while (true) { n++; displayText("tick"); wait(1000); sleep(seconds(1)); } });`; + + it("builds the programs itself: the step applies, the contribution does not, and the scenario comes back a Remastered one with the payload in it", async () => { + const h = host(); + h.open(1); + const lib = libraryStub(); + let compiles = 0; + const svc = new ScriptService(h.api, () => {}, compiler(() => { compiles++; }), builder); + svc.attach(); + h.provide(lib.svc); + expect(h.steps).toHaveLength(1); + const step = h.steps[0]; + expect(step).toMatchObject({ id: "build", label: "TrigScript" }); + // Nothing applied, then triggers alone: nothing to build either way. + expect(step.applies()).toBe(false); + expect(lib.contributions[0].applies()).toBe(false); + await svc.build(SRC); + expect(step.applies()).toBe(false); + expect(lib.contributions[0].applies()).toBe(false); + // A program the own build takes: the step, not the contribution; the manifest says nothing was refused. + await svc.build(PROGRAM); + expect(step.applies()).toBe(true); + expect(lib.contributions[0].applies()).toBe(false); + expect(svc.buildsOwn()).toBe(true); + expect(svc.whyNotOwn()).toBeNull(); + const manifest = readManifest(new Map([...h.api.document.extras.list().map((n) => [n, h.api.document.extras.get(n)!] as const)])); + expect(manifest?.programs).toBe(1); + expect(manifest?.ownRefusal).toBeUndefined(); + // The step: the scenario in, the built one out — no second compile, the apply's is fresh. + const heard: EudplibBuildEvent[] = []; + svc.onBuild((e) => heard.push(e)); + const before = compiles; + const out = await step.run({ map: chk(), fileName: "x.scx", purpose: "save", signal: new AbortController().signal }); + expect(compiles).toBe(before); + const s = new Sections(out); + expect(readFacts(s).version).toBe(206); + expect(s.get("STRx")!.length).toBeGreaterThan(10); + expect(s.get("TRIG")!.length % 2400).toBe(0); + expect(s.get("TRIG")!.length).toBeGreaterThan(0); + // Start, a line for the build itself, one for each fix (a Remastered file; STR written as STRx), the counts, done. + expect(heard.map((e) => e.kind)).toEqual(["start", "log", "log", "log", "log", "done"]); + expect(heard[0]).toMatchObject({ kind: "start", purpose: "save", contributors: ["trigscript"] }); + expect(heard.at(-1)).toMatchObject({ kind: "done", purpose: "save", chkBytes: out.length }); + expect((heard[1] as { line: string }).line).toMatch(/^Built by TrigScript itself: 1 program laid out into the map, no eudplib$/); + // The step going with the service. + svc.attach().dispose(); + }); + + it("a program its own build does not take goes through eudplib, and the manifest says why", async () => { + const h = host(); + h.open(1); + const lib = libraryStub(); + const svc = new ScriptService(h.api, () => {}, compiler(), builder); + svc.attach(); + h.provide(lib.svc); + await svc.build(THROUGH_EUDPLIB); + const manifest = readManifest(new Map([...h.api.document.extras.list().map((n) => [n, h.api.document.extras.get(n)!] as const)])); + expect(manifest?.ownRefusal).toMatch(/^the action Wait \(.*\), line 2$/); + expect(svc.buildsOwn()).toBe(false); + expect(svc.whyNotOwn()).toMatch(/^TrigScript's own build does not take the script yet: the action Wait/); + expect(h.steps[0].applies()).toBe(false); + expect(lib.contributions[0].applies()).toBe(true); + // Asked to build all the same (an older manifest, say): the refusal is the error, said to the listeners too. + const heard: EudplibBuildEvent[] = []; + svc.onBuild((e) => heard.push(e)); + await expect(h.steps[0].run({ map: chk(), fileName: "x.scx", purpose: "test", signal: new AbortController().signal })).rejects.toThrow(/^TrigScript's own build does not take the script: the action Wait .*\. Apply the script again to build it through eudplib\.$/); + expect(heard.at(-1)).toMatchObject({ kind: "failed", purpose: "test", from: "trigscript" }); + // Made right, the next apply takes it back. + await svc.build(PROGRAM); + expect(svc.buildsOwn()).toBe(true); + expect(h.steps[0].applies()).toBe(true); + expect(lib.contributions[0].applies()).toBe(false); + }); + + it("Magenta's triggers in the map send the programs through eudplib, with them", async () => { + const h = host(); + const d = h.open(1); + const lib = libraryStub(); + const svc = new ScriptService(h.api, () => {}, compiler(), builder); + svc.attach(); + h.provide(lib.svc); + await svc.build(PROGRAM); + expect(svc.buildsOwn()).toBe(true); + d.extras.set(MAGENTA_MEMBER, new Uint8Array([123, 125])); + expect(svc.buildsOwn()).toBe(false); + expect(svc.whyNotOwn()).toMatch(/^Magenta's triggers are in this map/); + expect(h.steps[0].applies()).toBe(false); + expect(lib.contributions[0].applies()).toBe(true); + d.extras.delete(MAGENTA_MEMBER); + expect(h.steps[0].applies()).toBe(true); + }); it("the work before a save applies a script that is newer than its block, and only then", async () => { const h = host(); @@ -252,11 +371,14 @@ describe("service: saving applies the script and builds its programs", () => { expect(lib.contributions).toHaveLength(1); const mine = lib.contributions[0]; expect(mine).toMatchObject({ id: "trigscript", label: "TrigScript" }); - // Nothing applied yet, then a script of triggers alone: nothing for eudplib, so Save stays what it was. + // Nothing applied yet, then a script of triggers alone: nothing for eudplib, so Save stays what it was. A program + // TrigScript builds itself is nothing for eudplib either; one it does not take is. expect(mine.applies()).toBe(false); await svc.build(SRC); expect(mine.applies()).toBe(false); await svc.build(PROGRAM); + expect(mine.applies()).toBe(false); + await svc.build(THROUGH_EUDPLIB); expect(mine.applies()).toBe(true); const before = compiles; const input = await mine.collect(ctx);