From 505416a4e8bdd8ea2b1df48a970ca9b347381a2c Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 15 Oct 2026 23:35:27 -0600 Subject: [PATCH 1/7] TrigScript 4: the plan, and A0 as built The lifted game as the checker (A), the language (B), the game pushed (C). A0 ran the same day: every probe plays headless in scm-sim's lifted game. Co-Authored-By: Claude Opus 5.5 --- docs/v4-plan.md | 500 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 500 insertions(+) create mode 100644 docs/v4-plan.md diff --git a/docs/v4-plan.md b/docs/v4-plan.md new file mode 100644 index 0000000..3ed9215 --- /dev/null +++ b/docs/v4-plan.md @@ -0,0 +1,500 @@ +# TrigScript 4 — the plan + +Written 2026-10-15. Status: nothing built. The question it answers: now that there is a +lifted copy of the game's own code (scm-sim's exact engine) beside eudplib, what does +TrigScript become if it uses the game itself as its checker, and how far can the language +go towards the TypeScript people actually write. + +The plan has three parts, in the order they would be built: + +- **A. The game as the checker.** Tests, the address check, desyncs, costs and the + debugger, run in the lifted game instead of (or beside) the simulator. +- **B. The language.** `async`/`await`, events, function values, `try`/`catch`, + `Local` and `sync()`, fixed-point numbers, typed memory. +- **C. The game, pushed.** What a map can do with that language, one probe at a time. + +A comes first because it makes every later slice cheaper to check: most of what a probe +answers today, the lifted game answers in the editor. It cannot answer everything (see +*What the lifted game cannot tell us*), so probes stay, fewer and narrower. + +## Where 3.12 stands + +TrigScript 3.12 compiles a `program()` to an IR, `python/trigscript.py` lowers the IR +with eudplib inside the eudplib plugin, and Save writes the built map. The language has +signed numbers, arrays and a heap, records, `Map`/`Set`, strings, classes, called +functions, recursion, callbacks, `Unit` objects, input through MSQC and chatEvent, and +`test()` blocks run by `compiler/simulateIr.ts` on the game's trigger schedule. + +Two costs have stayed with every slice since 3.0: + +1. **Each slice that touches the game has a probe the user plays by hand.** The probe's + lines say "(expect …)" and a person reads them in the game. Sixteen probes so far. +2. **The simulator is a model.** It answers what it was written to answer. A read it + does not model gives 0, and 9½ made that visible rather than fixing it. + +## What scm-sim changes + +scm-sim's exact engine is StarCraft 1.16.1's executable and storm.dll lifted from x86 to +TypeScript (`src/exact/`, the generated module under `data/lifted/`, never committed). +What is already measured there, and what this plan leans on: + +- It plays maps as well as replays: single player, UMS, the game's own computers, and + several people seated on one game instance (`src/exact/net.ts`). Node runs 2,200–4,100 + frames a second; a start takes 0.7 s. +- The whole of its state is one `DataView` at the game's own addresses (`LiftedGame.m`). + Reading a variable of a built map is a read at an address. +- Snapshots cost about 45 ms and 0.6 MB each, and `rewind.ts` seeks backwards from the + nearest one. That is time travel, already built for the replay viewer. +- Routines can be hooked by address when the module is generated (`liftgame.py --hook`), + as the two sound routines are, and any routine can be called between turns + (`L.callAt`), as the placement check is. +- Trigger text, countdown and outcome are read from memory (`ExactLiveEngine.triggerView`). +- **EUD, measured 2026-10-12:** extended-player Deaths *reads* work; `SetDeaths` *writes* + past the table are refused by 1.16.1's own bounds check. So an eudplib-built map, + which writes past the table from its first frame, does not run in the lifted game as + it stands. Lifting that check is the first job (slice 0). + +### What the lifted game cannot tell us + +The lifted game is 1.16.1. TrigScript programs build for Remastered, whose EUD support +is an emulation of 1.16.1's memory, not the memory itself. The two differ in ways we +have already met: + +| Probe | 1.16.1 meaning | Remastered | +| --- | --- | --- | +| Magenta 6, I | a unit's position written | ends the game ("EUD not supported") | +| Magenta 10, 1–3 | the tile buffer written through the MTXM pointer | nothing changes on screen | +| Magenta 10, 4 | sprite image flags (tint) | ends the game | +| Magenta 6, J K | a unit's own top speed | no effect; only the type's table counts | +| Magenta 6, Y H | the cloak status flags | no visible cloak | +| Magenta 5, 9 | the game speed written | no effect | +| Magenta 7 | a key held | Remastered sends one event a press, no key-up | + +Remastered's executable is packed and encrypted on disk, so its emulation table cannot +be read from the file. The user agreed on 2026-10-15 that Remastered may be taken +through Ghidra to settle rules the compiler needs, provided none of it ships. The way in +would be a dump of the running process, once it has unpacked itself, read in Ghidra; the +rules found go into the allowlist with "Remastered, disassembled" as their evidence, and +a probe still confirms each family before a library offers it. Until that is done, the +lifted game tells us **what an address means and what the triggers did with it**; +only a played probe tells us **whether Remastered allows it**. Part A turns that split +into a file (the allowlist) and a check, so the gap is seen at the line that falls into +it rather than in a game that ends. + +## Principles + +1. **It is TypeScript** (unchanged from the Remastered plan). A construct valid in + TypeScript with a sensible meaning in the game works; one without says why and what + to write instead. +2. **The simulator is the fast path; the game is the judge.** Every test still runs in + the simulator on each compile. *Run in the game* runs the same tests in the lifted + game. When the two disagree, that is a bug in one of them, and the Testing view says + both answers. +3. **The allowlist is a file of facts with sources.** Every address a built map touches + is on it with the probe and date that showed it working in Remastered, or the build + says so at the line. An `unsafe` block can go past it, with a reason the author writes. +4. **A desync is a compile error where it can be, a test failure where it cannot.** +5. **Only verified facts ship** (unchanged). A new family of addresses still gets a probe + the user plays; the lifted game shrinks what the probe has to ask. +6. **The engine stays behind the plugin edge.** The engine is a separate work (the + decision of 2026-10-12): TrigScript never vendors or imports it. TrigScript uses + `scm-sim.engine` when that plugin is installed and the person's own copy of the game is + there, and works as 3.12 does when it is not. CI has neither, so engine tests skip + there, as the game-data suites in scm-js do. + +## Part A — the game as the checker + +### A0. Spike: a built map runs in the lifted game + +What it has to show, in order: + +1. **A lifted game that lets EUD writes through.** The bounds check is code, and the + lifted code is TypeScript, so patching bytes in memory changes nothing. (As built, the + routine is replaced at run time; no second build was needed.) +2. **eudplib's output loads in 1.16.1.** Things to check: the CHK version the build + writes, `STRx` against `STR`, anything eudplib does only for Remastered. If 1.16.1 + refuses the map, can a load-time rewrite (as scm-sim does for 1.21 replays) fix it? +3. **eudTurbo holds.** It writes the countdown to 0 after each pass; 1.16.1 sets it to 30 + before running the pass, so every frame should run (verified for 3.12's schedule). +4. **The probes run headless.** `probes/spike.ts` first, then the other fifteen. Their + printed lines are read from `triggerView` and checked against their own "(expect …)" + text. A probe that has no input steps can then run without a person. + +Done when: every probe without input steps prints what it expects in the lifted game, and +each one that does not has a written reason (a 1.16.1/Remastered difference, or a bug). + +Size: 2–4 days, most of it in scm-sim. **No release.** + +**As built (2026-10-15, one day; scm-sim branch `eud-spike`, uncommitted).** Every probe +runs headless in the lifted game and says what it expected. + +- **No second build of the module.** The lifted module keeps a table of the routines + reached through a pointer, and `setEntry` replaces one at run time. The trigger loop + reaches the Deaths condition (0x42CB60) and the Set Deaths action (0x4C6CC0) that way, so + `src/exact/eud.ts` replaces those two and nothing is regenerated. Replays and Test Map + never see the replacements. +- **The scenario.** eudplib writes `VER` 206, `STRx`, and an eight-byte section of + negative length at the end. `classicScenario` writes `VER` 205, turns `STRx` into a `STR` + of the same size with every byte past the header in place, and drops the last section. +- **Where the strings are.** eudplib counts on Remastered keeping the strings section at + 0x191943C8. Its payload is relocated against that address, and it reads string offsets + from the four-byte table there. The game reads a two-byte table from wherever 0x5993D4 + points. `placeStrings` puts the `STRx` bytes at 0x191943C8 and a two-byte table for the + game just below them, both pointing at the same text. Then a text eudplib writes in place + is the text the game shows. Two layouts that seemed simpler were each wrong in one half: + table strings came out empty under one, made texts under the other. +- **The mask rule** (Remastered's `'SC'` records, which eudplib uses everywhere). The field + is `value & mask` and the number is `number & mask`. Set To writes the field, Add adds to + it and drops the carry, Subtract stops at 0. A condition compares `value & mask` with the + unmasked number. Two eudplib tricks fix this rule, and `tests/exact-eud.test.ts` holds + both: its divide-by-four (Subtract 3 << t under 7 << t) and its text decoding (Add a key + under 0xAA, then under 0x55, which is an exclusive or). The first rule I tried added the + unmasked number, and every made text came out scrambled. +- **Results.** All 15 probes were rebuilt with the 3.12 compiler and run for 3,000 frames, + 1–5 s each. The runner (`scripts/exact-eud.ts`) checks every "(expect …)" by its numbers: + **137 hold**. Of the 9 it flags, 7 hold when read by hand and 2 are one wrong + expectation (both below). It leaves 12 lines for a person, those with words in the + expectation (a name, "about 21"); all 12 read right. Not one address fell outside the + machine. +- **Read by hand, and right:** numbers I, reads E and input A put words between the value + and the bracket. The input probe stops after A, because nobody presses its keys. +- **One expectation is wrong, and the lifted game found it:** units K expects F's Marine at + 15 as the weakest of the Men. But the Zergling handed over at H is a Man too, and the + Marines hurt it to 5 during G. A person found a first version of the same mistake on + 2026-09-18; the fix made then was never replayed. +- **Not checked headless:** made texts in the objectives, a leaderboard label and a unit + name (strings L, M, O). The game shows them, but the runner reads only the text lines. + Anything that needs a person (the input probe, "select a Marine") is not checked either. +- **Found on the way:** scm-sim's reader of the game's text lines missed a line identical + to the one it overwrote ("Tick" each second, once the 13-line ring had gone round). It + now counts the slots the ring moved over as new. That was a bug in Test Map in Browser + too. + +### A1. The address tracer and the allowlist (4.0.0) + +**Tracing.** Hook the routines that run a trigger's conditions and actions. On entry, the +hook reads the condition or action record and works out the address it touches; for the +Deaths family, that is the table's base plus 4 × (player + 12 × unit). It records read or +write, the address, the frame and the trigger's own address. This costs a hook call per +condition, not a check per memory access. + +**Back to the line.** A trigger address has to become an IR node, and then a line. +Candidate: `trigscript.py` records, per IR node, the eudplib objects it made; after the +build eudplib gives each object its address (`GetObjectAddr`), and the build returns a +table `{ start, end, at }` beside the map. Addresses that eudplib's own runtime touches +(the triggers rewriting each other's next pointers) are kept as their own kind, which +Remastered is known to allow. + +**The allowlist.** `data/allowlist.json` in this repository: address ranges, read and/or +write, what the range is (from the disassembly), and the evidence (`"magenta 10, key 4, +2026-09-15: ends the game"`). It is filled first from Magenta's played probes and the 16 +TrigScript probes, so it starts with what is already known. + +**The check.** *Run in the game* traces every address the run touches and holds each one +against the allowlist: + +- on the list, allowed: nothing said; +- on the list, refused: an error at the line ("Remastered ends the game on this write: + Magenta probe 6, key I"); +- not on the list: a warning at the line ("not seen in Remastered: needs a probe"). The + map still builds. + +The trace only sees code the run reached, so the check is only as complete as the tests' +coverage. A1 therefore also shows coverage: lines that ran in the game, lines that did not. + +Ships as 4.0.0, the first version with *Run in the game*, because it adds a whole mode. + +### A2. Tests in the game (4.0.0, with A1) + +`test()` blocks run in the lifted game as well as the simulator. + +- **A test becomes a program.** The test body, with its world set-up, is compiled into a + test copy of the map as one more program, and its assertions record into an array the + runner reads from memory when the run ends. So the test runs with the game's own + trigger schedule, unit table and rules, and no new mechanism is needed inside the game. +- **Input.** `sim.type` becomes the text a chat command carries; `sim.click` becomes the + game commands a click sends; `sim.press` becomes a write of the key state the local + machine reads (MSQC reads keys locally). All three need checking against what MSQC + actually reads. +- **What the Testing view shows.** Each test gets two marks, simulator and game. When + they differ, the view says both answers, at the frame they parted. +- **Where it runs.** In the editor through `scm-sim.engine`; the service needs a new + method, roughly `run(map, { frames, seat, inputs, watch, trace })`, which means + contract version 2. Locally from a terminal: `npm run test:game` in this repository, + using `data/lifted/` when it is on the machine. + +A headless run draws nothing, and is allowed from any copy that unlocks the editor +(decided 2026-10-15, see *Open decisions*). + +### A3. The desync detector (4.1.0) + +Run two to eight copies of the game seated as different local players +(`seatPlayers`, local-player words set per copy, as `net.ts` does for multiplayer), feed +them identical turns, and hash each game's state every frame. At the first frame the +hashes differ, report the frame, the last traced trigger in each copy, and its line. + +A test can ask for it: `test("shop", { players: 4 }, …)`. The Testing view then shows +"desync at frame 312, line 48: the copies took different branches here". + +To check first: UMS with several people on one instance (melee is measured, UMS is not). + +### A4. What a frame costs (4.1.0) + +From A1's hooks: conditions and actions run per frame, by IR node and by line. Lifted +wall time is not the game's, so the count is the measure. The editor shows it per line +like the existing hints ("~1,200 actions a frame"), and as a graph over a test's run. + +Open: what count, on what machine, actually lags Remastered. A probe with a dial (run +k × 1,000 actions a frame, the user says when it stutters) gives the first number. + +### A5. The debugger in the game (4.2.0) + +9½'s debugger gets a second backend. + +- **Variables** are read from memory: the build's address table (A1) knows where each + `EUDVariable` and array lives. +- **Back as well as forward.** `rewind.ts` keeps a snapshot every N frames; a step back + restores the nearest snapshot and plays forward to the frame. +- **Watchpoints.** "Stop when this changes" compares a dword once a frame, which is cheap. +- **The game beside the code.** scm-sim renders the frame the debugger stands at. + +## Part B — the language + +Each slice states what changes in the IR, the simulator and the Python, and has a probe. +After A2, a probe without input steps also runs headless, and the user plays only what +needs Remastered's answer. + +### B1. `async` / `await` (4.3.0) + +The biggest change to the model since 3.0. Today a `sleep()` may only stand in a +program's own body or in an inlined function; a called function never sleeps. + +```ts +async function wave(n: number) { + createUnit(P8, units.ZergZergling, n * 4, locations.Gate); + await until(() => countUnits(P8, units.ZergZergling) === 0); + print(`Wave ${n} cleared`); +} + +program(async () => { + for (let n = 1; n <= 10; n++) { + const done = await Promise.race([wave(n), sleep(seconds(90)).then(() => false)]); + if (done === false) defeat(); + } +}); +``` + +- **A task** is a frame record on the heap (its locals, and which `await` it is parked at) + plus a resume point. The lowering is the one 3.0 uses for a program's top-level + `sleep`, a switch on the resume point, applied to every `async` function. +- **The scheduler** keeps the live tasks in a list on the heap, and each frame runs every + ready one until it awaits or ends. A `program()` is a task, so its meaning does not + change. +- **What can be awaited:** `sleep(…)`, `until(() => condition)`, any `async` call, + `Promise.all`, `Promise.race`, `Promise.any`. +- **Generators** (`function*`, `yield`) use the same machinery, for wave scripts and + for anything that hands out one step at a time. +- **Recursion with `await`** — the frame is already on the heap, so the recursion stack + of slice 8 may not be needed for an async function. To decide while building. +- **Errors:** `sleep()` outside an `async` function stays an error, with a fix that adds + `async`. An `async` function that never awaits is a hint. + +Open: whether `Promise.race` cancels the tasks that lost. JavaScript does not cancel +them, and a wave that keeps running after its race was lost is rarely what the author +meant. See *Open decisions*. + +### B2. Events (4.4.0) + +```ts +on.created(units.TerranMarine, (u) => { u.energy = 50; }); +on.died({ owner: P8 }, async (u) => { await sleep(frames(24)); createUnit(P8, u.type, 1, locations.Spawn); }); +on.order(units.ProtossHighTemplar, orders.CastPsionicStorm, (u) => …); +on.chat("-give {n}", (p, { n }) => …); +``` + +- **created**: eudplib's `EUDLoopNewCUnit` walks the units made since the last frame. +- **died**: a unit is kept in a table by the identity 8½'s unit keys already use (pointer + plus the slot's use count, which the leftovers probe showed holding in the game) and is + found gone when its slot no longer matches. Who killed it: scm-sim's analysis found a + killer's kill count rises one frame before the victim leaves the lists, which may give + `u.killedBy` on the same rule. That needs its own check. +- **order**: compares each unit's order with the last frame's; the cost is a scan, and the + hint says so. +- A handler is a task (B1), so it may await. + +### B3. Function values (4.5.0) + +```ts +const effects: ((u: Unit) => void)[] = [heal, slow, (u) => { u.hp -= 10; }]; +effects[random(effects.length)](target); +``` + +- A function value is a number, a function id. A call through it is a switch on the id + (or `EUDTypedFuncPtr`, which eudplib 0.81 has; the build may choose). +- **What a closure captures:** a `const` captured by value is exactly JavaScript, since + it cannot change. A captured `let` is the hard part: there is no garbage collector, and + the heap's rule is "the holder owns the block". Proposed: a `let` may be captured only by + a function that does not outlive the frame it was made in (passed down, never stored); + otherwise an error that says to make it `const` or a field. See *Open decisions*. +- This also gives 8½'s callbacks a way to work when they are not inlined: a callback + passed through two functions. + +### B4. `try` / `catch` / `throw` (4.5.0, with B3) + +- `throw` unwinds the frames on the heap, task frames and recursion frames both, to the + nearest `catch`. +- The faults the game reports today (out of memory, recursion too deep, an index out of + range) become thrown `RangeError`s that a program can catch. When nothing catches one, + it is said in red, as now. +- `finally` runs on every way out, `await` included. + +### B5. `Local` and `sync()` (4.6.0) + +Some values are true only on one machine: the local player, where its screen is, what it +has selected, what it is typing. TrigScript gives none of them out today; all input goes +through MSQC, which sends it to every machine. B5 offers them, typed so that a desync +cannot compile: + +```ts +const sel = localSelection(); // Local +centerView(sel[0]); // fine: the camera is the local machine's own +units.push(...sel); // error: a local value cannot change the game +const picked = await sync(sel[0]); // Unit, the same on every machine, a few frames later +``` + +- **The check** is a taint pass over the IR. `Local` reaches what only the local machine + sees (the camera, text shown to the current player, a sound for one player) and nothing + that changes the game state for all: a store into a shared variable, an action's target, + a condition of a branch that does either. +- **`sync()`** sends the value as a game command, as chatEvent's numbers already go + through MSQC's `val`, and resolves when every machine has it. It is `async` by nature, + which is why B5 comes after B1. +- **What can be local:** each read needs its address from the disassembly and a probe in + Remastered. A3 checks the taint pass's answers in practice. + +### B6. `fixed` and `Math` (4.7.0) + +- `fixed` is a 16.16 number with `+ - * /` and comparisons; mixing it with `number` + converts as TypeScript would round. +- `Math.sin`, `Math.cos`, `Math.atan2` work on the game's 256 directions. The tables are + computed at build time from the formula, and A2 checks them against the game's own + table. `Math.sqrt` and `Math.hypot` use an integer square root. +- Movement uses the actions Remastered allows: a location centred on a point and Move + Unit to it. A unit's position is never written; that ends the game. + +### B7. Typed memory (4.8.0) + +```ts +unsafe("the guard's cooldown, read for the HUD; probe 23", () => { + const cd = memory.unit(u).groundWeaponCooldown; // u8, at +0x… +}); +``` + +- The declarations for `memory.unit`, `memory.player`, `memory.sprite` and the others + are generated from the disassembly's structs, with each field's comment and its + allowlist state ("read: probe 12", "write: not seen", "write: ends the game"). +- Reading a field that is on the allowlist needs no `unsafe`. Writing one, or reading one + that is not on it, needs `unsafe` with a reason, and the reason is shown in the + map-requirements view (3.12). +- This is the door the crazy tier walks through, so it comes after the allowlist and the + tracer are settled. + +### B8. Built from the game (4.9.0) + +Tables a map could not afford to compute while it runs, computed by running the lifted +game when the map is built: + +- `ground.distance(a, b)` and `ground.region(x, y)`, from the game's own regions for this + map (Walkability already reads them out of the lifted engine). +- Anything else a function of the map: `atBuild(() => …)` runs in the lifted game at build + time and its result becomes a constant table. + +Cost: map size. The hint says how many bytes each table adds. + +## Part C — the game, pushed + +Each is a library over Part B, offered only after its probe. "Known" means a played probe +already says so. + +| Idea | What it needs | Known | +| --- | --- | --- | +| Terrain as a framebuffer | tile buffer writes | **No.** Magenta 10: Remastered does not draw them. Dropped. (My first answer on 2026-10-15 proposed it; that was wrong.) | +| Units moved freely every frame (bullet hell, orbits) | location + Move Unit, B6 for the paths | position writes end the game (Magenta 6); moving by action is ordinary, so the probe is how many a frame before it lags (A4's dial) | +| Panels of text: objectives, leaderboard label, unit names as live UI | made texts in those fields | **Yes**, the strings spike (2026-09-19): re-read every draw, written only on the target's machine | +| Custom command buttons | button set data in memory | unknown: probe | +| Custom spells | B2's `on.order`, Magenta's damage/heal verb | partly: the verb is verified, the order watch is not | +| Save codes | strings, a checksum, `chatted()` | needs no new address; a probe for the round trip | +| Unit stats changed while the game runs | the type tables | Magenta-verified fields only | +| Computer players written in TrigScript | B1 tasks, unit orders through the scratch-box trick | the trick is verified (Magenta); a behaviour library is new | +| Camera work for one player | `centerView`, B5 | ordinary actions; B5 makes them safe | + +## Slices + +| # | Slice | What it proves | Size | +| --- | --- | --- | --- | +| A0 | Spike: an eudplib map in the lifted game (scm-sim) | EUD writes through a hooked bounds check; 1.16.1 loads the built map; probes print their expectations headless | 2–4 days, no release | +| A1 + A2 | Address tracer, allowlist, tests in the game (4.0.0) | every address a run touches held against Remastered's known answers at the line; each test judged twice; coverage | 6–8 days, plus scm-sim's `run` (contract 2) | +| A3 + A4 | Desync detector, the cost of a frame (4.1.0) | several seated copies hashed every frame; actions per frame per line; the first lag number from a probe | 4–5 days, one probe (the dial) | +| A5 | The debugger in the game (4.2.0) | variables from memory, step back, watchpoints, the frame drawn | 3–4 days, no probe | +| B1 | `async` / `await`, generators (4.3.0) | tasks on the heap, a scheduler, `Promise.all`/`race`/`any`, `until()` | 6–8 days, probe | +| B2 | Events (4.4.0) | created / died / order / chat, handlers as tasks | 3–4 days, probe | +| B3 + B4 | Function values, `try` / `catch` (4.5.0) | calls through an id, the capture rule, unwinding through tasks | 5–6 days, probe | +| B5 | `Local` and `sync()` (4.6.0) | the taint pass; local reads, each probed; A3 confirms | 4–5 days, probe per read | +| B6 | `fixed` and `Math` (4.7.0) | 16.16 arithmetic, the direction tables against the game's | 2–3 days, probe | +| B7 | Typed memory (4.8.0) | generated structs with allowlist states; `unsafe` with a reason | 3–4 days, no probe of its own | +| B8 | Built from the game (4.9.0) | ground distances and regions baked in; `atBuild` | 3 days, probe | +| C | Libraries, one probe each (4.10 on) | the table above | per idea | + +Ship order for every release, as for 3.x: plugin commit and tag → scm-js pin, vendor, +guide and catalogue note → registry → ai-server prompt. Anything scm-sim needs (A0's +build, contract 2) ships first, then plugin-scm-sim, then TrigScript. + +## Work outside this repository + +- **scm-sim:** the EUD-permitting module build (A0); the trigger hooks (A1); `run(…)` with + seating, inputs, a trace and memory reads (A2, A3); UMS with several people on one + instance (A3). +- **plugin-scm-sim:** contract version 2 for `run`; the `present` and `status()` answers + saying whether the person's copy of the game allows a run. +- **plugin-eudplib:** the build returns the object address table (A1), if `trigscript.py` + cannot get it on its own. +- **scm-js:** nothing until the guide pages; `docs/trigscript.md` gains *Run in the game*. + +## Open decisions + +- ~~Does a headless test run count as play?~~ Decided 2026-10-15 (the user: "headless + test runs seem fine as long as theres no rendering"): a run in the game for tests, + traces, desyncs and costs is allowed from any copy that unlocks the editor, provided + nothing of the game is drawn. A5's "the game beside the code" draws, so it needs the + person's own copy, as Test Map does. +- **Does `Promise.race` cancel the losers?** JavaScript: no. Proposed: no, as JavaScript, + plus an explicit `task.cancel()` on what `spawn(…)` returns. +- **What a closure may capture.** Proposed above (B3): `const` by value, `let` only when + the closure does not outlive its frame. The alternative is reference-counted + environments: closer to JavaScript, more Python, and a new kind of fault. +- **How a trigger becomes a line.** The candidate is the object address table (A1). If + eudplib does not keep objects in one piece, the fallback is a marker action per IR node, + which costs triggers. +- ~~Where the hook for the EUD bounds check lives.~~ Settled by A0 (2026-10-15): nowhere + in the build. The two routines are replaced at run time through the module's table of + routines reached by pointer, only in a run that asks for EUD. +- **The version number.** 4.0.0 for A1 + A2 because *Run in the game* is a new mode, or + 3.13 until the language changes in B1. Proposed: 4.0.0 at A1 + A2. + +## Facts this plan leans on + +- scm-sim, `src/exact/` (2026-10-12 to 14): lifted 1.16.1 with storm.dll; maps, replays, + several people on one instance; 2,200–4,100 frames a second in Node; snapshots ~45 ms / + 0.6 MB; hooks by address at generation; `callAt` between turns; `triggerView`; + extended Deaths reads work and writes are refused by 1.16.1's check. +- Remastered's executable is encrypted; no disassembly of it is planned. +- Magenta's played probes 5–11 (2026-09-14/15): position, tint and image writes end the + game; tile buffer writes are not drawn; per-unit speed and cloak flags do nothing; key + hold does not exist; the scratch-box order trick and the damage/heal verb work. +- The strings spike (2026-09-19): objectives, leaderboard label, transmission line and + unit type name show a made text and are re-read every draw. +- eudplib 0.81 (checked in the wheel 2026-10-15): `EUDFuncPtr`, `EUDTypedFuncPtr`, + `EUDLoopNewUnit` / `EUDLoopNewCUnit`, `EUDLoopBullet`, `EUDLoopSprite`, + `QueueGameCommand` and its select / right-click forms. +- TrigScript 3.12 (2026-10-16): the simulator keeps the game's trigger schedule; + programs first run on frame 2; eudTurbo writes 0x6509A0 := 0 after the pass. From 21aa7a038c9c1cf473bce834df8efed94a5e3903 Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 15 Oct 2026 23:55:16 -0600 Subject: [PATCH 2/7] TrigScript 4, A1: a run in the lifted game held against Remastered, by line - python/trigscript.py, with the triggerMap setting: every trigger tagged with the statement it was lowered from (or eudplib's function, or "eudplib"), and its place in the payload logged once laid out. Off by default; a build without it is unchanged. - scripts/build-fixture.mts: TRIGGER_MAP=1 writes .triggers.json, from eudplib's log read whole from a file. - compiler/allowlist.ts: the allowlist's shape and checkTrace, byte by byte under each access's mask; findings by line, gravest first, said in words. - data/allowlist.json from scripts/allowlist.mts: Magenta's catalogue, three failures seen in the game, and what the 15 played probes touched. - scripts/game-check.mts: build, play in scm-sim's lifted game, check. - docs/v4-plan.md: A1 as built. Co-Authored-By: Claude Opus 5.5 --- compiler/allowlist.ts | 168 +++ compiler/generated/trigscriptPy.ts | 2 +- data/allowlist.json | 2073 ++++++++++++++++++++++++++++ docs/v4-plan.md | 44 + python/trigscript.py | 77 ++ scripts/allowlist.mts | 112 ++ scripts/build-fixture.mts | 20 +- scripts/game-check.mts | 47 + tests/allowlist.test.ts | 65 + 9 files changed, 2604 insertions(+), 4 deletions(-) create mode 100644 compiler/allowlist.ts create mode 100644 data/allowlist.json create mode 100644 scripts/allowlist.mts create mode 100644 scripts/game-check.mts create mode 100644 tests/allowlist.test.ts diff --git a/compiler/allowlist.ts b/compiler/allowlist.ts new file mode 100644 index 0000000..b9fe158 --- /dev/null +++ b/compiler/allowlist.ts @@ -0,0 +1,168 @@ +/** + * What Remastered is known to allow, and a run in the lifted game held against it (TrigScript 4, A1). + * + * The lifted game is 1.16.1: it says what an address means and what the triggers did with it, never whether + * Remastered's emulation of 1.16.1's memory would have let them. The allowlist says that, entry by entry, each with + * the evidence it rests on: a probe played in Remastered, Magenta's catalogue (itself verified by probes), or a + * failure seen in the game. `checkTrace` takes what a run touched (scm-sim's `scripts/exact-eud.ts --trace`) and + * says, by line, what Remastered was seen to refuse or ignore and what it has not been seen to do at all. + * + * A trace row is a dword and the bits that were looked at or changed (the record's mask); a row is held byte by + * byte, so a write of one byte under a mask needs only that byte allowed. + */ + +/** What Remastered does with a read or a write of an entry: lets it, ends the game, does nothing, or not known. */ +export type Verdict = "allowed" | "ends" | "ignored" | "unknown"; + +export interface AllowEntry { + /** What lies there ("unit table: hit points", "deaths table"). */ + name: string; + /** The first byte; with `stride`, the first byte of the first of `count` rows. */ + base: number; + stride?: number; + count?: number; + /** Bytes from `base` (within each row). */ + width: number; + read: Verdict; + write: Verdict; + /** What each verdict rests on, in words a person can follow up. */ + evidence: string[]; + /** A value that differs between the players' computers (the mouse, the screen): never to change the game itself. */ + local?: boolean; +} + +export interface Allowlist { + version: 1; + entries: AllowEntry[]; +} + +/** [origin, address, write (1) or read (0), mask, times] — origin as the build's map of triggers names it. */ +export type TraceRow = [string, number, number, number, number]; + +export interface Trace { + map?: string; + frames: number; + /** The map's own strings and eudplib's payload behind them, where Remastered keeps them: the map's own memory. */ + strings: { from: number; to: number }; + /** The lifted game's heap (what the game allocated: its trigger lists among it); absent in older traces. */ + heap?: { from: number; to: number }; + rows: TraceRow[]; +} + +export interface Finding { + /** "12:5" or "file.ts:12:5"; "fn:" for eudplib's own functions, "" for a program's frame, "eudplib", or "?". */ + origin: string; + line: number | null; + write: boolean; + verdict: Exclude; + /** The first address of the kind found, how many distinct dwords and how many times in all. */ + address: number; + addresses: number; + times: number; + /** The entry that decided it, or the table of the game it lies in when no entry covers it. */ + entry: string | null; + evidence: string[]; +} + +export interface TraceCheck { + findings: Finding[]; + /** Accesses (rows) by what decided them. */ + counted: { own: number; heap: number; allowed: number; ends: number; ignored: number; unknown: number }; +} + +/** 1.16.1's tables by name, so that what no entry covers is still said in words, and a loop over every unit is one finding. */ +const TABLES = [ + { name: "unit table", base: 0x59cca8, stride: 336, count: 1700 }, + { name: "deaths table", base: 0x58a364, stride: 4, count: 12 * 228 }, +]; +function placeOf(address: number): string { + for (const t of TABLES) { + const off = address - t.base; + if (off >= 0 && off < t.stride * t.count) return t.stride > 4 ? `${t.name} +0x${(off % t.stride).toString(16)}` : t.name; + } + return ""; +} + +/** Whether a line of the script made the access (and not eudplib's own code, or a program's frame). */ +export function isScriptOrigin(origin: string): boolean { + return /^(.+:)?\d+:\d+$/.test(origin); +} + +function lineOf(origin: string): number | null { + const m = /(\d+):\d+$/.exec(origin); + return m && isScriptOrigin(origin) ? Number(m[1]) : null; +} + +/** The bytes of the dword at `address` that `mask` names. */ +function bytesOf(address: number, mask: number): number[] { + const out: number[] = []; + for (let k = 0; k < 4; k++) if ((mask >>> (8 * k)) & 0xff) out.push(address + k); + return out; +} + +function covers(e: AllowEntry, byte: number): boolean { + const off = byte - e.base; + if (off < 0) return false; + if (!e.stride) return off < e.width; + return off % e.stride < e.width && Math.floor(off / e.stride) < (e.count ?? 1); +} + +/** How grave a verdict is when a row is summed up from its bytes: one byte not known makes the row not known. */ +const RANK: Record = { ends: 3, ignored: 2, unknown: 1, allowed: 0 }; +/** Which of several entries over one byte decides: a failure seen beats a success, a success beats no word at all. */ +const WEIGHT: Record = { ends: 3, ignored: 2, allowed: 1, unknown: 0 }; + +/** What the allowlist says of one byte, for a read or a write, and the entry that says it. */ +export function verdictOf(list: Allowlist, byte: number, write: boolean): { verdict: Verdict; entry: AllowEntry | null } { + let best: { verdict: Verdict; entry: AllowEntry | null } = { verdict: "unknown", entry: null }; + for (const e of list.entries) { + if (!covers(e, byte)) continue; + const v = write ? e.write : e.read; + if (best.entry === null || WEIGHT[v] > WEIGHT[best.verdict]) best = { verdict: v, entry: e }; + } + return best; +} + +/** + * Holds every row of a trace against the allowlist. The map's own memory is always allowed; the game's heap is + * allowed to eudplib's own code (its trigger lists live there, reached through the game's pointers) and unknown to + * a line of the script. Findings are grouped by origin, direction, verdict and the entry that decided them. + */ +export function checkTrace(trace: Trace, list: Allowlist): TraceCheck { + const counted = { own: 0, heap: 0, allowed: 0, ends: 0, ignored: 0, unknown: 0 }; + const groups = new Map }>(); + for (const [origin, address, write, mask, times] of trace.rows) { + if (address >= trace.strings.from && address < trace.strings.to) { counted.own++; continue; } + if (trace.heap && address >= trace.heap.from && address < trace.heap.to) { + if (!isScriptOrigin(origin)) { counted.heap++; continue; } + add(origin, address, write === 1, "unknown", null, ["the game's heap, read or written by a line of the script"], times); + continue; + } + let worst: { verdict: Verdict; entry: AllowEntry | null } = { verdict: "allowed", entry: null }; + for (const byte of bytesOf(address, mask)) { + const v = verdictOf(list, byte, write === 1); + if (RANK[v.verdict] > RANK[worst.verdict]) worst = v; + } + if (worst.verdict === "allowed") { counted.allowed++; continue; } + add(origin, address, write === 1, worst.verdict, worst.entry, worst.entry?.evidence ?? [], times); + } + function add(origin: string, address: number, write: boolean, verdict: Verdict, entry: AllowEntry | null, evidence: string[], times: number) { + counted[verdict as "ends" | "ignored" | "unknown"]++; + const place = entry?.name ?? (placeOf(address) || null); + const key = `${origin}|${write ? 1 : 0}|${verdict}|${place ?? address >>> 12}`; + const g = groups.get(key); + if (g) { g.times += times; if (!g.seen.has(address)) { g.seen.add(address); g.addresses++; } if (address < g.address) g.address = address; return; } + groups.set(key, { origin, line: lineOf(origin), write, verdict: verdict as Finding["verdict"], address, addresses: 1, times, entry: place, evidence, seen: new Set([address]) }); + } + const findings = [...groups.values()].map(({ seen: _seen, ...f }) => f) + .sort((a, b) => RANK[b.verdict] - RANK[a.verdict] || (a.line ?? 1e9) - (b.line ?? 1e9) || a.address - b.address); + return { findings, counted }; +} + +/** A finding as one line for a person: where, what, and what it rests on. */ +export function describeFinding(f: Finding): string { + const where = f.line !== null ? `line ${f.line}` : f.origin === "" ? "a program's frame" : f.origin.startsWith("fn:") ? `eudplib's ${f.origin.slice(3)}` : f.origin === "?" ? "eudplib's start" : f.origin; + const what = `${f.write ? "writes" : "reads"} ${f.entry ?? "0x" + f.address.toString(16)}${f.addresses > 1 ? ` (${f.addresses} dwords from 0x${f.address.toString(16)})` : f.entry ? ` at 0x${f.address.toString(16)}` : ""}`; + const verdict = f.verdict === "ends" ? "Remastered ends the game on this" : f.verdict === "ignored" ? "Remastered does nothing with this" : "not seen in Remastered: needs a probe"; + return `${where}: ${what} — ${verdict}${f.evidence.length ? ` (${f.evidence.join("; ")})` : ""}`; +} diff --git a/compiler/generated/trigscriptPy.ts b/compiler/generated/trigscriptPy.ts index 8f06e84..9b306e6 100644 --- a/compiler/generated/trigscriptPy.ts +++ b/compiler/generated/trigscriptPy.ts @@ -1,2 +1,2 @@ // Generated by scripts/embed-python.mts from python/trigscript.py — do not edit. -export const TRIGSCRIPT_PY: string = "\"\"\"\n[trigscript]\nir : /work/files/trigscript.json\n\nTrigScript's programs: the euddraft plugin that lowers the compiler's IR — data, never\ncode — into eudplib. The IR file is what the plugin's compiler wrote (docs/ir.md);\nthis module is handed to the eudplib library plugin as a source with every build, so the\ntwo halves of the IR version are always the same build.\n\nA program is a coroutine the game runs every frame. Its body is lowered to straight-line\neudplib triggers with jumps between labels: `if` / `while` / `switch` become conditional\njumps, and `sleep` stores the label to resume at in a state variable, sets a frame counter,\nand leaves the frame. The frame's entry counts the wait down, then jumps to the stored\nlabel. A program runs for the players it is owned by, as a trigger would: one owner runs\nit as that player while that player is in the game; several owners, All Players or a force\nrun it once each frame for every such player who is in the game, CurrentPlayer set, its\nvariables and its state as 12-slot arrays indexed by the player.\n\nText is written out in the IR: an action's `text` or `wav` is the string itself, which\neudplib adds to the built map's string table, or a number when the script named an index\nof the map's own. The map the user edits never holds a program's strings.\n\nA read is a value of the game taken when the expression is evaluated. What a condition compares\nis read from the game's tables where the table is that value (a player's deaths, kills, ore, gas)\nand otherwise found by asking the condition itself — \"at least 2^31? at least that plus 2^30? …\" —\nso a read means what the condition means, for a force's minerals or the units at a location alike.\nPlayer facts are bytes of the player tables. Text with values in it is printed through eudplib's\nstring buffer, for the player it is for and nobody else.\n\nA unit of the game is a pointer into the game's unit table — 1700 slots of 336 bytes — with the slot's\nuniqueness byte kept beside it: the game gives a dead unit's slot to the next unit made, so a unit kept\nin a variable is checked before every use (a sprite, an order other than \"die\", the same uniqueness\nbyte) and reads 0, and takes no write, once it is gone. A loop over units and a pick walk the whole\ntable with conditions whose address is moved on a slot at a time, as eudplib's own EUDLoopUnit2 does;\na dying unit is passed over. What a unit can be asked and told is what Magenta's probe maps saw\nworking in Remastered: no position, cloak or speed writes. The game's tables (`stats()`) are plain\ncells at addresses the IR carries; a speed is four flingy records, a colour two bytes, a name a string.\n\nWhat the players do — keys, clicks, the mouse, what they type — happens on one computer, and two\nplugins the build adds beside this one bring it to all of them in step. chatEvent (before this\nplugin) finds the line the local player typed; this plugin matches it against the programs'\npatterns, there and then, into a number for the pattern and up to three values; MSQC (after this\nplugin) sends those, the keys and the clicks to every computer as the player they came from, and\nkeeps each player's mouse in a location. They land in arrays registered by name — which is how the\nother two plugins' settings reach them — a cell per player, fresh every frame: an input reads 1 on\nthe frame it arrives. The IR's `input` lists what is asked for; the editor wrote the settings from it.\n\nNumbers keep one contract with the simulator: 32 bits, a `number` signed and a `u32` not, wrapping at\neither end. + - * and the bitwise operators are the same bits whichever way they are read; what reads\nthem one way or the other - a comparison, a division, a shift right, min and max, a printed number -\nsays which in the IR (`unsigned`), so nothing here works a type out. Where the game takes nothing below\nzero the compiler has already written max(v, 0), and a store keeps stopping at the top of what it holds.\nIn here a number known when the map is built is a Python int holding the 32 bits, 0 to 2^32 - 1.\n\nA text is kept one of two ways, a variable at a time (the IR says which). One that only ever holds texts written in\nthe script is the text's id in the built map's string table: a number. One that is made while the map is played is\nthree cells - where its bytes are (UTF-8, ended by a 0), the block of the heap it owns (0: none, the bytes are a\nstring of the table) and its length in characters. A made text is written into one scratch buffer, measured, and\ncopied into a block of just that size, whose first cell says which size; what holds the text owns the block, so\nassigning copies it, a value that was just made is moved, and whatever a variable held goes back to the heap once\nits new value is worked out. compiler/simulateIr.ts takes and gives blocks in the same order, so both run out at\nthe same text. A made text in an action's field goes over a string the build keeps for that kind of field, on the\ncomputer of the player the action is for and nowhere else: the game reads such a string again whenever it draws.\n\"\"\"\nimport json\n\nfrom eudplib import *\nfrom eudplib.core.mapdata.stringmap import ForceAddString\nfrom eudplib.memio.rwcommon import br1, br2, bw1\n\nIR_VERSION = 14\nFRAMES_PER_SECOND = 24\n# Where the game keeps what a read reads (1.16.1 addresses, which Remastered emulates). The player\n# tables are the ones Magenta's probes 5 and 8 read in the game.\nDEATHS_TABLE = 0x58A364\nKILLS_TABLE = 0x5878A4\nORE_TABLE = 0x57F0F0\nGAS_TABLE = 0x57F120\nPLAYER_BYTES = {\"slot\": 0x57F1B4, \"race\": 0x57F1C0}\n# Per race (144 bytes apart: Zerg, Terran, Protoss), a dword per player, in half supplies.\nSUPPLY_TABLES = {\"provided\": 0x582144, \"used\": 0x582174, \"max\": 0x5821A4}\n# The unit table, and a unit's dwords the scans test (as EPD offsets from the unit's own).\nUNIT_TABLE = 0x59CCA8\nUNIT_SIZE = 336\nUNIT_SLOTS = 1700\nUNIT_END = UNIT_TABLE + UNIT_SIZE * UNIT_SLOTS\nOFF_SPRITE, OFF_POS, OFF_OWNER_ORDER, OFF_TYPE, OFF_UID = 0x0C // 4, 0x28 // 4, 0x4C // 4, 0x64 // 4, 0xA4 // 4\n# units.dat: max hit points (dword, 256 to a point), max shields (word), the group flags a trigger's\n# Men / Buildings / Factories go by (byte), the flingy a type moves as (byte).\nUNITS_MAX_HP = 0x662350\nUNITS_MAX_SHIELDS = 0x660E00\nUNITS_GROUP = 0x6637A0\nUNITS_FLINGY = 0x6644F8\nGROUP_BITS = {230: 0x08, 231: 0x10, 232: 0x20}\n# flingy.dat, by flingy id: movement control (byte), top speed (dword), acceleration (word), halt distance (dword).\nFLINGY_CONTROL, FLINGY_SPEED, FLINGY_ACCELERATION, FLINGY_HALT = 0x6C9858, 0x6C9EF8, 0x6C9C78, 0x6C9930\nMINIMAP_COLOR_OFFSET = 0x581DD6 - 0x581D76\n# The location table, and the one an order borrows for the length of one action (put back after).\nMRGN = 0x58DC60\nSCRATCH_LOCATION = 255\nUNIT_TIMERS = {\"stim\": \"stimTimer\", \"ensnare\": \"ensnareTimer\", \"plague\": \"plagueTimer\", \"lockdown\": \"lockdownTimer\", \"maelstrom\": \"maelstromTimer\", \"irradiate\": \"irradiateTimer\", \"stasis\": \"stasisTimer\"}\nSTATUS_INVINCIBLE, STATUS_HALLUCINATION, STATUS_BURROWED, STATUS_CLOAKED = 0x04000000, 0x40000000, 0x00000010, 0x00000300\nCOND_COMMAND, COND_BRING, COND_ACCUMULATE, COND_KILL, COND_OPPONENTS, COND_DEATHS = 2, 3, 4, 5, 14, 15\nCURRENT_PLAYER = 13\n# The state of a program whose body ended: nothing resumes it.\nDONE = 0xFFFFFFFF\nU32 = 0xFFFFFFFF\nSIGN = 0x80000000\n\nMINUS_SIGN = Db(b\"-\\0\\0\\0\")\nNO_SIGN = Db(b\"\\0\\0\\0\\0\")\n\nwith open(settings[\"ir\"], encoding=\"utf-8\") as _f:\n IR = json.load(_f)\nif IR.get(\"version\") != IR_VERSION:\n raise RuntimeError(\"trigscript: the IR is version %r; this plugin reads version %d\" % (IR.get(\"version\"), IR_VERSION))\n\n\ndef where(node):\n \"\"\"\" at main.ts:12:5\" for a node with a position — what the editor parses back into a marker.\"\"\"\n at = node.get(\"at\") if isinstance(node, dict) else None\n if not at:\n return \"\"\n if at.get(\"file\"):\n return \" at %s:%s:%s\" % (at.get(\"file\"), at.get(\"line\"), at.get(\"column\"))\n return \" at %s:%s\" % (at.get(\"line\"), at.get(\"column\"))\n\n\nclass Fail(RuntimeError):\n pass\n\n\nclass Leave(Exception):\n \"\"\"Raised through the lowering when a statement ends the straight line (break / continue / return).\"\"\"\n\n\nclass Storage:\n \"\"\"A variable's cell: plain, or a 12-slot row of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n self.store = EUDArray([0] * 12) if self.rowed else EUDVariable(0) # initial: the variable's own cell\n\n def get(self):\n return self.store[self.player_of()] if self.rowed else self.store\n\n def set(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n if self.rowed:\n self.store[self.player_of()] = value\n else:\n self.store << value\n\n\nclass ArrayStorage:\n \"\"\"An array's cells: `length` of them, twelve rows of that in a per-player program, or - `values` - a list\n the script computed when it was built, which is in the map as it loads and which nothing writes. An index\n past either end (read from 0 up, so one below zero too) reads 0 and stores nothing.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.bits = decl.get(\"bits\")\n values = decl.get(\"values\")\n self.rowed = per_player and not decl.get(\"shared\") and values is None\n self.player_of = player_of\n if decl.get(\"texts\") is not None:\n # A list of texts the script has: a cell is the text's id in the built map's table.\n values = [text_id(t) for t in decl[\"texts\"]]\n self.store = EUDArray([int(v) & U32 for v in values] if values is not None else [0] * (self.length * (12 if self.rowed else 1))) # initial: a table's values, or cells declareArray sets\n\n def at(self, index):\n \"\"\"The cell's place among the store's: the player's row first.\"\"\"\n if not self.rowed:\n return index\n p = self.player_of()\n row = p * self.length if isinstance(p, int) else f_mul(p, self.length)\n return row + index\n\n def get(self, index):\n if isinstance(index, int):\n return self.store[self.at(index)] if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.store[self.at(index)]\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n if isinstance(index, int):\n if index < self.length:\n self.store[self.at(index)] = value\n return\n if EUDIf()(index <= self.length - 1):\n self.store[self.at(index)] = value\n EUDEndIf()\n\n def fill(self, value):\n if self.length <= 16:\n for i in range(self.length):\n self.set(i, value)\n return\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\nclass SliceStorage:\n \"\"\"A window on another array's cells - a row of a grid: cell i is cell `offset + i` of the array it is a window\n on, `offset` a variable of the program. Past its own end it reads 0 and stores nothing, so a row never reaches\n into the next; what it is a window on keeps its own ends (and its own width, and its row a player).\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.lowering = lowering\n\n def of(self):\n return self.lowering.array(self.decl[\"slice\"][\"of\"], self.decl)\n\n def offset(self):\n return self.lowering.var(self.decl[\"slice\"][\"offset\"], self.decl).get()\n\n def get(self, index):\n if isinstance(index, int):\n return self.of().get(self.offset() + index) if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.of().get(self.offset() + index)\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if isinstance(index, int):\n if index < self.length:\n self.of().set(self.offset() + index, value)\n return\n if EUDIf()(index <= self.length - 1):\n self.of().set(self.offset() + index, value)\n EUDEndIf()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\n# The heap the programs' growing arrays share. A block is a power of two of cells, four at least; one given back\n# waits in its size's list (its first cell links the next) for whoever wants that size next; new ground is taken\n# from the bottom up. Cell 0 is never handed out: 0 is \"no block\". compiler/simulateIr.ts counts the same way, so\n# both run out at the same push. (Recursion's stack is an array of its own, below: how deep a function may go is\n# then the same whatever the arrays hold.)\nHEAP_CELLS = min(1 << 20, max(1024, int(IR.get(\"heap\", 16384)))) # the map's script settings, or the default\nHEAP_SMALLEST = 4\nHEAP_ROOMS = []\nwhile HEAP_SMALLEST << len(HEAP_ROOMS) <= HEAP_CELLS:\n HEAP_ROOMS.append(HEAP_SMALLEST << len(HEAP_ROOMS))\n_HEAP = {}\n\n\ndef heap():\n \"\"\"The heap's cells and its functions, made when the first growing array is met.\"\"\"\n if _HEAP:\n return _HEAP\n cells = EUDArray(HEAP_CELLS)\n rooms = EUDArray(HEAP_ROOMS + [0xFFFFFFFF]) # initial: the sizes, never written\n free = EUDArray(len(HEAP_ROOMS)) # initial: no block waits\n top = EUDVariable(1) # initial: the heap's own state, for the whole game\n said = EUDVariable(0) # initial: the heap's own state\n\n @EUDFunc\n def take(k):\n \"\"\"A block of size class k: its place among the cells, or 0 when there is none.\"\"\"\n at = EUDVariable()\n at << free[k]\n if EUDIf()(at >= 1):\n free[k] = cells[at]\n EUDReturn(at)\n EUDEndIf()\n end = top + rooms[k]\n if EUDIf()(end <= HEAP_CELLS):\n at << top\n top << end\n EUDReturn(at)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def give(at, k):\n cells[at] = free[k]\n free[k] = at\n\n @EUDFunc\n def grow(ptr, length, room, k, need):\n \"\"\"Room for `need` cells: a larger block, the cells copied over, the old block given back. Returns the\n handle as it is now and whether there is room; when the heap has no block left the handle is unchanged.\"\"\"\n if EUDIf()(need <= room):\n EUDReturn(ptr, room, k, 1)\n EUDEndIf()\n nk = EUDVariable()\n nk << 0\n if EUDIf()(ptr >= 1):\n nk << k + 1\n EUDEndIf()\n if EUDWhile()(rooms[nk] < need):\n nk += 1\n EUDEndWhile()\n if EUDIf()(nk >= len(HEAP_ROOMS)):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n block = take(nk)\n if EUDIf()(block == 0):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n if EUDIf()(ptr >= 1):\n if EUDIf()(length >= 1):\n # An EUDArray's own value is where it is, as an EPD already.\n f_repmovsd_epd(cells + block, cells + ptr, length)\n EUDEndIf()\n give(ptr, k)\n EUDEndIf()\n EUDReturn(block, rooms[nk], nk, 1)\n\n @EUDFunc\n def push(ptr, length, room, k, value):\n \"\"\"One more cell at the end; the handle as it is afterwards. Nothing is pushed when the heap is full.\"\"\"\n ptr, room, k, ok = grow(ptr, length, room, k, length + 1)\n if EUDIf()(ok >= 1):\n cells[ptr + length] = value\n length += 1\n if EUDElse()():\n if EUDIf()(said == 0):\n said << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - an array could not grow (the programs' arrays share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDEndIf()\n EUDReturn(ptr, length, room, k)\n\n _HEAP.update(cells=cells, rooms=rooms, take=take, give=give, grow=grow, push=push)\n return _HEAP\n\n\n# The stack of the functions that call themselves. A function's variables are cells of the program, one of each, so\n# a call that may come back into the function it is in (the IR's `saves`) puts what that function holds here first\n# and takes it back after, with where the function returns to. One stack serves every program and every player:\n# such a function never sleeps, so the stack is empty whenever a frame ends. It is as many frames of the largest\n# frame as the map's script settings allow calls deep (the IR file's `stack`), and the depth is what is counted -\n# compiler/simulateIr.ts counts the same - so a program stops at the same call in both.\nSTACK_DEPTH = min(1 << 16, max(16, int(IR.get(\"stack\", 1024))))\nSTACK_CELLS_MAX = 1 << 20\nHANDLE = (\"ptr\", \"len\", \"room\", \"k\")\n_STACK = {}\n\n\ndef frame_cells(saves, kinds):\n return 1 + 4 * len(saves.get(\"arrays\", [])) + sum(3 if kinds.get(v) in (\"unit\", \"text\") else 1 for v in saves.get(\"vars\", []))\n\n\ndef largest_frame():\n most = [0]\n\n def walk(node, kinds):\n if isinstance(node, list):\n for x in node:\n walk(x, kinds)\n elif isinstance(node, dict):\n if isinstance(node.get(\"saves\"), dict):\n most[0] = max(most[0], frame_cells(node[\"saves\"], kinds))\n for v in node.values():\n if isinstance(v, (dict, list)):\n walk(v, kinds)\n\n def kinds_of(node, into):\n if isinstance(node, list):\n for x in node:\n kinds_of(x, into)\n elif isinstance(node, dict):\n if isinstance(node.get(\"id\"), str) and node.get(\"kind\") in (\"number\", \"boolean\", \"unit\", \"text\") and \"shared\" in node:\n # A text kept as its id is one cell, as a number is; one that was made is three.\n into[node[\"id\"]] = \"number\" if node[\"kind\"] == \"text\" and node.get(\"text\") == \"id\" else node[\"kind\"]\n for v in node.values():\n if isinstance(v, (dict, list)):\n kinds_of(v, into)\n return into\n\n for program in IR.get(\"programs\", []):\n walk(program.get(\"functions\", []), kinds_of(program, {}))\n return most[0]\n\n\ndef stack():\n \"\"\"The stack's cells, where the next frame goes (an EPD, so a cell is one write) and how deep it is.\"\"\"\n if _STACK:\n return _STACK\n frame = largest_frame()\n if frame * STACK_DEPTH > STACK_CELLS_MAX:\n raise Fail(\"trigscript: the stack would be %d cells - %d calls deep, %d cells a call - and %d is the most: lower the recursion depth in the script's Settings, or keep fewer variables in the function that calls itself\" % (frame * STACK_DEPTH, STACK_DEPTH, frame, STACK_CELLS_MAX))\n cells = EUDArray(max(1, frame) * STACK_DEPTH)\n base = cells # an EUDArray's own value is where it is, as an EPD already\n at = EUDVariable(base) # initial: the stack's own state; it is back here whenever a frame ends\n depth = EUDVariable(0) # initial: the stack's own state; nothing is on it between frames\n _STACK.update(cells=cells, base=base, at=at, depth=depth)\n return _STACK\n\n\nclass ListStorage:\n \"\"\"An array that grows: a handle - where its block is among the heap's cells (0: none yet), how many cells are\n in use, how many the block has room for, and the block's size class - a cell each, or a row of twelve each in\n a per-player program. Reads and stores are bounded by the cells in use; a store at exactly the length is a push.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no block\n self.handle = {name: make() for name in (\"ptr\", \"len\", \"room\", \"k\")}\n self.heap = heap()\n\n def field(self, name):\n cell = self.handle[name]\n return cell[self.player_of()] if self.rowed else cell\n\n def put(self, name, value):\n if self.rowed:\n self.handle[name][self.player_of()] = value\n else:\n self.handle[name] << value\n\n def length(self):\n return self.field(\"len\")\n\n def get(self, index):\n out = fresh(0)\n if EUDIf()(as_var(index) < as_var(self.field(\"len\"))):\n out << self.heap[\"cells\"][self.field(\"ptr\") + index]\n EUDEndIf()\n return out\n\n def push(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n ptr, length, room, k = self.heap[\"push\"](self.field(\"ptr\"), self.field(\"len\"), self.field(\"room\"), self.field(\"k\"), value)\n for name, v in ((\"ptr\", ptr), (\"len\", length), (\"room\", room), (\"k\", k)):\n self.put(name, v)\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n i, v, n = as_var(index), fresh(value), as_var(self.field(\"len\"))\n if EUDIf()(i < n):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n if EUDElseIf()(i == n):\n self.push(v) # xs[xs.length] = v, as JavaScript has it\n EUDEndIf()\n\n def pop(self):\n out = fresh(0)\n n = fresh(self.field(\"len\"))\n if EUDIf()(n >= 1):\n n -= 1\n self.put(\"len\", n)\n out << self.heap[\"cells\"][self.field(\"ptr\") + n]\n EUDEndIf()\n return out\n\n def set_length(self, value):\n v = as_var(value)\n if EUDIf()(v < as_var(self.field(\"len\"))):\n self.put(\"len\", v)\n EUDEndIf()\n\n def declare(self, values):\n \"\"\"Declared (again): the block it held goes back, and it starts over with these values.\"\"\"\n ptr = fresh(self.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n self.heap[\"give\"](ptr, self.field(\"k\"))\n EUDEndIf()\n for name in (\"ptr\", \"len\", \"room\", \"k\"):\n self.put(name, 0)\n for v in values:\n self.push(v)\n\n def declare_filled(self, value, count):\n \"\"\"The same with one value `count` times over: a loop, where a push a cell would be a push a cell in the map.\"\"\"\n if count <= 8:\n self.declare([value] * count)\n return\n self.declare([])\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= count - 1):\n self.push(v)\n i += 1\n EUDEndWhile()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i < as_var(self.field(\"len\"))):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n i += 1\n EUDEndWhile()\n\n\nclass InnerListStorage(ListStorage):\n \"\"\"An array that grows inside another: its handle is cell `index` of four arrays the outer one keeps (where the\n block is, how many cells are in use, its room, its size class), `index` a variable of the program. Everything\n else is a growing array's, which only ever asks for its handle's fields and puts them back.\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = False\n self.lowering = lowering\n self.heap = heap()\n\n def at(self):\n return self.lowering.var(self.decl[\"through\"][\"index\"], self.decl).get()\n\n def field(self, name):\n return self.lowering.array(self.decl[\"through\"][name], self.decl).get(self.at())\n\n def put(self, name, value):\n self.lowering.array(self.decl[\"through\"][name], self.decl).set(self.at(), value)\n\n\n# ── texts ──\nTEXT_BYTES = 1023 # the most a made text holds; compiler/ir.ts has the same number\nTEXT_FIELD_BYTES = 255 # the room of a string kept for an action's field or a unit type's name\nUNIT_MAP_STRING = 0x660260 # units.dat: the id of the name a map gives a unit type, a word each\n_TEXT = {}\n_SLOTS = {}\n\n\ndef texts():\n \"\"\"The texts' part of the run time, made when the first text that is made is met.\"\"\"\n if _TEXT:\n return _TEXT\n h = heap()\n cells, rooms, take, give = h[\"cells\"], h[\"rooms\"], h[\"take\"], h[\"give\"]\n # A text being made. It may run one part past its most before it is cut, and a part is at most that long again.\n scratch = Db(TEXT_BYTES + 1 + TEXT_BYTES + 1 + 8)\n empty = Db(4)\n said_cut = EUDVariable(0) # initial: said once a game\n said_full = EUDVariable(0) # initial: said once a game\n said_field = EUDVariable(0) # initial: said once a game\n\n @EUDFunc\n def count(addr):\n \"\"\"How many characters the text at addr is: every byte but those that continue a character (10xxxxxx).\"\"\"\n n = EUDVariable()\n n << 0\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n n += 1\n EUDEndIf()\n EUDEndInfLoop()\n EUDReturn(n)\n\n @EUDFunc\n def append(pos, src):\n \"\"\"The text at src written at pos, ended by a 0; where that 0 is. A text of the table may be any length, so no\n more of it than leaves the scratch whole: what is past a text's most is cut by make() anyway.\"\"\"\n br1.seekoffset(src)\n bw1.seekoffset(pos)\n if EUDWhile()(pos <= scratch + TEXT_BYTES + TEXT_BYTES):\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n bw1.writebyte(b)\n pos += 1\n EUDEndWhile()\n bw1.writebyte(0)\n EUDReturn(pos)\n\n @EUDFunc\n def make(end):\n \"\"\"The text in the scratch, which ends at `end`, into a block of its own: where it is, the block, its\n length in characters. Past what a text holds it is cut, never inside a character.\"\"\"\n if EUDIf()(end >= scratch + TEXT_BYTES + 1):\n end << scratch + TEXT_BYTES\n if EUDWhile()(f_bread(end).ExactlyX(0x80, 0xC0)):\n end -= 1\n EUDEndWhile()\n f_bwrite(end, 0)\n if EUDIf()(said_cut == 0):\n said_cut << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - one that is made holds %d bytes.\" % TEXT_BYTES)\n EUDEndIf()\n EUDEndIf()\n need = f_div(end - scratch, 4)[0] + 2\n k = EUDVariable()\n k << 0\n if EUDWhile()(rooms[k] < need):\n k += 1\n EUDEndWhile()\n block = take(k)\n if EUDIf()(block == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be made (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n cells[block] = k\n f_repmovsd_epd(cells + block + 1, EPD(scratch), need - 1)\n addr = f_mul(cells + block + 1, 4) + 0x58A364\n EUDReturn(addr, block, count(addr))\n\n @EUDFunc\n def copy(addr, block, length):\n \"\"\"A text as something to keep: itself when it owns no block, else a block of the same size with the same cells.\"\"\"\n if EUDIf()(block == 0):\n EUDReturn(addr, 0, length)\n EUDEndIf()\n k = cells[block]\n mine = take(k)\n if EUDIf()(mine == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be copied (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n f_repmovsd_epd(cells + mine, cells + block, rooms[k])\n EUDReturn(f_mul(cells + mine + 1, 4) + 0x58A364, mine, length)\n\n @EUDFunc\n def release(block):\n if EUDIf()(block >= 1):\n give(block, cells[block])\n EUDEndIf()\n\n @EUDFunc\n def skip(addr, n):\n \"\"\"Where character n of the text at addr starts; its end when it has fewer.\"\"\"\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n EUDBreakIf(n == 0)\n n -= 1\n EUDEndIf()\n addr += 1\n EUDEndInfLoop()\n EUDReturn(addr)\n\n @EUDFunc\n def size(addr):\n \"\"\"The bytes of the character that starts at addr; 0 at the text's end.\"\"\"\n b = f_bread(addr)\n n = EUDVariable()\n n << 1\n if EUDIf()(b == 0):\n n << 0\n if EUDElseIf()(b >= 0xF0):\n n << 4\n if EUDElseIf()(b >= 0xE0):\n n << 3\n if EUDElseIf()(b >= 0xC0):\n n << 2\n EUDEndIf()\n EUDReturn(n)\n\n @EUDFunc\n def slice_(addr, start, end):\n \"\"\"Characters start … end - 1 of the text at addr, made.\"\"\"\n a = skip(addr, start)\n z = EUDVariable()\n z << a\n if EUDIf()(end >= start + 1):\n z << skip(a, end - start)\n EUDEndIf()\n # A text of the table may be longer than one that is made: no more of it than the scratch holds, and make() cuts.\n n = z - a\n if EUDIf()(n >= TEXT_BYTES + 2):\n n << TEXT_BYTES + 1\n EUDEndIf()\n f_memcpy(scratch, a, n)\n f_bwrite(scratch + n, 0)\n EUDReturn(*make(scratch + n))\n\n @EUDFunc\n def pad(addr, length, width, fill, at_start):\n \"\"\"The text with `fill` over and over before or after it until it is `width` characters; as it is when it is that long, or `fill` is empty.\"\"\"\n pos = EUDVariable()\n pos << scratch\n need = EUDVariable()\n need << 0\n if EUDIf()([width >= length + 1, width <= 0x7FFFFFFF, f_bread(fill) >= 1]):\n need << width - length\n EUDEndIf()\n if EUDIf()(at_start == 0):\n pos << append(pos, addr)\n EUDEndIf()\n src = EUDVariable()\n src << fill\n if EUDWhile()([need >= 1, pos <= scratch + TEXT_BYTES]):\n n = size(src)\n if EUDIf()(n == 0):\n src << fill\n n << size(src)\n EUDEndIf()\n f_memcpy(pos, src, n)\n pos += n\n src += n\n need -= 1\n EUDEndWhile()\n f_bwrite(pos, 0)\n if EUDIf()([at_start >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def repeat(addr, times):\n pos = EUDVariable()\n pos << scratch\n f_bwrite(pos, 0)\n if EUDIf()([times <= 0x7FFFFFFF, f_bread(addr) >= 1]):\n if EUDWhile()([times >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n times -= 1\n EUDEndWhile()\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def starts(addr, find):\n \"\"\"Whether the text at addr starts with the one at find.\"\"\"\n br1.seekoffset(addr)\n br2.seekoffset(find)\n if EUDInfLoop()():\n want = br2.readbyte()\n if EUDIf()(want == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDBreakIfNot(br1.readbyte() == want)\n EUDEndInfLoop()\n EUDReturn(0)\n\n @EUDFunc\n def find_from(addr, find, start):\n \"\"\"The place, in characters, of the first match at or after character `start`; 0xFFFFFFFF when there is none.\"\"\"\n if EUDIf()(start >= count(addr) + 1):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, start)\n place = EUDVariable()\n place << start\n if EUDInfLoop()():\n if EUDIf()(starts(at, find) >= 1):\n EUDReturn(place)\n EUDEndIf()\n n = size(at)\n EUDBreakIf(n == 0)\n at += n\n place += 1\n EUDEndInfLoop()\n EUDReturn(0xFFFFFFFF)\n\n @EUDFunc\n def ends(addr, find):\n a, b = f_strlen(addr), f_strlen(find)\n if EUDIf()(b >= a + 1):\n EUDReturn(0)\n EUDEndIf()\n if EUDIf()(f_strcmp(addr + (a - b), find) == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def code(addr, index):\n \"\"\"The number of character `index`; 0xFFFFFFFF past either end.\"\"\"\n if EUDIf()(index >= 0x80000000):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, index)\n n = size(at)\n if EUDIf()(n == 0):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n br1.seekoffset(at)\n v = EUDVariable()\n v << br1.readbyte()\n if EUDIf()(n == 2):\n v << (v & 0x1F)\n if EUDElseIf()(n == 3):\n v << (v & 0x0F)\n if EUDElseIf()(n == 4):\n v << (v & 0x07)\n EUDEndIf()\n if EUDWhile()(n >= 2):\n v << f_mul(v, 64) + (br1.readbyte() & 0x3F)\n n -= 1\n EUDEndWhile()\n EUDReturn(v)\n\n @EUDFunc\n def show(dst, src):\n \"\"\"At most TEXT_FIELD_BYTES bytes of the text at src over the string at dst, never half a character.\"\"\"\n used = EUDVariable()\n used << 0\n if EUDInfLoop()():\n n = size(src)\n EUDBreakIf(n == 0)\n if EUDIf()(used + n >= TEXT_FIELD_BYTES + 1):\n if EUDIf()(said_field == 0):\n said_field << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - an action's text and a unit's name show %d bytes of one that is made.\" % TEXT_FIELD_BYTES)\n EUDEndIf()\n EUDBreak()\n EUDEndIf()\n f_memcpy(dst + used, src, n)\n used += n\n src += n\n EUDEndInfLoop()\n f_bwrite(dst + used, 0)\n\n @EUDFunc\n def address(id_):\n \"\"\"Where the text of the table with this id is; an empty one for id 0.\"\"\"\n if EUDIf()(id_ == 0):\n EUDReturn(empty)\n EUDEndIf()\n EUDReturn(GetMapStringAddr(id_))\n\n _TEXT.update(scratch=scratch, empty=empty, append=append, count=count, make=make, copy=copy, release=release, slice=slice_, pad=pad, repeat=repeat,\n starts=starts, ends=ends, find=find_from, code=code, show=show, size=size, address=address)\n return _TEXT\n\n\ndef slot(kind):\n \"\"\"The string of the built map's table kept for one kind of field, made the first time one is asked for: its\n room in bytes that nothing else shares, which is what ForceAddString is for.\"\"\"\n if kind not in _SLOTS:\n text = \"(TrigScript: %s)\" % kind\n _SLOTS[kind] = ForceAddString(text + \" \" * (TEXT_FIELD_BYTES - len(text)))\n return _SLOTS[kind]\n\n\n# The actions whose text the game shows from a string it reads again whenever it draws (played 2026-09-19), by the\n# kind of field: a player has one of each at a time, so one string a kind is enough.\nSLOT_OF_ACTION = {12: \"objectives\", 7: \"transmission\"}\nfor _t in (17, 18, 19, 20, 21, 33, 34, 35, 36, 37, 40):\n SLOT_OF_ACTION[_t] = \"leaderboard\"\n\n\ndef text_id(text):\n \"\"\"The id of a text written in the script; 0 for the empty one, which the table does not hold.\"\"\"\n return EncodeString(text) if text else 0\n\n\nclass TextVal:\n \"\"\"A text as the lowering holds it: where it is, the block it owns (the int 0: none), and its length in\n characters - an int, a variable, or None when nobody has counted yet. `taken`: the block is the value's own, so\n what receives the value keeps the block or gives it back; a variable's is only looked at.\"\"\"\n\n def __init__(self, addr, block, length, taken):\n self.addr, self.block, self._length, self.taken = addr, block, length, taken\n\n def length(self):\n if self._length is None:\n self._length = texts()[\"count\"](self.addr)\n return self._length\n\n\nclass TextStorage:\n \"\"\"A text variable that is made: three cells, or three rows of twelve in a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no text yet, and no block\n self.addr, self.block, self.len = make(), make(), make()\n\n def cell(self, cell):\n return cell[self.player_of()] if self.rowed else cell\n\n def get(self):\n # A variable that was never given a text is an empty one: its address is 0 until then.\n addr = fresh(self.cell(self.addr))\n if EUDIf()(addr == 0):\n addr << texts()[\"empty\"]\n EUDEndIf()\n return TextVal(addr, self.cell(self.block), self.cell(self.len), False)\n\n def write(self, addr, block, length):\n for cell, value in ((self.addr, addr), (self.block, block), (self.len, length)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n def take_out(self):\n \"\"\"The text, and its block with it: the variable holds no block from here on.\"\"\"\n v = self.get()\n out = TextVal(v.addr, fresh(v.block), fresh(v.length()), True)\n self.write(out.addr, 0, out.length())\n return out\n\n\nclass UnitRef:\n \"\"\"A unit of the game as the lowering holds it: the pointer, its EPD, and the slot's uniqueness byte\n (as it sits in its dword, masked 0xFF00). `uid` None is the unit of a loop's turn, there by\n construction; a pointer that is the int 0 is no unit at all.\"\"\"\n\n def __init__(self, ptr, epd, uid=None):\n self.ptr, self.epd, self.uid = ptr, epd, uid\n\n @property\n def none(self):\n return isinstance(self.ptr, int) and self.ptr == 0\n\n\nNO_UNIT = UnitRef(0, 0, 0)\n\n\nclass UnitStorage:\n \"\"\"A unit variable: three cells, or three 12-slot rows of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no unit\n self.ptr, self.epd, self.uid = make(), make(), make()\n\n def get(self):\n if self.rowed:\n p = self.player_of()\n return UnitRef(self.ptr[p], self.epd[p], self.uid[p])\n return UnitRef(self.ptr, self.epd, self.uid)\n\n def set(self, ref):\n uid = ref.uid\n if uid is None:\n # The unit of a loop's turn is there now: this is when its slot's uniqueness byte is taken.\n uid = f_maskread_epd(ref.epd + OFF_UID, 0xFF00)\n for cell, value in ((self.ptr, ref.ptr), (self.epd, ref.epd), (self.uid, uid)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n\nclass LoopUnit:\n \"\"\"The variable of a loop over units: the scan's own pointer, never kept past the loop.\"\"\"\n\n def __init__(self, ref):\n self.ref = ref\n\n def get(self):\n return self.ref\n\n def set(self, ref):\n raise Fail(\"trigscript: the unit of a loop's turn cannot be assigned\")\n\n\ndef saturate(value, bits):\n top = (1 << bits) - 1\n if isinstance(value, int):\n return min(value, top)\n v = EUDVariable()\n v << value\n if EUDIf()(v >= top + 1):\n v << top\n EUDEndIf()\n return v\n\n\ndef as_var(value):\n if isinstance(value, int):\n return EUDVariable(value & U32) # initial: a constant, never written\n return value\n\n\ndef fresh(value=0):\n \"\"\"A temporary that starts from `value` every time the code runs. `EUDVariable(n)` is not\n that: n is the cell's value when the map loads, so a temporary built that way and then\n written keeps what the last run left in it — `ticks + 1` went 1, 2, 4, 8 (found in the\n first played probe, 2026-09-18). Anything the lowering writes to starts here.\"\"\"\n v = EUDVariable()\n v << value\n return v\n\n\nclass Lowering:\n def __init__(self, program):\n self.p = program\n self.per_player = bool(program.get(\"perPlayer\"))\n self.owner = int(program.get(\"owner\", 0))\n self.slots = owner_slots(program)\n self.player = None\n self.vars = {}\n self.arrays = {a[\"id\"]: SliceStorage(a, self) if a.get(\"slice\") else InnerListStorage(a, self) if a.get(\"through\") else (ListStorage if a.get(\"dynamic\") else ArrayStorage)(a, self.per_player, self.player_of) for a in program.get(\"arrays\", [])}\n self.state = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.wait = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.resumes = [] # (index, Forward) for every sleep\n self.frame_end = None\n self.latches = {}\n # The functions that are called, by id, and each one's EUDFunc once something has called it.\n self.functions = {f[\"id\"]: f for f in program.get(\"functions\", [])}\n self.made = {}\n self.in_function = 0\n # The function being lowered, when it is one that calls itself: whose return address a frame keeps.\n self.within = []\n\n # ── storage ──\n def player_of(self):\n if self.player is None:\n raise Fail(\"trigscript: a per-player variable outside the player loop\")\n return self.player\n\n def declare(self, decl):\n if decl.get(\"kind\") == \"unit\":\n s = UnitStorage(decl, self.per_player, self.player_of)\n elif decl.get(\"kind\") == \"text\" and decl.get(\"text\") != \"id\":\n s = TextStorage(decl, self.per_player, self.player_of)\n else:\n s = Storage(decl, self.per_player, self.player_of)\n self.vars[decl[\"id\"]] = s\n return s\n\n def array(self, id_, node=None):\n a = self.arrays.get(id_)\n if a is None:\n raise Fail(\"trigscript: unknown array %r%s\" % (id_, where(node)))\n return a\n\n def var(self, id_, node=None):\n s = self.vars.get(id_)\n if s is None:\n raise Fail(\"trigscript: unknown variable %r%s\" % (id_, where(node)))\n return s\n\n def get_state(self):\n return self.state[self.player] if self.per_player else self.state\n\n def set_state(self, value):\n if self.per_player:\n self.state[self.player] = value\n else:\n self.state << value\n\n def get_wait(self):\n return self.wait[self.player] if self.per_player else self.wait\n\n def set_wait(self, value):\n if self.per_player:\n self.wait[self.player] = value\n else:\n self.wait << value\n\n # ── numbers: an int (the 32 bits, 0 … 2^32 - 1) or an EUDVariable ──\n def num(self, e):\n k = e[\"kind\"]\n if k in (\"textLength\", \"textIndexOf\", \"textCode\"):\n return self.text_number(e)\n if k == \"input\":\n return INPUT.read(e[\"input\"], self, e)\n if k == \"const\":\n return int(e[\"value\"]) & U32\n if k == \"var\":\n return self.var(e[\"id\"], e).get()\n if k == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"length\":\n a = self.array(e[\"array\"], e)\n return a.length() if isinstance(a, ListStorage) else a.length\n if k == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if k == \"cast\":\n return self.num(e[\"expr\"])\n if k == \"unary\":\n x = self.num(e[\"expr\"])\n return (-x) & U32 if isinstance(x, int) else 0 - x\n if k == \"binary\":\n return self.binary(e)\n if k == \"read\":\n return self.read(e)\n if k == \"randomInt\":\n # 0 … n - 1; an n of 0 gives 0, as a 0 divisor does.\n n = self.num(e[\"bound\"])\n if isinstance(n, int):\n if n <= 1:\n return 0\n return f_div(f_dwrand(), n)[1]\n out = fresh(0)\n if EUDIf()(n >= 1):\n out << f_div(f_dwrand(), n)[1]\n EUDEndIf()\n return out\n if k == \"unitField\":\n return self.unit_field(e)\n if k == \"unitPart\":\n return self.unit_part(e)\n if k == \"tableRead\":\n return self.table_read(e[\"cell\"], e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.num(e[\"whenTrue\"])\n if EUDElse()():\n t << self.num(e[\"whenFalse\"])\n EUDEndIf()\n return t\n if k == \"intrinsic\" and e[\"name\"] == \"abs\":\n x = self.num(e[\"args\"][0])\n if isinstance(x, int):\n return abs(signed(x)) & U32\n t = fresh(x)\n if EUDIf()(t >= SIGN):\n t << 0 - t\n EUDEndIf()\n return t\n if k == \"intrinsic\":\n a, b = [self.num(x) for x in e[\"args\"]]\n name = e[\"name\"]\n unsigned = bool(e.get(\"unsigned\"))\n if isinstance(a, int) and isinstance(b, int):\n key = (lambda v: v) if unsigned else signed\n return min(a, b, key=key) if name == \"min\" else max(a, b, key=key)\n t = EUDVariable()\n if EUDIf()(self.ordered(a, \"<=\" if name == \"min\" else \">=\", b, unsigned)):\n t << a\n if EUDElse()():\n t << b\n EUDEndIf()\n return t\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown expression %r%s\" % (k, where(e)))\n\n def binary(self, e):\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op = e[\"op\"]\n if op in BITWISE:\n return self.bitwise(op, a, b)\n both = isinstance(a, int) and isinstance(b, int)\n if op == \"+\":\n return (a + b) & U32 if both else a + b\n if op == \"-\":\n return (a - b) & U32 if both else a - b\n if op == \"*\":\n return (a * b) & U32 if both else f_mul(as_var(a), as_var(b))\n # / and %: towards zero, the remainder with the dividend's sign, unless both sides are u32s. A divisor of 0 gives 0.\n unsigned = bool(e.get(\"unsigned\"))\n pick = (lambda q, r: q) if op == \"/\" else (lambda q, r: r)\n if isinstance(b, int):\n if b == 0:\n raise Fail(\"trigscript: division by zero%s\" % where(e))\n if both:\n if unsigned:\n return pick(a // b, a % b)\n x, y = signed(a), signed(b)\n q = abs(x) // abs(y) * (-1 if (x < 0) != (y < 0) else 1)\n return pick(q, x - q * y) & U32\n return pick(*(f_div(a, b) if unsigned else f_div_towards_zero(fresh(a), signed(b))))\n out = fresh(0)\n if EUDIf()(b >= 1):\n q, r = f_div(as_var(a), b) if unsigned else f_div_towards_zero(fresh(a), fresh(b))\n out << pick(q, r)\n EUDEndIf()\n return out\n\n def ordered(self, a, op, b, unsigned):\n \"\"\"`a op b` as one condition, the two read as u32s (`unsigned` true), as signed numbers (false), or one of\n each (\"left\" / \"right\" names the u32) - exactly: a number below zero is smaller than any u32. At least one\n side is a variable. A signed order is the unsigned one with the top bit of both sides flipped.\"\"\"\n if unsigned in (\"left\", \"right\"):\n # s is the signed side, u the u32, and the comparison is turned to read s op u.\n s_, u_ = (b, a) if unsigned == \"left\" else (a, b)\n if unsigned == \"left\":\n op = FLIPPED[op]\n if isinstance(s_, int):\n if s_ >= SIGN:\n return always(op in (\"<\", \"<=\", \"!=\"))\n return self.ordered(s_, op, u_, True)\n sv = as_var(s_)\n if op in (\"<\", \"<=\", \"!=\"):\n return EUDOr(sv >= SIGN, relation(sv, op, u_))\n return EUDAnd(sv <= SIGN - 1, relation(sv, op, u_))\n if isinstance(a, int):\n a, b, op = b, a, FLIPPED[op]\n if unsigned or op in (\"==\", \"!=\"):\n return relation(as_var(a), op, b)\n return relation(a + SIGN, op, (b + SIGN) & U32 if isinstance(b, int) else b + SIGN)\n\n @staticmethod\n def bitwise(op, a, b):\n \"\"\"& | ^ << >> >>> over 32 bits. `>>` keeps the sign of what it shifts and `>>>` does not; a shift by 32 or\n more leaves nothing but that sign.\"\"\"\n if op in (\"<<\", \">>\", \">>>\"):\n if isinstance(a, int) and isinstance(b, int):\n if op == \"<<\":\n return 0 if b >= 32 else (a << b) & U32\n if op == \">>>\":\n return 0 if b >= 32 else a >> b\n return (signed(a) >> min(b, 31)) & U32\n if isinstance(b, int) and b == 0:\n return a\n if isinstance(b, int) and b >= 32 and op != \">>\":\n return 0\n if op == \"<<\":\n return f_bitlshift(fresh(a), b)\n if op == \">>>\":\n return f_bitrshift(fresh(a), b)\n # The sign kept: a number below zero is shifted as its complement, which has zeros where it has ones.\n x, below = fresh(a), fresh(0)\n if EUDIf()(x >= SIGN):\n below << 1\n x << ~x\n EUDEndIf()\n r = fresh(f_bitrshift(x, b)) if not (isinstance(b, int) and b >= 32) else fresh(0)\n if EUDIf()(below >= 1):\n r << ~r\n EUDEndIf()\n return r\n if isinstance(a, int) and isinstance(b, int):\n return {\"&\": a & b, \"|\": a | b, \"^\": a ^ b}[op]\n # Copies: eudplib computes in place into an operand nothing else refers to, and ours may be a variable's own cell.\n x, y = fresh(a), fresh(b)\n return x & y if op == \"&\" else x | y if op == \"|\" else x ^ y\n\n # ── reads: a value of the game ──\n def current(self):\n \"\"\"The player the program is running as: a slot, or the player loop's variable.\"\"\"\n return self.player if self.per_player else self.slots[0]\n\n def one_player(self, p, node):\n if p == CURRENT_PLAYER:\n return self.current()\n if 0 <= p < 12:\n return p\n raise Fail(\"trigscript: a read takes one player%s\" % where(node))\n\n def read(self, e):\n r = e[\"read\"]\n source = r.get(\"source\")\n if source == \"condition\":\n return self.read_condition(r[\"record\"], e)\n if source == \"player\" and r.get(\"fact\") == \"left\":\n return self.read_left(r, e)\n if source == \"player\":\n base = PLAYER_BYTES.get(r.get(\"fact\"))\n if base is None:\n raise Fail(\"trigscript: unknown player fact %r%s\" % (r.get(\"fact\"), where(e)))\n value = f_bread(base + self.one_player(r[\"player\"], e))\n if r.get(\"fact\") == \"slot\":\n # A computer of a Use Map Settings game keeps the map's own number, 5; a melee computer is 1\n # (the slice 2 probe read 5 for one). Both are \"a computer\": one number for the script.\n kind = fresh(value)\n if EUDIf()(kind == 5):\n kind << 1\n EUDEndIf()\n return kind\n return value\n if source == \"supply\":\n return self.read_supply(r, e)\n raise Fail(\"trigscript: unknown read %r%s\" % (source, where(e)))\n\n def read_left(self, r, e):\n \"\"\"1 once a player who was in the map's settings as a human or a computer is gone. Asked the way\n eudplib's f_playerexist asks (the player's trigger list), which the player loop already relies on.\n The byte table at 0x581D62 read 0 for players who were there, as it should, but nobody has seen it\n turn 1 in a game — a computer never leaves — so it is not what this rests on.\"\"\"\n p = self.one_player(r[\"player\"], e)\n if isinstance(p, int) and (p >= 8 or GetPlayerInfo(p).typestr not in (\"Human\", \"Computer\")):\n return 0\n gone = fresh(1)\n if EUDIf()(f_playerexist(p)):\n gone << 0\n EUDEndIf()\n return gone\n\n def read_supply(self, r, e):\n base = SUPPLY_TABLES.get(r.get(\"of\"))\n if base is None:\n raise Fail(\"trigscript: unknown supply %r%s\" % (r.get(\"of\"), where(e)))\n p = self.one_player(r[\"player\"], e)\n half = fresh(0)\n if r.get(\"race\") is None:\n # The race the player plays; a slot with none (neutral, empty) has no supply.\n race = f_bread(PLAYER_BYTES[\"race\"] + p)\n if EUDIf()(race <= 2):\n half << f_dwread_epd(EPD(base) + race * 36 + p)\n EUDEndIf()\n else:\n half << f_dwread_epd(EPD(base) + int(r[\"race\"]) * 36 + p)\n # As the top bar shows it: half a supply in use counts as one.\n if r[\"of\"] == \"used\":\n half += 1\n return half // 2\n\n def read_condition(self, rec, e):\n t, player, unit = rec[\"type\"], rec[\"player\"], rec[\"unitId\"]\n one = player == CURRENT_PLAYER or not 12 <= player <= 26 # not a group; beyond 26 is an EUD offset, read as it stands\n table = None\n if one and t in (COND_DEATHS, COND_KILL) and not 229 <= unit <= 232:\n table = (DEATHS_TABLE if t == COND_DEATHS else KILLS_TABLE, unit * 12)\n elif one and t == COND_ACCUMULATE and rec[\"resource\"] in (0, 1):\n table = (ORE_TABLE if rec[\"resource\"] == 0 else GAS_TABLE, 0)\n if table is not None:\n p = self.current() if player == CURRENT_PLAYER else player\n return f_dwread_epd(EPD(table[0]) + table[1] + p)\n # No table holds it (a group's sum, units counted at a location, a score, a clock): ask the\n # condition, a bit at a time from the top. A unit count stays under 2^12, a number of players under 2^4.\n bits = 12 if t in (COND_BRING, COND_COMMAND) else 4 if t == COND_OPPONENTS else 32\n found = fresh(0)\n for bit in reversed(range(bits)):\n probe = found + (1 << bit)\n if EUDIf()(Condition(rec[\"location\"], player, probe, unit, 0, t, rec[\"resource\"], rec[\"flags\"], eudx=rec.get(\"mask\", 0) or 0)):\n found << probe\n EUDEndIf()\n return found\n\n # ── booleans: an eudplib condition ──\n def cond(self, e):\n k = e[\"kind\"]\n if k == \"const\":\n return always(e[\"value\"])\n if k == \"cond\":\n return condition(e[\"record\"])\n if k == \"var\":\n return as_var(self.var(e[\"id\"], e).get()) >= 1\n if k == \"element\":\n return as_var(self.array(e[\"array\"], e).get(self.num(e[\"index\"]))) >= 1\n if k == \"pop\":\n return as_var(self.array(e[\"array\"], e).pop()) >= 1\n if k == \"test\":\n return as_var(self.num(e[\"expr\"])) >= 1\n if k == \"compare\":\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op, unsigned = e[\"op\"], e.get(\"unsigned\", False)\n if isinstance(a, int) and isinstance(b, int):\n x = a if unsigned in (True, \"left\") else signed(a)\n y = b if unsigned in (True, \"right\") else signed(b)\n return always(compare(x, op, y))\n # One comparison, built once: a comparison between variables writes into its own\n # condition, and one that is built and dropped is an orphan eudplib refuses.\n return self.ordered(a, op, b, unsigned)\n if k == \"and\":\n return EUDAnd(*[self.cond(c) for c in e[\"items\"]])\n if k == \"or\":\n return EUDOr(*[self.cond(c) for c in e[\"items\"]])\n if k == \"not\":\n return EUDNot(self.cond(e[\"expr\"]))\n if k == \"random\":\n return (f_rand() & 1) >= 1\n if k in (\"unitAlive\", \"unitFlag\", \"unitSame\"):\n return as_var(self.truth(e)) >= 1\n if k == \"edge\":\n return self.edge(e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.truth(e[\"whenTrue\"])\n if EUDElse()():\n t << self.truth(e[\"whenFalse\"])\n EUDEndIf()\n return t >= 1\n if k == \"call\":\n return as_var(self.call(e[\"call\"])) >= 1\n if k in (\"textCompare\", \"textTest\"):\n return as_var(self.text_truth(e)) >= 1\n raise Fail(\"trigscript: unknown condition %r%s\" % (k, where(e)))\n\n def truth(self, e):\n \"\"\"A boolean expression as 0 / 1.\"\"\"\n if e[\"kind\"] == \"const\":\n return 1 if e[\"value\"] else 0\n if e[\"kind\"] == \"var\":\n return self.var(e[\"id\"], e).get()\n if e[\"kind\"] == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if e[\"kind\"] == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if e[\"kind\"] == \"unitAlive\":\n ref = self.unit(e[\"unit\"])\n t = fresh(0)\n if not ref.none:\n self.when_alive(ref, lambda: t << 1)\n return t\n if e[\"kind\"] == \"unitFlag\":\n return self.unit_flag(e)\n if e[\"kind\"] == \"unitSame\":\n a, b = self.unit(e[\"left\"]), self.unit(e[\"right\"])\n t = fresh(0)\n if not a.none and not b.none:\n ap, bp = as_var(a.ptr), as_var(b.ptr)\n if EUDIf()([ap >= 1, ap == bp]):\n t << 1\n EUDEndIf()\n return t\n t = fresh(0)\n if EUDIf()(self.cond(e)):\n t << 1\n EUDEndIf()\n return t\n\n def edge(self, e):\n \"\"\"rose(c): true on the frame c becomes true; once(c): true the first time it holds.\"\"\"\n key = id(e)\n if key not in self.latches:\n self.latches[key] = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: the latch\n latch = self.latches[key]\n\n def get():\n return latch[self.player] if self.per_player else latch\n\n def put(v):\n if self.per_player:\n latch[self.player] = v\n else:\n latch << v\n\n fired = fresh(0)\n held = self.truth(e[\"cond\"])\n if EUDIf()(as_var(held) >= 1):\n if EUDIf()(as_var(get()) == 0):\n fired << 1\n put(1)\n EUDEndIf()\n if EUDElse()():\n if e[\"edge\"] == \"rose\":\n put(0)\n EUDEndIf()\n return fired >= 1\n\n # ── units: a pointer into the game's unit table, checked before use ──\n def unit(self, e):\n k = e[\"kind\"]\n if k == \"unitNull\":\n return NO_UNIT\n if k == \"unitVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"pick\":\n return self.pick(e)\n if k == \"unitAt\":\n # A unit kept as three numbers of the program (a cell each of an array of units): re-checked like any kept unit.\n ptr = self.num(e[\"ptr\"])\n if isinstance(ptr, int) and ptr == 0:\n return NO_UNIT\n return UnitRef(fresh(ptr), fresh(self.num(e[\"epd\"])), fresh(self.num(e[\"uid\"])))\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown unit expression %r%s\" % (k, where(e)))\n\n def unit_part(self, e):\n \"\"\"One of the three numbers a unit is kept as. The unit of a loop's turn has no uniqueness byte taken yet: it is read here.\"\"\"\n ref = self.unit(e[\"unit\"])\n if ref.none:\n return 0\n part = e[\"part\"]\n if part == \"ptr\":\n return ref.ptr\n if part == \"epd\":\n return ref.epd\n return f_maskread_epd(ref.epd + OFF_UID, 0xFF00) if ref.uid is None else ref.uid\n\n def when_alive(self, ref, body):\n \"\"\"`body()` when the unit is still the one that was kept: the slot has a sprite, its order is not\n \"die\", and its uniqueness byte is the one taken with the pointer.\"\"\"\n if ref.none:\n return\n if ref.uid is None:\n body()\n return\n ptr, epd = as_var(ref.ptr), as_var(ref.epd)\n if EUDIf()([ptr >= 1, MemoryEPD(epd + OFF_SPRITE, AtLeast, 1), MemoryXEPD(epd + OFF_OWNER_ORDER, AtLeast, 0x100, 0xFF00), MemoryXEPD(epd + OFF_UID, Exactly, ref.uid, 0xFF00)]):\n body()\n EUDEndIf()\n\n @staticmethod\n def cunit(ref):\n return CUnit(ref.epd, ptr=ref.ptr)\n\n def unit_field(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n self.when_alive(ref, lambda: out << self.field_of(self.cunit(ref), e[\"field\"], e))\n return out\n\n def field_of(self, cu, field, node):\n \"\"\"A unit's number, in the script's units: whole points for hit points (as the game shows them,\n a started point counting), shields and energy.\"\"\"\n if field == \"hp\":\n return f_div(cu.hp + 255, 256)[0]\n if field == \"maxHp\":\n return f_div(f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType), 256)[0]\n if field == \"shields\":\n return f_div(cu.shield, 256)[0]\n if field == \"maxShields\":\n return f_wread(UNITS_MAX_SHIELDS + cu.unitType * 2)\n if field == \"energy\":\n return f_div(cu.energy, 256)[0]\n if field in UNIT_TIMERS:\n return getattr(cu, UNIT_TIMERS[field])\n name = {\"owner\": \"owner\", \"type\": \"unitType\", \"x\": \"posX\", \"y\": \"posY\", \"kills\": \"killCount\", \"orderId\": \"orderID\", \"cooldown\": \"groundWeaponCooldown\", \"resources\": \"resourceAmount\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: unknown unit field %r%s\" % (field, where(node)))\n return getattr(cu, name)\n\n def unit_flag(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n\n def body():\n cu = self.cunit(ref)\n flag = e[\"flag\"]\n if flag == \"underAttack\":\n held = cu.attackNotifyTimer >= 1\n else:\n mask = {\"hallucinated\": STATUS_HALLUCINATION, \"cloaked\": STATUS_CLOAKED, \"burrowed\": STATUS_BURROWED, \"invincible\": STATUS_INVINCIBLE}.get(flag)\n if mask is None:\n raise Fail(\"trigscript: unknown unit flag %r%s\" % (flag, where(e)))\n held = cu.check_status_flag(mask)\n if EUDIf()(held):\n out << 1\n EUDEndIf()\n\n self.when_alive(ref, body)\n return out\n\n def location_bounds(self, number):\n base = EPD(MRGN + (int(number) - 1) * 20)\n return [f_dwread_epd(base + i) for i in range(4)]\n\n def scan(self, flt, node, body):\n \"\"\"The game's unit table, slot by slot: `body(ref, next_, exit_)` for every unit on the map the\n filter matches. Every test is one condition whose address moves on with the slot, so a slot\n that does not match costs a trigger or two.\"\"\"\n ptr, epd = EUDVariable(), EUDVariable()\n empty = MemoryEPD(0, Exactly, 0)\n dying = MemoryXEPD(0, Exactly, 0, 0xFF00)\n moving = [(empty, OFF_SPRITE), (dying, OFF_OWNER_ORDER)]\n match, values = [], []\n owner = flt.get(\"owner\")\n if owner is not None:\n who = self.current() if owner == CURRENT_PLAYER else int(owner)\n c = MemoryXEPD(0, Exactly, who if isinstance(who, int) else 0, 0xFF)\n match.append(c)\n moving.append((c, OFF_OWNER_ORDER))\n if not isinstance(who, int):\n values.append((c, who))\n kind = flt.get(\"type\")\n if kind is not None and kind < 228:\n c = MemoryXEPD(0, Exactly, int(kind), 0xFFFF)\n match.append(c)\n moving.append((c, OFF_TYPE))\n elif kind is not None and kind not in GROUP_BITS:\n raise Fail(\"trigscript: unknown unit type %r%s\" % (kind, where(node)))\n if flt.get(\"at\"):\n left, top, right, bottom = self.location_bounds(flt[\"at\"])\n # A location reaching past the map's edge starts below zero, which a unit's position never is.\n for v in (left, top):\n if EUDIf()(v >= 0x80000000):\n v << 0\n EUDEndIf()\n for comparison, mask, value in ((AtLeast, 0xFFFF, left), (AtMost, 0xFFFF, right), (AtLeast, 0xFFFF0000, f_bitlshift(top, 16)), (AtMost, 0xFFFF0000, f_bitlshift(bottom, 16))):\n c = MemoryXEPD(0, comparison, 0, mask)\n match.append(c)\n moving.append((c, OFF_POS))\n values.append((c, value))\n DoActions([ptr.SetNumber(UNIT_TABLE), epd.SetNumber(EPD(UNIT_TABLE))] + [SetMemory(c + 4, SetTo, EPD(UNIT_TABLE) + off) for c, off in moving])\n for c, value in values:\n f_dwwrite_epd(EPD(c + 8), value)\n head, next_, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n EUDJumpIf(ptr >= UNIT_END, exit_)\n EUDJumpIf(empty, next_)\n EUDJumpIf(dying, next_)\n if match:\n matched = Forward()\n EUDJumpIf(match, matched)\n EUDJump(next_)\n matched << NextTrigger()\n if kind in GROUP_BITS:\n group = fresh(f_bread(UNITS_GROUP + f_maskread_epd(epd + OFF_TYPE, 0xFFFF)))\n EUDJumpIf((group & GROUP_BITS[kind]) == 0, next_)\n body(UnitRef(ptr, epd, None), next_, exit_)\n next_ << NextTrigger()\n DoActions([SetMemory(c + 4, Add, UNIT_SIZE // 4) for c, _ in moving] + [ptr.AddNumber(UNIT_SIZE), epd.AddNumber(UNIT_SIZE // 4)])\n EUDJump(head)\n exit_ << NextTrigger()\n\n def pick(self, e):\n \"\"\"One unit among the matching: the first, the nearest to a location's centre, or one at random.\"\"\"\n by, flt = e[\"by\"], e.get(\"filter\", {})\n found_ptr, found_epd = fresh(0), fresh(0)\n\n def take(ref):\n found_ptr << ref.ptr\n found_epd << ref.epd\n\n if by == \"first\":\n def body(ref, next_, exit_):\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n elif by == \"nearest\":\n if e.get(\"mouse\") is not None:\n # A player's mouse, and nothing farther from it than `within`.\n cx = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"x\", \"player\": e[\"mouse\"]}, self, e))\n cy = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"y\", \"player\": e[\"mouse\"]}, self, e))\n least = fresh(int(e.get(\"within\", 48)) + 1)\n else:\n left, top, right, bottom = self.location_bounds(e[\"near\"])\n cx, cy = f_div(left + right, 2)[0], f_div(top + bottom, 2)[0]\n least = fresh(U32)\n\n def body(ref, next_, exit_):\n cu = self.cunit(ref)\n # |dx| + |dy| is enough to say which is nearest, and never overflows.\n d = distance(cu.posX, cx) + distance(cu.posY, cy)\n if EUDIf()(d < least):\n least << d\n take(ref)\n EUDEndIf()\n self.scan(flt, e, body)\n elif by == \"random\":\n # Count the matching, draw one, take the drawn one on a second pass.\n count = fresh(0)\n self.scan(flt, e, lambda ref, next_, exit_: count.__iadd__(1))\n none = Forward()\n EUDJumpIf(count == 0, none)\n drawn = f_div(f_dwrand(), count)[1]\n i = fresh(0)\n\n def body(ref, next_, exit_):\n hit = Forward()\n EUDJumpIf(i == drawn, hit)\n i.__iadd__(1)\n EUDJump(next_)\n hit << NextTrigger()\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n none << NextTrigger()\n else:\n raise Fail(\"trigscript: unknown pick %r%s\" % (by, where(e)))\n uid = fresh(0)\n if EUDIf()(found_ptr >= 1):\n uid << f_maskread_epd(found_epd + OFF_UID, 0xFF00)\n EUDEndIf()\n return UnitRef(found_ptr, found_epd, uid)\n\n def unit_loop(self, st, ctx):\n def body(ref, next_, exit_):\n self.vars[st[\"decl\"][\"id\"]] = LoopUnit(ref)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": next_}))\n self.scan(st.get(\"filter\", {}), st, body)\n\n def unit_write(self, st):\n ref = self.unit(st[\"unit\"])\n field = st[\"field\"]\n if field == \"invincible\":\n on = self.truth(st[\"value\"])\n\n def flag():\n cu = self.cunit(ref)\n if isinstance(on, int):\n cu.set_invincible() if on else cu.clear_invincible()\n return\n if EUDIf()(as_var(on) >= 1):\n cu.set_invincible()\n if EUDElse()():\n cu.clear_invincible()\n EUDEndIf()\n self.when_alive(ref, flag)\n return\n value = self.num(st[\"value\"])\n\n def write():\n cu = self.cunit(ref)\n if field == \"hp\":\n # Hit points at 0 are a dead unit, so that is what the write makes of it.\n if isinstance(value, int):\n if value == 0:\n cu.die()\n else:\n cu.hp = min(value, 0xFFFFFF) * 256\n return\n if EUDIf()(value == 0):\n cu.die()\n if EUDElse()():\n cu.hp = f_mul(saturate(value, 24), 256)\n EUDEndIf()\n elif field == \"shields\":\n cu.shield = points(value, 24)\n elif field == \"energy\":\n cu.energy = points(value, 8)\n elif field == \"kills\":\n cu.killCount = saturate(value, 8)\n elif field == \"resources\":\n cu.resourceAmount = saturate(value, 16)\n elif field == \"cooldown\":\n frames = saturate(value, 8)\n cu.groundWeaponCooldown = frames\n cu.airWeaponCooldown = frames\n cu.spellCooldown = frames\n elif field in UNIT_TIMERS:\n setattr(cu, UNIT_TIMERS[field], saturate(value, 8))\n else:\n raise Fail(\"trigscript: a unit's %s takes no write%s\" % (field, where(st)))\n self.when_alive(ref, write)\n\n def unit_do(self, st):\n ref = self.unit(st[\"unit\"])\n verb = st[\"verb\"]\n do = verb[\"do\"]\n amount = self.num(verb[\"amount\"]) if do in (\"damage\", \"heal\") else 0\n\n def act():\n cu = self.cunit(ref)\n if do == \"kill\":\n cu.die()\n elif do == \"remove\":\n cu.remove()\n elif do == \"give\":\n to = int(verb[\"to\"])\n cu.cgive(self.current() if to == CURRENT_PLAYER else to)\n elif do == \"order\":\n self.order(cu, verb, st)\n elif do == \"locate\":\n self.locate(cu, int(verb[\"location\"]))\n elif do in (\"damage\", \"heal\"):\n self.adjust(cu, do, amount, bool(verb.get(\"percent\")))\n else:\n raise Fail(\"trigscript: unknown unit verb %r%s\" % (do, where(st)))\n self.when_alive(ref, act)\n\n def order(self, cu, verb, node):\n \"\"\"The game's own Order, reaching this unit alone: a location is made a small box around the unit\n for the length of the action (the game did nothing with a box of no size), then put back.\"\"\"\n kind = {\"move\": Move, \"patrol\": Patrol, \"attack\": Attack}.get(verb[\"order\"])\n if kind is None:\n raise Fail(\"trigscript: unknown order %r%s\" % (verb[\"order\"], where(node)))\n base = EPD(MRGN + (SCRATCH_LOCATION - 1) * 20)\n kept = [f_dwread_epd(base + i) for i in range(5)]\n x, y = cu.posX, cu.posY\n for i, v in enumerate((x - 2, y - 2, x + 2, y + 2, 0)):\n f_dwwrite_epd(base + i, v)\n DoActions(Order(cu.unitType, cu.owner, SCRATCH_LOCATION, kind, int(verb[\"target\"])))\n for i, v in enumerate(kept):\n f_dwwrite_epd(base + i, v)\n\n def locate(self, cu, number):\n \"\"\"A location centred on the unit, its size kept.\"\"\"\n base = EPD(MRGN + (number - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n x, y = cu.posX - f_div(width, 2)[0], cu.posY - f_div(height, 2)[0]\n for i, v in enumerate((x, y, x + width, y + height)):\n f_dwwrite_epd(base + i, v)\n\n def center_location(self, st):\n \"\"\"A location centred on a point, its size kept.\"\"\"\n x, y = self.num(st[\"x\"]), self.num(st[\"y\"])\n base = EPD(MRGN + (int(st[\"location\"]) - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n nx, ny = x - f_div(width, 2)[0], y - f_div(height, 2)[0]\n for i, v in enumerate((nx, ny, nx + width, ny + height)):\n f_dwwrite_epd(base + i, v)\n\n def adjust(self, cu, do, amount, percent):\n \"\"\"Hit points down — at 0 the unit dies — or up to the type's maximum, in the game's own units (256 to a point).\"\"\"\n now = fresh(cu.hp)\n top = f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType)\n if percent:\n step = f_div(f_mul(top, as_var(amount)), 100)[0]\n else:\n step = amount * 256 if isinstance(amount, int) else f_mul(saturate(amount, 24), 256)\n if do == \"damage\":\n if EUDIf()(now <= step):\n cu.die()\n if EUDElse()():\n cu.hp = now - step\n EUDEndIf()\n return\n new = fresh(now + step)\n if EUDIf()(new >= top):\n new << top\n EUDEndIf()\n cu.hp = new\n\n # ── the game's tables ──\n def cell_address(self, c):\n index = c[\"index\"]\n if c.get(\"player\") and index == CURRENT_PLAYER:\n index = self.current()\n base = int(c[\"base\"]) + int(c.get(\"key\") or 0)\n return base + index * int(c[\"stride\"])\n\n def table_read(self, c, node):\n if c.get(\"special\"):\n raise Fail(\"trigscript: %s cannot be read%s\" % (c.get(\"name\"), where(node)))\n addr = self.cell_address(c)\n width = c[\"width\"]\n if width == \"bit\":\n v = fresh(0)\n if EUDIf()(MemoryX(addr, AtLeast, 1, 1 << int(c[\"bit\"]))):\n v << 1\n EUDEndIf()\n return v\n v = f_dwread(addr) if width == 4 else f_wread(addr) if width == 2 else f_bread(addr)\n scale = int(c.get(\"scale\") or 1)\n return f_div(v, scale)[0] if scale > 1 else v\n\n def table_write(self, st):\n c = st[\"cell\"]\n addr = self.cell_address(c)\n special = c.get(\"special\")\n if special == \"name\":\n if self.has_id(st[\"value\"]):\n f_wwrite(addr, self.text_id_of(st[\"value\"]))\n return\n # A name that was made: over the string kept for this unit type, on every computer - a name is nobody's in particular.\n made = self.text(st[\"value\"])\n sid = slot(\"the name of unit type %d\" % int(c[\"index\"]))\n texts()[\"show\"](GetMapStringAddr(sid), made.addr)\n self.used(made)\n f_wwrite(addr, sid)\n return\n width = c[\"width\"]\n if st.get(\"boolean\"):\n value = self.truth(st[\"value\"])\n else:\n value = self.num(st[\"value\"])\n scale = int(c.get(\"scale\") or 1)\n if scale > 1 and not st.get(\"scaled\"):\n value = value * scale if isinstance(value, int) else f_mul(value, scale)\n top = 1 if width == \"bit\" else (1 << (8 * width)) - 1\n if isinstance(value, int):\n value = min(value, top)\n elif width != 4 and width != \"bit\":\n value = saturate(value, 8 * width)\n if special == \"speed\":\n self.write_speed(int(c[\"index\"]), value)\n elif special == \"color\":\n f_bwrite(addr, value)\n f_bwrite(addr + MINIMAP_COLOR_OFFSET, value)\n elif width == \"bit\":\n mask = 1 << int(c[\"bit\"])\n if isinstance(value, int):\n DoActions(SetMemoryX(addr, SetTo, mask if value else 0, mask))\n else:\n if EUDIf()(value >= 1):\n DoActions(SetMemoryX(addr, SetTo, mask, mask))\n if EUDElse()():\n DoActions(SetMemoryX(addr, SetTo, 0, mask))\n EUDEndIf()\n elif width == 4:\n f_dwwrite(addr, value)\n elif width == 2:\n f_wwrite(addr, value)\n else:\n f_bwrite(addr, value)\n\n def write_speed(self, unit, speed):\n \"\"\"A unit type's top speed is its flingy's: the flingy is switched to table control and given the\n speed, with acceleration about a seventeenth of it and the braking distance v² / 2a to match\n (the Vulture's own figures), as Magenta writes it. Units made afterwards move at the new speed.\"\"\"\n flingy = f_bread(UNITS_FLINGY + unit)\n if isinstance(speed, int):\n acceleration = max(1, round(speed / 17))\n halt = max(1, round(speed * speed / (2 * acceleration)))\n else:\n acceleration = fresh(f_div(speed, 17)[0])\n if EUDIf()(acceleration == 0):\n acceleration << 1\n EUDEndIf()\n halt = fresh(f_div(f_mul(speed, speed), acceleration * 2)[0])\n if EUDIf()(halt == 0):\n halt << 1\n EUDEndIf()\n acceleration = saturate(acceleration, 16)\n f_bwrite(FLINGY_CONTROL + flingy, 0)\n f_dwwrite(FLINGY_SPEED + flingy * 4, speed)\n f_wwrite(FLINGY_ACCELERATION + flingy * 2, acceleration)\n f_dwwrite(FLINGY_HALT + flingy * 4, halt)\n\n # ── statements ──\n def block(self, statements, ctx):\n for st in statements:\n self.statement(st, ctx)\n\n def statement(self, st, ctx):\n k = st[\"kind\"]\n if k == \"declare\":\n s = self.declare(st[\"decl\"])\n if not st.get(\"failed\"):\n self.put(s, st[\"init\"], st[\"decl\"][\"kind\"])\n elif k == \"assignText\":\n self.put(self.var(st[\"target\"], st), st[\"value\"], \"text\")\n elif k == \"storeText\":\n self.store_text(st)\n elif k == \"releaseText\":\n block, i = self.array(st[\"block\"], st), fresh(self.num(st[\"index\"]))\n texts()[\"release\"](as_var(block.get(i)))\n block.set(i, 0)\n elif k == \"textLoop\":\n self.text_loop(st, ctx)\n elif k == \"assignUnit\":\n self.var(st[\"target\"], st).set(self.unit(st[\"value\"]))\n elif k == \"unitLoop\":\n self.unit_loop(st, ctx)\n elif k == \"unitWrite\":\n self.unit_write(st)\n elif k == \"unitDo\":\n self.unit_do(st)\n elif k == \"tableWrite\":\n self.table_write(st)\n elif k == \"assign\":\n self.var(st[\"target\"], st).set(self.num(st[\"value\"]))\n elif k == \"assignBool\":\n self.var(st[\"target\"], st).set(self.truth(st[\"value\"]))\n elif k == \"declareArray\":\n a = self.array(st[\"array\"], st)\n cell = self.num if a.decl[\"kind\"] == \"number\" else self.truth\n if isinstance(a, ListStorage):\n if st.get(\"fill\") is not None:\n v = cell(st[\"fill\"])\n a.declare_filled(v if isinstance(v, int) else fresh(v), int(a.decl[\"length\"]))\n else:\n values = [cell(v) for v in st.get(\"init\", [])]\n a.declare([v if isinstance(v, int) else fresh(v) for v in values])\n elif st.get(\"fill\") is not None:\n a.fill(cell(st[\"fill\"]))\n else:\n # Every value first, then the stores: [b, a] of two cells of the array itself is a swap.\n values = [cell(v) for v in st.get(\"init\", [])]\n values = [v if isinstance(v, int) else fresh(v) for v in values]\n for i, v in enumerate(values):\n a.set(i, v)\n elif k == \"push\":\n a = self.array(st[\"array\"], st)\n a.push((self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"]))\n elif k == \"pop\":\n self.array(st[\"array\"], st).pop()\n elif k == \"setLength\":\n self.array(st[\"array\"], st).set_length(self.num(st[\"value\"]))\n elif k == \"store\":\n a = self.array(st[\"array\"], st)\n value = (self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"])\n a.set(self.num(st[\"index\"]), value)\n elif k == \"if\":\n self.if_(st, ctx)\n elif k == \"while\":\n self.while_(st, ctx)\n elif k == \"do\":\n self.do_(st, ctx)\n elif k == \"for\":\n self.for_(st, ctx)\n elif k == \"unrolled\":\n self.unrolled(st, ctx)\n elif k == \"switch\":\n self.switch(st, ctx)\n elif k == \"break\":\n if \"break\" not in ctx:\n raise Fail(\"trigscript: break outside a loop%s\" % where(st))\n EUDJump(ctx[\"break\"])\n raise Leave()\n elif k == \"continue\":\n if \"continue\" not in ctx:\n raise Fail(\"trigscript: continue outside a loop%s\" % where(st))\n EUDJump(ctx[\"continue\"])\n raise Leave()\n elif k == \"return\":\n fn = ctx.get(\"fn\")\n if fn is None:\n raise Fail(\"trigscript: return outside a function%s\" % where(st))\n if st.get(\"value\") is not None and fn[\"result\"] is not None:\n self.put(fn[\"result\"], st[\"value\"], fn[\"kind\"])\n EUDJump(fn[\"end\"])\n raise Leave()\n elif k == \"sleep\":\n self.sleep(st)\n elif k == \"action\":\n self.action(st)\n elif k == \"print\":\n self.print_(st)\n elif k == \"centerLocation\":\n self.center_location(st)\n elif k == \"call\":\n self.call(st[\"call\"])\n elif k == \"block\":\n self.block(st[\"body\"], ctx)\n elif k == \"remark\":\n pass\n else:\n raise Fail(\"trigscript: unknown statement %r%s\" % (k, where(st)))\n\n def straight(self, statements, ctx):\n \"\"\"A statement list whose end may not be reached (a break inside): Leave stops it. True when the end was reached.\"\"\"\n try:\n self.block(statements, ctx)\n return True\n except Leave:\n return False\n\n def if_(self, st, ctx):\n else_l, end = Forward(), Forward()\n EUDJumpIfNot(self.cond(st[\"cond\"]), else_l)\n if self.straight(st[\"then\"], ctx):\n EUDJump(end)\n else_l << NextTrigger()\n if st.get(\"else\"):\n self.straight(st[\"else\"], ctx)\n end << NextTrigger()\n\n def while_(self, st, ctx):\n head, exit_ = Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n if self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": head})):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def do_(self, st, ctx):\n body, check, exit_ = Forward(), Forward(), Forward()\n body << NextTrigger()\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": check}))\n check << NextTrigger()\n EUDJumpIf(self.cond(st[\"cond\"]), body)\n exit_ << NextTrigger()\n\n def for_(self, st, ctx):\n head, update, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": update}))\n update << NextTrigger()\n if self.straight(st[\"update\"], ctx):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def unrolled(self, st, ctx):\n exit_ = Forward()\n for statements in st[\"iterations\"]:\n nxt = Forward()\n self.straight(statements, dict(ctx, **{\"break\": exit_, \"continue\": nxt}))\n nxt << NextTrigger()\n exit_ << NextTrigger()\n\n def switch(self, st, ctx):\n v = as_var(self.num(st[\"value\"]))\n exit_ = Forward()\n labels = [Forward() for _ in st[\"cases\"]]\n default = None\n for c, label in zip(st[\"cases\"], labels):\n if c[\"value\"] is None:\n default = label\n continue\n n = c[\"value\"]\n if n != n or n < -SIGN or n > U32: # NaN, or a value the variable never holds\n continue\n EUDJumpIf(v == int(n) & U32, label)\n EUDJump(default if default is not None else exit_)\n for c, label in zip(st[\"cases\"], labels):\n label << NextTrigger()\n self.straight(c[\"body\"], dict(ctx, **{\"break\": exit_}))\n exit_ << NextTrigger()\n\n def sleep(self, st):\n if self.in_function:\n raise Fail(\"trigscript: sleep inside a function that is called%s\" % where(st))\n if st.get(\"cycles\") is not None:\n frames = int(st[\"cycles\"])\n else:\n frames = max(1, int(round(float(st.get(\"ms\", 0)) * FRAMES_PER_SECOND / 1000)))\n index = len(self.resumes) + 1\n resume = Forward()\n self.resumes.append((index, resume))\n self.set_state(index)\n # The frame after this one is one frame later: sleep(frames(1)) goes on in the next frame, as the\n # simulator has it. (Until 3.4 the wait was one frame longer, so a loop sleeping a frame ran every other.)\n self.set_wait(frames - 1)\n EUDJump(self.frame_end)\n resume << NextTrigger()\n\n def action(self, st):\n r = st[\"record\"]\n fields = dict(locid1=r[\"location\"], strid=string_of(r[\"text\"]), wavid=string_of(r[\"wav\"]), time=r[\"time\"], player1=r[\"player\"], player2=r[\"target\"], unitid=r[\"unitId\"], acttype=r[\"type\"], amount=r[\"modifier\"], flags=r[\"flags\"])\n shown = None\n if st.get(\"text\") is not None:\n if self.has_id(st[\"text\"]):\n fields[\"strid\"] = self.text_id_of(st[\"text\"])\n else:\n kind = SLOT_OF_ACTION.get(int(r[\"type\"]))\n if kind is None:\n raise Fail(\"trigscript: this action's text cannot be one that is made%s\" % where(st))\n # Over the string kept for this kind of field - on the computer of the player the action is for, which is\n # the one the program is running as: the game reads the string again whenever it draws, and another\n # player's text must not be there when it does. Nothing of the script can read the string back.\n shown = self.text(st[\"text\"])\n if EUDIf()(IsUserCP()):\n texts()[\"show\"](GetMapStringAddr(slot(kind)), shown.addr)\n EUDEndIf()\n self.used(shown)\n fields[\"strid\"] = slot(kind)\n count = None\n for variable in st.get(\"variables\") or []:\n value = as_var(self.num(variable[\"expr\"]))\n field = variable[\"field\"]\n if field == \"modifier\":\n # A unit count: the byte field is not a variable's place, so the action is done once per\n # unit — as many as the variable says, 0 being none (in the record, 0 means \"all\").\n count = value\n continue\n name = {\"target\": \"player2\", \"time\": \"time\", \"player\": \"player1\", \"location\": \"locid1\", \"text\": \"strid\", \"wav\": \"wavid\", \"unitId\": \"unitid\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: no variable can stand in the %s field%s\" % (field, where(st)))\n if name == \"unitid\":\n # A unit type the game has: past the table, an action reads what is not a unit.\n value = fresh(value)\n if EUDIf()(value >= 228):\n value << 228\n EUDEndIf()\n fields[name] = value\n if count is None:\n DoActions(Action(**fields))\n return\n fields[\"amount\"] = 1\n for _ in EUDLoopRange(0, count):\n DoActions(Action(**fields))\n\n def print_(self, st):\n \"\"\"Text with values in it: every value first, then the text, shown only on the screen of the player it is for.\"\"\"\n args = []\n temps = []\n for part in st[\"parts\"]:\n k = part[\"kind\"]\n if k == \"text\":\n args.append(part[\"text\"])\n elif k == \"value\":\n v = self.text(part[\"text\"])\n temps.append(v)\n args.append(ptr2s(v.addr))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n args.append(str(v if part.get(\"unsigned\") else signed(v)))\n elif part.get(\"unsigned\"):\n args.append(v)\n else:\n # A number below zero: its minus sign, then how far below it is.\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n args.append(ptr2s(sign))\n args.append(size)\n elif k == \"name\":\n args.append(PName(self.one_player(part[\"player\"], st)))\n elif k == \"color\":\n args.append(PColor(self.one_player(part[\"player\"], st)))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n if not args:\n return\n # eudplib prints for the current player, and only on that player's own computer.\n show = (lambda: f_eprintln(*args)) if st.get(\"position\") == \"center\" else (lambda: GetGlobalStringBuffer().print(*args))\n to = int(st.get(\"to\", CURRENT_PLAYER))\n if to == CURRENT_PLAYER:\n show()\n self.used(*temps)\n return\n for p in group_slots(to, st):\n f_setcurpl(p)\n show()\n f_setcurpl(self.current())\n self.used(*temps)\n\n # ── texts ──\n def has_id(self, e):\n \"\"\"Whether a text is one of the built map's table whatever happens in the game (compiler/ir.ts#textHasId).\"\"\"\n k = e[\"kind\"]\n if k in (\"text\", \"textOf\"):\n return True\n if k == \"textVar\":\n return not isinstance(self.var(e[\"id\"], e), TextStorage)\n if k == \"textTernary\":\n return self.has_id(e[\"whenTrue\"]) and self.has_id(e[\"whenFalse\"])\n return False\n\n def text_id_of(self, e):\n \"\"\"The id of a text that has one: an int, or a variable.\"\"\"\n k = e[\"kind\"]\n if k == \"text\":\n return text_id(e[\"text\"])\n if k == \"textVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"textOf\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"textTernary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.text_id_of(e[\"whenTrue\"])\n if EUDElse()():\n t << self.text_id_of(e[\"whenFalse\"])\n EUDEndIf()\n return t\n raise Fail(\"trigscript: this text has no id%s\" % where(e))\n\n def used(self, *values):\n \"\"\"Values that have been used: a block that was the value's own goes back to the heap.\"\"\"\n for v in values:\n if v.taken and not isinstance(v.block, int):\n texts()[\"release\"](v.block)\n\n def texts_of(self, exprs):\n \"\"\"Several texts in the order they are written. A variable's text is only looked at - unless working a later\n one out runs a call, which may give that variable another text and its block back to the heap: then it is a copy.\"\"\"\n out = []\n for i, e in enumerate(exprs):\n v = self.text(e)\n if not v.taken and any(has_call(x) for x in exprs[i + 1:]):\n v = self.own(v)\n out.append(v)\n return out\n\n def own(self, v):\n \"\"\"A value as something to keep: its own block as it is, a copy of a variable's.\"\"\"\n if v.taken:\n return v\n addr, block, length = texts()[\"copy\"](v.addr, v.block, v.length())\n return TextVal(addr, block, length, True)\n\n def store_text(self, st):\n \"\"\"A text into cell i of three arrays (a row's): worked out first - it may be made from what the cells hold -\n then the block they held goes back, then they take the text, a copy of one that was only looked at.\"\"\"\n v = self.own(self.text(st[\"value\"]))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n i = fresh(self.num(st[\"index\"]))\n held = self.array(st[\"block\"], st)\n texts()[\"release\"](as_var(held.get(i)))\n self.array(st[\"addr\"], st).set(i, addr)\n held.set(i, block)\n self.array(st[\"chars\"], st).set(i, length)\n\n def put(self, storage, e, kind):\n \"\"\"A value into a variable. A text is worked out first - it may be made from what the variable holds - and only then does the block the variable held go back.\"\"\"\n if kind != \"text\":\n storage.set(self.value(e, kind))\n return\n if not isinstance(storage, TextStorage):\n storage.set(self.text_id_of(e))\n return\n v = self.own(self.text(e))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n texts()[\"release\"](storage.cell(storage.block))\n storage.write(addr, block, length)\n\n def written(self, parts, st):\n \"\"\"The parts of a text in the scratch, and where it ends. Every value is worked out first, since working one\n out may use the scratch itself; then the parts are written a group at a time, a group being what cannot\n come to more than a text holds, so that checking where the writing has got to between groups keeps it inside.\"\"\"\n T = texts()\n items, temps = [], []\n for part in parts:\n k = part[\"kind\"]\n if k == \"text\":\n data = part[\"text\"].encode(\"utf-8\")[:TEXT_BYTES].decode(\"utf-8\", \"ignore\")\n items.append(([data], len(data.encode(\"utf-8\")), None))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n data = str(v if part.get(\"unsigned\") else signed(v))\n items.append(([data], len(data), None))\n elif part.get(\"unsigned\"):\n items.append(([fresh(v)], 10, None))\n else:\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n items.append(([ptr2s(sign), size], 11, None))\n elif k == \"name\":\n items.append(([PName(self.one_player(part[\"player\"], st))], 25, None))\n elif k == \"color\":\n items.append(([PColor(self.one_player(part[\"player\"], st))], 1, None))\n elif k == \"value\":\n v = self.text(part[\"text\"])\n if not v.taken and has_call(parts[parts.index(part) + 1:]):\n v = self.own(v)\n temps.append(v)\n items.append((None, TEXT_BYTES, v))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n pos = fresh(T[\"scratch\"])\n f_bwrite(pos, 0)\n group, room, first = [], 0, [True]\n\n def flush():\n if not group:\n return\n if first[0]:\n pos << f_dbstr_print(pos, *group)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << f_dbstr_print(pos, *group)\n EUDEndIf()\n first[0] = False\n del group[:]\n\n for args, size, value in items:\n if value is not None:\n flush()\n room = 0\n if first[0]:\n pos << T[\"append\"](pos, value.addr)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << T[\"append\"](pos, value.addr)\n EUDEndIf()\n first[0] = False\n continue\n if room + size > TEXT_BYTES + 1:\n flush()\n room = 0\n group.extend(args)\n room += size\n flush()\n return pos, temps\n\n def text(self, e):\n \"\"\"A text: where it is, its block, its length (TextVal).\"\"\"\n T = texts()\n k = e[\"kind\"]\n if k == \"text\":\n return TextVal(fresh(GetMapStringAddr(e[\"text\"]) if e[\"text\"] else T[\"empty\"]), 0, len(e[\"text\"]), True)\n if k == \"textVar\" and isinstance(self.var(e[\"id\"], e), TextStorage):\n return self.var(e[\"id\"], e).get()\n if k == \"textAt\":\n # Cells that were never given a text are 0: the empty one, as a variable's are.\n i = fresh(self.num(e[\"index\"]))\n addr = fresh(self.array(e[\"addr\"], e).get(i))\n if EUDIf()(addr == 0):\n addr << T[\"empty\"]\n EUDEndIf()\n return TextVal(addr, fresh(self.array(e[\"block\"], e).get(i)), fresh(self.array(e[\"chars\"], e).get(i)), False)\n if self.has_id(e):\n return TextVal(T[\"address\"](as_var(self.text_id_of(e))), 0, None, True)\n if k == \"template\":\n pos, temps = self.written(e[\"parts\"], e)\n addr, block, length = T[\"make\"](pos)\n self.used(*temps)\n return TextVal(addr, block, length, True)\n if k == \"textTernary\":\n addr, block, length = EUDVariable(), EUDVariable(), EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n v = self.own(self.text(e[\"whenTrue\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n if EUDElse()():\n v = self.own(self.text(e[\"whenFalse\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n EUDEndIf()\n return TextVal(addr, block, length, True)\n if k == \"textSlice\":\n of = self.text(e[\"of\"])\n start = self.num(e[\"start\"]) if e.get(\"start\") is not None else 0\n end = self.num(e[\"end\"]) if e.get(\"end\") is not None else 0x7FFFFFFF\n addr, block, length = T[\"slice\"](of.addr, start, end)\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textPad\":\n of, fill = self.texts_of([e[\"of\"], e[\"with\"]])\n width = self.num(e[\"width\"])\n addr, block, length = T[\"pad\"](of.addr, of.length(), width, fill.addr, 1 if e[\"side\"] == \"start\" else 0)\n self.used(of, fill)\n return TextVal(addr, block, length, True)\n if k == \"textRepeat\":\n of = self.text(e[\"of\"])\n addr, block, length = T[\"repeat\"](of.addr, self.num(e[\"count\"]))\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textCall\":\n self.call(e[\"call\"])\n result = e[\"call\"].get(\"result\")\n if result is None:\n return TextVal(fresh(T[\"empty\"]), 0, 0, True)\n return self.var(result[\"decl\"][\"id\"], e).take_out()\n raise Fail(\"trigscript: unknown text %r%s\" % (k, where(e)))\n\n def text_number(self, e):\n T = texts()\n k = e[\"kind\"]\n if k == \"textIndexOf\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n start = self.num(e[\"from\"]) if e.get(\"from\") is not None else 0\n out = T[\"find\"](of.addr, find.addr, start)\n self.used(of, find)\n return out\n of = self.text(e[\"of\"])\n if k == \"textLength\":\n n = of.length()\n out = n if isinstance(n, int) else fresh(n)\n self.used(of)\n return out\n out = T[\"code\"](of.addr, self.num(e[\"index\"]))\n self.used(of)\n return out\n\n def text_truth(self, e):\n \"\"\"A comparison of two texts, or a test of one, as 0 / 1.\"\"\"\n T = texts()\n t = fresh(0)\n if e[\"kind\"] == \"textTest\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n if e[\"test\"] == \"startsWith\":\n t << T[\"starts\"](of.addr, find.addr)\n elif e[\"test\"] == \"endsWith\":\n t << T[\"ends\"](of.addr, find.addr)\n else:\n if EUDIfNot()(T[\"find\"](of.addr, find.addr, 0) == 0xFFFFFFFF):\n t << 1\n EUDEndIf()\n self.used(of, find)\n return t\n op = e[\"op\"]\n if op in (\"==\", \"!=\") and self.has_id(e[\"left\"]) and self.has_id(e[\"right\"]):\n # Two texts of the table: the same text is the same id.\n a, b = self.text_id_of(e[\"left\"]), self.text_id_of(e[\"right\"])\n if isinstance(a, int) and isinstance(b, int):\n return 1 if (a == b) == (op == \"==\") else 0\n if EUDIf()(as_var(a) == b):\n t << 1\n EUDEndIf()\n else:\n a, b = self.texts_of([e[\"left\"], e[\"right\"]])\n # The difference of the first bytes that differ, as a number of 32 bits: 0, below zero, or above.\n d = f_strcmp(a.addr, b.addr)\n self.used(a, b)\n if op in (\"==\", \"!=\"):\n if EUDIf()(d == 0):\n t << 1\n EUDEndIf()\n else:\n below = (lambda: d >= SIGN)\n if op in (\"<\", \">=\"):\n if EUDIf()(below()):\n t << 1\n EUDEndIf()\n else: # \"<=\" and \">\": whether it is not above\n if EUDIf()(EUDOr(d == 0, below())):\n t << 1\n EUDEndIf()\n return flipped(t) if op in (\">=\", \">\") else t\n return flipped(t) if op == \"!=\" else t\n\n def text_loop(self, st, ctx):\n \"\"\"`for (const ch of s)`: the text walked once, a character a turn, each a made text of its own.\"\"\"\n T = texts()\n turn = self.declare(st[\"decl\"])\n over = self.own(self.text(st[\"of\"]))\n held = fresh(over.block)\n at = fresh(over.addr)\n head, done = Forward(), Forward()\n inner = dict(ctx)\n inner[\"break\"], inner[\"continue\"] = done, head\n leave = None\n if ctx.get(\"fn\") is not None:\n # A return inside the loop leaves through here, so that the text the loop holds goes back first.\n leave = Forward()\n inner[\"fn\"] = dict(ctx[\"fn\"], end=leave)\n head << NextTrigger()\n n = T[\"size\"](at)\n EUDJumpIf(n == 0, done)\n f_memcpy(T[\"scratch\"], at, n)\n f_bwrite(T[\"scratch\"] + n, 0)\n addr, block, length = T[\"make\"](T[\"scratch\"] + n)\n T[\"release\"](turn.cell(turn.block))\n turn.write(addr, block, length)\n at += n\n if self.straight(st[\"body\"], inner):\n EUDJump(head)\n done << NextTrigger()\n T[\"release\"](held)\n if leave is not None:\n after = Forward()\n EUDJump(after)\n leave << NextTrigger()\n T[\"release\"](held)\n EUDJump(ctx[\"fn\"][\"end\"])\n after << NextTrigger()\n\n def value(self, e, kind):\n \"\"\"An expression as what a variable of `kind` holds.\"\"\"\n return self.num(e) if kind == \"number\" else self.unit(e) if kind == \"unit\" else self.truth(e)\n\n def function(self, id_, node):\n \"\"\"A called function: its parameters' cells, its result's, and its one body — an EUDFunc of no\n arguments, since the cells are the program's own (a row a player, as any variable is).\"\"\"\n made = self.made.get(id_)\n if made is not None:\n return made\n f = self.functions.get(id_)\n if f is None:\n raise Fail(\"trigscript: unknown function %r%s\" % (id_, where(node)))\n params = [self.declare(d) for d in f[\"params\"]]\n kind = f[\"result\"][\"kind\"] if f.get(\"result\") else \"void\"\n result = self.declare(f[\"result\"][\"decl\"]) if f.get(\"result\") else None\n lowering = self\n if f.get(\"recursive\"):\n return self.recursive_function(id_, f, params, kind, result)\n\n @EUDFunc\n def body():\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n lowering.in_function += 1\n try:\n lowering.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n lowering.in_function -= 1\n end << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n return made\n\n def recursive_function(self, id_, f, params, kind, result):\n \"\"\"A function that calls itself. eudplib's EUDFunc keeps one return address and is not there to be called\n until its body is whole, so this one is triggers of our own: the body in a scope apart, ended by a trigger\n whose next-trigger field is the return address - a cell a call writes, and a frame keeps. It is on record\n before its body is lowered, so a call of it met in that body finds it.\"\"\"\n start, tail = Forward(), Forward()\n # The return address a second time, in a variable: a frame keeps it from there, since reading a cell of\n # the game's memory back costs some thirty triggers and writing a variable out costs two.\n back = EUDVariable()\n\n def body():\n after = Forward()\n DoActions([SetNextPtr(tail, after), back.SetNumber(after)])\n EUDJump(start)\n after << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n entry = {\"f\": f, \"tail\": tail, \"back\": back}\n PushTriggerScope()\n start << NextTrigger()\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n self.in_function += 1\n self.within.append(entry)\n try:\n self.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n self.within.pop()\n self.in_function -= 1\n end << NextTrigger()\n tail << RawTrigger()\n PopTriggerScope()\n return made\n\n def saved_cells(self, saves, node):\n \"\"\"What a frame keeps, as (read, write) pairs a cell: the variables, a unit three cells, an array's handle four.\"\"\"\n pairs = []\n for id_ in saves.get(\"vars\", []):\n s = self.vars.get(id_)\n if s is None:\n # Declared further down the body: it has no cell yet, and the declaration that makes it sets it.\n continue\n if isinstance(s, UnitStorage):\n for cell in (s.ptr, s.epd, s.uid):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, TextStorage):\n for cell in (s.addr, s.block, s.len):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, Storage):\n pairs.append(self.cell_pair(s.store, s.rowed))\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, node)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n pairs.append(self.cell_pair(a.handle[name], a.rowed))\n return pairs\n\n def saved_texts(self, saves):\n \"\"\"The made texts among what a frame keeps: each owns a block, which the frame keeps for it.\"\"\"\n return [s for s in (self.vars.get(id_) for id_ in saves.get(\"vars\", [])) if isinstance(s, TextStorage)]\n\n def cell_pair(self, cell, rowed):\n if rowed:\n return (lambda: cell[self.player_of()]), (lambda v: cell.__setitem__(self.player_of(), v))\n return (lambda: cell), (lambda v: cell << v)\n\n def keep(self, call):\n \"\"\"Before a call that may come back into the function it is in: that function's cells and its return\n address onto the stack, the handles of its arrays set to \"no block\". Past the depth the map allows the\n program says so and stops for good.\"\"\"\n saves = call[\"saves\"]\n st = stack()\n if not self.within:\n raise Fail(\"trigscript: a call keeps a frame outside a function that calls itself%s\" % where(call))\n mine = self.within[-1]\n if EUDIf()(st[\"depth\"] >= STACK_DEPTH):\n GetGlobalStringBuffer().print(\"\\x06TrigScript: stack overflow in %s, line %s - %d calls deep. The program has stopped.\" % (saves.get(\"within\", \"a function\"), (call.get(\"at\") or {}).get(\"line\", \"?\"), STACK_DEPTH))\n st[\"depth\"] << 0\n st[\"at\"] << st[\"base\"]\n self.set_state(DONE)\n EUDJump(self.frame_end)\n EUDEndIf()\n pairs = self.saved_cells(saves, call)\n at = st[\"at\"]\n f_dwwrite_epd(at, mine[\"back\"])\n at += 1\n for read, _ in pairs:\n f_dwwrite_epd(at, read())\n at += 1\n st[\"depth\"] += 1\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n a.put(name, 0)\n # A text the function holds keeps its block on the stack; the variable holds none for the length of the call,\n # so that the inner run's first text does not give the outer run's block back.\n held = self.saved_texts(saves)\n for s in held:\n s.write(0, 0, 0)\n return pairs, mine, held\n\n def bring_back(self, call, kept):\n pairs, mine, held = kept\n st = stack()\n # The block the inner run left in a text goes back to the heap before the text is the outer run's again.\n for s in held:\n texts()[\"release\"](s.cell(s.block))\n # The block the inner run left in a handle goes back to the heap before the handle is the outer run's again.\n for id_ in call[\"saves\"].get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n ptr = fresh(a.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n a.heap[\"give\"](ptr, a.field(\"k\"))\n EUDEndIf()\n at = st[\"at\"]\n for _, write in reversed(pairs):\n at -= 1\n write(f_dwread_epd(at))\n at -= 1\n mine[\"back\"] << f_dwread_epd(at)\n f_dwwrite_epd(EPD(mine[\"tail\"]) + 1, mine[\"back\"])\n st[\"depth\"] -= 1\n\n def call_function(self, call, result):\n body, params, returned = self.function(call[\"fn\"], call)\n if len(params) != len(call[\"params\"]):\n raise Fail(\"trigscript: %s takes %d values, not %d%s\" % (call[\"fn\"], len(params), len(call[\"params\"]), where(call)))\n # Every argument first, then the parameters: an argument may be a call of this same function. A variable's\n # own cell is copied when a later argument holds a call, which may be of a function that writes that variable.\n values = []\n for i, p in enumerate(call[\"params\"]):\n if p[\"decl\"][\"kind\"] == \"text\":\n # A text is a copy of its own by the time the parameter takes it: three numbers, read now.\n t = self.own(self.text(p[\"init\"]))\n values.append((fresh(t.addr), fresh(t.block), fresh(t.length())))\n continue\n v = self.value(p[\"init\"], p[\"decl\"][\"kind\"])\n later = any(has_call(q[\"init\"]) for q in call[\"params\"][i + 1:])\n if later and p[\"decl\"][\"kind\"] != \"unit\" and not isinstance(v, int):\n v = fresh(v)\n values.append(v)\n # A call that may come back here keeps this function's frame: after the arguments are worked out (they read\n # it), before the parameters are set (they may be this function's own).\n kept = None\n if call.get(\"saves\"):\n # Copies: an argument may be one of this function's own parameters, which the ones set before it write over.\n values = [v if isinstance(v, (int, tuple)) else UnitRef(v.ptr if isinstance(v.ptr, int) else fresh(v.ptr), v.epd if isinstance(v.epd, int) else fresh(v.epd), v.uid if v.uid is None or isinstance(v.uid, int) else fresh(v.uid)) if isinstance(v, UnitRef) else fresh(v) for v in values]\n kept = self.keep(call)\n for s, v in zip(params, values):\n if isinstance(v, tuple):\n # What the parameter held from the call before goes back; then it holds this call's text.\n texts()[\"release\"](s.cell(s.block))\n s.write(*v)\n else:\n s.set(v)\n body()\n if kept is not None:\n self.bring_back(call, kept)\n if result is None:\n return 0\n if isinstance(result, TextStorage):\n # The function's text moves to the call's own result, from where a textCall takes it.\n v = returned.take_out()\n texts()[\"release\"](result.cell(result.block))\n result.write(v.addr, v.block, v.length())\n return 0\n result.set(returned.get())\n return result.get()\n\n def call(self, call):\n result = self.declare(call[\"result\"][\"decl\"]) if call.get(\"result\") else None\n if call.get(\"fn\") is not None:\n return self.call_function(call, result)\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and call[\"result\"][\"kind\"] != \"text\":\n result.set(NO_UNIT if call[\"result\"][\"kind\"] == \"unit\" else 0)\n for p in call[\"params\"]:\n s = self.declare(p[\"decl\"])\n self.put(s, p[\"init\"], p[\"decl\"][\"kind\"])\n end = Forward()\n self.straight(call[\"body\"], {\"fn\": {\"result\": result, \"kind\": call[\"result\"][\"kind\"] if call.get(\"result\") else \"void\", \"end\": end}})\n end << NextTrigger()\n return result.get() if result is not None else 0\n\n # ── one frame ──\n def frame(self):\n self.frame_end = Forward()\n start = Forward()\n skip = Forward()\n # Still waiting: count down and leave.\n EUDJumpIfNot(as_var(self.get_wait()) >= 1, skip)\n self.set_wait(as_var(self.get_wait()) - 1)\n EUDJump(self.frame_end)\n skip << NextTrigger()\n # The body once, resumes recorded as sleeps are met; the jump table is filled in after.\n table = Forward()\n EUDJump(table)\n start << NextTrigger()\n try:\n self.block(self.p[\"body\"], {})\n except Leave:\n pass\n # The body ended: the program stops for good, as the classic backend's does.\n self.set_state(DONE)\n EUDJump(self.frame_end)\n table << NextTrigger()\n EUDJumpIf(as_var(self.get_state()) == DONE, self.frame_end)\n for index, resume in self.resumes:\n EUDJumpIf(as_var(self.get_state()) == index, resume)\n EUDJump(start)\n self.frame_end << NextTrigger()\n\n def run(self):\n if self.per_player:\n for p in loop_players(self.slots):\n self.player = p\n f_setcurpl(p)\n self.frame()\n self.player = None\n else:\n # As a trigger owned by that player would: not at all once the player has left.\n if EUDIf()(f_playerexist(self.slots[0])):\n f_setcurpl(self.slots[0])\n self.frame()\n EUDEndIf()\n\n\ndef flipped(t):\n \"\"\"1 for 0 and 0 for anything else, in a variable of its own.\"\"\"\n out = fresh(0)\n if EUDIf()(t == 0):\n out << 1\n EUDEndIf()\n return out\n\n\ndef has_call(node):\n \"\"\"Whether a call is anywhere in a piece of the IR.\"\"\"\n if isinstance(node, list):\n return any(has_call(x) for x in node)\n if not isinstance(node, dict):\n return False\n if node.get(\"kind\") in (\"call\", \"textCall\") and isinstance(node.get(\"call\"), dict):\n return True\n return any(has_call(v) for v in node.values() if isinstance(v, (dict, list)))\n\n\ndef string_of(value):\n \"\"\"An action's text or sound: the string itself (eudplib adds it to the map), or an index the script named.\"\"\"\n if isinstance(value, str):\n return EncodeString(value) if value else 0\n return int(value or 0)\n\n\ndef group_slots(group, node):\n \"\"\"The player slots a player number stands for: a slot is itself, All Players and a force their human and computer players.\"\"\"\n playing = lambda p: GetPlayerInfo(p).typestr in (\"Human\", \"Computer\")\n if 0 <= group < 8:\n return [group]\n if group == 17:\n return [p for p in range(8) if playing(p)]\n if 18 <= group <= 21:\n return [p for p in range(8) if playing(p) and GetPlayerInfo(p).force == group - 18]\n raise Fail(\"trigscript: expected players 1 to 8, All Players or a force%s\" % where(node))\n\n\ndef owner_slots(program):\n \"\"\"The player slots a program runs for, from its owners and the map's player settings: a slot is\n itself, All Players and a force are their human and computer players — who a trigger runs for.\"\"\"\n slots = []\n for o in program.get(\"owners\", [program.get(\"owner\", 0)]):\n for p in group_slots(int(o), program):\n if p not in slots:\n slots.append(p)\n if not slots:\n raise Fail(\"trigscript: no human or computer player of this map is among the program's owners%s\" % where(program))\n return slots\n\n\ndef loop_players(slots):\n \"\"\"Each of `slots` who is in the game, as an EUDVariable: EUDLoopPlayer, for a list of our own.\"\"\"\n start, end = min(slots), max(slots)\n v = EUDVariable()\n v << start\n if EUDWhile()(v <= end):\n for i in range(start, end):\n if i not in slots:\n EUDContinueIf(v == i)\n EUDContinueIfNot(f_playerexist(v))\n yield v\n EUDSetContinuePoint()\n v += 1\n EUDEndWhile()\n\n\nBITWISE = (\"&\", \"|\", \"^\", \"<<\", \">>\", \">>>\")\nFLIPPED = {\"<\": \">\", \"<=\": \">=\", \">\": \"<\", \">=\": \"<=\", \"==\": \"==\", \"!=\": \"!=\"}\n\n\ndef points(value, bits):\n \"\"\"Whole points as the game stores them, 256 to a point; `bits` is how many bits of points the cell holds.\"\"\"\n if isinstance(value, int):\n return min(value, (1 << bits) - 1) * 256\n return f_mul(saturate(value, bits), 256)\n\n\ndef uses_random(node):\n if isinstance(node, dict):\n return node.get(\"kind\") in (\"random\", \"randomInt\") or (node.get(\"kind\") == \"pick\" and node.get(\"by\") == \"random\") or any(uses_random(v) for v in node.values())\n return isinstance(node, list) and any(uses_random(v) for v in node)\n\n\ndef distance(a, b):\n \"\"\"|a - b| of two places on the map, both from 0 up.\"\"\"\n d = fresh(a)\n d -= b\n if EUDIf()(d >= SIGN):\n d << 0 - d\n EUDEndIf()\n return d\n\n\ndef signed(v):\n \"\"\"The 32 bits as a signed number.\"\"\"\n v &= U32\n return v - (1 << 32) if v >= SIGN else v\n\n\ndef always(truth):\n \"\"\"A condition that is known when the map is built.\"\"\"\n return EUDVariable(1 if truth else 0) >= 1 # initial: a constant, never written\n\n\ndef relation(av, op, b):\n \"\"\"`av op b` between a variable and a variable or an int, both read from 0 up.\"\"\"\n if op == \"==\":\n return av == b\n if op == \"!=\":\n return av != b\n if op == \"<\":\n return av < b\n if op == \"<=\":\n return av <= b\n if op == \">\":\n return av > b\n return av >= b\n\n\ndef compare(a, op, b):\n return {\"==\": a == b, \"!=\": a != b, \"<\": a < b, \"<=\": a <= b, \">\": a > b, \">=\": a >= b}[op]\n\n\ndef condition(r):\n return Condition(r[\"location\"], r[\"player\"], r[\"amount\"], r[\"unitId\"], r[\"comparison\"], r[\"type\"], r[\"resource\"], r[\"flags\"], eudx=r.get(\"mask\", 0) or 0)\n\n\nclass Input:\n \"\"\"What the players do, as it reaches every computer: the cells chatEvent and MSQC write, by the\n names the editor put in their settings (compiler/input.ts, INPUT_NAMES), and the reads of them.\"\"\"\n\n MAX_NUMBER = 0xFFFFF\n\n def __init__(self, plan):\n self.plan = plan or {}\n self.keys = list(self.plan.get(\"keys\", []))\n self.buttons = list(self.plan.get(\"buttons\", []))\n self.chats = list(self.plan.get(\"chats\", []))\n self.mouse_base = self.plan.get(\"mouseBase\")\n # A person can sit in the map's human slots, which is where MSQC counts the mouse locations from.\n self.humans = [p for p in range(8) if GetPlayerInfo(p).typestr == \"Human\"]\n if plan and not self.humans:\n raise Fail(\"trigscript: a program reads keys, clicks, the mouse or chat, and the map has no human player to give any\")\n self.key_cells = [self.register(\"tsin_key%d\" % i, EUDArray(12)) for i in range(len(self.keys))]\n self.button_cells = [self.register(\"tsin_button%d\" % i, EUDArray(12)) for i in range(len(self.buttons))]\n self.captures = max([len(c[\"captures\"]) for c in self.chats] or [0])\n if self.chats:\n # Local: what chatEvent found on this computer, and what the patterns made of it.\n self.heard = self.register(\"tsin_heard\", EUDVariable())\n self.pointer = self.register(\"tsin_pointer\", EUDVariable())\n self.length = self.register(\"tsin_length\", EUDVariable())\n self.register(\"tsin_pattern\", EUDVariable())\n self.chat_local = self.register(\"tsin_chat\", EUDVariable())\n self.capture_local = [self.register(\"tsin_capture%d\" % i, EUDVariable()) for i in range(self.captures)]\n # Everyone's: what MSQC delivered this frame, by player; 0xFFFFFFFF on a frame with nothing.\n self.chat_in = self.register(\"tsin_chat_in\", EUDArray(12))\n self.capture_in = [self.register(\"tsin_capture%d_in\" % i, EUDArray(12)) for i in range(self.captures)]\n self.unit_table = None\n\n @staticmethod\n def register(name, cell):\n EUDRegisterObjectToNamespace(name, cell)\n return cell\n\n # ── reads ──\n def read(self, i, low, node):\n source = i.get(\"source\")\n p = low.one_player(i[\"player\"], node)\n if source == \"key\":\n return self.cell(self.key_cells, self.keys, i[\"key\"], node)[p]\n if source == \"click\":\n return self.cell(self.button_cells, self.buttons, i[\"button\"], node)[p]\n if source == \"mouse\":\n return self.mouse(p, 0 if i[\"axis\"] == \"x\" else 1, node)\n if source == \"chat\":\n number = self.chat_number(i[\"pattern\"], node)\n out = fresh(0)\n if EUDIf()(self.chat_in[p] == number):\n out << (1 if i.get(\"capture\") is None else self.capture_in[int(i[\"capture\"])][p])\n EUDEndIf()\n return out\n raise Fail(\"trigscript: unknown input %r%s\" % (source, where(node)))\n\n @staticmethod\n def cell(cells, names, name, node):\n if name not in names:\n raise Fail(\"trigscript: the IR's input plan has no %r%s\" % (name, where(node)))\n return cells[names.index(name)]\n\n def chat_number(self, pattern, node):\n for index, c in enumerate(self.chats):\n if c[\"pattern\"] == pattern:\n return index + 1\n raise Fail(\"trigscript: the IR's input plan has no pattern %r%s\" % (pattern, where(node)))\n\n def mouse(self, p, axis, node):\n \"\"\"MSQC keeps the mouse of the map's first human slot in location `mouseBase`, the next slot's in the next.\"\"\"\n if self.mouse_base is None:\n raise Fail(\"trigscript: the IR's input plan keeps no mouse%s\" % where(node))\n first = min(self.humans)\n cell = lambda h: EPD(MRGN + (int(self.mouse_base) - 1 + h - first) * 20) + axis\n if isinstance(p, int):\n return f_dwread_epd(cell(p)) if p in self.humans else 0\n out = fresh(0)\n for h in self.humans:\n if EUDIf()(p == h):\n out << f_dwread_epd(cell(h))\n EUDEndIf()\n return out\n\n # ── the typed line, on the computer it was typed on ──\n @staticmethod\n def hash_of(data):\n \"\"\"What `hashed` makes of the same bytes: capitals A to Z as small letters, h = h × 31 + byte.\"\"\"\n h = 0\n for b in data:\n if 65 <= b <= 90:\n b += 32\n h = (h * 31 + b) & U32\n return h\n\n def hashed(self, pos, stop_at_space):\n \"\"\"The hash of the line from `pos` to its end or, with `stop_at_space`, to the next space; `pos` moves past it.\"\"\"\n h = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = fresh(f_bread(self.pointer + pos))\n if stop_at_space:\n EUDBreakIf(ch == 32)\n if EUDIf()([ch >= 65, ch <= 90]):\n ch += 32\n EUDEndIf()\n h << f_mul(h, 31) + ch\n pos += 1\n EUDEndWhile()\n return h\n\n def units(self):\n \"\"\"Unit names by hash, in hash order, for a search by halves: two arrays side by side.\"\"\"\n if self.unit_table is None:\n by_hash = {}\n for name, unit in self.plan.get(\"unitNames\", []):\n by_hash.setdefault(self.hash_of(name.encode(\"utf-8\")), int(unit))\n ordered = sorted(by_hash.items())\n self.unit_table = (EUDArray([h for h, _ in ordered] or [0]), EUDArray([u for _, u in ordered] or [0]), len(ordered))\n return self.unit_table\n\n def capture(self, c, pos, fail):\n \"\"\"One value out of the line at `pos`, or a jump to `fail`.\"\"\"\n value = fresh(0)\n if c[\"kind\"] == \"number\":\n digits = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = f_bread(self.pointer + pos)\n EUDBreakIf(ch <= 47)\n EUDBreakIf(ch >= 58)\n value << f_mul(value, 10) + ch - 48\n if EUDIf()(value >= self.MAX_NUMBER + 1):\n value << self.MAX_NUMBER\n EUDEndIf()\n pos += 1\n digits += 1\n EUDEndWhile()\n EUDJumpIf(digits == 0, fail)\n elif c[\"kind\"] == \"word\":\n h = self.hashed(pos, True)\n found = fresh(0)\n for index, word in enumerate(c[\"words\"]):\n if EUDIf()(h == self.hash_of(word.encode(\"utf-8\"))):\n value << index\n found << 1\n EUDEndIf()\n EUDJumpIf(found == 0, fail)\n elif c[\"kind\"] == \"unit\":\n h = self.hashed(pos, False)\n hashes, units, count = self.units()\n lo, hi = fresh(0), fresh(count)\n if EUDWhile()(lo < hi):\n mid = f_div(lo + hi, 2)[0]\n if EUDIf()(hashes[mid] < h):\n lo << mid + 1\n if EUDElse()():\n hi << mid\n EUDEndIf()\n EUDEndWhile()\n EUDJumpIf(lo >= count, fail)\n EUDJumpIfNot(hashes[lo] == h, fail)\n value << units[lo]\n else:\n raise Fail(\"trigscript: unknown chat capture %r\" % (c[\"kind\"],))\n return value\n\n def match_line(self):\n \"\"\"The line the local player typed against every pattern, in order: the first that fits the whole\n line gives its number and values to the cells MSQC sends from. Nothing fits: nothing is sent.\"\"\"\n DoActions([self.chat_local.SetNumber(0)] + [c.SetNumber(0) for c in self.capture_local])\n if EUDIf()(self.heard >= 1):\n done = Forward()\n for index, chat in enumerate(self.chats):\n fail = Forward()\n pos = fresh(0)\n values = []\n for seg in chat[\"segments\"]:\n if isinstance(seg, str):\n data = seg.encode(\"utf-8\")\n EUDJumpIf(pos + len(data) >= self.length + 1, fail)\n EUDJumpIfNot(f_memcmp(self.pointer + pos, Db(data + b\"\\0\"), len(data)) == 0, fail)\n pos += len(data)\n else:\n values.append(self.capture(chat[\"captures\"][int(seg)], pos, fail))\n EUDJumpIfNot(pos == self.length, fail)\n DoActions(self.chat_local.SetNumber(index + 1))\n for cell, value in zip(self.capture_local, values):\n cell << value\n EUDJump(done)\n fail << NextTrigger()\n done << NextTrigger()\n EUDEndIf()\n\n\nINPUT = Input(IR.get(\"input\"))\nPROGRAMS = [Lowering(p) for p in IR.get(\"programs\", [])]\n\n\ndef onPluginStart():\n # eudplib's generator starts from the same seed in every game; the game's own randomness (a switch randomized) seeds it.\n if uses_random(IR.get(\"programs\", [])):\n f_randomize()\n\n\ndef beforeTriggerExec():\n # After chatEvent has looked for a typed line, before MSQC sends what it was.\n if INPUT.chats:\n INPUT.match_line()\n\n\ndef afterTriggerExec():\n for prog in PROGRAMS:\n prog.run()\n"; +export const TRIGSCRIPT_PY: string = "\"\"\"\n[trigscript]\nir : /work/files/trigscript.json\n\nTrigScript's programs: the euddraft plugin that lowers the compiler's IR — data, never\ncode — into eudplib. The IR file is what the plugin's compiler wrote (docs/ir.md);\nthis module is handed to the eudplib library plugin as a source with every build, so the\ntwo halves of the IR version are always the same build.\n\nA program is a coroutine the game runs every frame. Its body is lowered to straight-line\neudplib triggers with jumps between labels: `if` / `while` / `switch` become conditional\njumps, and `sleep` stores the label to resume at in a state variable, sets a frame counter,\nand leaves the frame. The frame's entry counts the wait down, then jumps to the stored\nlabel. A program runs for the players it is owned by, as a trigger would: one owner runs\nit as that player while that player is in the game; several owners, All Players or a force\nrun it once each frame for every such player who is in the game, CurrentPlayer set, its\nvariables and its state as 12-slot arrays indexed by the player.\n\nText is written out in the IR: an action's `text` or `wav` is the string itself, which\neudplib adds to the built map's string table, or a number when the script named an index\nof the map's own. The map the user edits never holds a program's strings.\n\nA read is a value of the game taken when the expression is evaluated. What a condition compares\nis read from the game's tables where the table is that value (a player's deaths, kills, ore, gas)\nand otherwise found by asking the condition itself — \"at least 2^31? at least that plus 2^30? …\" —\nso a read means what the condition means, for a force's minerals or the units at a location alike.\nPlayer facts are bytes of the player tables. Text with values in it is printed through eudplib's\nstring buffer, for the player it is for and nobody else.\n\nA unit of the game is a pointer into the game's unit table — 1700 slots of 336 bytes — with the slot's\nuniqueness byte kept beside it: the game gives a dead unit's slot to the next unit made, so a unit kept\nin a variable is checked before every use (a sprite, an order other than \"die\", the same uniqueness\nbyte) and reads 0, and takes no write, once it is gone. A loop over units and a pick walk the whole\ntable with conditions whose address is moved on a slot at a time, as eudplib's own EUDLoopUnit2 does;\na dying unit is passed over. What a unit can be asked and told is what Magenta's probe maps saw\nworking in Remastered: no position, cloak or speed writes. The game's tables (`stats()`) are plain\ncells at addresses the IR carries; a speed is four flingy records, a colour two bytes, a name a string.\n\nWhat the players do — keys, clicks, the mouse, what they type — happens on one computer, and two\nplugins the build adds beside this one bring it to all of them in step. chatEvent (before this\nplugin) finds the line the local player typed; this plugin matches it against the programs'\npatterns, there and then, into a number for the pattern and up to three values; MSQC (after this\nplugin) sends those, the keys and the clicks to every computer as the player they came from, and\nkeeps each player's mouse in a location. They land in arrays registered by name — which is how the\nother two plugins' settings reach them — a cell per player, fresh every frame: an input reads 1 on\nthe frame it arrives. The IR's `input` lists what is asked for; the editor wrote the settings from it.\n\nNumbers keep one contract with the simulator: 32 bits, a `number` signed and a `u32` not, wrapping at\neither end. + - * and the bitwise operators are the same bits whichever way they are read; what reads\nthem one way or the other - a comparison, a division, a shift right, min and max, a printed number -\nsays which in the IR (`unsigned`), so nothing here works a type out. Where the game takes nothing below\nzero the compiler has already written max(v, 0), and a store keeps stopping at the top of what it holds.\nIn here a number known when the map is built is a Python int holding the 32 bits, 0 to 2^32 - 1.\n\nA text is kept one of two ways, a variable at a time (the IR says which). One that only ever holds texts written in\nthe script is the text's id in the built map's string table: a number. One that is made while the map is played is\nthree cells - where its bytes are (UTF-8, ended by a 0), the block of the heap it owns (0: none, the bytes are a\nstring of the table) and its length in characters. A made text is written into one scratch buffer, measured, and\ncopied into a block of just that size, whose first cell says which size; what holds the text owns the block, so\nassigning copies it, a value that was just made is moved, and whatever a variable held goes back to the heap once\nits new value is worked out. compiler/simulateIr.ts takes and gives blocks in the same order, so both run out at\nthe same text. A made text in an action's field goes over a string the build keeps for that kind of field, on the\ncomputer of the player the action is for and nowhere else: the game reads such a string again whenever it draws.\n\"\"\"\nimport json\n\nfrom eudplib import *\nfrom eudplib.core.mapdata.stringmap import ForceAddString\nfrom eudplib.memio.rwcommon import br1, br2, bw1\n\nIR_VERSION = 14\nFRAMES_PER_SECOND = 24\n# Where the game keeps what a read reads (1.16.1 addresses, which Remastered emulates). The player\n# tables are the ones Magenta's probes 5 and 8 read in the game.\nDEATHS_TABLE = 0x58A364\nKILLS_TABLE = 0x5878A4\nORE_TABLE = 0x57F0F0\nGAS_TABLE = 0x57F120\nPLAYER_BYTES = {\"slot\": 0x57F1B4, \"race\": 0x57F1C0}\n# Per race (144 bytes apart: Zerg, Terran, Protoss), a dword per player, in half supplies.\nSUPPLY_TABLES = {\"provided\": 0x582144, \"used\": 0x582174, \"max\": 0x5821A4}\n# The unit table, and a unit's dwords the scans test (as EPD offsets from the unit's own).\nUNIT_TABLE = 0x59CCA8\nUNIT_SIZE = 336\nUNIT_SLOTS = 1700\nUNIT_END = UNIT_TABLE + UNIT_SIZE * UNIT_SLOTS\nOFF_SPRITE, OFF_POS, OFF_OWNER_ORDER, OFF_TYPE, OFF_UID = 0x0C // 4, 0x28 // 4, 0x4C // 4, 0x64 // 4, 0xA4 // 4\n# units.dat: max hit points (dword, 256 to a point), max shields (word), the group flags a trigger's\n# Men / Buildings / Factories go by (byte), the flingy a type moves as (byte).\nUNITS_MAX_HP = 0x662350\nUNITS_MAX_SHIELDS = 0x660E00\nUNITS_GROUP = 0x6637A0\nUNITS_FLINGY = 0x6644F8\nGROUP_BITS = {230: 0x08, 231: 0x10, 232: 0x20}\n# flingy.dat, by flingy id: movement control (byte), top speed (dword), acceleration (word), halt distance (dword).\nFLINGY_CONTROL, FLINGY_SPEED, FLINGY_ACCELERATION, FLINGY_HALT = 0x6C9858, 0x6C9EF8, 0x6C9C78, 0x6C9930\nMINIMAP_COLOR_OFFSET = 0x581DD6 - 0x581D76\n# The location table, and the one an order borrows for the length of one action (put back after).\nMRGN = 0x58DC60\nSCRATCH_LOCATION = 255\nUNIT_TIMERS = {\"stim\": \"stimTimer\", \"ensnare\": \"ensnareTimer\", \"plague\": \"plagueTimer\", \"lockdown\": \"lockdownTimer\", \"maelstrom\": \"maelstromTimer\", \"irradiate\": \"irradiateTimer\", \"stasis\": \"stasisTimer\"}\nSTATUS_INVINCIBLE, STATUS_HALLUCINATION, STATUS_BURROWED, STATUS_CLOAKED = 0x04000000, 0x40000000, 0x00000010, 0x00000300\nCOND_COMMAND, COND_BRING, COND_ACCUMULATE, COND_KILL, COND_OPPONENTS, COND_DEATHS = 2, 3, 4, 5, 14, 15\nCURRENT_PLAYER = 13\n# The state of a program whose body ended: nothing resumes it.\nDONE = 0xFFFFFFFF\nU32 = 0xFFFFFFFF\nSIGN = 0x80000000\n\nMINUS_SIGN = Db(b\"-\\0\\0\\0\")\nNO_SIGN = Db(b\"\\0\\0\\0\\0\")\n\nwith open(settings[\"ir\"], encoding=\"utf-8\") as _f:\n IR = json.load(_f)\nif IR.get(\"version\") != IR_VERSION:\n raise RuntimeError(\"trigscript: the IR is version %r; this plugin reads version %d\" % (IR.get(\"version\"), IR_VERSION))\n\n\ndef where(node):\n \"\"\"\" at main.ts:12:5\" for a node with a position — what the editor parses back into a marker.\"\"\"\n at = node.get(\"at\") if isinstance(node, dict) else None\n if not at:\n return \"\"\n if at.get(\"file\"):\n return \" at %s:%s:%s\" % (at.get(\"file\"), at.get(\"line\"), at.get(\"column\"))\n return \" at %s:%s\" % (at.get(\"line\"), at.get(\"column\"))\n\n\nclass Fail(RuntimeError):\n pass\n\n\nclass Leave(Exception):\n \"\"\"Raised through the lowering when a statement ends the straight line (break / continue / return).\"\"\"\n\n\nclass Storage:\n \"\"\"A variable's cell: plain, or a 12-slot row of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n self.store = EUDArray([0] * 12) if self.rowed else EUDVariable(0) # initial: the variable's own cell\n\n def get(self):\n return self.store[self.player_of()] if self.rowed else self.store\n\n def set(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n if self.rowed:\n self.store[self.player_of()] = value\n else:\n self.store << value\n\n\nclass ArrayStorage:\n \"\"\"An array's cells: `length` of them, twelve rows of that in a per-player program, or - `values` - a list\n the script computed when it was built, which is in the map as it loads and which nothing writes. An index\n past either end (read from 0 up, so one below zero too) reads 0 and stores nothing.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.bits = decl.get(\"bits\")\n values = decl.get(\"values\")\n self.rowed = per_player and not decl.get(\"shared\") and values is None\n self.player_of = player_of\n if decl.get(\"texts\") is not None:\n # A list of texts the script has: a cell is the text's id in the built map's table.\n values = [text_id(t) for t in decl[\"texts\"]]\n self.store = EUDArray([int(v) & U32 for v in values] if values is not None else [0] * (self.length * (12 if self.rowed else 1))) # initial: a table's values, or cells declareArray sets\n\n def at(self, index):\n \"\"\"The cell's place among the store's: the player's row first.\"\"\"\n if not self.rowed:\n return index\n p = self.player_of()\n row = p * self.length if isinstance(p, int) else f_mul(p, self.length)\n return row + index\n\n def get(self, index):\n if isinstance(index, int):\n return self.store[self.at(index)] if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.store[self.at(index)]\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n if isinstance(index, int):\n if index < self.length:\n self.store[self.at(index)] = value\n return\n if EUDIf()(index <= self.length - 1):\n self.store[self.at(index)] = value\n EUDEndIf()\n\n def fill(self, value):\n if self.length <= 16:\n for i in range(self.length):\n self.set(i, value)\n return\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\nclass SliceStorage:\n \"\"\"A window on another array's cells - a row of a grid: cell i is cell `offset + i` of the array it is a window\n on, `offset` a variable of the program. Past its own end it reads 0 and stores nothing, so a row never reaches\n into the next; what it is a window on keeps its own ends (and its own width, and its row a player).\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.lowering = lowering\n\n def of(self):\n return self.lowering.array(self.decl[\"slice\"][\"of\"], self.decl)\n\n def offset(self):\n return self.lowering.var(self.decl[\"slice\"][\"offset\"], self.decl).get()\n\n def get(self, index):\n if isinstance(index, int):\n return self.of().get(self.offset() + index) if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.of().get(self.offset() + index)\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if isinstance(index, int):\n if index < self.length:\n self.of().set(self.offset() + index, value)\n return\n if EUDIf()(index <= self.length - 1):\n self.of().set(self.offset() + index, value)\n EUDEndIf()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\n# The heap the programs' growing arrays share. A block is a power of two of cells, four at least; one given back\n# waits in its size's list (its first cell links the next) for whoever wants that size next; new ground is taken\n# from the bottom up. Cell 0 is never handed out: 0 is \"no block\". compiler/simulateIr.ts counts the same way, so\n# both run out at the same push. (Recursion's stack is an array of its own, below: how deep a function may go is\n# then the same whatever the arrays hold.)\nHEAP_CELLS = min(1 << 20, max(1024, int(IR.get(\"heap\", 16384)))) # the map's script settings, or the default\nHEAP_SMALLEST = 4\nHEAP_ROOMS = []\nwhile HEAP_SMALLEST << len(HEAP_ROOMS) <= HEAP_CELLS:\n HEAP_ROOMS.append(HEAP_SMALLEST << len(HEAP_ROOMS))\n_HEAP = {}\n\n\ndef heap():\n \"\"\"The heap's cells and its functions, made when the first growing array is met.\"\"\"\n if _HEAP:\n return _HEAP\n cells = EUDArray(HEAP_CELLS)\n rooms = EUDArray(HEAP_ROOMS + [0xFFFFFFFF]) # initial: the sizes, never written\n free = EUDArray(len(HEAP_ROOMS)) # initial: no block waits\n top = EUDVariable(1) # initial: the heap's own state, for the whole game\n said = EUDVariable(0) # initial: the heap's own state\n\n @EUDFunc\n def take(k):\n \"\"\"A block of size class k: its place among the cells, or 0 when there is none.\"\"\"\n at = EUDVariable()\n at << free[k]\n if EUDIf()(at >= 1):\n free[k] = cells[at]\n EUDReturn(at)\n EUDEndIf()\n end = top + rooms[k]\n if EUDIf()(end <= HEAP_CELLS):\n at << top\n top << end\n EUDReturn(at)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def give(at, k):\n cells[at] = free[k]\n free[k] = at\n\n @EUDFunc\n def grow(ptr, length, room, k, need):\n \"\"\"Room for `need` cells: a larger block, the cells copied over, the old block given back. Returns the\n handle as it is now and whether there is room; when the heap has no block left the handle is unchanged.\"\"\"\n if EUDIf()(need <= room):\n EUDReturn(ptr, room, k, 1)\n EUDEndIf()\n nk = EUDVariable()\n nk << 0\n if EUDIf()(ptr >= 1):\n nk << k + 1\n EUDEndIf()\n if EUDWhile()(rooms[nk] < need):\n nk += 1\n EUDEndWhile()\n if EUDIf()(nk >= len(HEAP_ROOMS)):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n block = take(nk)\n if EUDIf()(block == 0):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n if EUDIf()(ptr >= 1):\n if EUDIf()(length >= 1):\n # An EUDArray's own value is where it is, as an EPD already.\n f_repmovsd_epd(cells + block, cells + ptr, length)\n EUDEndIf()\n give(ptr, k)\n EUDEndIf()\n EUDReturn(block, rooms[nk], nk, 1)\n\n @EUDFunc\n def push(ptr, length, room, k, value):\n \"\"\"One more cell at the end; the handle as it is afterwards. Nothing is pushed when the heap is full.\"\"\"\n ptr, room, k, ok = grow(ptr, length, room, k, length + 1)\n if EUDIf()(ok >= 1):\n cells[ptr + length] = value\n length += 1\n if EUDElse()():\n if EUDIf()(said == 0):\n said << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - an array could not grow (the programs' arrays share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDEndIf()\n EUDReturn(ptr, length, room, k)\n\n _HEAP.update(cells=cells, rooms=rooms, take=take, give=give, grow=grow, push=push)\n return _HEAP\n\n\n# The stack of the functions that call themselves. A function's variables are cells of the program, one of each, so\n# a call that may come back into the function it is in (the IR's `saves`) puts what that function holds here first\n# and takes it back after, with where the function returns to. One stack serves every program and every player:\n# such a function never sleeps, so the stack is empty whenever a frame ends. It is as many frames of the largest\n# frame as the map's script settings allow calls deep (the IR file's `stack`), and the depth is what is counted -\n# compiler/simulateIr.ts counts the same - so a program stops at the same call in both.\nSTACK_DEPTH = min(1 << 16, max(16, int(IR.get(\"stack\", 1024))))\nSTACK_CELLS_MAX = 1 << 20\nHANDLE = (\"ptr\", \"len\", \"room\", \"k\")\n_STACK = {}\n\n\ndef frame_cells(saves, kinds):\n return 1 + 4 * len(saves.get(\"arrays\", [])) + sum(3 if kinds.get(v) in (\"unit\", \"text\") else 1 for v in saves.get(\"vars\", []))\n\n\ndef largest_frame():\n most = [0]\n\n def walk(node, kinds):\n if isinstance(node, list):\n for x in node:\n walk(x, kinds)\n elif isinstance(node, dict):\n if isinstance(node.get(\"saves\"), dict):\n most[0] = max(most[0], frame_cells(node[\"saves\"], kinds))\n for v in node.values():\n if isinstance(v, (dict, list)):\n walk(v, kinds)\n\n def kinds_of(node, into):\n if isinstance(node, list):\n for x in node:\n kinds_of(x, into)\n elif isinstance(node, dict):\n if isinstance(node.get(\"id\"), str) and node.get(\"kind\") in (\"number\", \"boolean\", \"unit\", \"text\") and \"shared\" in node:\n # A text kept as its id is one cell, as a number is; one that was made is three.\n into[node[\"id\"]] = \"number\" if node[\"kind\"] == \"text\" and node.get(\"text\") == \"id\" else node[\"kind\"]\n for v in node.values():\n if isinstance(v, (dict, list)):\n kinds_of(v, into)\n return into\n\n for program in IR.get(\"programs\", []):\n walk(program.get(\"functions\", []), kinds_of(program, {}))\n return most[0]\n\n\ndef stack():\n \"\"\"The stack's cells, where the next frame goes (an EPD, so a cell is one write) and how deep it is.\"\"\"\n if _STACK:\n return _STACK\n frame = largest_frame()\n if frame * STACK_DEPTH > STACK_CELLS_MAX:\n raise Fail(\"trigscript: the stack would be %d cells - %d calls deep, %d cells a call - and %d is the most: lower the recursion depth in the script's Settings, or keep fewer variables in the function that calls itself\" % (frame * STACK_DEPTH, STACK_DEPTH, frame, STACK_CELLS_MAX))\n cells = EUDArray(max(1, frame) * STACK_DEPTH)\n base = cells # an EUDArray's own value is where it is, as an EPD already\n at = EUDVariable(base) # initial: the stack's own state; it is back here whenever a frame ends\n depth = EUDVariable(0) # initial: the stack's own state; nothing is on it between frames\n _STACK.update(cells=cells, base=base, at=at, depth=depth)\n return _STACK\n\n\nclass ListStorage:\n \"\"\"An array that grows: a handle - where its block is among the heap's cells (0: none yet), how many cells are\n in use, how many the block has room for, and the block's size class - a cell each, or a row of twelve each in\n a per-player program. Reads and stores are bounded by the cells in use; a store at exactly the length is a push.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no block\n self.handle = {name: make() for name in (\"ptr\", \"len\", \"room\", \"k\")}\n self.heap = heap()\n\n def field(self, name):\n cell = self.handle[name]\n return cell[self.player_of()] if self.rowed else cell\n\n def put(self, name, value):\n if self.rowed:\n self.handle[name][self.player_of()] = value\n else:\n self.handle[name] << value\n\n def length(self):\n return self.field(\"len\")\n\n def get(self, index):\n out = fresh(0)\n if EUDIf()(as_var(index) < as_var(self.field(\"len\"))):\n out << self.heap[\"cells\"][self.field(\"ptr\") + index]\n EUDEndIf()\n return out\n\n def push(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n ptr, length, room, k = self.heap[\"push\"](self.field(\"ptr\"), self.field(\"len\"), self.field(\"room\"), self.field(\"k\"), value)\n for name, v in ((\"ptr\", ptr), (\"len\", length), (\"room\", room), (\"k\", k)):\n self.put(name, v)\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n i, v, n = as_var(index), fresh(value), as_var(self.field(\"len\"))\n if EUDIf()(i < n):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n if EUDElseIf()(i == n):\n self.push(v) # xs[xs.length] = v, as JavaScript has it\n EUDEndIf()\n\n def pop(self):\n out = fresh(0)\n n = fresh(self.field(\"len\"))\n if EUDIf()(n >= 1):\n n -= 1\n self.put(\"len\", n)\n out << self.heap[\"cells\"][self.field(\"ptr\") + n]\n EUDEndIf()\n return out\n\n def set_length(self, value):\n v = as_var(value)\n if EUDIf()(v < as_var(self.field(\"len\"))):\n self.put(\"len\", v)\n EUDEndIf()\n\n def declare(self, values):\n \"\"\"Declared (again): the block it held goes back, and it starts over with these values.\"\"\"\n ptr = fresh(self.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n self.heap[\"give\"](ptr, self.field(\"k\"))\n EUDEndIf()\n for name in (\"ptr\", \"len\", \"room\", \"k\"):\n self.put(name, 0)\n for v in values:\n self.push(v)\n\n def declare_filled(self, value, count):\n \"\"\"The same with one value `count` times over: a loop, where a push a cell would be a push a cell in the map.\"\"\"\n if count <= 8:\n self.declare([value] * count)\n return\n self.declare([])\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= count - 1):\n self.push(v)\n i += 1\n EUDEndWhile()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i < as_var(self.field(\"len\"))):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n i += 1\n EUDEndWhile()\n\n\nclass InnerListStorage(ListStorage):\n \"\"\"An array that grows inside another: its handle is cell `index` of four arrays the outer one keeps (where the\n block is, how many cells are in use, its room, its size class), `index` a variable of the program. Everything\n else is a growing array's, which only ever asks for its handle's fields and puts them back.\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = False\n self.lowering = lowering\n self.heap = heap()\n\n def at(self):\n return self.lowering.var(self.decl[\"through\"][\"index\"], self.decl).get()\n\n def field(self, name):\n return self.lowering.array(self.decl[\"through\"][name], self.decl).get(self.at())\n\n def put(self, name, value):\n self.lowering.array(self.decl[\"through\"][name], self.decl).set(self.at(), value)\n\n\n# ── texts ──\nTEXT_BYTES = 1023 # the most a made text holds; compiler/ir.ts has the same number\nTEXT_FIELD_BYTES = 255 # the room of a string kept for an action's field or a unit type's name\nUNIT_MAP_STRING = 0x660260 # units.dat: the id of the name a map gives a unit type, a word each\n_TEXT = {}\n_SLOTS = {}\n\n\ndef texts():\n \"\"\"The texts' part of the run time, made when the first text that is made is met.\"\"\"\n if _TEXT:\n return _TEXT\n h = heap()\n cells, rooms, take, give = h[\"cells\"], h[\"rooms\"], h[\"take\"], h[\"give\"]\n # A text being made. It may run one part past its most before it is cut, and a part is at most that long again.\n scratch = Db(TEXT_BYTES + 1 + TEXT_BYTES + 1 + 8)\n empty = Db(4)\n said_cut = EUDVariable(0) # initial: said once a game\n said_full = EUDVariable(0) # initial: said once a game\n said_field = EUDVariable(0) # initial: said once a game\n\n @EUDFunc\n def count(addr):\n \"\"\"How many characters the text at addr is: every byte but those that continue a character (10xxxxxx).\"\"\"\n n = EUDVariable()\n n << 0\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n n += 1\n EUDEndIf()\n EUDEndInfLoop()\n EUDReturn(n)\n\n @EUDFunc\n def append(pos, src):\n \"\"\"The text at src written at pos, ended by a 0; where that 0 is. A text of the table may be any length, so no\n more of it than leaves the scratch whole: what is past a text's most is cut by make() anyway.\"\"\"\n br1.seekoffset(src)\n bw1.seekoffset(pos)\n if EUDWhile()(pos <= scratch + TEXT_BYTES + TEXT_BYTES):\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n bw1.writebyte(b)\n pos += 1\n EUDEndWhile()\n bw1.writebyte(0)\n EUDReturn(pos)\n\n @EUDFunc\n def make(end):\n \"\"\"The text in the scratch, which ends at `end`, into a block of its own: where it is, the block, its\n length in characters. Past what a text holds it is cut, never inside a character.\"\"\"\n if EUDIf()(end >= scratch + TEXT_BYTES + 1):\n end << scratch + TEXT_BYTES\n if EUDWhile()(f_bread(end).ExactlyX(0x80, 0xC0)):\n end -= 1\n EUDEndWhile()\n f_bwrite(end, 0)\n if EUDIf()(said_cut == 0):\n said_cut << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - one that is made holds %d bytes.\" % TEXT_BYTES)\n EUDEndIf()\n EUDEndIf()\n need = f_div(end - scratch, 4)[0] + 2\n k = EUDVariable()\n k << 0\n if EUDWhile()(rooms[k] < need):\n k += 1\n EUDEndWhile()\n block = take(k)\n if EUDIf()(block == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be made (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n cells[block] = k\n f_repmovsd_epd(cells + block + 1, EPD(scratch), need - 1)\n addr = f_mul(cells + block + 1, 4) + 0x58A364\n EUDReturn(addr, block, count(addr))\n\n @EUDFunc\n def copy(addr, block, length):\n \"\"\"A text as something to keep: itself when it owns no block, else a block of the same size with the same cells.\"\"\"\n if EUDIf()(block == 0):\n EUDReturn(addr, 0, length)\n EUDEndIf()\n k = cells[block]\n mine = take(k)\n if EUDIf()(mine == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be copied (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n f_repmovsd_epd(cells + mine, cells + block, rooms[k])\n EUDReturn(f_mul(cells + mine + 1, 4) + 0x58A364, mine, length)\n\n @EUDFunc\n def release(block):\n if EUDIf()(block >= 1):\n give(block, cells[block])\n EUDEndIf()\n\n @EUDFunc\n def skip(addr, n):\n \"\"\"Where character n of the text at addr starts; its end when it has fewer.\"\"\"\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n EUDBreakIf(n == 0)\n n -= 1\n EUDEndIf()\n addr += 1\n EUDEndInfLoop()\n EUDReturn(addr)\n\n @EUDFunc\n def size(addr):\n \"\"\"The bytes of the character that starts at addr; 0 at the text's end.\"\"\"\n b = f_bread(addr)\n n = EUDVariable()\n n << 1\n if EUDIf()(b == 0):\n n << 0\n if EUDElseIf()(b >= 0xF0):\n n << 4\n if EUDElseIf()(b >= 0xE0):\n n << 3\n if EUDElseIf()(b >= 0xC0):\n n << 2\n EUDEndIf()\n EUDReturn(n)\n\n @EUDFunc\n def slice_(addr, start, end):\n \"\"\"Characters start … end - 1 of the text at addr, made.\"\"\"\n a = skip(addr, start)\n z = EUDVariable()\n z << a\n if EUDIf()(end >= start + 1):\n z << skip(a, end - start)\n EUDEndIf()\n # A text of the table may be longer than one that is made: no more of it than the scratch holds, and make() cuts.\n n = z - a\n if EUDIf()(n >= TEXT_BYTES + 2):\n n << TEXT_BYTES + 1\n EUDEndIf()\n f_memcpy(scratch, a, n)\n f_bwrite(scratch + n, 0)\n EUDReturn(*make(scratch + n))\n\n @EUDFunc\n def pad(addr, length, width, fill, at_start):\n \"\"\"The text with `fill` over and over before or after it until it is `width` characters; as it is when it is that long, or `fill` is empty.\"\"\"\n pos = EUDVariable()\n pos << scratch\n need = EUDVariable()\n need << 0\n if EUDIf()([width >= length + 1, width <= 0x7FFFFFFF, f_bread(fill) >= 1]):\n need << width - length\n EUDEndIf()\n if EUDIf()(at_start == 0):\n pos << append(pos, addr)\n EUDEndIf()\n src = EUDVariable()\n src << fill\n if EUDWhile()([need >= 1, pos <= scratch + TEXT_BYTES]):\n n = size(src)\n if EUDIf()(n == 0):\n src << fill\n n << size(src)\n EUDEndIf()\n f_memcpy(pos, src, n)\n pos += n\n src += n\n need -= 1\n EUDEndWhile()\n f_bwrite(pos, 0)\n if EUDIf()([at_start >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def repeat(addr, times):\n pos = EUDVariable()\n pos << scratch\n f_bwrite(pos, 0)\n if EUDIf()([times <= 0x7FFFFFFF, f_bread(addr) >= 1]):\n if EUDWhile()([times >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n times -= 1\n EUDEndWhile()\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def starts(addr, find):\n \"\"\"Whether the text at addr starts with the one at find.\"\"\"\n br1.seekoffset(addr)\n br2.seekoffset(find)\n if EUDInfLoop()():\n want = br2.readbyte()\n if EUDIf()(want == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDBreakIfNot(br1.readbyte() == want)\n EUDEndInfLoop()\n EUDReturn(0)\n\n @EUDFunc\n def find_from(addr, find, start):\n \"\"\"The place, in characters, of the first match at or after character `start`; 0xFFFFFFFF when there is none.\"\"\"\n if EUDIf()(start >= count(addr) + 1):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, start)\n place = EUDVariable()\n place << start\n if EUDInfLoop()():\n if EUDIf()(starts(at, find) >= 1):\n EUDReturn(place)\n EUDEndIf()\n n = size(at)\n EUDBreakIf(n == 0)\n at += n\n place += 1\n EUDEndInfLoop()\n EUDReturn(0xFFFFFFFF)\n\n @EUDFunc\n def ends(addr, find):\n a, b = f_strlen(addr), f_strlen(find)\n if EUDIf()(b >= a + 1):\n EUDReturn(0)\n EUDEndIf()\n if EUDIf()(f_strcmp(addr + (a - b), find) == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def code(addr, index):\n \"\"\"The number of character `index`; 0xFFFFFFFF past either end.\"\"\"\n if EUDIf()(index >= 0x80000000):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, index)\n n = size(at)\n if EUDIf()(n == 0):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n br1.seekoffset(at)\n v = EUDVariable()\n v << br1.readbyte()\n if EUDIf()(n == 2):\n v << (v & 0x1F)\n if EUDElseIf()(n == 3):\n v << (v & 0x0F)\n if EUDElseIf()(n == 4):\n v << (v & 0x07)\n EUDEndIf()\n if EUDWhile()(n >= 2):\n v << f_mul(v, 64) + (br1.readbyte() & 0x3F)\n n -= 1\n EUDEndWhile()\n EUDReturn(v)\n\n @EUDFunc\n def show(dst, src):\n \"\"\"At most TEXT_FIELD_BYTES bytes of the text at src over the string at dst, never half a character.\"\"\"\n used = EUDVariable()\n used << 0\n if EUDInfLoop()():\n n = size(src)\n EUDBreakIf(n == 0)\n if EUDIf()(used + n >= TEXT_FIELD_BYTES + 1):\n if EUDIf()(said_field == 0):\n said_field << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - an action's text and a unit's name show %d bytes of one that is made.\" % TEXT_FIELD_BYTES)\n EUDEndIf()\n EUDBreak()\n EUDEndIf()\n f_memcpy(dst + used, src, n)\n used += n\n src += n\n EUDEndInfLoop()\n f_bwrite(dst + used, 0)\n\n @EUDFunc\n def address(id_):\n \"\"\"Where the text of the table with this id is; an empty one for id 0.\"\"\"\n if EUDIf()(id_ == 0):\n EUDReturn(empty)\n EUDEndIf()\n EUDReturn(GetMapStringAddr(id_))\n\n _TEXT.update(scratch=scratch, empty=empty, append=append, count=count, make=make, copy=copy, release=release, slice=slice_, pad=pad, repeat=repeat,\n starts=starts, ends=ends, find=find_from, code=code, show=show, size=size, address=address)\n return _TEXT\n\n\ndef slot(kind):\n \"\"\"The string of the built map's table kept for one kind of field, made the first time one is asked for: its\n room in bytes that nothing else shares, which is what ForceAddString is for.\"\"\"\n if kind not in _SLOTS:\n text = \"(TrigScript: %s)\" % kind\n _SLOTS[kind] = ForceAddString(text + \" \" * (TEXT_FIELD_BYTES - len(text)))\n return _SLOTS[kind]\n\n\n# The actions whose text the game shows from a string it reads again whenever it draws (played 2026-09-19), by the\n# kind of field: a player has one of each at a time, so one string a kind is enough.\nSLOT_OF_ACTION = {12: \"objectives\", 7: \"transmission\"}\nfor _t in (17, 18, 19, 20, 21, 33, 34, 35, 36, 37, 40):\n SLOT_OF_ACTION[_t] = \"leaderboard\"\n\n\ndef text_id(text):\n \"\"\"The id of a text written in the script; 0 for the empty one, which the table does not hold.\"\"\"\n return EncodeString(text) if text else 0\n\n\nclass TextVal:\n \"\"\"A text as the lowering holds it: where it is, the block it owns (the int 0: none), and its length in\n characters - an int, a variable, or None when nobody has counted yet. `taken`: the block is the value's own, so\n what receives the value keeps the block or gives it back; a variable's is only looked at.\"\"\"\n\n def __init__(self, addr, block, length, taken):\n self.addr, self.block, self._length, self.taken = addr, block, length, taken\n\n def length(self):\n if self._length is None:\n self._length = texts()[\"count\"](self.addr)\n return self._length\n\n\nclass TextStorage:\n \"\"\"A text variable that is made: three cells, or three rows of twelve in a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no text yet, and no block\n self.addr, self.block, self.len = make(), make(), make()\n\n def cell(self, cell):\n return cell[self.player_of()] if self.rowed else cell\n\n def get(self):\n # A variable that was never given a text is an empty one: its address is 0 until then.\n addr = fresh(self.cell(self.addr))\n if EUDIf()(addr == 0):\n addr << texts()[\"empty\"]\n EUDEndIf()\n return TextVal(addr, self.cell(self.block), self.cell(self.len), False)\n\n def write(self, addr, block, length):\n for cell, value in ((self.addr, addr), (self.block, block), (self.len, length)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n def take_out(self):\n \"\"\"The text, and its block with it: the variable holds no block from here on.\"\"\"\n v = self.get()\n out = TextVal(v.addr, fresh(v.block), fresh(v.length()), True)\n self.write(out.addr, 0, out.length())\n return out\n\n\nclass UnitRef:\n \"\"\"A unit of the game as the lowering holds it: the pointer, its EPD, and the slot's uniqueness byte\n (as it sits in its dword, masked 0xFF00). `uid` None is the unit of a loop's turn, there by\n construction; a pointer that is the int 0 is no unit at all.\"\"\"\n\n def __init__(self, ptr, epd, uid=None):\n self.ptr, self.epd, self.uid = ptr, epd, uid\n\n @property\n def none(self):\n return isinstance(self.ptr, int) and self.ptr == 0\n\n\nNO_UNIT = UnitRef(0, 0, 0)\n\n\nclass UnitStorage:\n \"\"\"A unit variable: three cells, or three 12-slot rows of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no unit\n self.ptr, self.epd, self.uid = make(), make(), make()\n\n def get(self):\n if self.rowed:\n p = self.player_of()\n return UnitRef(self.ptr[p], self.epd[p], self.uid[p])\n return UnitRef(self.ptr, self.epd, self.uid)\n\n def set(self, ref):\n uid = ref.uid\n if uid is None:\n # The unit of a loop's turn is there now: this is when its slot's uniqueness byte is taken.\n uid = f_maskread_epd(ref.epd + OFF_UID, 0xFF00)\n for cell, value in ((self.ptr, ref.ptr), (self.epd, ref.epd), (self.uid, uid)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n\nclass LoopUnit:\n \"\"\"The variable of a loop over units: the scan's own pointer, never kept past the loop.\"\"\"\n\n def __init__(self, ref):\n self.ref = ref\n\n def get(self):\n return self.ref\n\n def set(self, ref):\n raise Fail(\"trigscript: the unit of a loop's turn cannot be assigned\")\n\n\ndef saturate(value, bits):\n top = (1 << bits) - 1\n if isinstance(value, int):\n return min(value, top)\n v = EUDVariable()\n v << value\n if EUDIf()(v >= top + 1):\n v << top\n EUDEndIf()\n return v\n\n\ndef as_var(value):\n if isinstance(value, int):\n return EUDVariable(value & U32) # initial: a constant, never written\n return value\n\n\ndef fresh(value=0):\n \"\"\"A temporary that starts from `value` every time the code runs. `EUDVariable(n)` is not\n that: n is the cell's value when the map loads, so a temporary built that way and then\n written keeps what the last run left in it — `ticks + 1` went 1, 2, 4, 8 (found in the\n first played probe, 2026-09-18). Anything the lowering writes to starts here.\"\"\"\n v = EUDVariable()\n v << value\n return v\n\n\nclass Lowering:\n def __init__(self, program):\n self.p = program\n self.per_player = bool(program.get(\"perPlayer\"))\n self.owner = int(program.get(\"owner\", 0))\n self.slots = owner_slots(program)\n self.player = None\n self.vars = {}\n self.arrays = {a[\"id\"]: SliceStorage(a, self) if a.get(\"slice\") else InnerListStorage(a, self) if a.get(\"through\") else (ListStorage if a.get(\"dynamic\") else ArrayStorage)(a, self.per_player, self.player_of) for a in program.get(\"arrays\", [])}\n self.state = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.wait = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.resumes = [] # (index, Forward) for every sleep\n self.frame_end = None\n self.latches = {}\n # The functions that are called, by id, and each one's EUDFunc once something has called it.\n self.functions = {f[\"id\"]: f for f in program.get(\"functions\", [])}\n self.made = {}\n self.in_function = 0\n # The function being lowered, when it is one that calls itself: whose return address a frame keeps.\n self.within = []\n\n # ── storage ──\n def player_of(self):\n if self.player is None:\n raise Fail(\"trigscript: a per-player variable outside the player loop\")\n return self.player\n\n def declare(self, decl):\n if decl.get(\"kind\") == \"unit\":\n s = UnitStorage(decl, self.per_player, self.player_of)\n elif decl.get(\"kind\") == \"text\" and decl.get(\"text\") != \"id\":\n s = TextStorage(decl, self.per_player, self.player_of)\n else:\n s = Storage(decl, self.per_player, self.player_of)\n self.vars[decl[\"id\"]] = s\n return s\n\n def array(self, id_, node=None):\n a = self.arrays.get(id_)\n if a is None:\n raise Fail(\"trigscript: unknown array %r%s\" % (id_, where(node)))\n return a\n\n def var(self, id_, node=None):\n s = self.vars.get(id_)\n if s is None:\n raise Fail(\"trigscript: unknown variable %r%s\" % (id_, where(node)))\n return s\n\n def get_state(self):\n return self.state[self.player] if self.per_player else self.state\n\n def set_state(self, value):\n if self.per_player:\n self.state[self.player] = value\n else:\n self.state << value\n\n def get_wait(self):\n return self.wait[self.player] if self.per_player else self.wait\n\n def set_wait(self, value):\n if self.per_player:\n self.wait[self.player] = value\n else:\n self.wait << value\n\n # ── numbers: an int (the 32 bits, 0 … 2^32 - 1) or an EUDVariable ──\n def num(self, e):\n k = e[\"kind\"]\n if k in (\"textLength\", \"textIndexOf\", \"textCode\"):\n return self.text_number(e)\n if k == \"input\":\n return INPUT.read(e[\"input\"], self, e)\n if k == \"const\":\n return int(e[\"value\"]) & U32\n if k == \"var\":\n return self.var(e[\"id\"], e).get()\n if k == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"length\":\n a = self.array(e[\"array\"], e)\n return a.length() if isinstance(a, ListStorage) else a.length\n if k == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if k == \"cast\":\n return self.num(e[\"expr\"])\n if k == \"unary\":\n x = self.num(e[\"expr\"])\n return (-x) & U32 if isinstance(x, int) else 0 - x\n if k == \"binary\":\n return self.binary(e)\n if k == \"read\":\n return self.read(e)\n if k == \"randomInt\":\n # 0 … n - 1; an n of 0 gives 0, as a 0 divisor does.\n n = self.num(e[\"bound\"])\n if isinstance(n, int):\n if n <= 1:\n return 0\n return f_div(f_dwrand(), n)[1]\n out = fresh(0)\n if EUDIf()(n >= 1):\n out << f_div(f_dwrand(), n)[1]\n EUDEndIf()\n return out\n if k == \"unitField\":\n return self.unit_field(e)\n if k == \"unitPart\":\n return self.unit_part(e)\n if k == \"tableRead\":\n return self.table_read(e[\"cell\"], e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.num(e[\"whenTrue\"])\n if EUDElse()():\n t << self.num(e[\"whenFalse\"])\n EUDEndIf()\n return t\n if k == \"intrinsic\" and e[\"name\"] == \"abs\":\n x = self.num(e[\"args\"][0])\n if isinstance(x, int):\n return abs(signed(x)) & U32\n t = fresh(x)\n if EUDIf()(t >= SIGN):\n t << 0 - t\n EUDEndIf()\n return t\n if k == \"intrinsic\":\n a, b = [self.num(x) for x in e[\"args\"]]\n name = e[\"name\"]\n unsigned = bool(e.get(\"unsigned\"))\n if isinstance(a, int) and isinstance(b, int):\n key = (lambda v: v) if unsigned else signed\n return min(a, b, key=key) if name == \"min\" else max(a, b, key=key)\n t = EUDVariable()\n if EUDIf()(self.ordered(a, \"<=\" if name == \"min\" else \">=\", b, unsigned)):\n t << a\n if EUDElse()():\n t << b\n EUDEndIf()\n return t\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown expression %r%s\" % (k, where(e)))\n\n def binary(self, e):\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op = e[\"op\"]\n if op in BITWISE:\n return self.bitwise(op, a, b)\n both = isinstance(a, int) and isinstance(b, int)\n if op == \"+\":\n return (a + b) & U32 if both else a + b\n if op == \"-\":\n return (a - b) & U32 if both else a - b\n if op == \"*\":\n return (a * b) & U32 if both else f_mul(as_var(a), as_var(b))\n # / and %: towards zero, the remainder with the dividend's sign, unless both sides are u32s. A divisor of 0 gives 0.\n unsigned = bool(e.get(\"unsigned\"))\n pick = (lambda q, r: q) if op == \"/\" else (lambda q, r: r)\n if isinstance(b, int):\n if b == 0:\n raise Fail(\"trigscript: division by zero%s\" % where(e))\n if both:\n if unsigned:\n return pick(a // b, a % b)\n x, y = signed(a), signed(b)\n q = abs(x) // abs(y) * (-1 if (x < 0) != (y < 0) else 1)\n return pick(q, x - q * y) & U32\n return pick(*(f_div(a, b) if unsigned else f_div_towards_zero(fresh(a), signed(b))))\n out = fresh(0)\n if EUDIf()(b >= 1):\n q, r = f_div(as_var(a), b) if unsigned else f_div_towards_zero(fresh(a), fresh(b))\n out << pick(q, r)\n EUDEndIf()\n return out\n\n def ordered(self, a, op, b, unsigned):\n \"\"\"`a op b` as one condition, the two read as u32s (`unsigned` true), as signed numbers (false), or one of\n each (\"left\" / \"right\" names the u32) - exactly: a number below zero is smaller than any u32. At least one\n side is a variable. A signed order is the unsigned one with the top bit of both sides flipped.\"\"\"\n if unsigned in (\"left\", \"right\"):\n # s is the signed side, u the u32, and the comparison is turned to read s op u.\n s_, u_ = (b, a) if unsigned == \"left\" else (a, b)\n if unsigned == \"left\":\n op = FLIPPED[op]\n if isinstance(s_, int):\n if s_ >= SIGN:\n return always(op in (\"<\", \"<=\", \"!=\"))\n return self.ordered(s_, op, u_, True)\n sv = as_var(s_)\n if op in (\"<\", \"<=\", \"!=\"):\n return EUDOr(sv >= SIGN, relation(sv, op, u_))\n return EUDAnd(sv <= SIGN - 1, relation(sv, op, u_))\n if isinstance(a, int):\n a, b, op = b, a, FLIPPED[op]\n if unsigned or op in (\"==\", \"!=\"):\n return relation(as_var(a), op, b)\n return relation(a + SIGN, op, (b + SIGN) & U32 if isinstance(b, int) else b + SIGN)\n\n @staticmethod\n def bitwise(op, a, b):\n \"\"\"& | ^ << >> >>> over 32 bits. `>>` keeps the sign of what it shifts and `>>>` does not; a shift by 32 or\n more leaves nothing but that sign.\"\"\"\n if op in (\"<<\", \">>\", \">>>\"):\n if isinstance(a, int) and isinstance(b, int):\n if op == \"<<\":\n return 0 if b >= 32 else (a << b) & U32\n if op == \">>>\":\n return 0 if b >= 32 else a >> b\n return (signed(a) >> min(b, 31)) & U32\n if isinstance(b, int) and b == 0:\n return a\n if isinstance(b, int) and b >= 32 and op != \">>\":\n return 0\n if op == \"<<\":\n return f_bitlshift(fresh(a), b)\n if op == \">>>\":\n return f_bitrshift(fresh(a), b)\n # The sign kept: a number below zero is shifted as its complement, which has zeros where it has ones.\n x, below = fresh(a), fresh(0)\n if EUDIf()(x >= SIGN):\n below << 1\n x << ~x\n EUDEndIf()\n r = fresh(f_bitrshift(x, b)) if not (isinstance(b, int) and b >= 32) else fresh(0)\n if EUDIf()(below >= 1):\n r << ~r\n EUDEndIf()\n return r\n if isinstance(a, int) and isinstance(b, int):\n return {\"&\": a & b, \"|\": a | b, \"^\": a ^ b}[op]\n # Copies: eudplib computes in place into an operand nothing else refers to, and ours may be a variable's own cell.\n x, y = fresh(a), fresh(b)\n return x & y if op == \"&\" else x | y if op == \"|\" else x ^ y\n\n # ── reads: a value of the game ──\n def current(self):\n \"\"\"The player the program is running as: a slot, or the player loop's variable.\"\"\"\n return self.player if self.per_player else self.slots[0]\n\n def one_player(self, p, node):\n if p == CURRENT_PLAYER:\n return self.current()\n if 0 <= p < 12:\n return p\n raise Fail(\"trigscript: a read takes one player%s\" % where(node))\n\n def read(self, e):\n r = e[\"read\"]\n source = r.get(\"source\")\n if source == \"condition\":\n return self.read_condition(r[\"record\"], e)\n if source == \"player\" and r.get(\"fact\") == \"left\":\n return self.read_left(r, e)\n if source == \"player\":\n base = PLAYER_BYTES.get(r.get(\"fact\"))\n if base is None:\n raise Fail(\"trigscript: unknown player fact %r%s\" % (r.get(\"fact\"), where(e)))\n value = f_bread(base + self.one_player(r[\"player\"], e))\n if r.get(\"fact\") == \"slot\":\n # A computer of a Use Map Settings game keeps the map's own number, 5; a melee computer is 1\n # (the slice 2 probe read 5 for one). Both are \"a computer\": one number for the script.\n kind = fresh(value)\n if EUDIf()(kind == 5):\n kind << 1\n EUDEndIf()\n return kind\n return value\n if source == \"supply\":\n return self.read_supply(r, e)\n raise Fail(\"trigscript: unknown read %r%s\" % (source, where(e)))\n\n def read_left(self, r, e):\n \"\"\"1 once a player who was in the map's settings as a human or a computer is gone. Asked the way\n eudplib's f_playerexist asks (the player's trigger list), which the player loop already relies on.\n The byte table at 0x581D62 read 0 for players who were there, as it should, but nobody has seen it\n turn 1 in a game — a computer never leaves — so it is not what this rests on.\"\"\"\n p = self.one_player(r[\"player\"], e)\n if isinstance(p, int) and (p >= 8 or GetPlayerInfo(p).typestr not in (\"Human\", \"Computer\")):\n return 0\n gone = fresh(1)\n if EUDIf()(f_playerexist(p)):\n gone << 0\n EUDEndIf()\n return gone\n\n def read_supply(self, r, e):\n base = SUPPLY_TABLES.get(r.get(\"of\"))\n if base is None:\n raise Fail(\"trigscript: unknown supply %r%s\" % (r.get(\"of\"), where(e)))\n p = self.one_player(r[\"player\"], e)\n half = fresh(0)\n if r.get(\"race\") is None:\n # The race the player plays; a slot with none (neutral, empty) has no supply.\n race = f_bread(PLAYER_BYTES[\"race\"] + p)\n if EUDIf()(race <= 2):\n half << f_dwread_epd(EPD(base) + race * 36 + p)\n EUDEndIf()\n else:\n half << f_dwread_epd(EPD(base) + int(r[\"race\"]) * 36 + p)\n # As the top bar shows it: half a supply in use counts as one.\n if r[\"of\"] == \"used\":\n half += 1\n return half // 2\n\n def read_condition(self, rec, e):\n t, player, unit = rec[\"type\"], rec[\"player\"], rec[\"unitId\"]\n one = player == CURRENT_PLAYER or not 12 <= player <= 26 # not a group; beyond 26 is an EUD offset, read as it stands\n table = None\n if one and t in (COND_DEATHS, COND_KILL) and not 229 <= unit <= 232:\n table = (DEATHS_TABLE if t == COND_DEATHS else KILLS_TABLE, unit * 12)\n elif one and t == COND_ACCUMULATE and rec[\"resource\"] in (0, 1):\n table = (ORE_TABLE if rec[\"resource\"] == 0 else GAS_TABLE, 0)\n if table is not None:\n p = self.current() if player == CURRENT_PLAYER else player\n return f_dwread_epd(EPD(table[0]) + table[1] + p)\n # No table holds it (a group's sum, units counted at a location, a score, a clock): ask the\n # condition, a bit at a time from the top. A unit count stays under 2^12, a number of players under 2^4.\n bits = 12 if t in (COND_BRING, COND_COMMAND) else 4 if t == COND_OPPONENTS else 32\n found = fresh(0)\n for bit in reversed(range(bits)):\n probe = found + (1 << bit)\n if EUDIf()(Condition(rec[\"location\"], player, probe, unit, 0, t, rec[\"resource\"], rec[\"flags\"], eudx=rec.get(\"mask\", 0) or 0)):\n found << probe\n EUDEndIf()\n return found\n\n # ── booleans: an eudplib condition ──\n def cond(self, e):\n k = e[\"kind\"]\n if k == \"const\":\n return always(e[\"value\"])\n if k == \"cond\":\n return condition(e[\"record\"])\n if k == \"var\":\n return as_var(self.var(e[\"id\"], e).get()) >= 1\n if k == \"element\":\n return as_var(self.array(e[\"array\"], e).get(self.num(e[\"index\"]))) >= 1\n if k == \"pop\":\n return as_var(self.array(e[\"array\"], e).pop()) >= 1\n if k == \"test\":\n return as_var(self.num(e[\"expr\"])) >= 1\n if k == \"compare\":\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op, unsigned = e[\"op\"], e.get(\"unsigned\", False)\n if isinstance(a, int) and isinstance(b, int):\n x = a if unsigned in (True, \"left\") else signed(a)\n y = b if unsigned in (True, \"right\") else signed(b)\n return always(compare(x, op, y))\n # One comparison, built once: a comparison between variables writes into its own\n # condition, and one that is built and dropped is an orphan eudplib refuses.\n return self.ordered(a, op, b, unsigned)\n if k == \"and\":\n return EUDAnd(*[self.cond(c) for c in e[\"items\"]])\n if k == \"or\":\n return EUDOr(*[self.cond(c) for c in e[\"items\"]])\n if k == \"not\":\n return EUDNot(self.cond(e[\"expr\"]))\n if k == \"random\":\n return (f_rand() & 1) >= 1\n if k in (\"unitAlive\", \"unitFlag\", \"unitSame\"):\n return as_var(self.truth(e)) >= 1\n if k == \"edge\":\n return self.edge(e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.truth(e[\"whenTrue\"])\n if EUDElse()():\n t << self.truth(e[\"whenFalse\"])\n EUDEndIf()\n return t >= 1\n if k == \"call\":\n return as_var(self.call(e[\"call\"])) >= 1\n if k in (\"textCompare\", \"textTest\"):\n return as_var(self.text_truth(e)) >= 1\n raise Fail(\"trigscript: unknown condition %r%s\" % (k, where(e)))\n\n def truth(self, e):\n \"\"\"A boolean expression as 0 / 1.\"\"\"\n if e[\"kind\"] == \"const\":\n return 1 if e[\"value\"] else 0\n if e[\"kind\"] == \"var\":\n return self.var(e[\"id\"], e).get()\n if e[\"kind\"] == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if e[\"kind\"] == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if e[\"kind\"] == \"unitAlive\":\n ref = self.unit(e[\"unit\"])\n t = fresh(0)\n if not ref.none:\n self.when_alive(ref, lambda: t << 1)\n return t\n if e[\"kind\"] == \"unitFlag\":\n return self.unit_flag(e)\n if e[\"kind\"] == \"unitSame\":\n a, b = self.unit(e[\"left\"]), self.unit(e[\"right\"])\n t = fresh(0)\n if not a.none and not b.none:\n ap, bp = as_var(a.ptr), as_var(b.ptr)\n if EUDIf()([ap >= 1, ap == bp]):\n t << 1\n EUDEndIf()\n return t\n t = fresh(0)\n if EUDIf()(self.cond(e)):\n t << 1\n EUDEndIf()\n return t\n\n def edge(self, e):\n \"\"\"rose(c): true on the frame c becomes true; once(c): true the first time it holds.\"\"\"\n key = id(e)\n if key not in self.latches:\n self.latches[key] = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: the latch\n latch = self.latches[key]\n\n def get():\n return latch[self.player] if self.per_player else latch\n\n def put(v):\n if self.per_player:\n latch[self.player] = v\n else:\n latch << v\n\n fired = fresh(0)\n held = self.truth(e[\"cond\"])\n if EUDIf()(as_var(held) >= 1):\n if EUDIf()(as_var(get()) == 0):\n fired << 1\n put(1)\n EUDEndIf()\n if EUDElse()():\n if e[\"edge\"] == \"rose\":\n put(0)\n EUDEndIf()\n return fired >= 1\n\n # ── units: a pointer into the game's unit table, checked before use ──\n def unit(self, e):\n k = e[\"kind\"]\n if k == \"unitNull\":\n return NO_UNIT\n if k == \"unitVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"pick\":\n return self.pick(e)\n if k == \"unitAt\":\n # A unit kept as three numbers of the program (a cell each of an array of units): re-checked like any kept unit.\n ptr = self.num(e[\"ptr\"])\n if isinstance(ptr, int) and ptr == 0:\n return NO_UNIT\n return UnitRef(fresh(ptr), fresh(self.num(e[\"epd\"])), fresh(self.num(e[\"uid\"])))\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown unit expression %r%s\" % (k, where(e)))\n\n def unit_part(self, e):\n \"\"\"One of the three numbers a unit is kept as. The unit of a loop's turn has no uniqueness byte taken yet: it is read here.\"\"\"\n ref = self.unit(e[\"unit\"])\n if ref.none:\n return 0\n part = e[\"part\"]\n if part == \"ptr\":\n return ref.ptr\n if part == \"epd\":\n return ref.epd\n return f_maskread_epd(ref.epd + OFF_UID, 0xFF00) if ref.uid is None else ref.uid\n\n def when_alive(self, ref, body):\n \"\"\"`body()` when the unit is still the one that was kept: the slot has a sprite, its order is not\n \"die\", and its uniqueness byte is the one taken with the pointer.\"\"\"\n if ref.none:\n return\n if ref.uid is None:\n body()\n return\n ptr, epd = as_var(ref.ptr), as_var(ref.epd)\n if EUDIf()([ptr >= 1, MemoryEPD(epd + OFF_SPRITE, AtLeast, 1), MemoryXEPD(epd + OFF_OWNER_ORDER, AtLeast, 0x100, 0xFF00), MemoryXEPD(epd + OFF_UID, Exactly, ref.uid, 0xFF00)]):\n body()\n EUDEndIf()\n\n @staticmethod\n def cunit(ref):\n return CUnit(ref.epd, ptr=ref.ptr)\n\n def unit_field(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n self.when_alive(ref, lambda: out << self.field_of(self.cunit(ref), e[\"field\"], e))\n return out\n\n def field_of(self, cu, field, node):\n \"\"\"A unit's number, in the script's units: whole points for hit points (as the game shows them,\n a started point counting), shields and energy.\"\"\"\n if field == \"hp\":\n return f_div(cu.hp + 255, 256)[0]\n if field == \"maxHp\":\n return f_div(f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType), 256)[0]\n if field == \"shields\":\n return f_div(cu.shield, 256)[0]\n if field == \"maxShields\":\n return f_wread(UNITS_MAX_SHIELDS + cu.unitType * 2)\n if field == \"energy\":\n return f_div(cu.energy, 256)[0]\n if field in UNIT_TIMERS:\n return getattr(cu, UNIT_TIMERS[field])\n name = {\"owner\": \"owner\", \"type\": \"unitType\", \"x\": \"posX\", \"y\": \"posY\", \"kills\": \"killCount\", \"orderId\": \"orderID\", \"cooldown\": \"groundWeaponCooldown\", \"resources\": \"resourceAmount\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: unknown unit field %r%s\" % (field, where(node)))\n return getattr(cu, name)\n\n def unit_flag(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n\n def body():\n cu = self.cunit(ref)\n flag = e[\"flag\"]\n if flag == \"underAttack\":\n held = cu.attackNotifyTimer >= 1\n else:\n mask = {\"hallucinated\": STATUS_HALLUCINATION, \"cloaked\": STATUS_CLOAKED, \"burrowed\": STATUS_BURROWED, \"invincible\": STATUS_INVINCIBLE}.get(flag)\n if mask is None:\n raise Fail(\"trigscript: unknown unit flag %r%s\" % (flag, where(e)))\n held = cu.check_status_flag(mask)\n if EUDIf()(held):\n out << 1\n EUDEndIf()\n\n self.when_alive(ref, body)\n return out\n\n def location_bounds(self, number):\n base = EPD(MRGN + (int(number) - 1) * 20)\n return [f_dwread_epd(base + i) for i in range(4)]\n\n def scan(self, flt, node, body):\n \"\"\"The game's unit table, slot by slot: `body(ref, next_, exit_)` for every unit on the map the\n filter matches. Every test is one condition whose address moves on with the slot, so a slot\n that does not match costs a trigger or two.\"\"\"\n ptr, epd = EUDVariable(), EUDVariable()\n empty = MemoryEPD(0, Exactly, 0)\n dying = MemoryXEPD(0, Exactly, 0, 0xFF00)\n moving = [(empty, OFF_SPRITE), (dying, OFF_OWNER_ORDER)]\n match, values = [], []\n owner = flt.get(\"owner\")\n if owner is not None:\n who = self.current() if owner == CURRENT_PLAYER else int(owner)\n c = MemoryXEPD(0, Exactly, who if isinstance(who, int) else 0, 0xFF)\n match.append(c)\n moving.append((c, OFF_OWNER_ORDER))\n if not isinstance(who, int):\n values.append((c, who))\n kind = flt.get(\"type\")\n if kind is not None and kind < 228:\n c = MemoryXEPD(0, Exactly, int(kind), 0xFFFF)\n match.append(c)\n moving.append((c, OFF_TYPE))\n elif kind is not None and kind not in GROUP_BITS:\n raise Fail(\"trigscript: unknown unit type %r%s\" % (kind, where(node)))\n if flt.get(\"at\"):\n left, top, right, bottom = self.location_bounds(flt[\"at\"])\n # A location reaching past the map's edge starts below zero, which a unit's position never is.\n for v in (left, top):\n if EUDIf()(v >= 0x80000000):\n v << 0\n EUDEndIf()\n for comparison, mask, value in ((AtLeast, 0xFFFF, left), (AtMost, 0xFFFF, right), (AtLeast, 0xFFFF0000, f_bitlshift(top, 16)), (AtMost, 0xFFFF0000, f_bitlshift(bottom, 16))):\n c = MemoryXEPD(0, comparison, 0, mask)\n match.append(c)\n moving.append((c, OFF_POS))\n values.append((c, value))\n DoActions([ptr.SetNumber(UNIT_TABLE), epd.SetNumber(EPD(UNIT_TABLE))] + [SetMemory(c + 4, SetTo, EPD(UNIT_TABLE) + off) for c, off in moving])\n for c, value in values:\n f_dwwrite_epd(EPD(c + 8), value)\n head, next_, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n EUDJumpIf(ptr >= UNIT_END, exit_)\n EUDJumpIf(empty, next_)\n EUDJumpIf(dying, next_)\n if match:\n matched = Forward()\n EUDJumpIf(match, matched)\n EUDJump(next_)\n matched << NextTrigger()\n if kind in GROUP_BITS:\n group = fresh(f_bread(UNITS_GROUP + f_maskread_epd(epd + OFF_TYPE, 0xFFFF)))\n EUDJumpIf((group & GROUP_BITS[kind]) == 0, next_)\n body(UnitRef(ptr, epd, None), next_, exit_)\n next_ << NextTrigger()\n DoActions([SetMemory(c + 4, Add, UNIT_SIZE // 4) for c, _ in moving] + [ptr.AddNumber(UNIT_SIZE), epd.AddNumber(UNIT_SIZE // 4)])\n EUDJump(head)\n exit_ << NextTrigger()\n\n def pick(self, e):\n \"\"\"One unit among the matching: the first, the nearest to a location's centre, or one at random.\"\"\"\n by, flt = e[\"by\"], e.get(\"filter\", {})\n found_ptr, found_epd = fresh(0), fresh(0)\n\n def take(ref):\n found_ptr << ref.ptr\n found_epd << ref.epd\n\n if by == \"first\":\n def body(ref, next_, exit_):\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n elif by == \"nearest\":\n if e.get(\"mouse\") is not None:\n # A player's mouse, and nothing farther from it than `within`.\n cx = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"x\", \"player\": e[\"mouse\"]}, self, e))\n cy = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"y\", \"player\": e[\"mouse\"]}, self, e))\n least = fresh(int(e.get(\"within\", 48)) + 1)\n else:\n left, top, right, bottom = self.location_bounds(e[\"near\"])\n cx, cy = f_div(left + right, 2)[0], f_div(top + bottom, 2)[0]\n least = fresh(U32)\n\n def body(ref, next_, exit_):\n cu = self.cunit(ref)\n # |dx| + |dy| is enough to say which is nearest, and never overflows.\n d = distance(cu.posX, cx) + distance(cu.posY, cy)\n if EUDIf()(d < least):\n least << d\n take(ref)\n EUDEndIf()\n self.scan(flt, e, body)\n elif by == \"random\":\n # Count the matching, draw one, take the drawn one on a second pass.\n count = fresh(0)\n self.scan(flt, e, lambda ref, next_, exit_: count.__iadd__(1))\n none = Forward()\n EUDJumpIf(count == 0, none)\n drawn = f_div(f_dwrand(), count)[1]\n i = fresh(0)\n\n def body(ref, next_, exit_):\n hit = Forward()\n EUDJumpIf(i == drawn, hit)\n i.__iadd__(1)\n EUDJump(next_)\n hit << NextTrigger()\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n none << NextTrigger()\n else:\n raise Fail(\"trigscript: unknown pick %r%s\" % (by, where(e)))\n uid = fresh(0)\n if EUDIf()(found_ptr >= 1):\n uid << f_maskread_epd(found_epd + OFF_UID, 0xFF00)\n EUDEndIf()\n return UnitRef(found_ptr, found_epd, uid)\n\n def unit_loop(self, st, ctx):\n def body(ref, next_, exit_):\n self.vars[st[\"decl\"][\"id\"]] = LoopUnit(ref)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": next_}))\n self.scan(st.get(\"filter\", {}), st, body)\n\n def unit_write(self, st):\n ref = self.unit(st[\"unit\"])\n field = st[\"field\"]\n if field == \"invincible\":\n on = self.truth(st[\"value\"])\n\n def flag():\n cu = self.cunit(ref)\n if isinstance(on, int):\n cu.set_invincible() if on else cu.clear_invincible()\n return\n if EUDIf()(as_var(on) >= 1):\n cu.set_invincible()\n if EUDElse()():\n cu.clear_invincible()\n EUDEndIf()\n self.when_alive(ref, flag)\n return\n value = self.num(st[\"value\"])\n\n def write():\n cu = self.cunit(ref)\n if field == \"hp\":\n # Hit points at 0 are a dead unit, so that is what the write makes of it.\n if isinstance(value, int):\n if value == 0:\n cu.die()\n else:\n cu.hp = min(value, 0xFFFFFF) * 256\n return\n if EUDIf()(value == 0):\n cu.die()\n if EUDElse()():\n cu.hp = f_mul(saturate(value, 24), 256)\n EUDEndIf()\n elif field == \"shields\":\n cu.shield = points(value, 24)\n elif field == \"energy\":\n cu.energy = points(value, 8)\n elif field == \"kills\":\n cu.killCount = saturate(value, 8)\n elif field == \"resources\":\n cu.resourceAmount = saturate(value, 16)\n elif field == \"cooldown\":\n frames = saturate(value, 8)\n cu.groundWeaponCooldown = frames\n cu.airWeaponCooldown = frames\n cu.spellCooldown = frames\n elif field in UNIT_TIMERS:\n setattr(cu, UNIT_TIMERS[field], saturate(value, 8))\n else:\n raise Fail(\"trigscript: a unit's %s takes no write%s\" % (field, where(st)))\n self.when_alive(ref, write)\n\n def unit_do(self, st):\n ref = self.unit(st[\"unit\"])\n verb = st[\"verb\"]\n do = verb[\"do\"]\n amount = self.num(verb[\"amount\"]) if do in (\"damage\", \"heal\") else 0\n\n def act():\n cu = self.cunit(ref)\n if do == \"kill\":\n cu.die()\n elif do == \"remove\":\n cu.remove()\n elif do == \"give\":\n to = int(verb[\"to\"])\n cu.cgive(self.current() if to == CURRENT_PLAYER else to)\n elif do == \"order\":\n self.order(cu, verb, st)\n elif do == \"locate\":\n self.locate(cu, int(verb[\"location\"]))\n elif do in (\"damage\", \"heal\"):\n self.adjust(cu, do, amount, bool(verb.get(\"percent\")))\n else:\n raise Fail(\"trigscript: unknown unit verb %r%s\" % (do, where(st)))\n self.when_alive(ref, act)\n\n def order(self, cu, verb, node):\n \"\"\"The game's own Order, reaching this unit alone: a location is made a small box around the unit\n for the length of the action (the game did nothing with a box of no size), then put back.\"\"\"\n kind = {\"move\": Move, \"patrol\": Patrol, \"attack\": Attack}.get(verb[\"order\"])\n if kind is None:\n raise Fail(\"trigscript: unknown order %r%s\" % (verb[\"order\"], where(node)))\n base = EPD(MRGN + (SCRATCH_LOCATION - 1) * 20)\n kept = [f_dwread_epd(base + i) for i in range(5)]\n x, y = cu.posX, cu.posY\n for i, v in enumerate((x - 2, y - 2, x + 2, y + 2, 0)):\n f_dwwrite_epd(base + i, v)\n DoActions(Order(cu.unitType, cu.owner, SCRATCH_LOCATION, kind, int(verb[\"target\"])))\n for i, v in enumerate(kept):\n f_dwwrite_epd(base + i, v)\n\n def locate(self, cu, number):\n \"\"\"A location centred on the unit, its size kept.\"\"\"\n base = EPD(MRGN + (number - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n x, y = cu.posX - f_div(width, 2)[0], cu.posY - f_div(height, 2)[0]\n for i, v in enumerate((x, y, x + width, y + height)):\n f_dwwrite_epd(base + i, v)\n\n def center_location(self, st):\n \"\"\"A location centred on a point, its size kept.\"\"\"\n x, y = self.num(st[\"x\"]), self.num(st[\"y\"])\n base = EPD(MRGN + (int(st[\"location\"]) - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n nx, ny = x - f_div(width, 2)[0], y - f_div(height, 2)[0]\n for i, v in enumerate((nx, ny, nx + width, ny + height)):\n f_dwwrite_epd(base + i, v)\n\n def adjust(self, cu, do, amount, percent):\n \"\"\"Hit points down — at 0 the unit dies — or up to the type's maximum, in the game's own units (256 to a point).\"\"\"\n now = fresh(cu.hp)\n top = f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType)\n if percent:\n step = f_div(f_mul(top, as_var(amount)), 100)[0]\n else:\n step = amount * 256 if isinstance(amount, int) else f_mul(saturate(amount, 24), 256)\n if do == \"damage\":\n if EUDIf()(now <= step):\n cu.die()\n if EUDElse()():\n cu.hp = now - step\n EUDEndIf()\n return\n new = fresh(now + step)\n if EUDIf()(new >= top):\n new << top\n EUDEndIf()\n cu.hp = new\n\n # ── the game's tables ──\n def cell_address(self, c):\n index = c[\"index\"]\n if c.get(\"player\") and index == CURRENT_PLAYER:\n index = self.current()\n base = int(c[\"base\"]) + int(c.get(\"key\") or 0)\n return base + index * int(c[\"stride\"])\n\n def table_read(self, c, node):\n if c.get(\"special\"):\n raise Fail(\"trigscript: %s cannot be read%s\" % (c.get(\"name\"), where(node)))\n addr = self.cell_address(c)\n width = c[\"width\"]\n if width == \"bit\":\n v = fresh(0)\n if EUDIf()(MemoryX(addr, AtLeast, 1, 1 << int(c[\"bit\"]))):\n v << 1\n EUDEndIf()\n return v\n v = f_dwread(addr) if width == 4 else f_wread(addr) if width == 2 else f_bread(addr)\n scale = int(c.get(\"scale\") or 1)\n return f_div(v, scale)[0] if scale > 1 else v\n\n def table_write(self, st):\n c = st[\"cell\"]\n addr = self.cell_address(c)\n special = c.get(\"special\")\n if special == \"name\":\n if self.has_id(st[\"value\"]):\n f_wwrite(addr, self.text_id_of(st[\"value\"]))\n return\n # A name that was made: over the string kept for this unit type, on every computer - a name is nobody's in particular.\n made = self.text(st[\"value\"])\n sid = slot(\"the name of unit type %d\" % int(c[\"index\"]))\n texts()[\"show\"](GetMapStringAddr(sid), made.addr)\n self.used(made)\n f_wwrite(addr, sid)\n return\n width = c[\"width\"]\n if st.get(\"boolean\"):\n value = self.truth(st[\"value\"])\n else:\n value = self.num(st[\"value\"])\n scale = int(c.get(\"scale\") or 1)\n if scale > 1 and not st.get(\"scaled\"):\n value = value * scale if isinstance(value, int) else f_mul(value, scale)\n top = 1 if width == \"bit\" else (1 << (8 * width)) - 1\n if isinstance(value, int):\n value = min(value, top)\n elif width != 4 and width != \"bit\":\n value = saturate(value, 8 * width)\n if special == \"speed\":\n self.write_speed(int(c[\"index\"]), value)\n elif special == \"color\":\n f_bwrite(addr, value)\n f_bwrite(addr + MINIMAP_COLOR_OFFSET, value)\n elif width == \"bit\":\n mask = 1 << int(c[\"bit\"])\n if isinstance(value, int):\n DoActions(SetMemoryX(addr, SetTo, mask if value else 0, mask))\n else:\n if EUDIf()(value >= 1):\n DoActions(SetMemoryX(addr, SetTo, mask, mask))\n if EUDElse()():\n DoActions(SetMemoryX(addr, SetTo, 0, mask))\n EUDEndIf()\n elif width == 4:\n f_dwwrite(addr, value)\n elif width == 2:\n f_wwrite(addr, value)\n else:\n f_bwrite(addr, value)\n\n def write_speed(self, unit, speed):\n \"\"\"A unit type's top speed is its flingy's: the flingy is switched to table control and given the\n speed, with acceleration about a seventeenth of it and the braking distance v² / 2a to match\n (the Vulture's own figures), as Magenta writes it. Units made afterwards move at the new speed.\"\"\"\n flingy = f_bread(UNITS_FLINGY + unit)\n if isinstance(speed, int):\n acceleration = max(1, round(speed / 17))\n halt = max(1, round(speed * speed / (2 * acceleration)))\n else:\n acceleration = fresh(f_div(speed, 17)[0])\n if EUDIf()(acceleration == 0):\n acceleration << 1\n EUDEndIf()\n halt = fresh(f_div(f_mul(speed, speed), acceleration * 2)[0])\n if EUDIf()(halt == 0):\n halt << 1\n EUDEndIf()\n acceleration = saturate(acceleration, 16)\n f_bwrite(FLINGY_CONTROL + flingy, 0)\n f_dwwrite(FLINGY_SPEED + flingy * 4, speed)\n f_wwrite(FLINGY_ACCELERATION + flingy * 2, acceleration)\n f_dwwrite(FLINGY_HALT + flingy * 4, halt)\n\n # ── statements ──\n def block(self, statements, ctx):\n for st in statements:\n self.statement(st, ctx)\n\n def statement(self, st, ctx):\n k = st[\"kind\"]\n if k == \"declare\":\n s = self.declare(st[\"decl\"])\n if not st.get(\"failed\"):\n self.put(s, st[\"init\"], st[\"decl\"][\"kind\"])\n elif k == \"assignText\":\n self.put(self.var(st[\"target\"], st), st[\"value\"], \"text\")\n elif k == \"storeText\":\n self.store_text(st)\n elif k == \"releaseText\":\n block, i = self.array(st[\"block\"], st), fresh(self.num(st[\"index\"]))\n texts()[\"release\"](as_var(block.get(i)))\n block.set(i, 0)\n elif k == \"textLoop\":\n self.text_loop(st, ctx)\n elif k == \"assignUnit\":\n self.var(st[\"target\"], st).set(self.unit(st[\"value\"]))\n elif k == \"unitLoop\":\n self.unit_loop(st, ctx)\n elif k == \"unitWrite\":\n self.unit_write(st)\n elif k == \"unitDo\":\n self.unit_do(st)\n elif k == \"tableWrite\":\n self.table_write(st)\n elif k == \"assign\":\n self.var(st[\"target\"], st).set(self.num(st[\"value\"]))\n elif k == \"assignBool\":\n self.var(st[\"target\"], st).set(self.truth(st[\"value\"]))\n elif k == \"declareArray\":\n a = self.array(st[\"array\"], st)\n cell = self.num if a.decl[\"kind\"] == \"number\" else self.truth\n if isinstance(a, ListStorage):\n if st.get(\"fill\") is not None:\n v = cell(st[\"fill\"])\n a.declare_filled(v if isinstance(v, int) else fresh(v), int(a.decl[\"length\"]))\n else:\n values = [cell(v) for v in st.get(\"init\", [])]\n a.declare([v if isinstance(v, int) else fresh(v) for v in values])\n elif st.get(\"fill\") is not None:\n a.fill(cell(st[\"fill\"]))\n else:\n # Every value first, then the stores: [b, a] of two cells of the array itself is a swap.\n values = [cell(v) for v in st.get(\"init\", [])]\n values = [v if isinstance(v, int) else fresh(v) for v in values]\n for i, v in enumerate(values):\n a.set(i, v)\n elif k == \"push\":\n a = self.array(st[\"array\"], st)\n a.push((self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"]))\n elif k == \"pop\":\n self.array(st[\"array\"], st).pop()\n elif k == \"setLength\":\n self.array(st[\"array\"], st).set_length(self.num(st[\"value\"]))\n elif k == \"store\":\n a = self.array(st[\"array\"], st)\n value = (self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"])\n a.set(self.num(st[\"index\"]), value)\n elif k == \"if\":\n self.if_(st, ctx)\n elif k == \"while\":\n self.while_(st, ctx)\n elif k == \"do\":\n self.do_(st, ctx)\n elif k == \"for\":\n self.for_(st, ctx)\n elif k == \"unrolled\":\n self.unrolled(st, ctx)\n elif k == \"switch\":\n self.switch(st, ctx)\n elif k == \"break\":\n if \"break\" not in ctx:\n raise Fail(\"trigscript: break outside a loop%s\" % where(st))\n EUDJump(ctx[\"break\"])\n raise Leave()\n elif k == \"continue\":\n if \"continue\" not in ctx:\n raise Fail(\"trigscript: continue outside a loop%s\" % where(st))\n EUDJump(ctx[\"continue\"])\n raise Leave()\n elif k == \"return\":\n fn = ctx.get(\"fn\")\n if fn is None:\n raise Fail(\"trigscript: return outside a function%s\" % where(st))\n if st.get(\"value\") is not None and fn[\"result\"] is not None:\n self.put(fn[\"result\"], st[\"value\"], fn[\"kind\"])\n EUDJump(fn[\"end\"])\n raise Leave()\n elif k == \"sleep\":\n self.sleep(st)\n elif k == \"action\":\n self.action(st)\n elif k == \"print\":\n self.print_(st)\n elif k == \"centerLocation\":\n self.center_location(st)\n elif k == \"call\":\n self.call(st[\"call\"])\n elif k == \"block\":\n self.block(st[\"body\"], ctx)\n elif k == \"remark\":\n pass\n else:\n raise Fail(\"trigscript: unknown statement %r%s\" % (k, where(st)))\n\n def straight(self, statements, ctx):\n \"\"\"A statement list whose end may not be reached (a break inside): Leave stops it. True when the end was reached.\"\"\"\n try:\n self.block(statements, ctx)\n return True\n except Leave:\n return False\n\n def if_(self, st, ctx):\n else_l, end = Forward(), Forward()\n EUDJumpIfNot(self.cond(st[\"cond\"]), else_l)\n if self.straight(st[\"then\"], ctx):\n EUDJump(end)\n else_l << NextTrigger()\n if st.get(\"else\"):\n self.straight(st[\"else\"], ctx)\n end << NextTrigger()\n\n def while_(self, st, ctx):\n head, exit_ = Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n if self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": head})):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def do_(self, st, ctx):\n body, check, exit_ = Forward(), Forward(), Forward()\n body << NextTrigger()\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": check}))\n check << NextTrigger()\n EUDJumpIf(self.cond(st[\"cond\"]), body)\n exit_ << NextTrigger()\n\n def for_(self, st, ctx):\n head, update, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": update}))\n update << NextTrigger()\n if self.straight(st[\"update\"], ctx):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def unrolled(self, st, ctx):\n exit_ = Forward()\n for statements in st[\"iterations\"]:\n nxt = Forward()\n self.straight(statements, dict(ctx, **{\"break\": exit_, \"continue\": nxt}))\n nxt << NextTrigger()\n exit_ << NextTrigger()\n\n def switch(self, st, ctx):\n v = as_var(self.num(st[\"value\"]))\n exit_ = Forward()\n labels = [Forward() for _ in st[\"cases\"]]\n default = None\n for c, label in zip(st[\"cases\"], labels):\n if c[\"value\"] is None:\n default = label\n continue\n n = c[\"value\"]\n if n != n or n < -SIGN or n > U32: # NaN, or a value the variable never holds\n continue\n EUDJumpIf(v == int(n) & U32, label)\n EUDJump(default if default is not None else exit_)\n for c, label in zip(st[\"cases\"], labels):\n label << NextTrigger()\n self.straight(c[\"body\"], dict(ctx, **{\"break\": exit_}))\n exit_ << NextTrigger()\n\n def sleep(self, st):\n if self.in_function:\n raise Fail(\"trigscript: sleep inside a function that is called%s\" % where(st))\n if st.get(\"cycles\") is not None:\n frames = int(st[\"cycles\"])\n else:\n frames = max(1, int(round(float(st.get(\"ms\", 0)) * FRAMES_PER_SECOND / 1000)))\n index = len(self.resumes) + 1\n resume = Forward()\n self.resumes.append((index, resume))\n self.set_state(index)\n # The frame after this one is one frame later: sleep(frames(1)) goes on in the next frame, as the\n # simulator has it. (Until 3.4 the wait was one frame longer, so a loop sleeping a frame ran every other.)\n self.set_wait(frames - 1)\n EUDJump(self.frame_end)\n resume << NextTrigger()\n\n def action(self, st):\n r = st[\"record\"]\n fields = dict(locid1=r[\"location\"], strid=string_of(r[\"text\"]), wavid=string_of(r[\"wav\"]), time=r[\"time\"], player1=r[\"player\"], player2=r[\"target\"], unitid=r[\"unitId\"], acttype=r[\"type\"], amount=r[\"modifier\"], flags=r[\"flags\"])\n shown = None\n if st.get(\"text\") is not None:\n if self.has_id(st[\"text\"]):\n fields[\"strid\"] = self.text_id_of(st[\"text\"])\n else:\n kind = SLOT_OF_ACTION.get(int(r[\"type\"]))\n if kind is None:\n raise Fail(\"trigscript: this action's text cannot be one that is made%s\" % where(st))\n # Over the string kept for this kind of field - on the computer of the player the action is for, which is\n # the one the program is running as: the game reads the string again whenever it draws, and another\n # player's text must not be there when it does. Nothing of the script can read the string back.\n shown = self.text(st[\"text\"])\n if EUDIf()(IsUserCP()):\n texts()[\"show\"](GetMapStringAddr(slot(kind)), shown.addr)\n EUDEndIf()\n self.used(shown)\n fields[\"strid\"] = slot(kind)\n count = None\n for variable in st.get(\"variables\") or []:\n value = as_var(self.num(variable[\"expr\"]))\n field = variable[\"field\"]\n if field == \"modifier\":\n # A unit count: the byte field is not a variable's place, so the action is done once per\n # unit — as many as the variable says, 0 being none (in the record, 0 means \"all\").\n count = value\n continue\n name = {\"target\": \"player2\", \"time\": \"time\", \"player\": \"player1\", \"location\": \"locid1\", \"text\": \"strid\", \"wav\": \"wavid\", \"unitId\": \"unitid\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: no variable can stand in the %s field%s\" % (field, where(st)))\n if name == \"unitid\":\n # A unit type the game has: past the table, an action reads what is not a unit.\n value = fresh(value)\n if EUDIf()(value >= 228):\n value << 228\n EUDEndIf()\n fields[name] = value\n if count is None:\n DoActions(Action(**fields))\n return\n fields[\"amount\"] = 1\n for _ in EUDLoopRange(0, count):\n DoActions(Action(**fields))\n\n def print_(self, st):\n \"\"\"Text with values in it: every value first, then the text, shown only on the screen of the player it is for.\"\"\"\n args = []\n temps = []\n for part in st[\"parts\"]:\n k = part[\"kind\"]\n if k == \"text\":\n args.append(part[\"text\"])\n elif k == \"value\":\n v = self.text(part[\"text\"])\n temps.append(v)\n args.append(ptr2s(v.addr))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n args.append(str(v if part.get(\"unsigned\") else signed(v)))\n elif part.get(\"unsigned\"):\n args.append(v)\n else:\n # A number below zero: its minus sign, then how far below it is.\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n args.append(ptr2s(sign))\n args.append(size)\n elif k == \"name\":\n args.append(PName(self.one_player(part[\"player\"], st)))\n elif k == \"color\":\n args.append(PColor(self.one_player(part[\"player\"], st)))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n if not args:\n return\n # eudplib prints for the current player, and only on that player's own computer.\n show = (lambda: f_eprintln(*args)) if st.get(\"position\") == \"center\" else (lambda: GetGlobalStringBuffer().print(*args))\n to = int(st.get(\"to\", CURRENT_PLAYER))\n if to == CURRENT_PLAYER:\n show()\n self.used(*temps)\n return\n for p in group_slots(to, st):\n f_setcurpl(p)\n show()\n f_setcurpl(self.current())\n self.used(*temps)\n\n # ── texts ──\n def has_id(self, e):\n \"\"\"Whether a text is one of the built map's table whatever happens in the game (compiler/ir.ts#textHasId).\"\"\"\n k = e[\"kind\"]\n if k in (\"text\", \"textOf\"):\n return True\n if k == \"textVar\":\n return not isinstance(self.var(e[\"id\"], e), TextStorage)\n if k == \"textTernary\":\n return self.has_id(e[\"whenTrue\"]) and self.has_id(e[\"whenFalse\"])\n return False\n\n def text_id_of(self, e):\n \"\"\"The id of a text that has one: an int, or a variable.\"\"\"\n k = e[\"kind\"]\n if k == \"text\":\n return text_id(e[\"text\"])\n if k == \"textVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"textOf\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"textTernary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.text_id_of(e[\"whenTrue\"])\n if EUDElse()():\n t << self.text_id_of(e[\"whenFalse\"])\n EUDEndIf()\n return t\n raise Fail(\"trigscript: this text has no id%s\" % where(e))\n\n def used(self, *values):\n \"\"\"Values that have been used: a block that was the value's own goes back to the heap.\"\"\"\n for v in values:\n if v.taken and not isinstance(v.block, int):\n texts()[\"release\"](v.block)\n\n def texts_of(self, exprs):\n \"\"\"Several texts in the order they are written. A variable's text is only looked at - unless working a later\n one out runs a call, which may give that variable another text and its block back to the heap: then it is a copy.\"\"\"\n out = []\n for i, e in enumerate(exprs):\n v = self.text(e)\n if not v.taken and any(has_call(x) for x in exprs[i + 1:]):\n v = self.own(v)\n out.append(v)\n return out\n\n def own(self, v):\n \"\"\"A value as something to keep: its own block as it is, a copy of a variable's.\"\"\"\n if v.taken:\n return v\n addr, block, length = texts()[\"copy\"](v.addr, v.block, v.length())\n return TextVal(addr, block, length, True)\n\n def store_text(self, st):\n \"\"\"A text into cell i of three arrays (a row's): worked out first - it may be made from what the cells hold -\n then the block they held goes back, then they take the text, a copy of one that was only looked at.\"\"\"\n v = self.own(self.text(st[\"value\"]))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n i = fresh(self.num(st[\"index\"]))\n held = self.array(st[\"block\"], st)\n texts()[\"release\"](as_var(held.get(i)))\n self.array(st[\"addr\"], st).set(i, addr)\n held.set(i, block)\n self.array(st[\"chars\"], st).set(i, length)\n\n def put(self, storage, e, kind):\n \"\"\"A value into a variable. A text is worked out first - it may be made from what the variable holds - and only then does the block the variable held go back.\"\"\"\n if kind != \"text\":\n storage.set(self.value(e, kind))\n return\n if not isinstance(storage, TextStorage):\n storage.set(self.text_id_of(e))\n return\n v = self.own(self.text(e))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n texts()[\"release\"](storage.cell(storage.block))\n storage.write(addr, block, length)\n\n def written(self, parts, st):\n \"\"\"The parts of a text in the scratch, and where it ends. Every value is worked out first, since working one\n out may use the scratch itself; then the parts are written a group at a time, a group being what cannot\n come to more than a text holds, so that checking where the writing has got to between groups keeps it inside.\"\"\"\n T = texts()\n items, temps = [], []\n for part in parts:\n k = part[\"kind\"]\n if k == \"text\":\n data = part[\"text\"].encode(\"utf-8\")[:TEXT_BYTES].decode(\"utf-8\", \"ignore\")\n items.append(([data], len(data.encode(\"utf-8\")), None))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n data = str(v if part.get(\"unsigned\") else signed(v))\n items.append(([data], len(data), None))\n elif part.get(\"unsigned\"):\n items.append(([fresh(v)], 10, None))\n else:\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n items.append(([ptr2s(sign), size], 11, None))\n elif k == \"name\":\n items.append(([PName(self.one_player(part[\"player\"], st))], 25, None))\n elif k == \"color\":\n items.append(([PColor(self.one_player(part[\"player\"], st))], 1, None))\n elif k == \"value\":\n v = self.text(part[\"text\"])\n if not v.taken and has_call(parts[parts.index(part) + 1:]):\n v = self.own(v)\n temps.append(v)\n items.append((None, TEXT_BYTES, v))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n pos = fresh(T[\"scratch\"])\n f_bwrite(pos, 0)\n group, room, first = [], 0, [True]\n\n def flush():\n if not group:\n return\n if first[0]:\n pos << f_dbstr_print(pos, *group)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << f_dbstr_print(pos, *group)\n EUDEndIf()\n first[0] = False\n del group[:]\n\n for args, size, value in items:\n if value is not None:\n flush()\n room = 0\n if first[0]:\n pos << T[\"append\"](pos, value.addr)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << T[\"append\"](pos, value.addr)\n EUDEndIf()\n first[0] = False\n continue\n if room + size > TEXT_BYTES + 1:\n flush()\n room = 0\n group.extend(args)\n room += size\n flush()\n return pos, temps\n\n def text(self, e):\n \"\"\"A text: where it is, its block, its length (TextVal).\"\"\"\n T = texts()\n k = e[\"kind\"]\n if k == \"text\":\n return TextVal(fresh(GetMapStringAddr(e[\"text\"]) if e[\"text\"] else T[\"empty\"]), 0, len(e[\"text\"]), True)\n if k == \"textVar\" and isinstance(self.var(e[\"id\"], e), TextStorage):\n return self.var(e[\"id\"], e).get()\n if k == \"textAt\":\n # Cells that were never given a text are 0: the empty one, as a variable's are.\n i = fresh(self.num(e[\"index\"]))\n addr = fresh(self.array(e[\"addr\"], e).get(i))\n if EUDIf()(addr == 0):\n addr << T[\"empty\"]\n EUDEndIf()\n return TextVal(addr, fresh(self.array(e[\"block\"], e).get(i)), fresh(self.array(e[\"chars\"], e).get(i)), False)\n if self.has_id(e):\n return TextVal(T[\"address\"](as_var(self.text_id_of(e))), 0, None, True)\n if k == \"template\":\n pos, temps = self.written(e[\"parts\"], e)\n addr, block, length = T[\"make\"](pos)\n self.used(*temps)\n return TextVal(addr, block, length, True)\n if k == \"textTernary\":\n addr, block, length = EUDVariable(), EUDVariable(), EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n v = self.own(self.text(e[\"whenTrue\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n if EUDElse()():\n v = self.own(self.text(e[\"whenFalse\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n EUDEndIf()\n return TextVal(addr, block, length, True)\n if k == \"textSlice\":\n of = self.text(e[\"of\"])\n start = self.num(e[\"start\"]) if e.get(\"start\") is not None else 0\n end = self.num(e[\"end\"]) if e.get(\"end\") is not None else 0x7FFFFFFF\n addr, block, length = T[\"slice\"](of.addr, start, end)\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textPad\":\n of, fill = self.texts_of([e[\"of\"], e[\"with\"]])\n width = self.num(e[\"width\"])\n addr, block, length = T[\"pad\"](of.addr, of.length(), width, fill.addr, 1 if e[\"side\"] == \"start\" else 0)\n self.used(of, fill)\n return TextVal(addr, block, length, True)\n if k == \"textRepeat\":\n of = self.text(e[\"of\"])\n addr, block, length = T[\"repeat\"](of.addr, self.num(e[\"count\"]))\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textCall\":\n self.call(e[\"call\"])\n result = e[\"call\"].get(\"result\")\n if result is None:\n return TextVal(fresh(T[\"empty\"]), 0, 0, True)\n return self.var(result[\"decl\"][\"id\"], e).take_out()\n raise Fail(\"trigscript: unknown text %r%s\" % (k, where(e)))\n\n def text_number(self, e):\n T = texts()\n k = e[\"kind\"]\n if k == \"textIndexOf\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n start = self.num(e[\"from\"]) if e.get(\"from\") is not None else 0\n out = T[\"find\"](of.addr, find.addr, start)\n self.used(of, find)\n return out\n of = self.text(e[\"of\"])\n if k == \"textLength\":\n n = of.length()\n out = n if isinstance(n, int) else fresh(n)\n self.used(of)\n return out\n out = T[\"code\"](of.addr, self.num(e[\"index\"]))\n self.used(of)\n return out\n\n def text_truth(self, e):\n \"\"\"A comparison of two texts, or a test of one, as 0 / 1.\"\"\"\n T = texts()\n t = fresh(0)\n if e[\"kind\"] == \"textTest\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n if e[\"test\"] == \"startsWith\":\n t << T[\"starts\"](of.addr, find.addr)\n elif e[\"test\"] == \"endsWith\":\n t << T[\"ends\"](of.addr, find.addr)\n else:\n if EUDIfNot()(T[\"find\"](of.addr, find.addr, 0) == 0xFFFFFFFF):\n t << 1\n EUDEndIf()\n self.used(of, find)\n return t\n op = e[\"op\"]\n if op in (\"==\", \"!=\") and self.has_id(e[\"left\"]) and self.has_id(e[\"right\"]):\n # Two texts of the table: the same text is the same id.\n a, b = self.text_id_of(e[\"left\"]), self.text_id_of(e[\"right\"])\n if isinstance(a, int) and isinstance(b, int):\n return 1 if (a == b) == (op == \"==\") else 0\n if EUDIf()(as_var(a) == b):\n t << 1\n EUDEndIf()\n else:\n a, b = self.texts_of([e[\"left\"], e[\"right\"]])\n # The difference of the first bytes that differ, as a number of 32 bits: 0, below zero, or above.\n d = f_strcmp(a.addr, b.addr)\n self.used(a, b)\n if op in (\"==\", \"!=\"):\n if EUDIf()(d == 0):\n t << 1\n EUDEndIf()\n else:\n below = (lambda: d >= SIGN)\n if op in (\"<\", \">=\"):\n if EUDIf()(below()):\n t << 1\n EUDEndIf()\n else: # \"<=\" and \">\": whether it is not above\n if EUDIf()(EUDOr(d == 0, below())):\n t << 1\n EUDEndIf()\n return flipped(t) if op in (\">=\", \">\") else t\n return flipped(t) if op == \"!=\" else t\n\n def text_loop(self, st, ctx):\n \"\"\"`for (const ch of s)`: the text walked once, a character a turn, each a made text of its own.\"\"\"\n T = texts()\n turn = self.declare(st[\"decl\"])\n over = self.own(self.text(st[\"of\"]))\n held = fresh(over.block)\n at = fresh(over.addr)\n head, done = Forward(), Forward()\n inner = dict(ctx)\n inner[\"break\"], inner[\"continue\"] = done, head\n leave = None\n if ctx.get(\"fn\") is not None:\n # A return inside the loop leaves through here, so that the text the loop holds goes back first.\n leave = Forward()\n inner[\"fn\"] = dict(ctx[\"fn\"], end=leave)\n head << NextTrigger()\n n = T[\"size\"](at)\n EUDJumpIf(n == 0, done)\n f_memcpy(T[\"scratch\"], at, n)\n f_bwrite(T[\"scratch\"] + n, 0)\n addr, block, length = T[\"make\"](T[\"scratch\"] + n)\n T[\"release\"](turn.cell(turn.block))\n turn.write(addr, block, length)\n at += n\n if self.straight(st[\"body\"], inner):\n EUDJump(head)\n done << NextTrigger()\n T[\"release\"](held)\n if leave is not None:\n after = Forward()\n EUDJump(after)\n leave << NextTrigger()\n T[\"release\"](held)\n EUDJump(ctx[\"fn\"][\"end\"])\n after << NextTrigger()\n\n def value(self, e, kind):\n \"\"\"An expression as what a variable of `kind` holds.\"\"\"\n return self.num(e) if kind == \"number\" else self.unit(e) if kind == \"unit\" else self.truth(e)\n\n def function(self, id_, node):\n \"\"\"A called function: its parameters' cells, its result's, and its one body — an EUDFunc of no\n arguments, since the cells are the program's own (a row a player, as any variable is).\"\"\"\n made = self.made.get(id_)\n if made is not None:\n return made\n f = self.functions.get(id_)\n if f is None:\n raise Fail(\"trigscript: unknown function %r%s\" % (id_, where(node)))\n params = [self.declare(d) for d in f[\"params\"]]\n kind = f[\"result\"][\"kind\"] if f.get(\"result\") else \"void\"\n result = self.declare(f[\"result\"][\"decl\"]) if f.get(\"result\") else None\n lowering = self\n if f.get(\"recursive\"):\n return self.recursive_function(id_, f, params, kind, result)\n\n @EUDFunc\n def body():\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n lowering.in_function += 1\n try:\n lowering.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n lowering.in_function -= 1\n end << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n return made\n\n def recursive_function(self, id_, f, params, kind, result):\n \"\"\"A function that calls itself. eudplib's EUDFunc keeps one return address and is not there to be called\n until its body is whole, so this one is triggers of our own: the body in a scope apart, ended by a trigger\n whose next-trigger field is the return address - a cell a call writes, and a frame keeps. It is on record\n before its body is lowered, so a call of it met in that body finds it.\"\"\"\n start, tail = Forward(), Forward()\n # The return address a second time, in a variable: a frame keeps it from there, since reading a cell of\n # the game's memory back costs some thirty triggers and writing a variable out costs two.\n back = EUDVariable()\n\n def body():\n after = Forward()\n DoActions([SetNextPtr(tail, after), back.SetNumber(after)])\n EUDJump(start)\n after << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n entry = {\"f\": f, \"tail\": tail, \"back\": back}\n PushTriggerScope()\n start << NextTrigger()\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n self.in_function += 1\n self.within.append(entry)\n try:\n self.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n self.within.pop()\n self.in_function -= 1\n end << NextTrigger()\n tail << RawTrigger()\n PopTriggerScope()\n return made\n\n def saved_cells(self, saves, node):\n \"\"\"What a frame keeps, as (read, write) pairs a cell: the variables, a unit three cells, an array's handle four.\"\"\"\n pairs = []\n for id_ in saves.get(\"vars\", []):\n s = self.vars.get(id_)\n if s is None:\n # Declared further down the body: it has no cell yet, and the declaration that makes it sets it.\n continue\n if isinstance(s, UnitStorage):\n for cell in (s.ptr, s.epd, s.uid):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, TextStorage):\n for cell in (s.addr, s.block, s.len):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, Storage):\n pairs.append(self.cell_pair(s.store, s.rowed))\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, node)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n pairs.append(self.cell_pair(a.handle[name], a.rowed))\n return pairs\n\n def saved_texts(self, saves):\n \"\"\"The made texts among what a frame keeps: each owns a block, which the frame keeps for it.\"\"\"\n return [s for s in (self.vars.get(id_) for id_ in saves.get(\"vars\", [])) if isinstance(s, TextStorage)]\n\n def cell_pair(self, cell, rowed):\n if rowed:\n return (lambda: cell[self.player_of()]), (lambda v: cell.__setitem__(self.player_of(), v))\n return (lambda: cell), (lambda v: cell << v)\n\n def keep(self, call):\n \"\"\"Before a call that may come back into the function it is in: that function's cells and its return\n address onto the stack, the handles of its arrays set to \"no block\". Past the depth the map allows the\n program says so and stops for good.\"\"\"\n saves = call[\"saves\"]\n st = stack()\n if not self.within:\n raise Fail(\"trigscript: a call keeps a frame outside a function that calls itself%s\" % where(call))\n mine = self.within[-1]\n if EUDIf()(st[\"depth\"] >= STACK_DEPTH):\n GetGlobalStringBuffer().print(\"\\x06TrigScript: stack overflow in %s, line %s - %d calls deep. The program has stopped.\" % (saves.get(\"within\", \"a function\"), (call.get(\"at\") or {}).get(\"line\", \"?\"), STACK_DEPTH))\n st[\"depth\"] << 0\n st[\"at\"] << st[\"base\"]\n self.set_state(DONE)\n EUDJump(self.frame_end)\n EUDEndIf()\n pairs = self.saved_cells(saves, call)\n at = st[\"at\"]\n f_dwwrite_epd(at, mine[\"back\"])\n at += 1\n for read, _ in pairs:\n f_dwwrite_epd(at, read())\n at += 1\n st[\"depth\"] += 1\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n a.put(name, 0)\n # A text the function holds keeps its block on the stack; the variable holds none for the length of the call,\n # so that the inner run's first text does not give the outer run's block back.\n held = self.saved_texts(saves)\n for s in held:\n s.write(0, 0, 0)\n return pairs, mine, held\n\n def bring_back(self, call, kept):\n pairs, mine, held = kept\n st = stack()\n # The block the inner run left in a text goes back to the heap before the text is the outer run's again.\n for s in held:\n texts()[\"release\"](s.cell(s.block))\n # The block the inner run left in a handle goes back to the heap before the handle is the outer run's again.\n for id_ in call[\"saves\"].get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n ptr = fresh(a.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n a.heap[\"give\"](ptr, a.field(\"k\"))\n EUDEndIf()\n at = st[\"at\"]\n for _, write in reversed(pairs):\n at -= 1\n write(f_dwread_epd(at))\n at -= 1\n mine[\"back\"] << f_dwread_epd(at)\n f_dwwrite_epd(EPD(mine[\"tail\"]) + 1, mine[\"back\"])\n st[\"depth\"] -= 1\n\n def call_function(self, call, result):\n body, params, returned = self.function(call[\"fn\"], call)\n if len(params) != len(call[\"params\"]):\n raise Fail(\"trigscript: %s takes %d values, not %d%s\" % (call[\"fn\"], len(params), len(call[\"params\"]), where(call)))\n # Every argument first, then the parameters: an argument may be a call of this same function. A variable's\n # own cell is copied when a later argument holds a call, which may be of a function that writes that variable.\n values = []\n for i, p in enumerate(call[\"params\"]):\n if p[\"decl\"][\"kind\"] == \"text\":\n # A text is a copy of its own by the time the parameter takes it: three numbers, read now.\n t = self.own(self.text(p[\"init\"]))\n values.append((fresh(t.addr), fresh(t.block), fresh(t.length())))\n continue\n v = self.value(p[\"init\"], p[\"decl\"][\"kind\"])\n later = any(has_call(q[\"init\"]) for q in call[\"params\"][i + 1:])\n if later and p[\"decl\"][\"kind\"] != \"unit\" and not isinstance(v, int):\n v = fresh(v)\n values.append(v)\n # A call that may come back here keeps this function's frame: after the arguments are worked out (they read\n # it), before the parameters are set (they may be this function's own).\n kept = None\n if call.get(\"saves\"):\n # Copies: an argument may be one of this function's own parameters, which the ones set before it write over.\n values = [v if isinstance(v, (int, tuple)) else UnitRef(v.ptr if isinstance(v.ptr, int) else fresh(v.ptr), v.epd if isinstance(v.epd, int) else fresh(v.epd), v.uid if v.uid is None or isinstance(v.uid, int) else fresh(v.uid)) if isinstance(v, UnitRef) else fresh(v) for v in values]\n kept = self.keep(call)\n for s, v in zip(params, values):\n if isinstance(v, tuple):\n # What the parameter held from the call before goes back; then it holds this call's text.\n texts()[\"release\"](s.cell(s.block))\n s.write(*v)\n else:\n s.set(v)\n body()\n if kept is not None:\n self.bring_back(call, kept)\n if result is None:\n return 0\n if isinstance(result, TextStorage):\n # The function's text moves to the call's own result, from where a textCall takes it.\n v = returned.take_out()\n texts()[\"release\"](result.cell(result.block))\n result.write(v.addr, v.block, v.length())\n return 0\n result.set(returned.get())\n return result.get()\n\n def call(self, call):\n result = self.declare(call[\"result\"][\"decl\"]) if call.get(\"result\") else None\n if call.get(\"fn\") is not None:\n return self.call_function(call, result)\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and call[\"result\"][\"kind\"] != \"text\":\n result.set(NO_UNIT if call[\"result\"][\"kind\"] == \"unit\" else 0)\n for p in call[\"params\"]:\n s = self.declare(p[\"decl\"])\n self.put(s, p[\"init\"], p[\"decl\"][\"kind\"])\n end = Forward()\n self.straight(call[\"body\"], {\"fn\": {\"result\": result, \"kind\": call[\"result\"][\"kind\"] if call.get(\"result\") else \"void\", \"end\": end}})\n end << NextTrigger()\n return result.get() if result is not None else 0\n\n # ── one frame ──\n def frame(self):\n self.frame_end = Forward()\n start = Forward()\n skip = Forward()\n # Still waiting: count down and leave.\n EUDJumpIfNot(as_var(self.get_wait()) >= 1, skip)\n self.set_wait(as_var(self.get_wait()) - 1)\n EUDJump(self.frame_end)\n skip << NextTrigger()\n # The body once, resumes recorded as sleeps are met; the jump table is filled in after.\n table = Forward()\n EUDJump(table)\n start << NextTrigger()\n try:\n self.block(self.p[\"body\"], {})\n except Leave:\n pass\n # The body ended: the program stops for good, as the classic backend's does.\n self.set_state(DONE)\n EUDJump(self.frame_end)\n table << NextTrigger()\n EUDJumpIf(as_var(self.get_state()) == DONE, self.frame_end)\n for index, resume in self.resumes:\n EUDJumpIf(as_var(self.get_state()) == index, resume)\n EUDJump(start)\n self.frame_end << NextTrigger()\n\n def run(self):\n if self.per_player:\n for p in loop_players(self.slots):\n self.player = p\n f_setcurpl(p)\n self.frame()\n self.player = None\n else:\n # As a trigger owned by that player would: not at all once the player has left.\n if EUDIf()(f_playerexist(self.slots[0])):\n f_setcurpl(self.slots[0])\n self.frame()\n EUDEndIf()\n\n\ndef flipped(t):\n \"\"\"1 for 0 and 0 for anything else, in a variable of its own.\"\"\"\n out = fresh(0)\n if EUDIf()(t == 0):\n out << 1\n EUDEndIf()\n return out\n\n\ndef has_call(node):\n \"\"\"Whether a call is anywhere in a piece of the IR.\"\"\"\n if isinstance(node, list):\n return any(has_call(x) for x in node)\n if not isinstance(node, dict):\n return False\n if node.get(\"kind\") in (\"call\", \"textCall\") and isinstance(node.get(\"call\"), dict):\n return True\n return any(has_call(v) for v in node.values() if isinstance(v, (dict, list)))\n\n\ndef string_of(value):\n \"\"\"An action's text or sound: the string itself (eudplib adds it to the map), or an index the script named.\"\"\"\n if isinstance(value, str):\n return EncodeString(value) if value else 0\n return int(value or 0)\n\n\ndef group_slots(group, node):\n \"\"\"The player slots a player number stands for: a slot is itself, All Players and a force their human and computer players.\"\"\"\n playing = lambda p: GetPlayerInfo(p).typestr in (\"Human\", \"Computer\")\n if 0 <= group < 8:\n return [group]\n if group == 17:\n return [p for p in range(8) if playing(p)]\n if 18 <= group <= 21:\n return [p for p in range(8) if playing(p) and GetPlayerInfo(p).force == group - 18]\n raise Fail(\"trigscript: expected players 1 to 8, All Players or a force%s\" % where(node))\n\n\ndef owner_slots(program):\n \"\"\"The player slots a program runs for, from its owners and the map's player settings: a slot is\n itself, All Players and a force are their human and computer players — who a trigger runs for.\"\"\"\n slots = []\n for o in program.get(\"owners\", [program.get(\"owner\", 0)]):\n for p in group_slots(int(o), program):\n if p not in slots:\n slots.append(p)\n if not slots:\n raise Fail(\"trigscript: no human or computer player of this map is among the program's owners%s\" % where(program))\n return slots\n\n\ndef loop_players(slots):\n \"\"\"Each of `slots` who is in the game, as an EUDVariable: EUDLoopPlayer, for a list of our own.\"\"\"\n start, end = min(slots), max(slots)\n v = EUDVariable()\n v << start\n if EUDWhile()(v <= end):\n for i in range(start, end):\n if i not in slots:\n EUDContinueIf(v == i)\n EUDContinueIfNot(f_playerexist(v))\n yield v\n EUDSetContinuePoint()\n v += 1\n EUDEndWhile()\n\n\nBITWISE = (\"&\", \"|\", \"^\", \"<<\", \">>\", \">>>\")\nFLIPPED = {\"<\": \">\", \"<=\": \">=\", \">\": \"<\", \">=\": \"<=\", \"==\": \"==\", \"!=\": \"!=\"}\n\n\ndef points(value, bits):\n \"\"\"Whole points as the game stores them, 256 to a point; `bits` is how many bits of points the cell holds.\"\"\"\n if isinstance(value, int):\n return min(value, (1 << bits) - 1) * 256\n return f_mul(saturate(value, bits), 256)\n\n\ndef uses_random(node):\n if isinstance(node, dict):\n return node.get(\"kind\") in (\"random\", \"randomInt\") or (node.get(\"kind\") == \"pick\" and node.get(\"by\") == \"random\") or any(uses_random(v) for v in node.values())\n return isinstance(node, list) and any(uses_random(v) for v in node)\n\n\ndef distance(a, b):\n \"\"\"|a - b| of two places on the map, both from 0 up.\"\"\"\n d = fresh(a)\n d -= b\n if EUDIf()(d >= SIGN):\n d << 0 - d\n EUDEndIf()\n return d\n\n\ndef signed(v):\n \"\"\"The 32 bits as a signed number.\"\"\"\n v &= U32\n return v - (1 << 32) if v >= SIGN else v\n\n\ndef always(truth):\n \"\"\"A condition that is known when the map is built.\"\"\"\n return EUDVariable(1 if truth else 0) >= 1 # initial: a constant, never written\n\n\ndef relation(av, op, b):\n \"\"\"`av op b` between a variable and a variable or an int, both read from 0 up.\"\"\"\n if op == \"==\":\n return av == b\n if op == \"!=\":\n return av != b\n if op == \"<\":\n return av < b\n if op == \"<=\":\n return av <= b\n if op == \">\":\n return av > b\n return av >= b\n\n\ndef compare(a, op, b):\n return {\"==\": a == b, \"!=\": a != b, \"<\": a < b, \"<=\": a <= b, \">\": a > b, \">=\": a >= b}[op]\n\n\ndef condition(r):\n return Condition(r[\"location\"], r[\"player\"], r[\"amount\"], r[\"unitId\"], r[\"comparison\"], r[\"type\"], r[\"resource\"], r[\"flags\"], eudx=r.get(\"mask\", 0) or 0)\n\n\nclass Input:\n \"\"\"What the players do, as it reaches every computer: the cells chatEvent and MSQC write, by the\n names the editor put in their settings (compiler/input.ts, INPUT_NAMES), and the reads of them.\"\"\"\n\n MAX_NUMBER = 0xFFFFF\n\n def __init__(self, plan):\n self.plan = plan or {}\n self.keys = list(self.plan.get(\"keys\", []))\n self.buttons = list(self.plan.get(\"buttons\", []))\n self.chats = list(self.plan.get(\"chats\", []))\n self.mouse_base = self.plan.get(\"mouseBase\")\n # A person can sit in the map's human slots, which is where MSQC counts the mouse locations from.\n self.humans = [p for p in range(8) if GetPlayerInfo(p).typestr == \"Human\"]\n if plan and not self.humans:\n raise Fail(\"trigscript: a program reads keys, clicks, the mouse or chat, and the map has no human player to give any\")\n self.key_cells = [self.register(\"tsin_key%d\" % i, EUDArray(12)) for i in range(len(self.keys))]\n self.button_cells = [self.register(\"tsin_button%d\" % i, EUDArray(12)) for i in range(len(self.buttons))]\n self.captures = max([len(c[\"captures\"]) for c in self.chats] or [0])\n if self.chats:\n # Local: what chatEvent found on this computer, and what the patterns made of it.\n self.heard = self.register(\"tsin_heard\", EUDVariable())\n self.pointer = self.register(\"tsin_pointer\", EUDVariable())\n self.length = self.register(\"tsin_length\", EUDVariable())\n self.register(\"tsin_pattern\", EUDVariable())\n self.chat_local = self.register(\"tsin_chat\", EUDVariable())\n self.capture_local = [self.register(\"tsin_capture%d\" % i, EUDVariable()) for i in range(self.captures)]\n # Everyone's: what MSQC delivered this frame, by player; 0xFFFFFFFF on a frame with nothing.\n self.chat_in = self.register(\"tsin_chat_in\", EUDArray(12))\n self.capture_in = [self.register(\"tsin_capture%d_in\" % i, EUDArray(12)) for i in range(self.captures)]\n self.unit_table = None\n\n @staticmethod\n def register(name, cell):\n EUDRegisterObjectToNamespace(name, cell)\n return cell\n\n # ── reads ──\n def read(self, i, low, node):\n source = i.get(\"source\")\n p = low.one_player(i[\"player\"], node)\n if source == \"key\":\n return self.cell(self.key_cells, self.keys, i[\"key\"], node)[p]\n if source == \"click\":\n return self.cell(self.button_cells, self.buttons, i[\"button\"], node)[p]\n if source == \"mouse\":\n return self.mouse(p, 0 if i[\"axis\"] == \"x\" else 1, node)\n if source == \"chat\":\n number = self.chat_number(i[\"pattern\"], node)\n out = fresh(0)\n if EUDIf()(self.chat_in[p] == number):\n out << (1 if i.get(\"capture\") is None else self.capture_in[int(i[\"capture\"])][p])\n EUDEndIf()\n return out\n raise Fail(\"trigscript: unknown input %r%s\" % (source, where(node)))\n\n @staticmethod\n def cell(cells, names, name, node):\n if name not in names:\n raise Fail(\"trigscript: the IR's input plan has no %r%s\" % (name, where(node)))\n return cells[names.index(name)]\n\n def chat_number(self, pattern, node):\n for index, c in enumerate(self.chats):\n if c[\"pattern\"] == pattern:\n return index + 1\n raise Fail(\"trigscript: the IR's input plan has no pattern %r%s\" % (pattern, where(node)))\n\n def mouse(self, p, axis, node):\n \"\"\"MSQC keeps the mouse of the map's first human slot in location `mouseBase`, the next slot's in the next.\"\"\"\n if self.mouse_base is None:\n raise Fail(\"trigscript: the IR's input plan keeps no mouse%s\" % where(node))\n first = min(self.humans)\n cell = lambda h: EPD(MRGN + (int(self.mouse_base) - 1 + h - first) * 20) + axis\n if isinstance(p, int):\n return f_dwread_epd(cell(p)) if p in self.humans else 0\n out = fresh(0)\n for h in self.humans:\n if EUDIf()(p == h):\n out << f_dwread_epd(cell(h))\n EUDEndIf()\n return out\n\n # ── the typed line, on the computer it was typed on ──\n @staticmethod\n def hash_of(data):\n \"\"\"What `hashed` makes of the same bytes: capitals A to Z as small letters, h = h × 31 + byte.\"\"\"\n h = 0\n for b in data:\n if 65 <= b <= 90:\n b += 32\n h = (h * 31 + b) & U32\n return h\n\n def hashed(self, pos, stop_at_space):\n \"\"\"The hash of the line from `pos` to its end or, with `stop_at_space`, to the next space; `pos` moves past it.\"\"\"\n h = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = fresh(f_bread(self.pointer + pos))\n if stop_at_space:\n EUDBreakIf(ch == 32)\n if EUDIf()([ch >= 65, ch <= 90]):\n ch += 32\n EUDEndIf()\n h << f_mul(h, 31) + ch\n pos += 1\n EUDEndWhile()\n return h\n\n def units(self):\n \"\"\"Unit names by hash, in hash order, for a search by halves: two arrays side by side.\"\"\"\n if self.unit_table is None:\n by_hash = {}\n for name, unit in self.plan.get(\"unitNames\", []):\n by_hash.setdefault(self.hash_of(name.encode(\"utf-8\")), int(unit))\n ordered = sorted(by_hash.items())\n self.unit_table = (EUDArray([h for h, _ in ordered] or [0]), EUDArray([u for _, u in ordered] or [0]), len(ordered))\n return self.unit_table\n\n def capture(self, c, pos, fail):\n \"\"\"One value out of the line at `pos`, or a jump to `fail`.\"\"\"\n value = fresh(0)\n if c[\"kind\"] == \"number\":\n digits = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = f_bread(self.pointer + pos)\n EUDBreakIf(ch <= 47)\n EUDBreakIf(ch >= 58)\n value << f_mul(value, 10) + ch - 48\n if EUDIf()(value >= self.MAX_NUMBER + 1):\n value << self.MAX_NUMBER\n EUDEndIf()\n pos += 1\n digits += 1\n EUDEndWhile()\n EUDJumpIf(digits == 0, fail)\n elif c[\"kind\"] == \"word\":\n h = self.hashed(pos, True)\n found = fresh(0)\n for index, word in enumerate(c[\"words\"]):\n if EUDIf()(h == self.hash_of(word.encode(\"utf-8\"))):\n value << index\n found << 1\n EUDEndIf()\n EUDJumpIf(found == 0, fail)\n elif c[\"kind\"] == \"unit\":\n h = self.hashed(pos, False)\n hashes, units, count = self.units()\n lo, hi = fresh(0), fresh(count)\n if EUDWhile()(lo < hi):\n mid = f_div(lo + hi, 2)[0]\n if EUDIf()(hashes[mid] < h):\n lo << mid + 1\n if EUDElse()():\n hi << mid\n EUDEndIf()\n EUDEndWhile()\n EUDJumpIf(lo >= count, fail)\n EUDJumpIfNot(hashes[lo] == h, fail)\n value << units[lo]\n else:\n raise Fail(\"trigscript: unknown chat capture %r\" % (c[\"kind\"],))\n return value\n\n def match_line(self):\n \"\"\"The line the local player typed against every pattern, in order: the first that fits the whole\n line gives its number and values to the cells MSQC sends from. Nothing fits: nothing is sent.\"\"\"\n DoActions([self.chat_local.SetNumber(0)] + [c.SetNumber(0) for c in self.capture_local])\n if EUDIf()(self.heard >= 1):\n done = Forward()\n for index, chat in enumerate(self.chats):\n fail = Forward()\n pos = fresh(0)\n values = []\n for seg in chat[\"segments\"]:\n if isinstance(seg, str):\n data = seg.encode(\"utf-8\")\n EUDJumpIf(pos + len(data) >= self.length + 1, fail)\n EUDJumpIfNot(f_memcmp(self.pointer + pos, Db(data + b\"\\0\"), len(data)) == 0, fail)\n pos += len(data)\n else:\n values.append(self.capture(chat[\"captures\"][int(seg)], pos, fail))\n EUDJumpIfNot(pos == self.length, fail)\n DoActions(self.chat_local.SetNumber(index + 1))\n for cell, value in zip(self.capture_local, values):\n cell << value\n EUDJump(done)\n fail << NextTrigger()\n done << NextTrigger()\n EUDEndIf()\n\n\n# Where each trigger came from, for a run in the lifted game to name the line a trigger belongs to (TrigScript 4,\n# A1). Only when the build asks for it (the `triggerMap` setting): every RawTrigger made is tagged with the\n# statement being lowered, or with the eudplib function whose body it is, and once the payload is laid out one\n# header line and lines of rows say where each trigger sits in it:\n# TRIGSCRIPT-MAP {\"payload\": , \"count\": }\n# TRIGSCRIPT-ROWS [[offset, conditions, actions, origin], ...] (400 a line, as many lines as it takes)\n# An origin is \"line:column\" (or \"file:line:column\"), \"fn:\" for a function of eudplib's, \"\" for what no\n# statement made (a program's frame, its sleeps), or \"eudplib\" for what eudplib made before this file was read. The payload sits at the end of the strings section, so a run\n# finds it at the section's address plus its length less the payload's.\nif settings.get(\"triggerMap\"):\n import eudplib.core.allocator.payload as _payload\n from eudplib.core.eudfunc.eudfuncn import EUDFuncN as _EUDFuncN\n from eudplib.core.rawtrigger.rawtriggerdef import RawTrigger as _RawTrigger\n\n _ORIGINS = []\n _MADE = {}\n\n def _origin_of(at):\n if not at:\n return \"\"\n if at.get(\"file\"):\n return \"%s:%s:%s\" % (at.get(\"file\"), at.get(\"line\"), at.get(\"column\"))\n return \"%s:%s\" % (at.get(\"line\"), at.get(\"column\"))\n\n _trigger_init = _RawTrigger.__init__\n\n def _tagged_trigger(self, *args, **kwargs):\n _trigger_init(self, *args, **kwargs)\n _MADE[self] = _ORIGINS[-1] if _ORIGINS else \"\"\n\n _RawTrigger.__init__ = _tagged_trigger\n\n _func_body = _EUDFuncN._create_func_body\n\n def _tagged_body(self):\n _ORIGINS.append(\"fn:\" + getattr(self._bodyfunc, \"__name__\", \"?\"))\n try:\n return _func_body(self)\n finally:\n _ORIGINS.pop()\n\n _EUDFuncN._create_func_body = _tagged_body\n\n _statement = Lowering.statement\n\n def _tagged_statement(self, st, ctx):\n _ORIGINS.append(_origin_of(st.get(\"at\")) or (_ORIGINS[-1] if _ORIGINS else \"\"))\n try:\n return _statement(self, st, ctx)\n finally:\n _ORIGINS.pop()\n\n Lowering.statement = _tagged_statement\n\n _construct = _payload._construct_payload\n\n def _construct_and_say():\n built = _construct()\n found = _payload._found_objects_dict\n rows = []\n # (Every trigger in the payload, also those eudplib made before this file was read, at its own import:\n # those say \"eudplib\".)\n for trg, i in found.items():\n if not isinstance(trg, _RawTrigger):\n continue\n origin = _MADE.get(trg, \"eudplib\")\n rows.append([_payload._payload_builder.get_object_addr(i).offset, len(trg._conditions), len(trg._actions), origin])\n rows.sort()\n # (In parts, so that no one line of the log runs to megabytes.)\n print(\"TRIGSCRIPT-MAP \" + json.dumps({\"payload\": len(built.data), \"count\": len(rows)}, separators=(\",\", \":\")))\n for k in range(0, len(rows), 400):\n print(\"TRIGSCRIPT-ROWS \" + json.dumps(rows[k:k + 400], separators=(\",\", \":\")))\n return built\n\n _payload._construct_payload = _construct_and_say\n\n\nINPUT = Input(IR.get(\"input\"))\nPROGRAMS = [Lowering(p) for p in IR.get(\"programs\", [])]\n\n\ndef onPluginStart():\n # eudplib's generator starts from the same seed in every game; the game's own randomness (a switch randomized) seeds it.\n if uses_random(IR.get(\"programs\", [])):\n f_randomize()\n\n\ndef beforeTriggerExec():\n # After chatEvent has looked for a typed line, before MSQC sends what it was.\n if INPUT.chats:\n INPUT.match_line()\n\n\ndef afterTriggerExec():\n for prog in PROGRAMS:\n prog.run()\n"; diff --git a/data/allowlist.json b/data/allowlist.json new file mode 100644 index 0000000..df05cdd --- /dev/null +++ b/data/allowlist.json @@ -0,0 +1,2073 @@ +{ + "version": 1, + "entries": [ + { + "name": "max hit points of a unit type", + "base": 6693712, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.maxHp, verified by its probes" + ] + }, + { + "name": "max shields of a unit type", + "base": 6688256, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.maxShields, verified by its probes" + ] + }, + { + "name": "armor of a unit type", + "base": 6684360, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.armor, verified by its probes" + ] + }, + { + "name": "build time of a unit type", + "base": 6685736, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.buildTime, verified by its probes" + ] + }, + { + "name": "mineral cost of a unit type", + "base": 6699144, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.mineralCost, verified by its probes" + ] + }, + { + "name": "gas cost of a unit type", + "base": 6683904, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.gasCost, verified by its probes" + ] + }, + { + "name": "supply used by a unit type", + "base": 6700264, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.supplyRequired, verified by its probes" + ] + }, + { + "name": "supply provided by a unit type", + "base": 6702792, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.supplyProvided, verified by its probes" + ] + }, + { + "name": "sight range of a unit type", + "base": 6697528, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.sightRange, verified by its probes" + ] + }, + { + "name": "target acquisition range of a unit type", + "base": 6696376, + "width": 1, + "stride": 1, + "count": 228, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta unit.seekRange, not verified" + ] + }, + { + "name": "ground weapon of a unit type", + "base": 6698680, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.groundWeapon, verified by its probes" + ] + }, + { + "name": "air weapon of a unit type", + "base": 6690528, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.airWeapon, verified by its probes" + ] + }, + { + "name": "speed of a unit type", + "base": 7118584, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.speed, verified by its probes" + ] + }, + { + "name": "name of a unit type", + "base": 6685280, + "width": 2, + "stride": 2, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.name, verified by its probes" + ] + }, + { + "name": "size of a unit type", + "base": 6693248, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.sizeClass, verified by its probes" + ] + }, + { + "name": "looks of a unit type", + "base": 6702328, + "width": 1, + "stride": 1, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.graphics, verified by its probes" + ] + }, + { + "name": "detector flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.detector, verified by its probes" + ] + }, + { + "name": "permanent cloak flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.permanentCloak, verified by its probes" + ] + }, + { + "name": "cloak ability flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.cloakable, verified by its probes" + ] + }, + { + "name": "burrow ability flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.burrowable, verified by its probes" + ] + }, + { + "name": "regeneration flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.regeneratesHp, verified by its probes" + ] + }, + { + "name": "invincible flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.invincible, verified by its probes" + ] + }, + { + "name": "hero flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.hero, verified by its probes" + ] + }, + { + "name": "organic flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.organic, verified by its probes" + ] + }, + { + "name": "mechanical flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.mechanical, verified by its probes" + ] + }, + { + "name": "robotic flag of a unit type", + "base": 6701184, + "width": 4, + "stride": 4, + "count": 228, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta unit.robotic, verified by its probes" + ] + }, + { + "name": "damage of a weapon", + "base": 6647472, + "width": 2, + "stride": 2, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.damage, verified by its probes" + ] + }, + { + "name": "damage bonus per upgrade of a weapon", + "base": 6649464, + "width": 2, + "stride": 2, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.bonus, verified by its probes" + ] + }, + { + "name": "cooldown of a weapon", + "base": 6647736, + "width": 1, + "stride": 1, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.cooldown, verified by its probes" + ] + }, + { + "name": "damage factor of a weapon", + "base": 6644960, + "width": 1, + "stride": 1, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.factor, verified by its probes" + ] + }, + { + "name": "range of a weapon", + "base": 6648944, + "width": 4, + "stride": 4, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.range, verified by its probes" + ] + }, + { + "name": "minimum range of a weapon", + "base": 6646296, + "width": 4, + "stride": 4, + "count": 130, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta weapon.minRange, verified by its probes" + ] + }, + { + "name": "mineral cost of an upgrade", + "base": 6641472, + "width": 2, + "stride": 2, + "count": 61, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta upgrade.mineralCost, verified by its probes" + ] + }, + { + "name": "gas cost of an upgrade", + "base": 6641728, + "width": 2, + "stride": 2, + "count": 61, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta upgrade.gasCost, verified by its probes" + ] + }, + { + "name": "research time of an upgrade", + "base": 6642560, + "width": 2, + "stride": 2, + "count": 61, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta upgrade.time, verified by its probes" + ] + }, + { + "name": "maximum level of an upgrade", + "base": 6641408, + "width": 1, + "stride": 1, + "count": 61, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta upgrade.maxLevel, not verified" + ] + }, + { + "name": "mineral cost of a technology", + "base": 6644296, + "width": 2, + "stride": 2, + "count": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta tech.mineralCost, verified by its probes" + ] + }, + { + "name": "gas cost of a technology", + "base": 6644208, + "width": 2, + "stride": 2, + "count": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta tech.gasCost, verified by its probes" + ] + }, + { + "name": "research time of a technology", + "base": 6644696, + "width": 2, + "stride": 2, + "count": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta tech.time, verified by its probes" + ] + }, + { + "name": "energy cost of a technology", + "base": 6644608, + "width": 2, + "stride": 2, + "count": 44, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta tech.energy, verified by its probes" + ] + }, + { + "name": "minerals of a player", + "base": 5763312, + "width": 4, + "stride": 4, + "count": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.minerals, verified by its probes" + ] + }, + { + "name": "gas of a player", + "base": 5763360, + "width": 4, + "stride": 4, + "count": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.gas, verified by its probes" + ] + }, + { + "name": "upgrade level of a player", + "base": 5821104, + "width": 552, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.upgradeLevel, verified by its probes" + ] + }, + { + "name": "technology researched by a player", + "base": 5820228, + "width": 288, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.techResearched, verified by its probes" + ] + }, + { + "name": "alliance of a player toward another", + "base": 5822004, + "width": 144, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.alliance, verified by its probes" + ] + }, + { + "name": "shared vision between players", + "base": 5763564, + "width": 4, + "stride": 4, + "count": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.vision, verified by its probes" + ] + }, + { + "name": "colour of a player", + "base": 5774710, + "width": 1, + "stride": 1, + "count": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.color, verified by its probes" + ] + }, + { + "name": "supply provided to a player", + "base": 5775684, + "width": 336, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.supplyProvided, verified by its probes" + ] + }, + { + "name": "supply used by a player", + "base": 5775732, + "width": 336, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.supplyUsed, verified by its probes" + ] + }, + { + "name": "supply cap of a player", + "base": 5775780, + "width": 336, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta player.supplyMax, verified by its probes" + ] + }, + { + "name": "slot of a player", + "base": 5763508, + "width": 1, + "stride": 1, + "count": 12, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta player.slotType, verified by its probes" + ] + }, + { + "name": "race of a player", + "base": 5763520, + "width": 1, + "stride": 1, + "count": 12, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta player.race, verified by its probes" + ] + }, + { + "name": "whether a player has left", + "base": 5774690, + "width": 1, + "stride": 1, + "count": 8, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta player.left, not verified" + ] + }, + { + "name": "game speed", + "base": 7135188, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.speed, verified by its probes" + ] + }, + { + "name": "trigger timer", + "base": 6621600, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta game.triggerTimer, verified by its probes" + ] + }, + { + "name": "local player", + "base": 5318276, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.localPlayer, verified by its probes" + ], + "local": true + }, + { + "name": "mouse x on screen", + "base": 7134660, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.mouseX, verified by its probes" + ], + "local": true + }, + { + "name": "mouse y on screen", + "base": 7134662, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.mouseY, verified by its probes" + ], + "local": true + }, + { + "name": "screen x on the map", + "base": 6456460, + "width": 4, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta game.screenX, not verified" + ], + "local": true + }, + { + "name": "screen y on the map", + "base": 6456488, + "width": 4, + "read": "unknown", + "write": "unknown", + "evidence": [ + "Magenta game.screenY, not verified" + ], + "local": true + }, + { + "name": "keyboard key state", + "base": 5859864, + "width": 1, + "stride": 1, + "count": 256, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.key, verified by its probes" + ], + "local": true + }, + { + "name": "frames the game has run", + "base": 5763644, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.frames, verified by its probes" + ] + }, + { + "name": "seconds on the game clock", + "base": 5822200, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta game.seconds, verified by its probes" + ] + }, + { + "name": "hit points of a placed unit", + "base": 5885104, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.hp, verified by its probes" + ] + }, + { + "name": "shields of a placed unit", + "base": 5885192, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.shields, verified by its probes" + ] + }, + { + "name": "energy of a placed unit", + "base": 5885258, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.energy, verified by its probes" + ] + }, + { + "name": "owner of a placed unit", + "base": 5885172, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.owner, verified by its probes" + ] + }, + { + "name": "type of a placed unit", + "base": 5885196, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta cunit.type, verified by its probes" + ] + }, + { + "name": "x position of a placed unit", + "base": 5885136, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta cunit.x, verified by its probes" + ] + }, + { + "name": "y position of a placed unit", + "base": 5885138, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta cunit.y, verified by its probes" + ] + }, + { + "name": "invincibility of a placed unit", + "base": 5885316, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.invincible, verified by its probes" + ] + }, + { + "name": "hallucination flag of a placed unit", + "base": 5885316, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "Magenta cunit.hallucination, verified by its probes" + ] + }, + { + "name": "cloak state of a placed unit", + "base": 5885316, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "Magenta cunit.cloaked, verified by its probes" + ] + }, + { + "name": "unit table: position", + "base": 5885136, + "stride": 336, + "count": 1700, + "width": 4, + "read": "allowed", + "write": "ends", + "evidence": [ + "Magenta probe 6, key I (2026-09-14): writing a unit's position ends the game, \"EUD not supported\"" + ] + }, + { + "name": "unit table: cloak flags", + "base": 5885317, + "stride": 336, + "count": 1700, + "width": 1, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Magenta probe 6, keys Y and H (2026-09-14): status flags 0x100 and 0x200 written, no visible cloak" + ] + }, + { + "name": "game speed", + "base": 7135188, + "width": 4, + "read": "allowed", + "write": "ignored", + "evidence": [ + "Magenta probe 5, key 9 (2026-09-14): written, the game kept its speed" + ] + }, + { + "name": "0x512684", + "base": 5318276, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x512685", + "base": 5318277, + "width": 3, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a284", + "base": 5350020, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a290", + "base": 5350032, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a29c", + "base": 5350044, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2a8", + "base": 5350056, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2b4", + "base": 5350068, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2c0", + "base": 5350080, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2cc", + "base": 5350092, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x51a2d8", + "base": 5350104, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57eeeb", + "base": 5762795, + "width": 7, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), strings (2026-09-19), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57ef0f", + "base": 5762831, + "width": 11, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes classes (2026-09-19), reads (2026-09-18), strings (2026-09-19), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f0d8", + "base": 5763288, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f0f0", + "base": 5763312, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f120", + "base": 5763360, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f1b4", + "base": 5763508, + "width": 3, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f1c0", + "base": 5763520, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x57f23c", + "base": 5763644, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x581d60", + "base": 5774688, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x581d76", + "base": 5774710, + "width": 1, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes reads (2026-09-18), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x581d7e", + "base": 5774718, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x581dd6", + "base": 5774806, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x582178", + "base": 5775736, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x5821d4", + "base": 5775828, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x582204", + "base": 5775876, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x582234", + "base": 5775924, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x587f94", + "base": 5799828, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58a364", + "base": 5808996, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58a388", + "base": 5809032, + "width": 12, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58cf45", + "base": 5820229, + "width": 1, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58d2b7", + "base": 5821111, + "width": 1, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58dc60", + "base": 5823584, + "width": 16, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes classes (2026-09-19), leftovers (2026-09-19), units (2026-09-18), arrays (2026-09-19), callbacks (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58dc70", + "base": 5823600, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58dc74", + "base": 5823604, + "width": 16, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58dc84", + "base": 5823620, + "width": 1204, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58e138", + "base": 5824824, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58e14c", + "base": 5824844, + "width": 3820, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x58f038", + "base": 5828664, + "width": 20, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a49", + "base": 5859913, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a5d", + "base": 5859933, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a69", + "base": 5859945, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a6f", + "base": 5859951, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x596a8e", + "base": 5859982, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6283f8", + "base": 6456312, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x628438", + "base": 6456376, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x62848c", + "base": 6456460, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6284a8", + "base": 6456488, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6284b8", + "base": 6456504, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640b60", + "base": 6556512, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640c3a", + "base": 6556730, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640d14", + "base": 6556948, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640dee", + "base": 6557166, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640ec8", + "base": 6557384, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x640fa2", + "base": 6557602, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x64107c", + "base": 6557820, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x641156", + "base": 6558038, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x641230", + "base": 6558256, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x64130a", + "base": 6558474, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6413e4", + "base": 6558692, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x641598", + "base": 6559128, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x64159c", + "base": 6559132, + "width": 8, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6415a4", + "base": 6559140, + "width": 48, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes reads (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6509a0", + "base": 6621600, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6509b0", + "base": 6621616, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), input (2026-09-18), inside (2026-09-19), leftovers (2026-09-19), map (2026-09-19), numbers (2026-09-18), reads (2026-09-18), recursion (2026-09-19), spike (2026-09-18), strings (2026-09-19), units (2026-09-18), v3 (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x654887", + "base": 6637703, + "width": 15, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x654aa0", + "base": 6638240, + "width": 4, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x656eb0", + "base": 6647472, + "width": 2, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x660260", + "base": 6685280, + "width": 2, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes strings (2026-09-19), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6602aa", + "base": 6685354, + "width": 2, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x661002", + "base": 6688770, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x66158c", + "base": 6690188, + "width": 2, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x66171a", + "base": 6690586, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x661998", + "base": 6691224, + "width": 8, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6620d2", + "base": 6693074, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x662350", + "base": 6693712, + "width": 4, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x662948", + "base": 6695240, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x662df2", + "base": 6696434, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x66318a", + "base": 6697354, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x663272", + "base": 6697586, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6636f2", + "base": 6698738, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637a0", + "base": 6698912, + "width": 2, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637c0", + "base": 6698944, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637c3", + "base": 6698947, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637c5", + "base": 6698949, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6637da", + "base": 6698970, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x66380a", + "base": 6699018, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x663823", + "base": 6699043, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x663888", + "base": 6699144, + "width": 2, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x664081", + "base": 6701185, + "width": 1, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x664101", + "base": 6701313, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x664168", + "base": 6701416, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6644f8", + "base": 6702328, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x664532", + "base": 6702386, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6c98a6", + "base": 7116966, + "width": 1, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6c9a68", + "base": 7117416, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6c9d14", + "base": 7118100, + "width": 2, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6ca030", + "base": 7118896, + "width": 4, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6cddc0", + "base": 7134656, + "width": 1, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "0x6cddc4", + "base": 7134660, + "width": 8, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x8", + "base": 5885104, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xc", + "base": 5885108, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x10", + "base": 5885112, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x28", + "base": 5885136, + "width": 4, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes classes (2026-09-19), input (2026-09-18), leftovers (2026-09-19), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x34", + "base": 5885148, + "width": 4, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x4c", + "base": 5885172, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x4d", + "base": 5885173, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x4e", + "base": 5885174, + "width": 2, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x64", + "base": 5885196, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "unknown", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x68", + "base": 5885200, + "width": 8, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x8f", + "base": 5885239, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xa2", + "base": 5885258, + "width": 2, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xa5", + "base": 5885261, + "width": 1, + "stride": 336, + "count": 1700, + "read": "allowed", + "write": "allowed", + "evidence": [ + "touched by the probes arrays (2026-09-19), callbacks (2026-09-19), classes (2026-09-19), functions (2026-09-19), leftovers (2026-09-19), numbers (2026-09-18), recursion (2026-09-19), units (2026-09-18), input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xdc", + "base": 5885316, + "width": 3, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0xdf", + "base": 5885319, + "width": 1, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes input (2026-09-18), units (2026-09-18), each played in Remastered with every line as expected" + ] + }, + { + "name": "unit table: +0x117", + "base": 5885375, + "width": 1, + "stride": 336, + "count": 1700, + "read": "unknown", + "write": "allowed", + "evidence": [ + "touched by the probes units (2026-09-18), each played in Remastered with every line as expected" + ] + } + ] +} diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 3ed9215..8d5a46f 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -204,6 +204,50 @@ coverage. A1 therefore also shows coverage: lines that ran in the game, lines th Ships as 4.0.0, the first version with *Run in the game*, because it adds a whole mode. +**As built (2026-10-15, under Node; the editor's half comes with A2).** One command does it: +`npx tsx scripts/game-check.mts probes/units.ts`. It builds the script, plays it in the lifted +game with nobody at it, prints the probe's own checks, then lists each access Remastered was +seen to refuse or ignore, or has never been seen to do, by line. + +- **Back to the line** works as the candidate said, without touching eudplib. + `python/trigscript.py`, when the build sets `triggerMap`, wraps `RawTrigger.__init__`, + `Lowering.statement` and `EUDFuncN._create_func_body`, so every trigger is tagged with + the statement being lowered (or the eudplib function whose body it is). It also wraps + eudplib's module-level `_construct_payload` to log where each tagged trigger landed: + offset, conditions, actions, origin, 400 a line. The runner turns those offsets into the + address of every condition and action record (the payload sits at the end of `STRx`), and + the hooks already see each record's address. Triggers eudplib made at its own import, and + its variables (trigger-shaped, but not `RawTrigger`s), are put down as "eudplib". Only + eudplib's start code in the scenario's own TRIG stays unnamed. +- **The trace** (`scm-sim scripts/exact-eud.ts --trace`) is one row per origin, dword and + direction, with the union of the masks, so a check is byte by byte. A run of the units + probe has 42,815 rows. +- **The allowlist** (`data/allowlist.json`, made by `scripts/allowlist.mts`, 191 entries) + has three sources: + - Magenta's catalogue: 73 entries, a verified yes counts as allowed. + - Three failures seen in the game: unit position write ends the game; cloak bits and game + speed writes are ignored. + - The traces of the 15 played probes: 115 runs of bytes, the unit table folded into one + entry per field. Each probe passed in Remastered, so what its build touches was + allowed there. The traces come from today's builds, not the ones played, so this is + evidence by family of address, and a probe played again settles a new one. + + The map's own memory (strings and payload) is always allowed. eudplib's walk of the + game's heap (its trigger lists) is allowed; a line of the script in the heap is not seen. +- **What it showed with Magenta's catalogue alone** (an allowlist built without the + probes): nearly every line of every probe touches something Magenta never verified. + Examples are 0x6509B0 (eudplib's current-player trick, written around every action that + takes a player), the trigger lists at 0x51A284, and unit-table fields read by unit + loops. So the played probes are the larger part of the evidence. The check names each + such line, and an unseen field of the unit table is one finding however many units the + loop read. +- **Not yet:** the check sees only what the run reached (no coverage view yet), and runs only + under Node. The editor needs scm-sim's `run` (A2) before it can show a finding at its line. +- **Found on the way:** `spawnSync` keeps 1 MB of a child's output by default, and + plugin-eudplib's `build-map.mts` exits before a long log has gone down its pipe. It now + writes the log to `EUDPLIB_LOG_FILE` when asked, and `build-fixture.mts` reads it from + there. + ### A2. Tests in the game (4.0.0, with A1) `test()` blocks run in the lifted game as well as the simulator. diff --git a/python/trigscript.py b/python/trigscript.py index b30cbd6..2dcd061 100644 --- a/python/trigscript.py +++ b/python/trigscript.py @@ -3096,6 +3096,83 @@ def match_line(self): EUDEndIf() +# Where each trigger came from, for a run in the lifted game to name the line a trigger belongs to (TrigScript 4, +# A1). Only when the build asks for it (the `triggerMap` setting): every RawTrigger made is tagged with the +# statement being lowered, or with the eudplib function whose body it is, and once the payload is laid out one +# header line and lines of rows say where each trigger sits in it: +# TRIGSCRIPT-MAP {"payload": , "count": } +# TRIGSCRIPT-ROWS [[offset, conditions, actions, origin], ...] (400 a line, as many lines as it takes) +# An origin is "line:column" (or "file:line:column"), "fn:" for a function of eudplib's, "" for what no +# statement made (a program's frame, its sleeps), or "eudplib" for what eudplib made before this file was read. The payload sits at the end of the strings section, so a run +# finds it at the section's address plus its length less the payload's. +if settings.get("triggerMap"): + import eudplib.core.allocator.payload as _payload + from eudplib.core.eudfunc.eudfuncn import EUDFuncN as _EUDFuncN + from eudplib.core.rawtrigger.rawtriggerdef import RawTrigger as _RawTrigger + + _ORIGINS = [] + _MADE = {} + + def _origin_of(at): + if not at: + return "" + if at.get("file"): + return "%s:%s:%s" % (at.get("file"), at.get("line"), at.get("column")) + return "%s:%s" % (at.get("line"), at.get("column")) + + _trigger_init = _RawTrigger.__init__ + + def _tagged_trigger(self, *args, **kwargs): + _trigger_init(self, *args, **kwargs) + _MADE[self] = _ORIGINS[-1] if _ORIGINS else "" + + _RawTrigger.__init__ = _tagged_trigger + + _func_body = _EUDFuncN._create_func_body + + def _tagged_body(self): + _ORIGINS.append("fn:" + getattr(self._bodyfunc, "__name__", "?")) + try: + return _func_body(self) + finally: + _ORIGINS.pop() + + _EUDFuncN._create_func_body = _tagged_body + + _statement = Lowering.statement + + def _tagged_statement(self, st, ctx): + _ORIGINS.append(_origin_of(st.get("at")) or (_ORIGINS[-1] if _ORIGINS else "")) + try: + return _statement(self, st, ctx) + finally: + _ORIGINS.pop() + + Lowering.statement = _tagged_statement + + _construct = _payload._construct_payload + + def _construct_and_say(): + built = _construct() + found = _payload._found_objects_dict + rows = [] + # (Every trigger in the payload, also those eudplib made before this file was read, at its own import: + # those say "eudplib".) + for trg, i in found.items(): + if not isinstance(trg, _RawTrigger): + continue + origin = _MADE.get(trg, "eudplib") + rows.append([_payload._payload_builder.get_object_addr(i).offset, len(trg._conditions), len(trg._actions), origin]) + rows.sort() + # (In parts, so that no one line of the log runs to megabytes.) + print("TRIGSCRIPT-MAP " + json.dumps({"payload": len(built.data), "count": len(rows)}, separators=(",", ":"))) + for k in range(0, len(rows), 400): + print("TRIGSCRIPT-ROWS " + json.dumps(rows[k:k + 400], separators=(",", ":"))) + return built + + _payload._construct_payload = _construct_and_say + + INPUT = Input(IR.get("input")) PROGRAMS = [Lowering(p) for p in IR.get("programs", [])] diff --git a/scripts/allowlist.mts b/scripts/allowlist.mts new file mode 100644 index 0000000..1be67f7 --- /dev/null +++ b/scripts/allowlist.mts @@ -0,0 +1,112 @@ +/** + * Writes data/allowlist.json: what Remastered is known to allow, each entry with what it rests on (see + * compiler/allowlist.ts). Three sources, in this order: + * + * 1. Magenta's catalogue (plugin-magenta's src/catalogue/eud.json): every entry with its address, its Remastered + * read/write and whether a probe verified it. A verified yes is allowed; anything else is not known. + * 2. Failures seen in the game, written out below with the probe that showed each. + * 3. The traces of TrigScript's probes run in the lifted game (scm-sim's `scripts/exact-eud.ts --trace`): every + * probe here was played in Remastered with every line as expected, so whatever its build reads or writes was + * allowed there. A trace is made from today's build of the probe, not the build that was played; the families of + * addresses are the same, and a probe played again is what makes a new one certain. + * + * npx tsx scripts/allowlist.mts .trace.json> [--magenta=] [--out=] + */ +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { basename, join, resolve } from "node:path"; +import type { AllowEntry, Allowlist, Trace, Verdict } from "../compiler/allowlist"; + +const root = resolve(import.meta.dirname, ".."); +const args = process.argv.slice(2); +const traceDir = args.find((a) => !a.startsWith("--")); +const magentaPath = args.find((a) => a.startsWith("--magenta="))?.slice(10) ?? join(root, "..", "plugin-magenta", "src", "catalogue", "eud.json"); +if (!traceDir || !existsSync(traceDir)) { console.error("usage: allowlist.mts .trace.json> [--magenta=]"); process.exit(2); } + +/** When each probe was played in Remastered with every line as expected (docs/eud-plan.md's slices). */ +const PLAYED: Record = { + spike: "2026-09-18", v3: "2026-09-18", reads: "2026-09-18", units: "2026-09-18", input: "2026-09-18", numbers: "2026-09-18", + arrays: "2026-09-19", functions: "2026-09-19", recursion: "2026-09-19", callbacks: "2026-09-19", inside: "2026-09-19", + strings: "2026-09-19", classes: "2026-09-19", map: "2026-09-19", leftovers: "2026-09-19", +}; +/** 1.16.1's unit table: 1700 rows of 336 bytes. A probe's reads of it become one entry per field, not one per unit. */ +const UNITS = { base: 0x59cca8, stride: 336, count: 1700 }; +const ROWS = { stride: UNITS.stride, count: UNITS.count }; + +const entries: AllowEntry[] = []; + +// 1. Magenta's catalogue. +interface MagentaEntry { + id: string; name: string; width: number | "bit"; verified?: boolean; local?: boolean; + address?: { base: string; terms?: { arg: string; stride: number }[] }; + args?: { name: string; max?: number }[]; + remastered?: { read?: boolean; write?: boolean }; +} +const magenta = JSON.parse(readFileSync(magentaPath, "utf8")) as { entries: MagentaEntry[] }; +for (const e of magenta.entries) { + if (!e.address?.base) continue; + const base = Number(e.address.base), terms = e.address.terms ?? []; + const width = e.width === "bit" ? 4 : e.width; + const max = (arg: string) => e.args?.find((a) => a.name === arg)?.max ?? 1; + const verdict = (yes: boolean | undefined): Verdict => (yes && e.verified ? "allowed" : "unknown"); + const evidence = [`Magenta ${e.id}${e.verified ? ", verified by its probes" : ", not verified"}`]; + const shape = terms.length === 1 + ? { stride: terms[0].stride, count: max(terms[0].arg) } + : terms.length > 1 ? { width: terms.reduce((n, t) => n + t.stride * (max(t.arg) - 1), 0) + width } : {}; + entries.push({ name: e.name.toLowerCase(), base, width, ...shape, read: verdict(e.remastered?.read), write: verdict(e.remastered?.write), evidence, ...(e.local ? { local: true } : {}) }); +} + +// 2. What the game was seen to refuse or ignore (Magenta's docs/candidates.md). +entries.push( + { name: "unit table: position", base: 0x59ccd0, ...ROWS, width: 4, read: "allowed", write: "ends", evidence: ["Magenta probe 6, key I (2026-09-14): writing a unit's position ends the game, \"EUD not supported\""] }, + { name: "unit table: cloak flags", base: 0x59cd85, ...ROWS, width: 1, read: "allowed", write: "ignored", evidence: ["Magenta probe 6, keys Y and H (2026-09-14): status flags 0x100 and 0x200 written, no visible cloak"] }, + { name: "game speed", base: 0x6cdfd4, width: 4, read: "allowed", write: "ignored", evidence: ["Magenta probe 5, key 9 (2026-09-14): written, the game kept its speed"] }, +); + +// 3. The probes' traces. +/** byte address (or unit-table field) → what was seen there */ +const seen = new Map; write: Set }>(); +let probes = 0; +for (const file of readdirSync(traceDir).filter((f) => f.endsWith(".trace.json")).sort()) { + const probe = basename(file, ".trace.json"); + if (!PLAYED[probe]) { console.warn(`${file}: no play recorded for a probe called ${probe}; left out`); continue; } + probes++; + const trace = JSON.parse(readFileSync(join(traceDir, file), "utf8")) as Trace; + for (const [, address, write, mask] of trace.rows) { + if (address >= trace.strings.from && address < trace.strings.to) continue; + if (trace.heap && address >= trace.heap.from && address < trace.heap.to) continue; + for (let k = 0; k < 4; k++) { + if (!((mask >>> (8 * k)) & 0xff)) continue; + const byte = address + k, off = byte - UNITS.base; + const units = off >= 0 && off < UNITS.stride * UNITS.count; + const at = units ? UNITS.base + (off % UNITS.stride) : byte; + const key = `${units ? "u" : "b"}${at}`; + const s = seen.get(key) ?? { base: at, width: 1, units, read: new Set(), write: new Set() }; + (write ? s.write : s.read).add(probe); + seen.set(key, s); + } + } +} +// Runs of neighbouring bytes seen by the same probes the same way become one entry. +const bytes = [...seen.values()].sort((a, b) => Number(a.units) - Number(b.units) || a.base - b.base); +const same = (a: Set, b: Set) => a.size === b.size && [...a].every((x) => b.has(x)); +const runs: typeof bytes = []; +for (const b of bytes) { + const last = runs[runs.length - 1]; + if (last && last.units === b.units && last.base + last.width === b.base && same(last.read, b.read) && same(last.write, b.write)) last.width++; + else runs.push({ ...b }); +} +const named = (set: Set) => [...set].sort().map((p) => `${p} (${PLAYED[p]})`); +for (const r of runs) { + const evidence = [...new Set([...named(r.read), ...named(r.write)])]; + entries.push({ + name: r.units ? `unit table: +0x${(r.base - UNITS.base).toString(16)}` : `0x${r.base.toString(16)}`, + base: r.base, width: r.width, ...(r.units ? { stride: UNITS.stride, count: UNITS.count } : {}), + read: r.read.size ? "allowed" : "unknown", write: r.write.size ? "allowed" : "unknown", + evidence: [`touched by the probes ${evidence.join(", ")}, each played in Remastered with every line as expected`], + }); +} + +const list: Allowlist = { version: 1, entries }; +const out = args.find((a) => a.startsWith("--out="))?.slice(6) ?? join(root, "data", "allowlist.json"); +writeFileSync(out, JSON.stringify(list, null, 1) + "\n"); +console.log(`${out}: ${entries.length} entries — ${magenta.entries.length} from Magenta's catalogue, 3 failures, ${runs.length} runs from ${probes} probes' traces`); diff --git a/scripts/build-fixture.mts b/scripts/build-fixture.mts index e6e1524..34ec6fd 100644 --- a/scripts/build-fixture.mts +++ b/scripts/build-fixture.mts @@ -4,7 +4,7 @@ * plugin-eudplib checkout's `scripts/build-map.mts`, write the built map. For probe maps * that get played. * - * npx tsx scripts/build-fixture.mts probes/spike.ts fixtures/eud/spike-eud.scx [map.scx] [../plugin-eudplib] + * [TRIGGER_MAP=1] npx tsx scripts/build-fixture.mts probes/spike.ts fixtures/eud/spike-eud.scx [map.scx] [../plugin-eudplib] * * The output lands in the ignored fixtures/ folder: it is built on a Blizzard map, which is never committed. * @@ -95,10 +95,24 @@ writeFileSync(mapPath, withStrings); const extra = process.env.EXTRA_PLUGIN; const extraName = extra?.slice(0, extra.indexOf("=")); const sections = buildPlugins(r.input, "/work/files/trigscript.json"); +// TRIGGER_MAP=1: the Python says where each trigger it made sits in the payload (a TRIGSCRIPT-MAP line in the +// log), and that line is written beside the map as .triggers.json, for scm-sim's run in the lifted game. +if (process.env.TRIGGER_MAP) (sections.trigscript as Record).triggerMap = "1"; const { eudTurbo, ...before } = sections; writeFileSync(pluginsPath, JSON.stringify(extraName ? { ...before, [extraName]: {}, eudTurbo } : sections)); const extraArgs = extra && extraName ? [`${extraName}=${resolve(extra.slice(extraName.length + 1))}`] : []; -const res = spawnSync("npx", ["tsx", join(eudplib, "scripts", "build-map.mts"), mapPath, resolve(outPath), pluginsPath, `trigscript=${join(root, "python", "trigscript.py")}`, `file=trigscript.json=${irPath}`, ...extraArgs], { cwd: eudplib, encoding: "utf8", env: { ...process.env, EUDPLIB_LOG: "1" } }); +const logPath = join(dir, "eudplib.log"); +const res = spawnSync("npx", ["tsx", join(eudplib, "scripts", "build-map.mts"), mapPath, resolve(outPath), pluginsPath, `trigscript=${join(root, "python", "trigscript.py")}`, `file=trigscript.json=${irPath}`, ...extraArgs], { cwd: eudplib, encoding: "utf8", maxBuffer: 256 << 20, env: { ...process.env, EUDPLIB_LOG: "1", EUDPLIB_LOG_FILE: logPath } }); +// (The log whole, from the file: the copy on stdout can be cut short when it runs to megabytes.) +const fullLog = existsSync(logPath) ? readFileSync(logPath, "utf8") : res.stdout; rmSync(dir, { recursive: true, force: true }); -process.stdout.write(res.stdout); +const logLines = fullLog.split("\n"); +const mapLine = logLines.find((l) => l.startsWith("TRIGSCRIPT-MAP ")); +if (mapLine) { + const head = JSON.parse(mapLine.slice("TRIGSCRIPT-MAP ".length)) as { payload: number; count: number }; + const triggers = logLines.filter((l) => l.startsWith("TRIGSCRIPT-ROWS ")).flatMap((l) => JSON.parse(l.slice("TRIGSCRIPT-ROWS ".length)) as unknown[]); + if (triggers.length !== head.count) { console.error(`the map of triggers came out with ${triggers.length} of ${head.count} rows`); process.exit(1); } + writeFileSync(resolve(outPath).replace(/\.scx$/, ".triggers.json"), JSON.stringify({ payload: head.payload, triggers })); +} +process.stdout.write(res.stdout.split("\n").filter((l) => !l.startsWith("TRIGSCRIPT-MAP ") && !l.startsWith("TRIGSCRIPT-ROWS ")).join("\n")); if (res.status !== 0) { console.error(res.stderr); process.exit(1); } diff --git a/scripts/game-check.mts b/scripts/game-check.mts new file mode 100644 index 0000000..14be10e --- /dev/null +++ b/scripts/game-check.mts @@ -0,0 +1,47 @@ +/** + * A script played in the lifted game and held against what Remastered is known to allow (TrigScript 4, A1): + * built as Save builds it with the map of triggers on (scripts/build-fixture.mts, TRIGGER_MAP=1), played with nobody + * at it by a scm-sim checkout's scripts/exact-eud.ts (its lifted game, EUD let through, nothing drawn), and every + * dword the run read or wrote checked against data/allowlist.json, line by line. + * + * npx tsx scripts/game-check.mts [--frames=N] [--allowlist=path] [map.scx] + * + * A built map is taken as it is, with its .triggers.json beside it when there is one. Needs a scm-sim + * checkout beside this repository (or SCM_SIM_DIR) with its lifted game generated; says so when it is not there. + * Exits 1 when the run touched something Remastered ends the game on, or a probe line came out not as expected. + */ +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { checkTrace, describeFinding, type Allowlist, type Trace } from "../compiler/allowlist"; + +const root = resolve(import.meta.dirname, ".."); +const args = process.argv.slice(2); +const flag = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3); +const [input, baseMap] = args.filter((a) => !a.startsWith("--")); +if (!input || !existsSync(input)) { console.error("usage: game-check.mts [--frames=N] [--allowlist=path] [map.scx]"); process.exit(2); } +const sim = resolve(process.env.SCM_SIM_DIR ?? join(root, "..", "scm-sim")); +if (!existsSync(join(sim, "scripts", "exact-eud.ts"))) { console.error(`no scm-sim checkout with scripts/exact-eud.ts at ${sim} (set SCM_SIM_DIR)`); process.exit(2); } +const list = JSON.parse(readFileSync(flag("allowlist") ?? join(root, "data", "allowlist.json"), "utf8")) as Allowlist; + +const dir = mkdtempSync(join(tmpdir(), "trigscript-game-")); +try { + let map = resolve(input); + if (/\.ts$/.test(input)) { + map = join(dir, basename(input, ".ts") + ".scx"); + const built = spawnSync("npx", ["tsx", join(root, "scripts", "build-fixture.mts"), resolve(input), map, ...(baseMap ? [resolve(baseMap)] : [])], { cwd: root, encoding: "utf8", maxBuffer: 256 << 20, env: { ...process.env, TRIGGER_MAP: "1" } }); + if (built.status !== 0) { console.error(built.stdout.split("\n").slice(-20).join("\n"), built.stderr); process.exit(1); } + } + const tracePath = join(dir, "trace.json"); + const run = spawnSync("node", ["--experimental-strip-types", join(sim, "scripts", "exact-eud.ts"), map, `--frames=${flag("frames") ?? 3000}`, "--quiet", `--trace=${tracePath}`], { cwd: sim, encoding: "utf8", maxBuffer: 256 << 20 }); + process.stdout.write(run.stdout.split("\n").filter((l) => !/^pages (read|written):/.test(l)).join("\n")); + if (!existsSync(tracePath)) { console.error(run.stderr || "the run left no trace"); process.exit(1); } + const trace = JSON.parse(readFileSync(tracePath, "utf8")) as Trace; + const { findings, counted } = checkTrace(trace, list); + console.log(`\nRemastered: ${trace.rows.length} distinct accesses — ${counted.own} the map's own memory, ${counted.heap} eudplib in the game's heap, ${counted.allowed} known allowed, ${counted.ends} ending the game, ${counted.ignored} ignored, ${counted.unknown} never seen`); + for (const f of findings) console.log(` ${f.verdict === "ends" ? "ENDS THE GAME" : f.verdict === "ignored" ? "IGNORED" : "not seen"} ${describeFinding(f)}`); + if (findings.some((f) => f.verdict === "ends") || run.status !== 0) process.exitCode = 1; +} finally { + rmSync(dir, { recursive: true, force: true }); +} diff --git a/tests/allowlist.test.ts b/tests/allowlist.test.ts new file mode 100644 index 0000000..60fe1a0 --- /dev/null +++ b/tests/allowlist.test.ts @@ -0,0 +1,65 @@ +// The allowlist check (compiler/allowlist.ts) on traces made up for it: the map's own memory, the game's heap, +// what Magenta verified, what the game was seen to refuse or ignore, and what nobody has tried — byte by byte. +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { checkTrace, describeFinding, verdictOf, type Allowlist, type Trace, type TraceRow } from "../compiler/allowlist"; + +const list = JSON.parse(readFileSync(join(import.meta.dirname, "..", "data", "allowlist.json"), "utf8")) as Allowlist; +const UNIT = (i: number, off: number) => 0x59cca8 + 336 * i + off; +const trace = (rows: TraceRow[]): Trace => ({ frames: 100, strings: { from: 0x19190000, to: 0x191b0000 }, heap: { from: 0x30000000, to: 0x40000000 }, rows }); + +describe("the allowlist", () => { + it("has Magenta's verified fields allowed and the failures seen in the game", () => { + expect(verdictOf(list, UNIT(5, 8), true).verdict).toBe("allowed"); // a unit's hit points + expect(verdictOf(list, UNIT(5, 0x28), false).verdict).toBe("allowed"); // its position, read + expect(verdictOf(list, UNIT(5, 0x28), true).verdict).toBe("ends"); // … and written + expect(verdictOf(list, 0x6cdfd4, true).verdict).toBe("ignored"); // the game speed + expect(verdictOf(list, 0x7fff0000, false).verdict).toBe("unknown"); + }); + it("names what each verdict rests on", () => { + const e = verdictOf(list, UNIT(5, 0x28), true).entry!; + expect(e.evidence.join(" ")).toMatch(/Magenta probe 6/); + }); +}); + +describe("checkTrace", () => { + it("lets the map's own memory and eudplib's walk of the game's heap through", () => { + const r = checkTrace(trace([["12:3", 0x19191000, 1, 0xffffffff, 4], ["eudplib", 0x30a30010, 0, 0xffffffff, 2], ["", 0x30a30014, 1, 0xffffffff, 1]]), list); + expect(r.findings).toEqual([]); + expect(r.counted).toMatchObject({ own: 1, heap: 2 }); + }); + it("says at the line when a line of the script reaches into the game's heap", () => { + const r = checkTrace(trace([["40:7", 0x30a30010, 1, 0xffffffff, 1]]), list); + expect(r.findings).toMatchObject([{ line: 40, verdict: "unknown", write: true }]); + }); + it("finds a write Remastered ends the game on, and says why", () => { + const r = checkTrace(trace([["7:1", UNIT(3, 0x28), 1, 0xffffffff, 1], ["7:1", UNIT(9, 0x28), 1, 0x0000ffff, 1]]), list); + expect(r.findings).toHaveLength(1); + expect(r.findings[0]).toMatchObject({ line: 7, verdict: "ends", entry: "unit table: position", addresses: 2, times: 2 }); + expect(describeFinding(r.findings[0])).toMatch(/^line 7: writes unit table: position .* Remastered ends the game on this \(Magenta probe 6/); + }); + it("holds a masked write byte by byte: the cloak bits and the invincible bit share a dword", () => { + const flags = UNIT(2, 0xdc); + expect(checkTrace(trace([["3:1", flags, 1, 0x00000300, 1]]), list).findings).toMatchObject([{ verdict: "ignored", entry: "unit table: cloak flags" }]); + expect(checkTrace(trace([["3:1", flags, 1, 0x04000000, 1]]), list).findings).toEqual([]); + }); + it("says what nobody has tried, and puts the gravest first", () => { + const r = checkTrace(trace([["20:1", 0x7fff0000, 0, 0xffffffff, 1], ["5:1", 0x6cdfd4, 1, 0xffffffff, 1], ["9:1", UNIT(0, 0x28), 1, 0xffffffff, 1]]), list); + expect(r.findings.map((f) => f.verdict)).toEqual(["ends", "ignored", "unknown"]); + expect(describeFinding(r.findings[2])).toBe("line 20: reads 0x7fff0000 — not seen in Remastered: needs a probe"); + }); + it("calls eudplib's own code by its name", () => { + const r = checkTrace(trace([["fn:f_dwread_epd", 0x7fff0000, 0, 0xffffffff, 1]]), list); + expect(describeFinding(r.findings[0])).toMatch(/^eudplib's f_dwread_epd: reads/); + }); +}); + +describe("what no entry covers", () => { + it("is said by the table of the game it lies in, one finding a field however many units", () => { + const off = 0x7c; // a field no probe and no catalogue entry has touched + const r = checkTrace(trace([0, 1, 2, 500].map((i): TraceRow => ["38:3", UNIT(i, off), 0, 0xffffffff, 1])), list); + expect(r.findings).toHaveLength(1); + expect(describeFinding(r.findings[0])).toBe("line 38: reads unit table +0x7c (4 dwords from 0x59cd24) — not seen in Remastered: needs a probe"); + }); +}); From cadc31788f8086831f3f6e002a027adfcf38e5d2 Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 16 Oct 2026 00:22:07 -0600 Subject: [PATCH 3/7] Simulator: a unit a trigger or a program kills is not a death Kill Unit, Kill Unit At Location, a unit's kill() and hit points brought to 0 by damage() or a write all counted a death in the simulator. The lifted game counts none of them: only a unit that dies in the game, shot or killed by a spell, is a death (the editor's trigger reference, checked in the game, says the same of Kill Unit). Found by the first tests run in the lifted game. sim.kill() in a test stays a fight, and still counts. Co-Authored-By: Claude Opus 5.5 --- compiler/simulateIr.ts | 6 +++--- compiler/world.ts | 15 ++++++++++----- tests/testing.test.ts | 2 +- tests/world.test.ts | 9 ++++++--- 4 files changed, 20 insertions(+), 12 deletions(-) diff --git a/compiler/simulateIr.ts b/compiler/simulateIr.ts index 7903d6c..ca68b57 100644 --- a/compiler/simulateIr.ts +++ b/compiler/simulateIr.ts @@ -405,7 +405,7 @@ class ProgramRun { const amount = verb.do === "damage" || verb.do === "heal" ? yield* this.amount(verb.amount) : 0; if (!u) return; switch (verb.do) { - case "kill": case "remove": this.sim.game.gone(u, verb.do === "kill"); this.sim.unitEvent(this, verb.do === "kill" ? ActionType.KillUnit : ActionType.RemoveUnit, u, at); break; + case "kill": case "remove": this.sim.game.gone(u, verb.do === "kill", undefined, false); this.sim.unitEvent(this, verb.do === "kill" ? ActionType.KillUnit : ActionType.RemoveUnit, u, at); break; case "give": { const to = this.sim.slotOf(verb.to); if (to < 12) u.owner = to; break; } case "order": this.sim.unitEvent(this, ActionType.Order, u, at, { location: verb.target, text: verb.order }); break; case "locate": this.sim.centre(verb.location, u.x, u.y); break; @@ -414,7 +414,7 @@ class ProgramRun { const step = verb.percent ? Math.floor((u.maxHp * 256 * amount) / 100) : amount * 256; const raw = verb.do === "damage" ? Math.max(0, u.hp * 256 - step) : Math.min(u.maxHp * 256, u.hp * 256 + step); u.hp = Math.ceil(raw / 256); - if (u.hp === 0) { this.sim.game.gone(u, true); this.sim.unitEvent(this, ActionType.KillUnit, u, at); } + if (u.hp === 0) { this.sim.game.gone(u, true, undefined, false); this.sim.unitEvent(this, ActionType.KillUnit, u, at); } break; } } @@ -849,7 +849,7 @@ class ProgramRun { const value = yield* this.amount(s.value as NumExpr); if (!u) return "next"; (u as unknown as Record)[field] = Math.min(value, UNIT_FIELD_MAX[field as UnitNumField] ?? 0xffff_ffff); - if (field === "hp" && u.hp === 0) { this.sim.game.gone(u, true); this.sim.unitEvent(this, ActionType.KillUnit, u, s.at); } + if (field === "hp" && u.hp === 0) { this.sim.game.gone(u, true, undefined, false); this.sim.unitEvent(this, ActionType.KillUnit, u, s.at); } return "next"; } case "unitDo": yield* this.unitDo(s.unit, s.verb, s.at); return "next"; diff --git a/compiler/world.ts b/compiler/world.ts index 84e0304..1cfecb6 100644 --- a/compiler/world.ts +++ b/compiler/world.ts @@ -181,12 +181,17 @@ export class World { }); } - /** Off the map, its place free for the next unit made. `killed`: it died — counted as a death, and as a kill of `by` when someone is named. */ - gone(unit: SimUnit, killed: boolean, by?: number): void { + /** + * Off the map, its place free for the next unit made. `killed`: it died. `fought`: it died in the game — shot, or + * killed by a spell — and so counts as a death, and as a kill of `by` when someone is named. A unit a trigger or a + * program kills (Kill Unit, a unit's kill(), hit points written down to 0) dies without counting: the lifted game + * says so for all three (TrigScript 4, A2, 2026-10-16), as the editor's trigger reference does for Kill Unit. + */ + gone(unit: SimUnit, killed: boolean, by?: number, fought = true): void { if (!unit.alive) return; unit.alive = false; if (this.table[unit.slot] === unit) this.table[unit.slot] = null; - if (!killed) return; + if (!killed || !fought) return; this.onDeath(unit); if (by !== undefined) this.killed.set(unit.type * 4096 + by, (this.killed.get(unit.type * 4096 + by) ?? 0) + 1); } @@ -288,9 +293,9 @@ export class World { for (const owner of owners) if (owner < 12) for (let i = 0; i < Math.max(1, a.modifier); i++) { const u = this.create(a.unitId, owner, x, y, properties); if (u) made?.push(u); } return true; } - case ActionType.KillUnit: for (const u of this.matching(owners, a.unitId)) this.gone(u, true); return true; + case ActionType.KillUnit: for (const u of this.matching(owners, a.unitId)) this.gone(u, true, undefined, false); return true; case ActionType.RemoveUnit: for (const u of this.matching(owners, a.unitId)) this.gone(u, false); return true; - case ActionType.KillUnitAt: for (const u of this.some(this.matching(owners, a.unitId, a.location), a.modifier)) this.gone(u, true); return true; + case ActionType.KillUnitAt: for (const u of this.some(this.matching(owners, a.unitId, a.location), a.modifier)) this.gone(u, true, undefined, false); return true; case ActionType.RemoveUnitAt: for (const u of this.some(this.matching(owners, a.unitId, a.location), a.modifier)) this.gone(u, false); return true; case ActionType.GiveUnits: { const to = this.players.of(a.target, current)[0]; diff --git a/tests/testing.test.ts b/tests/testing.test.ts index 381eb7e..1e24255 100644 --- a/tests/testing.test.ts +++ b/tests/testing.test.ts @@ -42,7 +42,7 @@ test("the second wave is bigger", (sim) => { expect(sim.count(P2, units.ZergZergling, SPAWN)).toBe(12); expect(sim).toHavePrinted("Wave 2"); expect(sim).not.toHavePrinted(/Wave 3/); - expect(sim.deaths(P1, units.TerranMarine)).toBe(2); + expect(sim.deaths(P1, units.TerranMarine)).toBe(0); // killed by the program's trigger: not deaths }); test("nothing happens with nobody on the beacon", (sim) => { sim.seconds(2); diff --git a/tests/world.test.ts b/tests/world.test.ts index a640f84..5ab33d7 100644 --- a/tests/world.test.ts +++ b/tests/world.test.ts @@ -60,7 +60,9 @@ describe("units are made", () => { }); describe("what is done to units", () => { - it("gives, moves, kills some and removes the rest; deaths are counted, removals are not", () => { + // (Neither a trigger's kill nor a removal is a death: only a unit that dies in the game counts. The lifted game says + // so — TrigScript 4, A2 — and the editor's trigger reference, checked in the game, says so of Kill Unit.) + it("gives, moves, kills some and removes the rest; neither the kills nor the removals are deaths", () => { const sim = run(` createUnit(P1, units.TerranMarine, 5, ${HOME}); giveUnits(P1, P2, units.TerranMarine, 2, ${HOME}); @@ -69,7 +71,7 @@ describe("what is done to units", () => { killUnitAt(P1, units.TerranMarine, 2, ${HOME}); removeUnit(P2, units.TerranMarine); print(\`\${a} \${b} \${countUnits(P1, units.AnyUnit)} \${countUnits(P2, units.AnyUnit)} \${deaths(P1, units.TerranMarine)} \${deaths(P2, units.TerranMarine)}\`);`); - expect(shown(sim)).toContain("3 2 1 0 2 0"); + expect(shown(sim)).toContain("3 2 1 0 0 0"); expect(sim.units.filter((u) => u.owner === 1).map((u) => [u.x, u.y])).toEqual([[1100, 1050], [1100, 1050]]); }); @@ -109,7 +111,8 @@ describe("what is done to units", () => { const programs = new ProgramSimulation(r.ir, { world, strings: r.strings, table: TABLE }); for (let i = 0; i < 4; i++) { world.step(); programs.step(); } expect(world.events.map((e) => e.text).filter(Boolean)).toEqual(["three left"]); - expect(world.death(0, ZERGLING)).toBe(1); + // (Killed by the program: gone, and not a death.) + expect(world.death(0, ZERGLING)).toBe(0); }); }); From 75f74cd885ede71a23216dce88e10a43cf085dd0 Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 16 Oct 2026 00:22:07 -0600 Subject: [PATCH 4/7] TrigScript 4, A2: a script's tests run in the lifted game - compiler/gameSim.ts: GameSim, the world a test is handed in the game, over a GameHandle the host supplies (scm-sim's testGame); place, kill (counted as a fight by the game's own routine), remove, give and move through the game's trigger actions and a scratch location; keys and the mouse as the local machine's memory; a typed line as the chat command; every read from memory. readLocations: the map's locations once eudplib has put them right. - compiler/testing.ts: TestWorldLike and a brand both worlds carry; TestRunOptions.makeWorld. - compiler/gameTests.ts + dist/gametest.js: the compile's JavaScript (CompileResult.modules) run again beside the game, without TypeScript. - scripts/test-game.mts: each test in the game and in the simulator, said twice; probes/game-tests.ts passes in both. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 2 +- compiler/compiler.ts | 7 +- compiler/gameSim.ts | 322 ++++++++++++++++++++++++++++++++++++++ compiler/gameTestEntry.ts | 6 + compiler/gameTests.ts | 42 +++++ compiler/testing.ts | 68 ++++++-- package.json | 2 +- probes/game-tests.ts | 62 ++++++++ scripts/test-game.mts | 93 +++++++++++ 9 files changed, 589 insertions(+), 15 deletions(-) create mode 100644 compiler/gameSim.ts create mode 100644 compiler/gameTestEntry.ts create mode 100644 compiler/gameTests.ts create mode 100644 probes/game-tests.ts create mode 100644 scripts/test-game.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a09e90c..ef008c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,5 +29,5 @@ jobs: # `dist/testing.js` and its declarations are what another repository's tests import at # a tag to run triggers, so they are held to the same rule. with: - artifacts: dist/compiler.js dist/testing.js dist/testing.d.ts + artifacts: dist/compiler.js dist/testing.js dist/testing.d.ts dist/gametest.js secrets: inherit diff --git a/compiler/compiler.ts b/compiler/compiler.ts index 6b881c1..aaff165 100644 --- a/compiler/compiler.ts +++ b/compiler/compiler.ts @@ -150,6 +150,11 @@ export interface CompileResult { input: InputPlan | null; /** The script's `test()`s, and what running them found; null when it has none, or did not compile that far. */ tests: TestReport | null; + /** + * The script as JavaScript, file by file, when it has tests and compiled: what runs them somewhere else — in the + * lifted game, beside the engine (`gameTests.ts`) — without TypeScript there. + */ + modules?: [string, LinkedFile][]; /** No errors: `triggers` and `ir` are the complete output. */ ok: boolean; } @@ -401,7 +406,7 @@ export function compileScript(ts: typeof TS, files: ScriptFiles, names: ScriptNa ? runTests(registry, { triggers, sources, ir, strings: collector.strings, locate: where }, options.tests) : { list: listTests(registry).list, results: [], only: [], ms: 0 }; } - return result({ ir, input, triggers, sources, strings: collector.strings, variables, programs, buildTime, hints, tests }); + return result({ ir, input, triggers, sources, strings: collector.strings, variables, programs, buildTime, hints, tests, ...(registry.empty ? {} : { modules: [...linked] }) }); } /** diff --git a/compiler/gameSim.ts b/compiler/gameSim.ts new file mode 100644 index 0000000..7c08113 --- /dev/null +++ b/compiler/gameSim.ts @@ -0,0 +1,322 @@ +/** + * The world a test is handed when it runs in the lifted game (TrigScript 4, A2): `sim` as the simulator's `TestSim` + * has it, but every frame is the game's own and every read is the game's memory. It drives a `GameHandle`, which + * whatever hosts the game supplies (scm-sim's `src/exact/testGame.ts`); nothing here imports the game. + * + * What a test does to the world goes through the game's own trigger actions, run between frames as the player's + * trigger would run them: Create Unit for `place`, and for one unit Kill, Remove, Give or Move with a location + * centred on it (the last of the map's 255, put back afterwards). A typed line is the game's chat command; a key and + * the mouse are the local machine's memory, where MSQC reads them. + * + * Not in the game yet, and said so when asked: a program's variables (`program()`, which needs the build to say + * where each lives) and what another player was shown. A + * program's fault is the red line it prints ("TrigScript: …"), its line not known. + */ +import { ActionType } from "../vendor/triggers"; +import type { At } from "./ir"; +import { msqcKey } from "./input"; +import { ExpectError, WORLD, type TestEvent, type TestWorldLike } from "./testing"; +import type { SimBounds, SimUnit } from "./world"; + +/** What a host of the lifted game hands a test (scm-sim's `TestGameHandle`). */ +export interface GameHandle { + readonly m: DataView; + readonly u8: Uint8Array; + readonly frame: number; + readonly localPlayer: number; + readonly over: boolean; + step(): void; + lines(): { frame: number; text: string }[]; + action(record: Uint8Array, player: number): number; + chat(text: string): void; + call(address: number, registers: Record, args?: number[]): number; + reset(): void; +} + +// 1.16.1's addresses, which Remastered's emulation keeps. +const UNITS = 0x59cca8, UNIT_BYTES = 336, UNIT_COUNT = 1700, FIRST_UNIT = 0x628430; +const ORE = 0x57f0f0, GAS = 0x57f120, DEATHS = 0x58a364, KILLS = 0x5878a4, SWITCHES = 0x58dc40, LOCATIONS = 0x58dc60; +const MAX_HP = 0x662350, MAX_SHIELDS = 0x660e00, KEYS = 0x596a18, BUTTONS = 0x6cddc0, MOUSE = 0x6cddc4; +const SCRATCH = 255, SCRATCH_TO = 254; +/** The routine a unit that dies in the game goes through to be counted: its owner's death, the killer's kill (0x488AF0). */ +const COUNT_DEATH = 0x488af0; +const BUTTON_BITS = { left: 2, right: 8, middle: 32 } as const; +const FRAMES_PER_SECOND = 24, UNTIL_FRAMES = 2400; + +/** The frame from which the game's own locations are the map's (eudplib has put them right by then). */ +export const LOCATIONS_READY = 2; + +function readLocation(game: GameHandle, n: number): SimBounds | undefined { + const m = game.m, at = LOCATIONS + 20 * (n - 1); + const b = { left: m.getUint32(at, true), top: m.getUint32(at + 4, true), right: m.getUint32(at + 8, true), bottom: m.getUint32(at + 12, true) }; + return b.right > b.left || b.bottom > b.top ? b : undefined; +} + +/** + * The map's locations as the game holds them once eudplib has put them right: played to `LOCATIONS_READY` and put + * back to frame 0. For `GameSim`'s `locations`, and for the simulator's world of the same map. + */ +export function readLocations(game: GameHandle): Record { + game.reset(); + while (game.frame < LOCATIONS_READY && !game.over) game.step(); + const out: Record = {}; + for (let n = 1; n <= 255; n++) { const b = readLocation(game, n); if (b) out[n] = b; } + game.reset(); + return out; +} + +/** Each unit type's hit points and shields as the game's tables have them: the simulator's `unitStats` for the same map. */ +export function readUnitStats(game: GameHandle): Record { + const out: Record = {}; + for (let t = 0; t < 228; t++) out[t] = { hp: Math.floor(game.m.getInt32(MAX_HP + 4 * t, true) / 256), shields: game.m.getUint16(MAX_SHIELDS + 2 * t, true) }; + return out; +} + +/** The virtual key MSQC's name for a key stands for. */ +function virtualKey(key: string): number { + const k = msqcKey(key); + if (/^[A-Z0-9]$/.test(k)) return k.charCodeAt(0); + if (/^F([1-9]|1[0-2])$/.test(k)) return 0x6f + Number(k.slice(1)); + if (/^NUMPAD[0-9]$/.test(k)) return 0x60 + Number(k.slice(6)); + const named: Record = { SPACE: 0x20, ENTER: 0x0d, ESC: 0x1b, TAB: 0x09, SHIFT: 0x10, LCTRL: 0x11, LALT: 0x12, LEFT: 0x25, UP: 0x26, RIGHT: 0x27, DOWN: 0x28, BACK: 0x08, DELETE: 0x2e, INSERT: 0x2d, HOME: 0x24, END: 0x23, PGUP: 0x21, PGDN: 0x22 }; + if (k in named) return named[k]; + throw new ExpectError(`press: "${key}" is not a key the game reports`); +} + +/** A line as a player reads it: the game's colour codes and eudplib's padding (carriage returns) taken out. */ +const clean = (text: string) => text.replace(/[\x01-\x1f]/g, ""); + +/** A unit as the game holds it: every property read from its slot while it is the same unit, its last value after. */ +class GameUnit implements SimUnit { + readonly slot: number; + readonly uid: number; + private readonly at: number; + private last: Partial = {}; + private readonly game: GameHandle; + constructor(game: GameHandle, slot: number) { + this.game = game; + this.slot = slot; + this.at = UNITS + UNIT_BYTES * slot; + this.uid = game.u8[this.at + 0xa5]; + } + get alive(): boolean { + const { m, u8 } = this.game; + return u8[this.at + 0xa5] === this.uid && m.getUint32(this.at + 0x0c, true) !== 0 && u8[this.at + 0x4d] !== 0; + } + private read(key: K, f: () => SimUnit[K]): SimUnit[K] { + if (this.alive) (this.last as Record)[key] = f(); + return (this.last[key] ?? 0) as SimUnit[K]; + } + private get m() { return this.game.m; } + private flag(bit: number): boolean { return (this.m.getUint32(this.at + 0xdc, true) & bit) !== 0; } + get type() { return this.read("type", () => this.m.getUint16(this.at + 0x64, true)); } + get owner() { return this.read("owner", () => this.game.u8[this.at + 0x4c]); } + get x() { return this.read("x", () => this.m.getInt16(this.at + 0x28, true)); } + get y() { return this.read("y", () => this.m.getInt16(this.at + 0x2a, true)); } + get hp() { return this.read("hp", () => Math.ceil(this.m.getInt32(this.at + 0x08, true) / 256)); } + set hp(v: number) { if (this.alive) this.m.setInt32(this.at + 0x08, Math.max(0, v) * 256, true); } + get maxHp() { return Math.floor(this.m.getInt32(MAX_HP + 4 * this.type, true) / 256); } + get shields() { return this.read("shields", () => Math.floor(this.m.getInt32(this.at + 0x60, true) / 256)); } + get maxShields() { return this.m.getUint16(MAX_SHIELDS + 2 * this.type, true); } + get energy() { return this.read("energy", () => Math.floor(this.m.getUint16(this.at + 0xa2, true) / 256)); } + get kills() { return this.read("kills", () => this.game.u8[this.at + 0x8f]); } + get orderId() { return this.read("orderId", () => this.game.u8[this.at + 0x4d]); } + get cooldown() { return this.read("cooldown", () => this.game.u8[this.at + 0x55]); } + get resources() { return this.read("resources", () => this.m.getUint16(this.at + 0xd0, true)); } + get invincible() { return this.read("invincible", () => this.flag(0x04000000)); } + get cloaked() { return this.read("cloaked", () => this.flag(0x00000300)); } + get burrowed() { return this.read("burrowed", () => this.flag(0x00000010)); } + get hallucinated() { return this.read("hallucinated", () => this.flag(0x40000000)); } + // Not read from the game yet: the spell timers and whether the unit is under attack. + stim = 0; ensnare = 0; plague = 0; lockdown = 0; maelstrom = 0; irradiate = 0; stasis = 0; underAttack = false; +} + +export class GameSim implements TestWorldLike { + readonly [WORLD] = true as const; + faultsExpected = false; + private shown: { frame: number; text: string }[] = []; + private readonly units_ = new Map(); + private readonly game: GameHandle; + private readonly known: Record; + + /** + * `locations`: the map's locations as the script means them. eudplib keeps them scrambled in the game until its + * first frames put them right (frame 2 on a probe's map), so a test that acts at frame 0 needs them from elsewhere + * (`readLocations`); from then on the game's own are read. + */ + constructor(game: GameHandle, options: { locations?: Record } = {}) { + this.game = game; + this.known = options.locations ?? {}; + game.reset(); + this.take(); + } + + private take(): void { for (const l of this.game.lines()) this.shown.push(l); } + private record(type: number, fields: { location?: number; player?: number; number?: number; unit?: number; count?: number }): Uint8Array { + const r = new Uint8Array(32), v = new DataView(r.buffer); + v.setUint32(0x00, fields.location ?? 0, true); + v.setUint32(0x10, fields.player ?? 0, true); + v.setUint32(0x14, fields.number ?? 0, true); + v.setUint16(0x18, fields.unit ?? 0, true); + r[0x1a] = type; + r[0x1b] = fields.count ?? 0; + r[0x1c] = 0x04; // (always display: the flag every action the editor writes carries) + return r; + } + private act(record: Uint8Array, player: number): void { this.game.action(record, player); this.take(); } + + get frame(): number { return this.game.frame; } + seed(_n: number): this { return this; } + + /* ── time ── */ + + frames(n = 1): this { + for (let i = 0; i < n && !this.game.over; i++) { this.game.step(); this.take(); this.releaseKeys(); } + return this; + } + seconds(n: number): this { return this.frames(Math.round(n * FRAMES_PER_SECOND)); } + until(done: () => unknown, most = UNTIL_FRAMES): this { + for (let i = 0; ; i++) { + if (done()) return this; + if (i >= most) throw new ExpectError(`until: still not true after ${most} frames`); + this.frames(1); + } + } + + /* ── what the test does to the world ── */ + + private slots(): Set { + const out = new Set(), m = this.game.m; + for (let p = m.getUint32(FIRST_UNIT, true), guard = 0; p && guard < UNIT_COUNT; p = m.getUint32(p + 0x04, true), guard++) out.add((p - UNITS) / UNIT_BYTES); + return out; + } + private unit(slot: number): GameUnit { + const u8 = this.game.u8, uid = u8[UNITS + UNIT_BYTES * slot + 0xa5]; + const was = this.units_.get(slot); + if (was && was.uid === uid) return was; + const u = new GameUnit(this.game, slot); + this.units_.set(slot, u); + return u; + } + + /** + * `run(location)` with a scratch location set to `b` — its four sides, and its elevation flags cleared (a flag + * left set keeps the units of that elevation out of it, and at frame 0 they are eudplib's scrambled bits) — then + * put back as it was, all twenty bytes. + */ + private scratch(n: number, b: SimBounds, run: (location: number) => T): T { + const u8 = this.game.u8, m = this.game.m, at = LOCATIONS + 20 * (n - 1), was = u8.slice(at, at + 20); + [b.left, b.top, b.right, b.bottom].forEach((v, i) => m.setUint32(at + 4 * i, Math.max(0, v), true)); + m.setUint16(at + 0x12, 0, true); + try { return run(n); } finally { u8.set(was, at); } + } + private within255(b: SimBounds, run: (location: number) => T): T { return this.scratch(SCRATCH, b, run); } + place(player: number, type: number, at: number, n = 1): SimUnit[] { + const where = this.location(at); + if (!where) throw new ExpectError(`place: there is no location ${at}`); + const before = this.slots(); + this.within255(where, (loc) => this.act(this.record(ActionType.CreateUnit, { location: loc, player, unit: type, count: Math.min(255, n) }), player)); + return [...this.slots()].filter((s) => !before.has(s)).map((s) => this.unit(s)).filter((u) => u.type === type && u.owner === player); + } + /** One unit, by the scratch location centred on it for one action. */ + private onOne(unit: SimUnit, run: (location: number) => void): this { + if (unit.alive) this.within255({ left: unit.x - 1, top: unit.y - 1, right: unit.x + 1, bottom: unit.y + 1 }, run); + return this; + } + /** + * What a fight is in a test, as `TestSim` has it: the unit dies and counts as its owner's death, and as a kill of + * `by`'s. The game's Kill Unit counts nothing (only a death in a fight does), so the game's own routine that counts + * one is called first, the unit and the killer handed to it as the game hands them, then the unit is killed. + */ + kill(unit: SimUnit, by?: number): this { + if (!unit.alive) return this; + this.game.call(COUNT_DEATH, { edi: UNITS + UNIT_BYTES * unit.slot, edx: by ?? unit.owner, ecx: 0 }); + return this.onOne(unit, (loc) => this.act(this.record(ActionType.KillUnitAt, { location: loc, player: unit.owner, unit: unit.type, count: 1 }), unit.owner)); + } + remove(unit: SimUnit): this { + return this.onOne(unit, (loc) => this.act(this.record(ActionType.RemoveUnitAt, { location: loc, player: unit.owner, unit: unit.type, count: 1 }), unit.owner)); + } + give(unit: SimUnit, to: number): this { + return this.onOne(unit, (loc) => this.act(this.record(ActionType.GiveUnits, { location: loc, player: unit.owner, number: to, unit: unit.type, count: 1 }), unit.owner)); + } + move(unit: SimUnit, to: number | { x: number; y: number }): this { + // (The destination through a second scratch location, so that a location still scrambled at frame 0 is not used.) + const b = typeof to === "number" ? this.location(to) : { left: to.x, top: to.y, right: to.x, bottom: to.y }; + if (!b) throw new ExpectError(`move: there is no location ${String(to)}`); + return this.scratch(SCRATCH_TO, b, (dest) => this.onOne(unit, (loc) => this.act(this.record(ActionType.MoveUnit, { location: loc, player: unit.owner, number: dest, unit: unit.type, count: 1 }), unit.owner))); + } + + /* ── what the players do: the local player only ── */ + + private held: number[] = []; + private local(player: number | undefined, what: string): void { + if (player !== undefined && player !== this.game.localPlayer) throw new ExpectError(`${what}: in the game only the player at this computer (P${this.game.localPlayer + 1}) can do it`); + } + /** A key down for one frame, as a press is (MSQC sees it down, then up). */ + press(key: string, player?: number): this { this.local(player, "press"); const k = virtualKey(key); this.game.u8[KEYS + k] = 1; this.held.push(k); return this; } + click(button: "left" | "right" | "middle" = "left", player?: number): this { + this.local(player, "click"); + const m = this.game.m; + m.setUint32(BUTTONS, m.getUint32(BUTTONS, true) | BUTTON_BITS[button], true); + this.held.push(-BUTTON_BITS[button]); + return this; + } + private releaseKeys(): void { + if (!this.held.length) return; + const m = this.game.m; + for (const k of this.held.splice(0)) { if (k >= 0) this.game.u8[KEYS + k] = 0; else m.setUint32(BUTTONS, m.getUint32(BUTTONS, true) & ~-k, true); } + } + type(line: string, player?: number): this { this.local(player, "type"); this.game.chat(line); return this; } + moveMouse(x: number, y: number, player?: number): this { + this.local(player, "moveMouse"); + this.game.m.setUint16(MOUSE, x, true); + this.game.m.setUint16(MOUSE + 2, y, true); + return this; + } + + /* ── the world read back ── */ + + private owners(player: number): number[] { + if (player < 12) return [player]; + if (player === 13) return [this.game.localPlayer]; + if (player === 17) return Array.from({ length: 12 }, (_, i) => i); + throw new ExpectError(`player ${player}: only one player, Current Player or All Players are read in the game yet`); + } + private within(u: SimUnit, at: number): boolean { + const b = this.location(at); + return !!b && u.x >= b.left && u.x <= b.right && u.y >= b.top && u.y <= b.bottom; + } + units(filter: { owner?: number; type?: number; at?: number } = {}): SimUnit[] { + const owners = filter.owner === undefined ? null : this.owners(filter.owner); + return [...this.slots()].sort((a, b) => a - b).map((s) => this.unit(s)) + .filter((u) => (owners === null || owners.includes(u.owner)) && (filter.type === undefined || u.type === filter.type) && (filter.at === undefined || this.within(u, filter.at))); + } + count(player: number, type: number, at?: number): number { return this.units({ owner: player, type, at }).length; } + resources(player: number): { ore: number; gas: number } { + const m = this.game.m; + return { ore: m.getUint32(ORE + 4 * player, true), gas: m.getUint32(GAS + 4 * player, true) }; + } + deaths(player: number, type: number): number { return this.game.m.getUint32(DEATHS + 4 * (player + 12 * type), true); } + kills(player: number, type: number): number { return this.owners(player).reduce((n, p) => n + this.game.m.getUint32(KILLS + 4 * (p + 12 * type), true), 0); } + switch(n: number): boolean { return ((this.game.u8[SWITCHES + (n >> 3)] >> (n & 7)) & 1) === 1; } + location(n: number): SimBounds | undefined { + if (n < 1 || n > 255) return undefined; + if (this.game.frame < LOCATIONS_READY) return this.known[n] ? { ...this.known[n] } : undefined; + return readLocation(this.game, n); + } + program(): Record { + throw new ExpectError("sim.program(…) reads a program's variables in the simulator only, for now: in the game, read what the program did (printed, resources, units)"); + } + + /** What was shown, in order, with the game's colour codes taken out. `player`: only the player at this computer is seen. */ + printed(player?: number): string[] { + if (player !== undefined && player !== this.game.localPlayer) throw new ExpectError(`printed(P${player + 1}): in the game only what the player at this computer (P${this.game.localPlayer + 1}) was shown is seen`); + return this.shown.map((l) => clean(l.text)); + } + log(): TestEvent[] { return this.shown.map((l) => ({ frame: l.frame, text: `Display Text Message — ${clean(l.text)}`, player: this.game.localPlayer })); } + get events(): TestEvent[] { return this.log(); } + /** A program's faults, as it says them in the game: a red line that begins "TrigScript:". */ + get faults(): { at: At; message: string; cycle: number; frame: number; program: number }[] { + return this.shown.filter((l) => /^\W*TrigScript: /.test(l.text)).map((l) => ({ at: { file: "", line: 0, column: 0 } as At, message: clean(l.text).replace(/^TrigScript: /, ""), cycle: 0, frame: l.frame, program: 0 })); + } +} diff --git a/compiler/gameTestEntry.ts b/compiler/gameTestEntry.ts new file mode 100644 index 0000000..b5551bb --- /dev/null +++ b/compiler/gameTestEntry.ts @@ -0,0 +1,6 @@ +/** + * `dist/gametest.js`: what a host of the lifted game imports to run a script's tests beside it (see `gameTests.ts`). + * The plugin never loads it itself; it hands its text to the host. + */ +export { runGameTests, type GameTestPayload } from "./gameTests"; +export { GameSim, readLocations, readUnitStats, type GameHandle } from "./gameSim"; diff --git a/compiler/gameTests.ts b/compiler/gameTests.ts new file mode 100644 index 0000000..1c0b970 --- /dev/null +++ b/compiler/gameTests.ts @@ -0,0 +1,42 @@ +/** + * A script's tests run in the lifted game (TrigScript 4, A2), where the game is: beside the engine, in whatever + * worker hosts it, with no TypeScript there. The compile hands over the script as JavaScript (`CompileResult.modules`) + * and the names it was compiled with; this runs the modules again to register the tests, and runs each with a + * `GameSim` over the host's `GameHandle`, the game put back to frame 0 before each. What it returns is plain data, + * the `TestReport` the editor already shows for the simulator. + * + * Bundled on its own as `dist/gametest.js`, which the plugin hands to the host as text. + */ +import { ENTRY_FILE } from "./compiler"; +import { MODULE_NAME } from "./api"; +import { GameSim, readLocations, type GameHandle } from "./gameSim"; +import { locate, runModules, type LinkedFile } from "./link"; +import type { ScriptNames } from "./names"; +import { Collector, createRuntime } from "./runtime"; +import { isTestFile, runTests, TestRegistry, TESTING_NAMES, type TestReport } from "./testing"; +import type { SimBounds } from "./world"; + +export interface GameTestPayload { + modules: [string, LinkedFile][]; + names: ScriptNames; + /** Run only these: tests of these files, or with these ids. Absent: all. */ + files?: string[]; + ids?: string[]; + /** The map's locations as the script means them; read from the game (frame 2) when absent. */ + locations?: Record; +} + +export function runGameTests(game: GameHandle, payload: GameTestPayload): TestReport & { error?: string } { + const linked = new Map(payload.modules); + const collector = new Collector(); + const registry = new TestRegistry(); + const where = (err: unknown) => { const at = locate(err, linked); return at.file ? { file: at.file, line: at.line ?? 1, ...(at.column ? { column: at.column } : {}) } : null; }; + registry.where = () => where(new Error()) ?? { file: ENTRY_FILE, line: 1 }; + const runtime = { ...createRuntime(payload.names, collector), ...registry.library() }; + collector.running = true; + const failure = runModules(linked, ENTRY_FILE, runtime, MODULE_NAME, { imported: TESTING_NAMES, then: [...linked.keys()].filter(isTestFile).sort() }); + collector.running = false; + if (failure) return { list: [], results: [], only: [], ms: 0, error: `${failure.file ?? ENTRY_FILE}:${failure.line ?? 1}: ${failure.message}` }; + const locations = payload.locations ?? readLocations(game); + return runTests(registry, { triggers: [], sources: [], ir: [], strings: [], locate: where }, { files: payload.files, ids: payload.ids, makeWorld: () => new GameSim(game, { locations }) }); +} diff --git a/compiler/testing.ts b/compiler/testing.ts index 014ee5a..4085063 100644 --- a/compiler/testing.ts +++ b/compiler/testing.ts @@ -25,8 +25,8 @@ import type { SimBounds, SimUnit, SimUnitInit, SimUnitProperties } from "./world /* ── What is registered ── */ type Mode = "run" | "skip" | "only"; -type TestFn = (sim: TestSim, ...args: unknown[]) => unknown; -type Hook = (sim: TestSim) => unknown; +type TestFn = (sim: TestWorldLike, ...args: unknown[]) => unknown; +type Hook = (sim: TestWorldLike) => unknown; interface Where { file: string; line: number; column?: number } interface Suite { @@ -112,6 +112,11 @@ export interface TestWorld { export interface TestRunOptions { world?: TestWorld; + /** + * A world other than the simulator's for each test (the lifted game's, `GameSim`): made fresh for every test, + * given the player the test asked to be (`{ as: P2 }`). Absent: the simulator. + */ + makeWorld?: (as?: number) => TestWorldLike; /** Run only these: tests of these files, or with these ids (a suite's id runs what is under it). Absent: all. */ files?: string[]; ids?: string[]; @@ -212,13 +217,51 @@ export class TestRegistry { /** The names `library()` gives: a script imports them, they are not globals (a script may well have a `test` of its own). */ export const TESTING_NAMES = ["test", "it", "describe", "beforeEach", "afterEach", "expect"] as const; +/* ── The world a test is handed ── */ + +/** What every world a test may be handed carries: the simulator's (`TestSim`) and the lifted game's (`GameSim`). */ +export const WORLD = Symbol.for("trigscript.world"); +export const isWorld = (v: unknown): v is TestWorldLike => typeof v === "object" && v !== null && (v as { [WORLD]?: unknown })[WORLD] === true; + +/** What a test can ask of its world, whichever it was handed. */ +export interface TestWorldLike { + readonly [WORLD]: true; + faultsExpected: boolean; + readonly frame: number; + seed(n: number): this; + frames(n?: number): this; + seconds(n: number): this; + until(done: () => unknown, most?: number): this; + place(player: number, type: number, at: number, n?: number): SimUnit[]; + kill(unit: SimUnit, by?: number): this; + remove(unit: SimUnit): this; + give(unit: SimUnit, to: number): this; + move(unit: SimUnit, to: number | { x: number; y: number }): this; + press(key: string, player?: number): this; + click(button?: "left" | "right" | "middle", player?: number): this; + type(line: string, player?: number): this; + moveMouse(x: number, y: number, player?: number): this; + count(player: number, type: number, at?: number): number; + units(filter?: { owner?: number; type?: number; at?: number }): SimUnit[]; + resources(player: number): { ore: number; gas: number }; + deaths(player: number, type: number): number; + kills(player: number, type: number): number; + switch(n: number): boolean; + location(n: number): SimBounds | undefined; + program(name?: string | number, player?: number): Record; + printed(player?: number): string[]; + log(sourceOf?: (trigger: number) => Where | null): TestEvent[]; + readonly events: TestEvent[]; + readonly faults: { at: At; message: string; cycle: number; frame: number; program: number }[]; +} + /* ── expect ── */ const show = (v: unknown): string => { if (typeof v === "string") return JSON.stringify(v); if (typeof v === "function") return "a function"; if (v instanceof RegExp) return String(v); - if (v instanceof TestSim) return "sim"; + if (isWorld(v)) return "sim"; try { return JSON.stringify(v, (_, x) => (typeof x === "bigint" ? String(x) : x === undefined ? "undefined" : x)) ?? String(v); } catch { return String(v); } }; const brief = (v: unknown): string => { const s = show(v); return s.length > 60 ? `${s.slice(0, 57)}…` : s; }; @@ -237,11 +280,11 @@ function expect(actual: unknown) { /** `holds` is the matcher's answer; `what` finishes "expected …". */ const check = (holds: boolean, what: string, expected?: unknown) => { if (holds !== not) return; - const got = actual instanceof TestSim ? "" : `, got ${brief(actual)}`; - throw new ExpectError(`expected ${not ? "not " : ""}${what}${got}`, expected === undefined ? undefined : show(expected), actual instanceof TestSim ? undefined : show(actual)); + const got = isWorld(actual) ? "" : `, got ${brief(actual)}`; + throw new ExpectError(`expected ${not ? "not " : ""}${what}${got}`, expected === undefined ? undefined : show(expected), isWorld(actual) ? undefined : show(actual)); }; const number = (name: string): number => { if (typeof actual !== "number") throw new ExpectError(`${name}: ${brief(actual)} is not a number`); return actual; }; - const sim = (name: string): TestSim => { if (!(actual instanceof TestSim)) throw new ExpectError(`${name} is asked of the world: expect(sim).${name}(…)`); return actual; }; + const sim = (name: string): TestWorldLike => { if (!isWorld(actual)) throw new ExpectError(`${name} is asked of the world: expect(sim).${name}(…)`); return actual; }; return { toBe: (e: unknown) => check(Object.is(actual, e), brief(e), e), toEqual: (e: unknown) => check(same(actual, e), brief(e), e), @@ -293,8 +336,9 @@ const mulberry = (seed: number) => () => { return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; -/** The world a test is handed. */ -export class TestSim { +/** The world a test is handed: the simulator's. */ +export class TestSim implements TestWorldLike { + readonly [WORLD] = true as const; /** Set by `expect(sim).toHaveFaulted`: the faults are the test's business, and do not fail it by themselves. */ faultsExpected = false; private rng = mulberry(DEFAULT_SEED); @@ -476,7 +520,7 @@ export function runTests(registry: TestRegistry, ctx: TestContext, options: Test for (let s: Suite | null = test.parent; s; s = s.parent) chain.unshift(s); const skipped = test.mode === "skip" || chain.some((s) => s.mode === "skip") || (narrowed.has(info.file) && test.mode !== "only" && !chain.some((s) => s.mode === "only")); if (skipped) { results.push({ id: info.id, status: "skipped", printed: [], events: [], frames: 0, ms: 0 }); continue; } - results.push(runOne(info.id, test, chain, ctx, options.world ?? {})); + results.push(runOne(info.id, test, chain, ctx, options.world ?? {}, options.makeWorld)); } } finally { registry.running = false; @@ -484,14 +528,14 @@ export function runTests(registry: TestRegistry, ctx: TestContext, options: Test return { list, results, only, ms: Date.now() - started }; } -function runOne(id: string, test: Case, chain: Suite[], ctx: TestContext, world: TestWorld): TestResult { +function runOne(id: string, test: Case, chain: Suite[], ctx: TestContext, world: TestWorld, makeWorld?: (as?: number) => TestWorldLike): TestResult { const began = Date.now(); - let sim: TestSim | null = null; + let sim: TestWorldLike | null = null; const done = (extra: Partial): TestResult => ({ id, status: "passed", printed: sim?.printed() ?? [], events: (sim?.log((t) => ctx.sources[t] ?? null) ?? []).slice(0, 500), frames: sim?.frame ?? 0, ms: Date.now() - began, ...extra, }); try { - sim = new TestSim(ctx.triggers, ctx.ir, ctx.strings, world, test.as); + sim = makeWorld ? makeWorld(test.as) : new TestSim(ctx.triggers, ctx.ir, ctx.strings, world, test.as); for (const s of chain) for (const h of s.before) h(sim); const returned = test.fn(sim, ...test.args); if (returned && typeof (returned as { then?: unknown }).then === "function") throw new Error("A test is not async: what it returned is a promise. Nothing in sim waits."); diff --git a/package.json b/package.json index 573b321..02e6c3e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "./package.json": "./package.json" }, "scripts": { - "build": "esbuild plugin.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/plugin.js --banner:js=\"// Generated by npm run build - do not edit; the source is plugin.ts.\" && esbuild compiler/entry.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/compiler.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/entry.ts. The compile worker imports this when the plugin is compiled into the editor.\" && esbuild compiler/testingEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --external:node:* --outfile=dist/testing.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/testingEntry.ts. The plugin never loads this: it is what another repository's tests import to run triggers.\" && dts-bundle-generator --silent --no-banner --project tsconfig.json -o dist/testing.d.ts compiler/testingEntry.ts", + "build": "esbuild plugin.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/plugin.js --banner:js=\"// Generated by npm run build - do not edit; the source is plugin.ts.\" && esbuild compiler/entry.ts --bundle --format=esm --target=es2022 --platform=browser --outfile=dist/compiler.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/entry.ts. The compile worker imports this when the plugin is compiled into the editor.\" && esbuild compiler/testingEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --external:node:* --outfile=dist/testing.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/testingEntry.ts. The plugin never loads this: it is what another repository's tests import to run triggers.\" && dts-bundle-generator --silent --no-banner --project tsconfig.json -o dist/testing.d.ts compiler/testingEntry.ts && esbuild compiler/gameTestEntry.ts --bundle --format=esm --target=es2022 --platform=neutral --outfile=dist/gametest.js --banner:js=\"// Generated by npm run build - do not edit; the source is compiler/gameTestEntry.ts. The plugin hands its text to a host of the lifted game, which runs a script's tests beside the game.\"", "dev": "npm run build -- --watch", "typecheck": "tsc -p tsconfig.json", "test": "vitest run", diff --git a/probes/game-tests.ts b/probes/game-tests.ts new file mode 100644 index 0000000..562c1dd --- /dev/null +++ b/probes/game-tests.ts @@ -0,0 +1,62 @@ +// TrigScript 4, A2: a script whose tests run in the lifted game as well as in the simulator. +// npx tsx scripts/test-game.mts probes/game-tests.ts +// Each kind of thing a test does to the world or reads back from it, once: time, a typed line, a key, units placed, +// killed, given and moved, and what was printed. Built like a probe (Player 1 Terran, Player 2 a Zerg computer, +// location 1 "Home" by Player 1's base, location 2 "Away" beside it). +import { test, expect, program, displayText, setResources, chatted, keyPressed, createUnit, sleep, frames, seconds, units, P1, P2, type Location } from "trigscript"; + +const HOME = 1 as Location, AWAY = 2 as Location; + +program(() => { + let ticks = 0; + while (true) { + ticks += 1; + setResources(P1, "set", ticks, "gas"); + if (ticks == 3) displayText("three ticks"); + sleep(seconds(1)); + } +}, { owner: P1 }); + +program(() => { + while (true) { + const m = chatted(P1, "-give {n}"); + if (m) { setResources(P1, "add", m.n, "ore"); displayText(`gave ${m.n}`); } + if (keyPressed(P1, "Q")) { createUnit(P1, units.TerranFirebat, 1, HOME); displayText("Q: a Firebat"); } + sleep(frames(1)); + } +}, { owner: P1 }); + +test("gas counts the seconds", (sim) => { + sim.seconds(5); + expect(sim.resources(P1).gas).toBe(5); + expect(sim).toHavePrinted("three ticks"); +}); + +test("a typed line gives ore", (sim) => { + sim.frames(10); + const before = sim.resources(P1).ore; + sim.type("-give 250").seconds(1); + expect(sim.resources(P1).ore).toBe(before + 250); + expect(sim).toHavePrinted("gave 250"); +}); + +test("a key makes a Firebat", (sim) => { + sim.frames(10).press("Q").frames(10); + expect(sim.count(P1, units.TerranFirebat, HOME)).toBe(1); + expect(sim).toHavePrinted("Q: a Firebat"); +}); + +test("units placed, killed, given and moved", (sim) => { + const made = sim.place(P1, units.TerranMarine, HOME, 3); + expect(made).toHaveLength(3); + expect(made[0].hp).toBe(40); + sim.kill(made[0]).frames(30); + expect(made[0].alive).toBe(false); + expect(sim.deaths(P1, units.TerranMarine)).toBe(1); + sim.give(made[1], P2).frames(1); + expect(made[1].owner).toBe(P2); + sim.move(made[2], AWAY).frames(1); + const away = sim.location(AWAY)!; + expect(made[2].x).toBeGreaterThanOrEqual(away.left); + expect(made[2].x).toBeLessThanOrEqual(away.right); +}); diff --git a/scripts/test-game.mts b/scripts/test-game.mts new file mode 100644 index 0000000..a31afe9 --- /dev/null +++ b/scripts/test-game.mts @@ -0,0 +1,93 @@ +/** + * A script's tests run in the lifted game (TrigScript 4, A2), each beside its run in the simulator: the script built + * as Save builds it (scripts/build-fixture.mts), started in a scm-sim checkout's lifted game with EUD let through + * and nothing drawn, and every `test()` handed a `GameSim` over it (compiler/gameSim.ts) — the game put back to + * frame 0 before each. The same tests then run in the simulator over the map's own world, and each is said twice. + * + * npx tsx scripts/test-game.mts [map.scx] + * + * Needs a scm-sim checkout beside this repository (or SCM_SIM_DIR) with its lifted game generated. + */ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; +import { pathToFileURL } from "node:url"; +import ts from "typescript"; +import { compileScript } from "../compiler/compiler"; +import { readLocations, readUnitStats, type GameHandle } from "../compiler/gameSim"; +import { defaultScriptNames } from "../compiler/names"; +import type { TestReport, TestWorld } from "../compiler/testing"; +import { defaultLib } from "../bundle/lib.mjs"; + +const root = resolve(import.meta.dirname, ".."); +const [script, baseMap] = process.argv.slice(2); +if (!script || !existsSync(script)) { console.error("usage: test-game.mts [map.scx]"); process.exit(2); } +const sim = resolve(process.env.SCM_SIM_DIR ?? join(root, "..", "scm-sim")); +if (!existsSync(join(sim, "src", "exact", "testGame.ts"))) { console.error(`no scm-sim checkout with src/exact/testGame.ts at ${sim} (set SCM_SIM_DIR)`); process.exit(2); } +const from = (p: string) => import(pathToFileURL(join(sim, p)).href); + +const dir = mkdtempSync(join(tmpdir(), "trigscript-tests-")); +try { + // The map, built as Save builds it. + const built = join(dir, basename(script, ".ts") + ".scx"); + const b = spawnSync("npx", ["tsx", join(root, "scripts", "build-fixture.mts"), resolve(script), built, ...(baseMap ? [resolve(baseMap)] : [])], { cwd: root, encoding: "utf8", maxBuffer: 256 << 20 }); + if (b.status !== 0) { console.error(b.stdout.split("\n").slice(-20).join("\n"), b.stderr); process.exit(1); } + + // The game, at frame 0, EUD let through. + const { Archive } = await import(pathToFileURL(join(sim, "node_modules", "mopaq", "dist", "index.mjs")).href); + const { exactMissing, startExactGame } = await from("src/exact/node.ts"); + const { classicScenario, installEud, placeStrings } = await from("src/exact/eud.ts"); + const { testGame } = await from("src/exact/testGame.ts"); + const missing = exactMissing() as string[]; + if (missing.length) { console.error("the lifted game cannot run here; missing:\n " + missing.join("\n ")); process.exit(1); } + const chk = Archive.open(new Uint8Array(readFileSync(built))).readFile("staredit\\scenario.chk") as Uint8Array; + const classic = classicScenario(chk); + const chkPath = join(dir, "scenario.chk"); + writeFileSync(chkPath, classic); + const run = await startExactGame(chkPath, { game: "ums" }); + const strings = placeStrings(run.lifted, chk); + installEud(run.lifted, strings); + // (eudplib's payload lies outside what the game's snapshot covers: the handle keeps it itself.) + const handle = testGame(run.engine, run.lifted, [{ from: strings.gameTable, to: 0x191943c8 + strings.bytes }]) as GameHandle; + + // The tests in the simulator over the map's own world, as the editor runs them; then in the game, the way a host + // of the game runs them: the compile's JavaScript handed to dist/gametest.js, loaded from its text. + const source = { "main.ts": readFileSync(script, "utf8") }; + const names = defaultScriptNames(); + // (The locations from the game once eudplib has put them right: in the built map they are scrambled.) + const locations = readLocations(handle); + const world = await mapWorld(classic, locations, readUnitStats(handle)); + const compiled = compileScript(ts, source, names, { lib: defaultLib(), tests: { world } }); + const simulated = compiled.tests; + if (!compiled.modules) { console.error(compiled.ok ? "the script has no tests" : compiled.diagnostics.map((d) => `${d.file}:${d.line}: ${d.message}`).join("\n")); process.exit(1); } + const bundle = join(dir, "gametest.js"); + spawnSync("npx", ["esbuild", join(root, "compiler", "gameTestEntry.ts"), "--bundle", "--format=esm", "--target=es2022", "--platform=neutral", `--outfile=${bundle}`], { cwd: root }); + const host = await import(`data:text/javascript;base64,${Buffer.from(readFileSync(bundle)).toString("base64")}`) as typeof import("../compiler/gameTestEntry"); + const inGame = host.runGameTests(handle, { modules: compiled.modules, names, locations }) as TestReport & { error?: string }; + if (inGame.error) { console.error(`the script did not run beside the game: ${inGame.error}`); process.exit(1); } + const bySim = new Map((simulated?.results ?? []).map((r) => [r.id, r])); + let failed = 0; + for (const r of inGame.results) { + const s = bySim.get(r.id); + const mark = (st: string | undefined) => (st === "passed" ? "✓" : st === "failed" ? "✗" : st === "skipped" ? "–" : "?"); + if (r.status === "failed") failed++; + console.log(`${mark(r.status)} game ${mark(s?.status)} simulator ${r.id.replace(/^.*?::/, "")} (${r.frames} frames, ${r.ms} ms)`); + if (r.status === "failed") console.log(` in the game: ${r.message}${r.at ? ` — line ${r.at.line}` : ""}${r.actual !== undefined ? `\n got ${r.actual}` : ""}`); + if (s?.status === "failed") console.log(` in the simulator: ${s.message}${s.at ? ` — line ${s.at.line}` : ""}${s.actual !== undefined ? `\n got ${s.actual}` : ""}`); + if (s && s.status !== r.status) console.log(" the two disagree: one of them is wrong"); + } + console.log(`\n${inGame.results.length} tests in the game: ${inGame.results.length - failed} passed, ${failed} failed`); + if (failed) process.exitCode = 1; +} finally { + rmSync(dir, { recursive: true, force: true }); +} + +/** The world the editor would hand the simulator for this map: its units, the locations and stats read from the game, P1 a person, P2 a computer. */ +async function mapWorld(chk: Uint8Array, locations: TestWorld["locations"], unitStats: TestWorld["unitStats"]): Promise { + const EDITOR = resolve(process.env.SCMJS_DIR ?? join(root, "..", "scm-js")); + const { parseScenario } = await import(join(EDITOR, "src", "formats", "chk", "scenario.ts")); + const scn = parseScenario(chk); + const units = (scn.units ?? []).filter((u: { unitId: number }) => u.unitId !== 214).map((u: { unitId: number; owner: number; x: number; y: number }) => ({ type: u.unitId, owner: u.owner, x: u.x, y: u.y })); + return { units, locations, unitStats, players: [0, 1], forces: { 0: 0, 1: 1 }, computers: [1] }; +} From df6d27510cbb149b3ad4ab6e1b81928bb1e16827 Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 16 Oct 2026 00:31:23 -0600 Subject: [PATCH 5/7] TrigScript 4, A2: tests in the game from the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run All Tests in the Game (Testing view and palette): the map built as Save builds it, exported whole, handed with dist/gametest.js's text and the compile's JavaScript to the scm-sim plugin's engine through its new test(); each test gets a second mark, "game", red where the two disagree, and the Test Results panel says what the game found. vendor/scmSim.ts is the part of the scm-sim contract TrigScript uses; it never imports the engine. docs/v4-plan.md: A2 as built — a test is not a program, the traps met, the simulator bug the first run found. Co-Authored-By: Claude Opus 5.5 --- compile.ts | 7 ++++ docs/v4-plan.md | 58 +++++++++++++++++++++++++++++++++ editor.ts | 85 ++++++++++++++++++++++++++++++++++++++++++++++-- ko.ts | 17 ++++++++++ service.ts | 11 +++++++ vendor/scmSim.ts | 28 ++++++++++++++++ 6 files changed, 203 insertions(+), 3 deletions(-) create mode 100644 vendor/scmSim.ts diff --git a/compile.ts b/compile.ts index 416d16f..7aff1c5 100644 --- a/compile.ts +++ b/compile.ts @@ -41,6 +41,13 @@ export const libUrl = (dist: string = DEFAULT_DIST) => `${dist.replace(/\/+$/, " export const compilerUrl = (dist: string = DEFAULT_DIST) => dist === DEFAULT_DIST ? `https://cdn.jsdelivr.net/gh/scm-js/plugin-trigscript@v${VERSION}/dist/compiler.js` : `${dist.replace(/\/+$/, "")}/compiler.js`; +/** + * What runs a script's tests beside the lifted game (`dist/gametest.js`, from `compiler/gameTestEntry.ts`), at this + * release's own tag like the compiler: its text is handed to the engine's page (TrigScript 4, A2). + */ +export const gametestUrl = (dist: string = DEFAULT_DIST) => + dist === DEFAULT_DIST ? `https://cdn.jsdelivr.net/gh/scm-js/plugin-trigscript@v${VERSION}/dist/gametest.js` : `${dist.replace(/\/+$/, "")}/gametest.js`; + /** * What the worker is told to import. The editor's loader turns a fetched plugin into * `blob:` modules, and the compiler's own blob URL is the compiler the main thread would diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 8d5a46f..02a6809 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -270,6 +270,64 @@ seen to refuse or ignore, or has never been seen to do, by line. A headless run draws nothing, and is allowed from any copy that unlocks the editor (decided 2026-10-15, see *Open decisions*). +**As built (2026-10-16).** One part of the design above was wrong and was not built: a test does +*not* become a program. A test is synchronous JavaScript that steps frames and reads between them +(`until(() => …)` among it), which cannot be compiled into triggers ahead of time. Instead the test +stays JavaScript and is handed a world backed by the game, and it runs where the game is. + +- **`GameSim`** (`compiler/gameSim.ts`) has `TestSim`'s surface over a small synchronous + `GameHandle`. scm-sim supplies the handle (`src/exact/testGame.ts`), and TrigScript never imports + the engine. + - Every read is the game's memory. + - `place`, `kill`, `remove`, `give` and `move` are the game's own trigger actions, run between + frames as a player's trigger would run them, through a scratch location (255; 254 for a + destination) whose twenty bytes are put back afterwards. + - `kill` is a fight, as `TestSim` has it: the game's death-counting routine (0x488AF0) is called + first, so `by` gets the kill. + - A typed line is the game's own chat command (0x5C). chatEvent reads it from the text lines as + in play, and MSQC sends it on. + - A key and a mouse button are the local machine's memory (0x596A18, 0x6CDDC0), held for one + frame. + - Not yet: `program()` (a program's variables; the build would have to say where each lives), + and what another player was shown. +- **The same tests, twice.** `TestRunOptions.makeWorld` hands each test a world other than the + simulator's. A compile also returns the script as JavaScript (`CompileResult.modules`). + `compiler/gameTests.ts`, bundled as `dist/gametest.js` (198 KB, no TypeScript in it), runs those + modules again beside the game, registers the tests and runs each with a `GameSim`. The game is + put back to frame 0 before each test. +- **Where it runs.** + - Under Node: `npx tsx scripts/test-game.mts ` prints each test's game and simulator + marks side by side. + - In the editor: **Run All Tests in the Game** in the Testing view and the palette. It builds + the map as Save does, exports it whole (a bare `.chk` has no room for what the build steps + make), and hands it, with `gametest.js`'s text and the modules, to scm-sim's analysis page + through the plugin's new `test()` (contract unchanged in version; `status().tests`). The page + reads the scenario out of the map, starts it in a worker of its own with EUD let through, + loads the module from its text and answers with the report. Each test then shows a second + mark, "game", which turns red when the two disagree. Nothing is drawn, so under the decision + of 2026-10-15 any copy of the game that unlocks the editor is enough. +- **Traps met.** + - eudplib keeps the map's locations scrambled until its first frames put them right (by frame 2 + on a probe's map). So an action at frame 0 goes through a scratch location given the map's + real bounds, read from the game once (`readLocations`), with the elevation flags cleared, + since scrambled flags keep units out of a location. + - The game's snapshot covers only the pages it had mapped at the start. eudplib's payload lies + outside them, so a reset left eudplib's variables and its self-rewriting triggers as the last + test left them, and typed lines and keys went unanswered from the second test on. The handle + now keeps that range itself. +- **What the first run found:** a simulator bug. Kill Unit, Kill Unit At Location, a unit's + `kill()` and hit points written down to 0 all counted a death in the simulator. The game counts + none of them, and the editor's trigger reference, checked in the real game, says the same of + Kill Unit. Fixed in the simulator (`World.gone(…, fought)`), with three tests corrected to the + game's rule. +- **Checked:** + - `probes/game-tests.ts` (time, a typed line, a key; units placed, killed, given and moved) + passes in the game and in the simulator under Node, and through scm-sim's analysis page in + headless Chromium (3.3 s, game start included). + - plugin-scm-sim's bridge has two new tests. + - The editor's button has not been driven end to end in the built editor yet. Its parts have: + compile, export and the page, each on its own. + ### A3. The desync detector (4.1.0) Run two to eight copies of the game seated as different local players diff --git a/editor.ts b/editor.ts index 2579b71..0cf3b05 100644 --- a/editor.ts +++ b/editor.ts @@ -27,7 +27,7 @@ * Monaco and TypeScript come from the CDN on first open. */ import type { PluginApi } from "@scm-js/plugin-api"; -import { CompileSuperseded, retainCompileWorker } from "./compile"; +import { CompileSuperseded, gametestUrl, retainCompileWorker } from "./compile"; import { ENTRY_FILE, normalizePath, type CompileResult, type ScriptDiagnostic, type ScriptFiles, type TriggerSource } from "./compiler/compiler"; import { HEAP_CELLS, HEAP_CELLS_MAX, HEAP_CELLS_MIN, STACK_CELLS_MAX, STACK_DEPTH, STACK_DEPTH_MAX, STACK_DEPTH_MIN, heapCells, stackDepth } from "./compiler/ir"; import { largestFrame } from "./compiler/recursion"; @@ -42,7 +42,7 @@ import { } from "./monaco"; import { renamedKeys, renamesInUse, replaceReferences, type Renamed } from "./refs"; import { FILE_NAME } from "./script"; -import { isTestFile, type TestInfo, type TestRunOptions } from "./compiler/testing"; +import { isTestFile, type TestInfo, type TestReport, type TestResult, type TestRunOptions } from "./compiler/testing"; import { countTests, foldPlayers, idsUnder, marksOf, mergeReport, NO_TESTS, notesOf, playersLabel, stateOf, stateOfCounts, summary, testTree, warningsOf, type TestNode, type TestState, type TestStateName } from "./testState"; import { testName } from "./service"; import { applyMoves, buildTree, filesUnder, folderOf, movesOf, refuseMoves, tabLabels, validFolder, type Moves, type TreeNode } from "./tree"; @@ -326,7 +326,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa null, ...["import", "newFile", "newFolder"].map(menuItem), null, - ...["runTests", "runFailedTests"].map(menuItem), + ...["runTests", "runFailedTests", "runTestsInGame"].map(menuItem), null, ...["problems", "output", "panel", "explorer", "testing"].map(menuItem), null, @@ -355,6 +355,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const testingView = shell.sidebarView({ id: "testing", title: t("Testing"), icon: "beaker", actions: [ { icon: "run-all", title: t("Run all tests"), run: () => { void runTests(); } }, { icon: "run-errors", title: t("Run the tests that failed"), run: () => { void runFailedTests(); } }, + { icon: "target", title: t("Run all tests in the game (the lifted game of the scm-sim plugin): each gets a second mark"), run: () => { void runTestsInGame(); } }, { icon: "filter", title: t("Show only the tests that fail"), run: () => { onlyFailing = !onlyFailing; renderTesting(); } }, ] }); testingView.body.append(testingList); @@ -432,6 +433,13 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa /** Running every test took more than `TESTS_SLOW_MS` the last time. */ let testsSlow = false; let testsRunning = false; + /** + * The tests' last results in the lifted game (TrigScript 4, A2), by id: a mark of their own beside the simulator's, + * kept until the next run in the game. The game is the judge: where the two differ, the simulator is wrong. + */ + let gameResults = new Map(); + /** The text of `dist/gametest.js`, fetched once. */ + let gametest: Promise | null = null; /** The files with a tab, in the order they were opened. */ let openTabs: string[] = [normalizePath(options.file ?? ENTRY_FILE)]; /** What Apply, Play and the builds of the programs reported, oldest first. */ @@ -694,6 +702,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa stateIcon(state), el("span", { className: state === "failed" ? "tsd-name tsd-problem" : "tsd-name" }, name), result && result.status !== "skipped" ? el("span", { className: "tsd-about" }, t("{ms} ms", { ms: result.ms })) : undefined, + node.kind === "test" && gameResults.has(node.info.id) ? gameMark(gameResults.get(node.info.id)!, result) : undefined, el("span", { className: "tsd-row-actions" }, shell.iconButton({ icon: "play", title: node.kind === "test" ? t("Run this test") : t("Run these tests"), run: () => { void runTests(node.kind === "file" ? { files: [node.path] } : { ids }); } }).element, ), @@ -708,6 +717,66 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa testingList.replaceChildren(...rows); }; + /** A test's mark from the game, beside the simulator's: its state, and a word when the two disagree. */ + const gameMark = (game: TestResult, simulated: TestResult | undefined) => { + const state: TestStateName = game.status === "failed" ? "failed" : game.status === "passed" ? "passed" : "skipped"; + const differ = simulated && simulated.status !== "skipped" && game.status !== "skipped" && simulated.status !== game.status; + return el("span", { className: differ ? "tsd-about tsd-problem" : "tsd-about", title: differ + ? t("In the game: {status}. The simulator and the game disagree: the game is right, so the simulator is wrong here (or the test leans on something it does differently).", { status: game.status === "failed" ? game.message ?? t("failed") : t("passed") }) + : game.status === "failed" ? t("In the game: {message}", { message: game.message ?? t("failed") }) : t("In the game: passed") }, + stateIcon(state), t("game")); + }; + + /** + * The tests run in the lifted game (TrigScript 4, A2): the map built as Save builds it, handed with the script's + * JavaScript and `dist/gametest.js` to the scm-sim plugin's engine page, which runs each test beside the game. The + * simulator runs them too, so that each test has both marks. + */ + const runTestsInGame = async (filter: Pick = {}) => { + if (!editor || testsRunning || building || testing || !ready) return; + const engine = svc.engine(); + if (!engine?.present || !engine.test) { setStatus("error", t("Running tests in the game needs the scm-sim plugin (0.8 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….")); return; } + testsRunning = true; + renderTesting(); + try { + const status = await engine.status(); + if (!status.available) { setStatus("error", t("Not run in the game: {message}", { message: status.reason ?? t("the engine cannot run a game now") })); return; } + if (!status.tests) { setStatus("error", t("Not run in the game: the engine's page is from before tests in the game. Reload the editor and try again.")); return; } + if (!(await build()) || cancelled) return; + const a = await compileNow({ world: svc.world(), ...filter }); + if (!a || cancelled) return; + if (!a.compiled.ok) { setStatus("error", t("Tests not run: {n, plural, one {# problem} other {# problems}} in the script.", { n: a.compiled.diagnostics.length }), NOTICE_MS); shell.showPanel("problems"); return; } + if (!a.compiled.modules) { setStatus("info", t("The script has no tests.")); return; } + if (!a.compiled.ir.length) { setStatus("info", t("Tests run in the game only for a script with a program: without one, nothing of the script is built into the map.")); return; } + const names = svc.names()?.names; + if (!names) { setStatus("error", t("No map is open.")); return; } + setStatus("busy", t("Building the map…")); + // (Whole: a bare .chk has no room for what the build steps make, so the engine's page reads the scenario out of it.) + const file = await api.document.export({ format: "scx" }); + if (!file || cancelled) { if (!file) setStatus("error", t("No map is open.")); return; } + gametest ??= fetch(gametestUrl(svc.dist())).then((r) => { if (!r.ok) throw new Error(`gametest.js: ${r.status}`); return r.text(); }); + const module = await gametest.catch((err) => { gametest = null; throw err; }); + setStatus("busy", t("Running the tests in the game…")); + const report = await engine.test(new Uint8Array(await file.arrayBuffer()), module, { modules: a.compiled.modules, names, files: filter.files, ids: filter.ids }) as TestReport & { error?: string }; + if (cancelled) return; + if (report.error) { setStatus("error", t("Not run in the game: {message}", { message: report.error })); return; } + const ran = report.results.filter((x) => x.status !== "skipped"); + const asked = new Set(ran.map((x) => x.id)); + gameResults = new Map([...gameResults].filter(([id]) => !asked.has(id) && tests.list.some((x) => x.id === id))); + for (const r of ran) gameResults.set(r.id, r); + const failed = ran.filter((x) => x.status === "failed"); + const differ = ran.filter((x) => { const s = tests.results.get(x.id); return s && s.status !== "skipped" && s.status !== x.status; }); + if (failed.length) { chosenTest = failed[0].id; shell.showPanel("tests"); } + const said = t("In the game: {passed} of {n, plural, one {# test} other {# tests}} passed.", { passed: ran.length - failed.length, n: ran.length }); + setStatus(failed.length || differ.length ? "error" : "ok", differ.length ? `${said} ${t("The simulator disagrees on {n, plural, one {# test} other {# tests}}.", { n: differ.length })}` : said, NOTICE_MS); + } catch (err) { + setStatus("error", t("Not run in the game: {message}", { message: (err as Error).message })); + } finally { + testsRunning = false; + render(); + } + }; + /** Test Results: the chosen test's message, what it printed, and what happened by frame, each a link to its line. */ const renderResults = () => { const failed = tests.list.filter((x) => x.kind === "test" && tests.results.get(x.id)?.status === "failed"); @@ -728,6 +797,15 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa if (r.expected !== undefined) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, t("expected")), el("span", { className: "msg" }, r.expected))); if (r.actual !== undefined) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, t("got")), el("span", { className: "msg" }, r.actual))); } + const game = gameResults.get(info.id); + if (game && game.status !== "skipped") { + const gat = game.at ?? { file: info.file, line: info.line }; + list.append(el("li", { className: game.status === "failed" ? "tsd-fault" : "tsd-plain", title: t("Go to where it failed in the game"), onClick: () => goTo(gat.file, gat.line, gat.column ?? 1) }, + el("span", { className: "frame" }, t("in the game")), + el("span", { className: "msg" }, game.status === "failed" ? game.message ?? t("failed") : t("passed")), + el("span", { className: "where" }, game.status === "failed" ? where({ file: gat.file, line: gat.line }) : t("{frames, plural, one {# frame} other {# frames}} · {ms} ms", { frames: game.frames, ms: game.ms })))); + if (game.status === "failed" && game.actual !== undefined) list.append(el("li", { className: "tsd-plain" }, el("span", { className: "frame" }, t("got")), el("span", { className: "msg" }, game.actual))); + } // The same line several times over — to each of eight players, say — is one row that says how often. const printed: { line: string; times: number }[] = []; for (const line of r.printed) { const last = printed[printed.length - 1]; if (last?.line === line) last.times++; else printed.push({ line, times: 1 }); } @@ -1441,6 +1519,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa { id: "save", label: t("Save the Map"), key: { code: "KeyS", mod: true }, run: () => { void api.document.save(); } }, { id: "test", label: t("Play the Map"), key: { code: "F5" }, run: () => { void test(); } }, { id: "runTests", label: t("Run All Tests"), run: () => { void runTests(); } }, + { id: "runTestsInGame", label: t("Run All Tests in the Game"), run: () => { void runTestsInGame(); } }, { id: "runTestAtCursor", label: t("Run Test at Cursor"), context: true, run: () => { void runTestAtCursor(); } }, { id: "runFailedTests", label: t("Run Failed Tests"), run: () => { void runFailedTests(); } }, { id: "testing", label: t("Show Testing"), run: () => testingView.show() }, diff --git a/ko.ts b/ko.ts index 3cefbd0..2b9b9a0 100644 --- a/ko.ts +++ b/ko.ts @@ -270,4 +270,21 @@ export const KO: Record = { "{n} skipped": "{n}개 건너뜀", "{what}. Click for its log": "{what}. 클릭하면 로그를 봅니다", "{what}; {n, plural, one {# import} other {# imports}} rewritten.": "{what}. import {n}개를 고쳐 썼습니다.", + "Run all tests in the game (the lifted game of the scm-sim plugin): each gets a second mark": "모든 테스트를 게임에서 실행합니다(scm-sim 플러그인의 옮겨 온 게임). 테스트마다 두 번째 표시가 붙습니다", + "In the game: {status}. The simulator and the game disagree: the game is right, so the simulator is wrong here (or the test leans on something it does differently).": "게임에서: {status}. 시뮬레이터와 게임의 결과가 다릅니다. 게임이 옳으므로 여기서는 시뮬레이터가 틀렸습니다(또는 테스트가 둘이 다르게 하는 일에 기대고 있습니다).", + "passed": "통과", + "In the game: {message}": "게임에서: {message}", + "In the game: passed": "게임에서: 통과", + "game": "게임", + "Running tests in the game needs the scm-sim plugin (0.8 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….": "테스트를 게임에서 실행하려면 scmjs.dev에서 scm-sim 플러그인(0.8 이상)이 필요합니다. 플러그인 ▸ 플러그인 관리…에서 설치하거나 켜세요.", + "Not run in the game: {message}": "게임에서 실행하지 못함: {message}", + "the engine cannot run a game now": "엔진이 지금 게임을 실행할 수 없습니다", + "Not run in the game: the engine's page is from before tests in the game. Reload the editor and try again.": "게임에서 실행하지 못함: 엔진 페이지가 게임 속 테스트보다 오래된 판입니다. 편집기를 새로 고친 뒤 다시 시도하세요.", + "Tests run in the game only for a script with a program: without one, nothing of the script is built into the map.": "테스트는 프로그램이 있는 스크립트만 게임에서 실행됩니다. 프로그램이 없으면 스크립트의 어떤 것도 맵에 빌드되지 않습니다.", + "Running the tests in the game…": "게임에서 테스트를 실행하는 중…", + "In the game: {passed} of {n, plural, one {# test} other {# tests}} passed.": "게임에서: 테스트 {n}개 중 {passed}개 통과.", + "The simulator disagrees on {n, plural, one {# test} other {# tests}}.": "테스트 {n}개에서 시뮬레이터의 결과가 다릅니다.", + "Go to where it failed in the game": "게임에서 실패한 곳으로 이동", + "in the game": "게임에서", + "Run All Tests in the Game": "게임에서 모든 테스트 실행", }; diff --git a/service.ts b/service.ts index a0c8adf..a237c3c 100644 --- a/service.ts +++ b/service.ts @@ -32,6 +32,7 @@ import { } from "./script"; import { english, msg, type Translate } from "./i18n"; import { EUDPLIB_SERVICE, type EudplibBuildEvent, type EudplibInput, type EudplibService } from "./vendor/eudplib"; +import { SCM_SIM_SERVICE, type ScmSimEngine } from "./vendor/scmSim"; /** * A compile, with what it was compiled *for*: the map it read its names from and the @@ -188,6 +189,16 @@ export class ScriptService { return this.api.services.get(EUDPLIB_SERVICE); } + /** The scm-sim plugin's engine, or null when it is not installed or is off: where a script's tests run in the game. */ + engine(): ScmSimEngine | null { + return this.api.services.get(SCM_SIM_SERVICE); + } + + /** Called at once and whenever the scm-sim plugin arrives or goes. */ + watchEngine(listener: (service: ScmSimEngine | null) => void): { dispose(): void } { + return this.api.services.watch(SCM_SIM_SERVICE, (s) => listener(s)); + } + /** Called at once and whenever the eudplib plugin arrives or goes. */ watchLibrary(listener: (service: EudplibService | null) => void): { dispose(): void } { return this.api.services.watch(EUDPLIB_SERVICE, (s) => listener(s)); diff --git a/vendor/scmSim.ts b/vendor/scmSim.ts new file mode 100644 index 0000000..a9568f1 --- /dev/null +++ b/vendor/scmSim.ts @@ -0,0 +1,28 @@ +/** + * The part of the scm-sim plugin's `scm-sim.engine` service that TrigScript uses — a copy of + * what `contract.d.ts` in github.com/scm-js/plugin-scm-sim says of it, taken by `import type` + * only. TrigScript never imports the engine: it hands the engine's page its tests (TrigScript + * 4, A2), and the page runs them beside the game. + */ +export const SCM_SIM_SERVICE = "scm-sim.engine"; + +export interface ScmSimStatus { + available: boolean; + /** When not available: why, in words for the person. */ + reason: string | null; + /** The version of the test request the page answers; null or absent: it answers none. */ + tests?: number | null; +} + +export interface ScmSimEngine { + /** Whether this editor has the engine beside it at all (scmjs.dev, or a stand-in for the site). */ + readonly present: boolean; + status(): Promise; + /** + * A script's tests beside the game: the map as Save builds it (a whole .scx; the page reads + * its scenario) or that scenario as eudplib built it, the text of a module + * whose `runGameTests(game, payload)` runs them, and its payload; resolves with what the + * module returned. Absent from the plugin before 0.8.0. + */ + test?(scenario: Uint8Array, module: string, payload: unknown, options?: { signal?: AbortSignal; onProgress?: (p: { stage: "starting" }) => void }): Promise; +} From 66fbfaa4201a297eac98aea33c35d0ea3306666b Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 16 Oct 2026 00:52:32 -0600 Subject: [PATCH 6/7] TrigScript 4, A2: the game's mark beside the simulator's, checked in the editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run All Tests in the Game was driven end to end in a built editor (scm-js main under /editor/, scm-sim's analysis page beside it, the local plugin builds): 4 of 5 passed in the game, the fifth — sim.program(), which the game cannot answer yet — shown as a disagreement. The game's mark now sits next to the simulator's icon, where a narrow sidebar still shows it, ringed when the two differ; the scm-sim plugin it needs is 0.9 (0.8.0 is out without test()). Co-Authored-By: Claude Opus 5.5 --- docs/v4-plan.md | 15 +++++++++++++-- editor.ts | 11 ++++++----- ko.ts | 3 +-- shell.ts | 2 ++ vendor/scmSim.ts | 2 +- 5 files changed, 23 insertions(+), 10 deletions(-) diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 02a6809..424ac46 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -325,8 +325,19 @@ stays JavaScript and is handed a world backed by the game, and it runs where the passes in the game and in the simulator under Node, and through scm-sim's analysis page in headless Chromium (3.3 s, game start included). - plugin-scm-sim's bridge has two new tests. - - The editor's button has not been driven end to end in the built editor yet. Its parts have: - compile, export and the page, each on its own. + - **End to end in a built editor (2026-10-16):** scm-js `main` built under `/editor/` and + served by scm-sim's dev server beside its analysis page, with the local TrigScript and + scm-sim plugin builds in place of the released ones, driven in headless Chromium: + 1. The probe map is opened and the script written into it. + 2. **Run all tests in the game** is clicked. + 3. eudplib asks for its one-time runtime and gets it, then builds the 721 KB map in 3.7 s. + 4. The status bar says "In the game: 4 of 5 tests passed. The simulator disagrees on 1 + test." That came about 9 seconds after the click. + + The fifth test reads `sim.program()`, which the game cannot answer yet, and it shows as + meant: a ringed red mark beside the simulator's green one, and the game's reason on its own + line in Test Results. The game's mark sits beside the simulator's icon rather than at the + row's end, where a narrow sidebar cut it off in the first run. ### A3. The desync detector (4.1.0) diff --git a/editor.ts b/editor.ts index 0cf3b05..6840b73 100644 --- a/editor.ts +++ b/editor.ts @@ -700,9 +700,10 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa }; const row = el("li", { className: node.kind === "test" && node.info.id === chosenTest ? "tsd-row tsd-active" : "tsd-row", title: result?.message ?? name, onClick: open }, stateIcon(state), + // (The game's mark next to the simulator's, where a narrow sidebar still shows it.) + node.kind === "test" && gameResults.has(node.info.id) ? gameMark(gameResults.get(node.info.id)!, result) : undefined, el("span", { className: state === "failed" ? "tsd-name tsd-problem" : "tsd-name" }, name), result && result.status !== "skipped" ? el("span", { className: "tsd-about" }, t("{ms} ms", { ms: result.ms })) : undefined, - node.kind === "test" && gameResults.has(node.info.id) ? gameMark(gameResults.get(node.info.id)!, result) : undefined, el("span", { className: "tsd-row-actions" }, shell.iconButton({ icon: "play", title: node.kind === "test" ? t("Run this test") : t("Run these tests"), run: () => { void runTests(node.kind === "file" ? { files: [node.path] } : { ids }); } }).element, ), @@ -717,14 +718,14 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa testingList.replaceChildren(...rows); }; - /** A test's mark from the game, beside the simulator's: its state, and a word when the two disagree. */ + /** A test's mark from the game, the second beside the simulator's: its state, ringed when the two disagree, and in words on hover. */ const gameMark = (game: TestResult, simulated: TestResult | undefined) => { const state: TestStateName = game.status === "failed" ? "failed" : game.status === "passed" ? "passed" : "skipped"; const differ = simulated && simulated.status !== "skipped" && game.status !== "skipped" && simulated.status !== game.status; - return el("span", { className: differ ? "tsd-about tsd-problem" : "tsd-about", title: differ + return el("span", { className: differ ? "tsd-game tsd-game-differs" : "tsd-game", title: differ ? t("In the game: {status}. The simulator and the game disagree: the game is right, so the simulator is wrong here (or the test leans on something it does differently).", { status: game.status === "failed" ? game.message ?? t("failed") : t("passed") }) : game.status === "failed" ? t("In the game: {message}", { message: game.message ?? t("failed") }) : t("In the game: passed") }, - stateIcon(state), t("game")); + stateIcon(state)); }; /** @@ -735,7 +736,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const runTestsInGame = async (filter: Pick = {}) => { if (!editor || testsRunning || building || testing || !ready) return; const engine = svc.engine(); - if (!engine?.present || !engine.test) { setStatus("error", t("Running tests in the game needs the scm-sim plugin (0.8 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….")); return; } + if (!engine?.present || !engine.test) { setStatus("error", t("Running tests in the game needs the scm-sim plugin (0.9 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….")); return; } testsRunning = true; renderTesting(); try { diff --git a/ko.ts b/ko.ts index 2b9b9a0..f0c149e 100644 --- a/ko.ts +++ b/ko.ts @@ -275,8 +275,7 @@ export const KO: Record = { "passed": "통과", "In the game: {message}": "게임에서: {message}", "In the game: passed": "게임에서: 통과", - "game": "게임", - "Running tests in the game needs the scm-sim plugin (0.8 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….": "테스트를 게임에서 실행하려면 scmjs.dev에서 scm-sim 플러그인(0.8 이상)이 필요합니다. 플러그인 ▸ 플러그인 관리…에서 설치하거나 켜세요.", + "Running tests in the game needs the scm-sim plugin (0.9 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….": "테스트를 게임에서 실행하려면 scmjs.dev에서 scm-sim 플러그인(0.9 이상)이 필요합니다. 플러그인 ▸ 플러그인 관리…에서 설치하거나 켜세요.", "Not run in the game: {message}": "게임에서 실행하지 못함: {message}", "the engine cannot run a game now": "엔진이 지금 게임을 실행할 수 없습니다", "Not run in the game: the engine's page is from before tests in the game. Reload the editor and try again.": "게임에서 실행하지 못함: 엔진 페이지가 게임 속 테스트보다 오래된 판입니다. 편집기를 새로 고친 뒤 다시 시도하세요.", diff --git a/shell.ts b/shell.ts index 67c2be4..e690997 100644 --- a/shell.ts +++ b/shell.ts @@ -230,6 +230,8 @@ ${Object.entries(ICONS).map(([name, code]) => `.tsd-i-${name}::before { content: .tsd-row.tsd-active { background: var(--bg-4); color: var(--text); } .tsd-row .tsd-name { flex: 0 1 auto; overflow: hidden; text-overflow: ellipsis; } .tsd-row .tsd-about { flex: 1 1 0; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--text-faint); font-size: var(--fs-sm); } +.tsd-row .tsd-game { display: inline-flex; flex: none; margin-left: -2px; border-radius: 50%; } +.tsd-row .tsd-game-differs { box-shadow: 0 0 0 1px var(--danger); } .tsd-row .tsd-problem, .tsd-tab .tsd-problem { color: var(--danger); } .tsd-row .tsd-count { flex: none; margin-left: auto; color: var(--danger); font-size: var(--fs-sm); } .tsd-row .tsd-icon-button { display: none; margin-left: 0; } diff --git a/vendor/scmSim.ts b/vendor/scmSim.ts index a9568f1..fbcc2ff 100644 --- a/vendor/scmSim.ts +++ b/vendor/scmSim.ts @@ -22,7 +22,7 @@ export interface ScmSimEngine { * A script's tests beside the game: the map as Save builds it (a whole .scx; the page reads * its scenario) or that scenario as eudplib built it, the text of a module * whose `runGameTests(game, payload)` runs them, and its payload; resolves with what the - * module returned. Absent from the plugin before 0.8.0. + * module returned. Absent from the plugin before 0.9.0. */ test?(scenario: Uint8Array, module: string, payload: unknown, options?: { signal?: AbortSignal; onProgress?: (p: { stage: "starting" }) => void }): Promise; } From 00855182fe12c2ffef8af236c54119a83b909ad3 Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 16 Oct 2026 09:49:28 -0600 Subject: [PATCH 7/7] TrigScript 4, A2: sim.program() in the game The lowering says where each program's variables are when the build asks it to (the variableMap setting): the address of every cell once eudplib has placed the payload, in TRIGSCRIPT-VARS / TRIGSCRIPT-PROGRAM lines of the build's log. compiler/gameVariables.ts reads those lines and reads a program's variables out of the game's memory as the simulator's lookup does, so GameSim answers sim.program(). Run All Tests in the Game and scripts/test-game.mts ask for the map; Output leaves those lines out. probes/game-program.ts passes in the game and the simulator; in a built editor all five tests of the end-to-end run pass in the game. The scm-sim plugin's test() ships in 0.10 (0.9.0 went out with the Replay view's zoom), and the message says so. Co-Authored-By: Claude Opus 5.5 --- compiler/gameSim.ts | 26 ++++-- compiler/gameTestEntry.ts | 1 + compiler/gameTests.ts | 5 +- compiler/gameVariables.ts | 140 +++++++++++++++++++++++++++++ compiler/generated/trigscriptPy.ts | 2 +- docs/v4-plan.md | 30 ++++++- editor.ts | 22 +++-- ko.ts | 2 +- probes/game-program.ts | 77 ++++++++++++++++ python/trigscript.py | 114 +++++++++++++++++++++++ scripts/build-fixture.mts | 9 +- scripts/test-game.mts | 6 +- service.ts | 15 +++- tests/game-variables.test.ts | 108 ++++++++++++++++++++++ vendor/scmSim.ts | 2 +- 15 files changed, 534 insertions(+), 25 deletions(-) create mode 100644 compiler/gameVariables.ts create mode 100644 probes/game-program.ts create mode 100644 tests/game-variables.test.ts diff --git a/compiler/gameSim.ts b/compiler/gameSim.ts index 7c08113..1997d00 100644 --- a/compiler/gameSim.ts +++ b/compiler/gameSim.ts @@ -8,13 +8,14 @@ * centred on it (the last of the map's 255, put back afterwards). A typed line is the game's chat command; a key and * the mouse are the local machine's memory, where MSQC reads them. * - * Not in the game yet, and said so when asked: a program's variables (`program()`, which needs the build to say - * where each lives) and what another player was shown. A - * program's fault is the red line it prints ("TrigScript: …"), its line not known. + * A program's variables (`program()`) are read where the build said they are (`gameVariables.ts`), when it was asked + * to say. Not in the game yet, and said so when asked: what another player was shown. A program's fault is the red + * line it prints ("TrigScript: …"), its line not known. */ import { ActionType } from "../vendor/triggers"; import type { At } from "./ir"; import { msqcKey } from "./input"; +import { programVariables, type VariableMap } from "./gameVariables"; import { ExpectError, WORLD, type TestEvent, type TestWorldLike } from "./testing"; import type { SimBounds, SimUnit } from "./world"; @@ -138,15 +139,18 @@ export class GameSim implements TestWorldLike { private readonly units_ = new Map(); private readonly game: GameHandle; private readonly known: Record; + private readonly variables: VariableMap | null; /** * `locations`: the map's locations as the script means them. eudplib keeps them scrambled in the game until its * first frames put them right (frame 2 on a probe's map), so a test that acts at frame 0 needs them from elsewhere - * (`readLocations`); from then on the game's own are read. + * (`readLocations`); from then on the game's own are read. `variables`: where the build put each program's + * variables, for `program()`. */ - constructor(game: GameHandle, options: { locations?: Record } = {}) { + constructor(game: GameHandle, options: { locations?: Record; variables?: VariableMap | null } = {}) { this.game = game; this.known = options.locations ?? {}; + this.variables = options.variables ?? null; game.reset(); this.take(); } @@ -304,8 +308,16 @@ export class GameSim implements TestWorldLike { if (this.game.frame < LOCATIONS_READY) return this.known[n] ? { ...this.known[n] } : undefined; return readLocation(this.game, n); } - program(): Record { - throw new ExpectError("sim.program(…) reads a program's variables in the simulator only, for now: in the game, read what the program did (printed, resources, units)"); + program(name: string | number = 0, player?: number): Record { + if (!this.variables) throw new ExpectError("sim.program(…): the build did not say where the programs' variables are, so the game cannot be read for them"); + return programVariables({ m: this.game.m, u8: this.game.u8, unitAt: (ptr, uid) => this.unitAt(ptr, uid) }, this.variables, name, player); + } + /** The unit a variable holds: by its pointer, while its slot still has the uniqueness byte the variable kept with it. */ + private unitAt(ptr: number, uid: number): SimUnit | null { + const slot = (ptr - UNITS) / UNIT_BYTES; + if (!Number.isInteger(slot) || slot < 0 || slot >= UNIT_COUNT || this.game.u8[ptr + 0xa5] !== ((uid >>> 8) & 0xff)) return null; + const u = this.unit(slot); + return u.alive ? u : null; } /** What was shown, in order, with the game's colour codes taken out. `player`: only the player at this computer is seen. */ diff --git a/compiler/gameTestEntry.ts b/compiler/gameTestEntry.ts index b5551bb..4a6f4a2 100644 --- a/compiler/gameTestEntry.ts +++ b/compiler/gameTestEntry.ts @@ -4,3 +4,4 @@ */ export { runGameTests, type GameTestPayload } from "./gameTests"; export { GameSim, readLocations, readUnitStats, type GameHandle } from "./gameSim"; +export { programVariables, readVariableMap, type VariableMap } from "./gameVariables"; diff --git a/compiler/gameTests.ts b/compiler/gameTests.ts index 1c0b970..3c4ce79 100644 --- a/compiler/gameTests.ts +++ b/compiler/gameTests.ts @@ -10,6 +10,7 @@ import { ENTRY_FILE } from "./compiler"; import { MODULE_NAME } from "./api"; import { GameSim, readLocations, type GameHandle } from "./gameSim"; +import type { VariableMap } from "./gameVariables"; import { locate, runModules, type LinkedFile } from "./link"; import type { ScriptNames } from "./names"; import { Collector, createRuntime } from "./runtime"; @@ -24,6 +25,8 @@ export interface GameTestPayload { ids?: string[]; /** The map's locations as the script means them; read from the game (frame 2) when absent. */ locations?: Record; + /** Where the build put each program's variables (`readVariableMap` of its log), for `sim.program()`. */ + variables?: VariableMap | null; } export function runGameTests(game: GameHandle, payload: GameTestPayload): TestReport & { error?: string } { @@ -38,5 +41,5 @@ export function runGameTests(game: GameHandle, payload: GameTestPayload): TestRe collector.running = false; if (failure) return { list: [], results: [], only: [], ms: 0, error: `${failure.file ?? ENTRY_FILE}:${failure.line ?? 1}: ${failure.message}` }; const locations = payload.locations ?? readLocations(game); - return runTests(registry, { triggers: [], sources: [], ir: [], strings: [], locate: where }, { files: payload.files, ids: payload.ids, makeWorld: () => new GameSim(game, { locations }) }); + return runTests(registry, { triggers: [], sources: [], ir: [], strings: [], locate: where }, { files: payload.files, ids: payload.ids, makeWorld: () => new GameSim(game, { locations, variables: payload.variables }) }); } diff --git a/compiler/gameVariables.ts b/compiler/gameVariables.ts new file mode 100644 index 0000000..0680218 --- /dev/null +++ b/compiler/gameVariables.ts @@ -0,0 +1,140 @@ +/** + * Where a built map keeps each program's variables, and reading them back out of the game's memory (TrigScript 4, + * A2): `sim.program()` for a test that runs in the lifted game. The lowering says where every cell is when the build + * asks it to (the `variableMap` setting of `python/trigscript.py`), in lines of the build's log; this reads those + * lines, and reads a program's variables by the names in the source as the simulator's `ProgramRun.lookup` does. + * + * Two things differ from the simulator, both because the game has every cell from the start: a variable not yet + * declared reads as its zero rather than as missing, and of two declarations with one name the later in the source + * wins rather than the later to run. A list inside a list and a row of a grid are not read (a record of them is). + */ +import type { SimUnit } from "./world"; + +export interface GameVariable { + id: string; + kind: "number" | "boolean" | "unit" | "text"; + /** How a text is kept: `id` a string of the table, `made` characters somewhere in memory. */ + text?: "id" | "made" | null; + bits?: 8 | 16 | null; + unsigned: boolean; + /** A per-player program's own: each cell is a row of twelve, one a player. */ + rowed: boolean; + /** The address of each of its cells (`value`; `addr` + `len`; `ptr` + `uid`), null for one nothing in the map uses. */ + cells: Record; +} + +export interface GameArray { + id: string; + kind: "number" | "boolean"; + bits?: 8 | 16 | null; + unsigned: boolean; + length: number; + rowed: boolean; + /** Where the cells are; or, of one that grows, its handle's `ptr` (its block among the heap's cells) and `len`. */ + at?: number | null; + dynamic?: boolean; + ptr?: number | null; + len?: number | null; +} + +export interface GameProgramPlaces { + index: number; + name?: string | null; + perPlayer: boolean; + owners: number[]; + vars: GameVariable[]; + arrays: GameArray[]; +} + +export interface VariableMap { + /** The heap's first cell, where growing arrays and made texts keep theirs; null when nothing grows. */ + heap: number | null; + /** Where the map's strings are: a text of the table is read through it. */ + strings: number; + programs: GameProgramPlaces[]; +} + +const HEAD = "TRIGSCRIPT-VARS ", PROGRAM = "TRIGSCRIPT-PROGRAM "; + +/** A line of the build's log that is for a run in the lifted game and not for a person (the trigger map's too). */ +export const isMachineLine = (line: string): boolean => /^TRIGSCRIPT-[A-Z]+ /.test(line); + +/** The variable map in a build's log; null when the build did not say one (or said it cut short). */ +export function readVariableMap(lines: Iterable): VariableMap | null { + let head: { programs: number; heap: number | null; strings: number } | null = null; + const programs: GameProgramPlaces[] = []; + for (const line of lines) { + if (line.startsWith(HEAD)) head = JSON.parse(line.slice(HEAD.length)); + else if (line.startsWith(PROGRAM)) programs.push(JSON.parse(line.slice(PROGRAM.length))); + } + if (!head || programs.length !== head.programs) return null; + return { heap: head.heap, strings: head.strings, programs: programs.sort((a, b) => a.index - b.index) }; +} + +/** What reading needs of the game: its memory, and a unit by its pointer and the uniqueness byte kept with it. */ +export interface VariableReader { + m: DataView; + u8: Uint8Array; + unitAt(ptr: number, uid: number): SimUnit | null; +} + +const MOST_TEXT = 1 << 16; +const utf8 = new TextDecoder(); + +/** The program's variables by their names in the source, as `TestSim.program()` gives them. Throws for no such program. */ +export function programVariables(game: VariableReader, map: VariableMap, name: string | number = 0, player?: number): Record { + const p = map.programs.find((x) => (typeof name === "number" ? x.index === name : x.name === name) && (player === undefined || x.owners.includes(player))); + if (!p) { + const known = [...new Set(map.programs.map((x) => x.name).filter(Boolean))]; + throw new Error(typeof name === "number" + ? `There is no program ${name}: the script has ${map.programs.length}.` + : `There is no program named "${name}"${player !== undefined ? ` that runs for P${player + 1}` : ""}. ${known.length ? `Named: ${known.join(", ")}.` : "A program is named by its option: program(() => { … }, { name: \"waves\" })."}`); + } + const who = player ?? p.owners[0] ?? 0; + const { m, u8 } = game; + const raw = (at: number | null | undefined, rowed: boolean): number => (at == null ? 0 : m.getUint32(rowed ? at + 4 * who : at, true)); + const num = (v: number, t: { kind: string; bits?: number | null; unsigned: boolean }): number | boolean => (t.kind === "boolean" ? v !== 0 : t.bits || t.unsigned ? v >>> 0 : v | 0); + const cString = (at: number): string => { let end = at; while (end < u8.length && end - at < MOST_TEXT && u8[end] !== 0) end++; return utf8.decode(u8.subarray(at, end)); }; + const text = (v: GameVariable): string => { + if (v.text === "made") { const at = raw(v.cells.addr, v.rowed); return at ? cString(at) : ""; } + const id = raw(v.cells.value, v.rowed); + return id ? cString(map.strings + m.getUint32(map.strings + 4 * id, true)) : ""; + }; + const value = (v: GameVariable): unknown => { + if (v.kind === "unit") { const ptr = raw(v.cells.ptr, v.rowed); return ptr ? game.unitAt(ptr, raw(v.cells.uid, v.rowed)) : null; } + if (v.kind === "text") return text(v); + return num(raw(v.cells.value, v.rowed), v); + }; + const cells = (a: GameArray): (number | boolean)[] => { + if (a.dynamic) { + if (map.heap == null) return []; + const ptr = raw(a.ptr, a.rowed), n = Math.min(raw(a.len, a.rowed), MOST_TEXT); + return Array.from({ length: n }, (_, i) => num(m.getUint32(map.heap! + 4 * (ptr + i), true), a)); + } + if (a.at == null) return new Array(a.length).fill(num(0, a)); + const row = a.rowed ? who * a.length : 0; + return Array.from({ length: a.length }, (_, i) => num(m.getUint32(a.at! + 4 * (row + i), true), a)); + }; + + // As the simulator looks a name up: a number or a boolean, a text, an array, a unit, then a record of fields. + const named = (id: string, n: string) => id === n || id.startsWith(`${n}#`); + const last = (xs: T[], n: string): T | undefined => { let found: T | undefined; for (const x of xs) if (named(x.id, n)) found = x; return found; }; + const lookup = (n: string): unknown => { + const plain = last(p.vars.filter((v) => v.kind === "number" || v.kind === "boolean"), n); + if (plain) return value(plain); + const t = last(p.vars.filter((v) => v.kind === "text"), n); + if (t) return text(t); + const a = last(p.arrays, n); + if (a) return cells(a); + const u = last(p.vars.filter((v) => v.kind === "unit"), n); + if (u) return value(u); + const field = (id: string) => (id.startsWith(`${n}.`) ? id.slice(n.length + 1).split("#")[0] : null); + const record: Record = {}; + for (const v of p.vars) { const f = field(v.id); if (f && v.kind !== "unit" && !(v.kind !== "text" && f.includes(" ("))) record[f] = value(v); } + if (Object.keys(record).length) return record; + const rows: Record[] = []; + for (const arr of p.arrays) { const f = field(arr.id); if (f) cells(arr).forEach((cell, i) => { (rows[i] ??= {})[f] = cell; }); } + return rows.length ? rows : undefined; + }; + return new Proxy({}, { get: (_, key) => (typeof key === "string" ? lookup(key) : undefined), has: (_, key) => typeof key === "string" && lookup(key) !== undefined }); +} diff --git a/compiler/generated/trigscriptPy.ts b/compiler/generated/trigscriptPy.ts index 9b306e6..ee99c2a 100644 --- a/compiler/generated/trigscriptPy.ts +++ b/compiler/generated/trigscriptPy.ts @@ -1,2 +1,2 @@ // Generated by scripts/embed-python.mts from python/trigscript.py — do not edit. -export const TRIGSCRIPT_PY: string = "\"\"\"\n[trigscript]\nir : /work/files/trigscript.json\n\nTrigScript's programs: the euddraft plugin that lowers the compiler's IR — data, never\ncode — into eudplib. The IR file is what the plugin's compiler wrote (docs/ir.md);\nthis module is handed to the eudplib library plugin as a source with every build, so the\ntwo halves of the IR version are always the same build.\n\nA program is a coroutine the game runs every frame. Its body is lowered to straight-line\neudplib triggers with jumps between labels: `if` / `while` / `switch` become conditional\njumps, and `sleep` stores the label to resume at in a state variable, sets a frame counter,\nand leaves the frame. The frame's entry counts the wait down, then jumps to the stored\nlabel. A program runs for the players it is owned by, as a trigger would: one owner runs\nit as that player while that player is in the game; several owners, All Players or a force\nrun it once each frame for every such player who is in the game, CurrentPlayer set, its\nvariables and its state as 12-slot arrays indexed by the player.\n\nText is written out in the IR: an action's `text` or `wav` is the string itself, which\neudplib adds to the built map's string table, or a number when the script named an index\nof the map's own. The map the user edits never holds a program's strings.\n\nA read is a value of the game taken when the expression is evaluated. What a condition compares\nis read from the game's tables where the table is that value (a player's deaths, kills, ore, gas)\nand otherwise found by asking the condition itself — \"at least 2^31? at least that plus 2^30? …\" —\nso a read means what the condition means, for a force's minerals or the units at a location alike.\nPlayer facts are bytes of the player tables. Text with values in it is printed through eudplib's\nstring buffer, for the player it is for and nobody else.\n\nA unit of the game is a pointer into the game's unit table — 1700 slots of 336 bytes — with the slot's\nuniqueness byte kept beside it: the game gives a dead unit's slot to the next unit made, so a unit kept\nin a variable is checked before every use (a sprite, an order other than \"die\", the same uniqueness\nbyte) and reads 0, and takes no write, once it is gone. A loop over units and a pick walk the whole\ntable with conditions whose address is moved on a slot at a time, as eudplib's own EUDLoopUnit2 does;\na dying unit is passed over. What a unit can be asked and told is what Magenta's probe maps saw\nworking in Remastered: no position, cloak or speed writes. The game's tables (`stats()`) are plain\ncells at addresses the IR carries; a speed is four flingy records, a colour two bytes, a name a string.\n\nWhat the players do — keys, clicks, the mouse, what they type — happens on one computer, and two\nplugins the build adds beside this one bring it to all of them in step. chatEvent (before this\nplugin) finds the line the local player typed; this plugin matches it against the programs'\npatterns, there and then, into a number for the pattern and up to three values; MSQC (after this\nplugin) sends those, the keys and the clicks to every computer as the player they came from, and\nkeeps each player's mouse in a location. They land in arrays registered by name — which is how the\nother two plugins' settings reach them — a cell per player, fresh every frame: an input reads 1 on\nthe frame it arrives. The IR's `input` lists what is asked for; the editor wrote the settings from it.\n\nNumbers keep one contract with the simulator: 32 bits, a `number` signed and a `u32` not, wrapping at\neither end. + - * and the bitwise operators are the same bits whichever way they are read; what reads\nthem one way or the other - a comparison, a division, a shift right, min and max, a printed number -\nsays which in the IR (`unsigned`), so nothing here works a type out. Where the game takes nothing below\nzero the compiler has already written max(v, 0), and a store keeps stopping at the top of what it holds.\nIn here a number known when the map is built is a Python int holding the 32 bits, 0 to 2^32 - 1.\n\nA text is kept one of two ways, a variable at a time (the IR says which). One that only ever holds texts written in\nthe script is the text's id in the built map's string table: a number. One that is made while the map is played is\nthree cells - where its bytes are (UTF-8, ended by a 0), the block of the heap it owns (0: none, the bytes are a\nstring of the table) and its length in characters. A made text is written into one scratch buffer, measured, and\ncopied into a block of just that size, whose first cell says which size; what holds the text owns the block, so\nassigning copies it, a value that was just made is moved, and whatever a variable held goes back to the heap once\nits new value is worked out. compiler/simulateIr.ts takes and gives blocks in the same order, so both run out at\nthe same text. A made text in an action's field goes over a string the build keeps for that kind of field, on the\ncomputer of the player the action is for and nowhere else: the game reads such a string again whenever it draws.\n\"\"\"\nimport json\n\nfrom eudplib import *\nfrom eudplib.core.mapdata.stringmap import ForceAddString\nfrom eudplib.memio.rwcommon import br1, br2, bw1\n\nIR_VERSION = 14\nFRAMES_PER_SECOND = 24\n# Where the game keeps what a read reads (1.16.1 addresses, which Remastered emulates). The player\n# tables are the ones Magenta's probes 5 and 8 read in the game.\nDEATHS_TABLE = 0x58A364\nKILLS_TABLE = 0x5878A4\nORE_TABLE = 0x57F0F0\nGAS_TABLE = 0x57F120\nPLAYER_BYTES = {\"slot\": 0x57F1B4, \"race\": 0x57F1C0}\n# Per race (144 bytes apart: Zerg, Terran, Protoss), a dword per player, in half supplies.\nSUPPLY_TABLES = {\"provided\": 0x582144, \"used\": 0x582174, \"max\": 0x5821A4}\n# The unit table, and a unit's dwords the scans test (as EPD offsets from the unit's own).\nUNIT_TABLE = 0x59CCA8\nUNIT_SIZE = 336\nUNIT_SLOTS = 1700\nUNIT_END = UNIT_TABLE + UNIT_SIZE * UNIT_SLOTS\nOFF_SPRITE, OFF_POS, OFF_OWNER_ORDER, OFF_TYPE, OFF_UID = 0x0C // 4, 0x28 // 4, 0x4C // 4, 0x64 // 4, 0xA4 // 4\n# units.dat: max hit points (dword, 256 to a point), max shields (word), the group flags a trigger's\n# Men / Buildings / Factories go by (byte), the flingy a type moves as (byte).\nUNITS_MAX_HP = 0x662350\nUNITS_MAX_SHIELDS = 0x660E00\nUNITS_GROUP = 0x6637A0\nUNITS_FLINGY = 0x6644F8\nGROUP_BITS = {230: 0x08, 231: 0x10, 232: 0x20}\n# flingy.dat, by flingy id: movement control (byte), top speed (dword), acceleration (word), halt distance (dword).\nFLINGY_CONTROL, FLINGY_SPEED, FLINGY_ACCELERATION, FLINGY_HALT = 0x6C9858, 0x6C9EF8, 0x6C9C78, 0x6C9930\nMINIMAP_COLOR_OFFSET = 0x581DD6 - 0x581D76\n# The location table, and the one an order borrows for the length of one action (put back after).\nMRGN = 0x58DC60\nSCRATCH_LOCATION = 255\nUNIT_TIMERS = {\"stim\": \"stimTimer\", \"ensnare\": \"ensnareTimer\", \"plague\": \"plagueTimer\", \"lockdown\": \"lockdownTimer\", \"maelstrom\": \"maelstromTimer\", \"irradiate\": \"irradiateTimer\", \"stasis\": \"stasisTimer\"}\nSTATUS_INVINCIBLE, STATUS_HALLUCINATION, STATUS_BURROWED, STATUS_CLOAKED = 0x04000000, 0x40000000, 0x00000010, 0x00000300\nCOND_COMMAND, COND_BRING, COND_ACCUMULATE, COND_KILL, COND_OPPONENTS, COND_DEATHS = 2, 3, 4, 5, 14, 15\nCURRENT_PLAYER = 13\n# The state of a program whose body ended: nothing resumes it.\nDONE = 0xFFFFFFFF\nU32 = 0xFFFFFFFF\nSIGN = 0x80000000\n\nMINUS_SIGN = Db(b\"-\\0\\0\\0\")\nNO_SIGN = Db(b\"\\0\\0\\0\\0\")\n\nwith open(settings[\"ir\"], encoding=\"utf-8\") as _f:\n IR = json.load(_f)\nif IR.get(\"version\") != IR_VERSION:\n raise RuntimeError(\"trigscript: the IR is version %r; this plugin reads version %d\" % (IR.get(\"version\"), IR_VERSION))\n\n\ndef where(node):\n \"\"\"\" at main.ts:12:5\" for a node with a position — what the editor parses back into a marker.\"\"\"\n at = node.get(\"at\") if isinstance(node, dict) else None\n if not at:\n return \"\"\n if at.get(\"file\"):\n return \" at %s:%s:%s\" % (at.get(\"file\"), at.get(\"line\"), at.get(\"column\"))\n return \" at %s:%s\" % (at.get(\"line\"), at.get(\"column\"))\n\n\nclass Fail(RuntimeError):\n pass\n\n\nclass Leave(Exception):\n \"\"\"Raised through the lowering when a statement ends the straight line (break / continue / return).\"\"\"\n\n\nclass Storage:\n \"\"\"A variable's cell: plain, or a 12-slot row of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n self.store = EUDArray([0] * 12) if self.rowed else EUDVariable(0) # initial: the variable's own cell\n\n def get(self):\n return self.store[self.player_of()] if self.rowed else self.store\n\n def set(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n if self.rowed:\n self.store[self.player_of()] = value\n else:\n self.store << value\n\n\nclass ArrayStorage:\n \"\"\"An array's cells: `length` of them, twelve rows of that in a per-player program, or - `values` - a list\n the script computed when it was built, which is in the map as it loads and which nothing writes. An index\n past either end (read from 0 up, so one below zero too) reads 0 and stores nothing.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.bits = decl.get(\"bits\")\n values = decl.get(\"values\")\n self.rowed = per_player and not decl.get(\"shared\") and values is None\n self.player_of = player_of\n if decl.get(\"texts\") is not None:\n # A list of texts the script has: a cell is the text's id in the built map's table.\n values = [text_id(t) for t in decl[\"texts\"]]\n self.store = EUDArray([int(v) & U32 for v in values] if values is not None else [0] * (self.length * (12 if self.rowed else 1))) # initial: a table's values, or cells declareArray sets\n\n def at(self, index):\n \"\"\"The cell's place among the store's: the player's row first.\"\"\"\n if not self.rowed:\n return index\n p = self.player_of()\n row = p * self.length if isinstance(p, int) else f_mul(p, self.length)\n return row + index\n\n def get(self, index):\n if isinstance(index, int):\n return self.store[self.at(index)] if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.store[self.at(index)]\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n if isinstance(index, int):\n if index < self.length:\n self.store[self.at(index)] = value\n return\n if EUDIf()(index <= self.length - 1):\n self.store[self.at(index)] = value\n EUDEndIf()\n\n def fill(self, value):\n if self.length <= 16:\n for i in range(self.length):\n self.set(i, value)\n return\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\nclass SliceStorage:\n \"\"\"A window on another array's cells - a row of a grid: cell i is cell `offset + i` of the array it is a window\n on, `offset` a variable of the program. Past its own end it reads 0 and stores nothing, so a row never reaches\n into the next; what it is a window on keeps its own ends (and its own width, and its row a player).\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.lowering = lowering\n\n def of(self):\n return self.lowering.array(self.decl[\"slice\"][\"of\"], self.decl)\n\n def offset(self):\n return self.lowering.var(self.decl[\"slice\"][\"offset\"], self.decl).get()\n\n def get(self, index):\n if isinstance(index, int):\n return self.of().get(self.offset() + index) if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.of().get(self.offset() + index)\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if isinstance(index, int):\n if index < self.length:\n self.of().set(self.offset() + index, value)\n return\n if EUDIf()(index <= self.length - 1):\n self.of().set(self.offset() + index, value)\n EUDEndIf()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\n# The heap the programs' growing arrays share. A block is a power of two of cells, four at least; one given back\n# waits in its size's list (its first cell links the next) for whoever wants that size next; new ground is taken\n# from the bottom up. Cell 0 is never handed out: 0 is \"no block\". compiler/simulateIr.ts counts the same way, so\n# both run out at the same push. (Recursion's stack is an array of its own, below: how deep a function may go is\n# then the same whatever the arrays hold.)\nHEAP_CELLS = min(1 << 20, max(1024, int(IR.get(\"heap\", 16384)))) # the map's script settings, or the default\nHEAP_SMALLEST = 4\nHEAP_ROOMS = []\nwhile HEAP_SMALLEST << len(HEAP_ROOMS) <= HEAP_CELLS:\n HEAP_ROOMS.append(HEAP_SMALLEST << len(HEAP_ROOMS))\n_HEAP = {}\n\n\ndef heap():\n \"\"\"The heap's cells and its functions, made when the first growing array is met.\"\"\"\n if _HEAP:\n return _HEAP\n cells = EUDArray(HEAP_CELLS)\n rooms = EUDArray(HEAP_ROOMS + [0xFFFFFFFF]) # initial: the sizes, never written\n free = EUDArray(len(HEAP_ROOMS)) # initial: no block waits\n top = EUDVariable(1) # initial: the heap's own state, for the whole game\n said = EUDVariable(0) # initial: the heap's own state\n\n @EUDFunc\n def take(k):\n \"\"\"A block of size class k: its place among the cells, or 0 when there is none.\"\"\"\n at = EUDVariable()\n at << free[k]\n if EUDIf()(at >= 1):\n free[k] = cells[at]\n EUDReturn(at)\n EUDEndIf()\n end = top + rooms[k]\n if EUDIf()(end <= HEAP_CELLS):\n at << top\n top << end\n EUDReturn(at)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def give(at, k):\n cells[at] = free[k]\n free[k] = at\n\n @EUDFunc\n def grow(ptr, length, room, k, need):\n \"\"\"Room for `need` cells: a larger block, the cells copied over, the old block given back. Returns the\n handle as it is now and whether there is room; when the heap has no block left the handle is unchanged.\"\"\"\n if EUDIf()(need <= room):\n EUDReturn(ptr, room, k, 1)\n EUDEndIf()\n nk = EUDVariable()\n nk << 0\n if EUDIf()(ptr >= 1):\n nk << k + 1\n EUDEndIf()\n if EUDWhile()(rooms[nk] < need):\n nk += 1\n EUDEndWhile()\n if EUDIf()(nk >= len(HEAP_ROOMS)):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n block = take(nk)\n if EUDIf()(block == 0):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n if EUDIf()(ptr >= 1):\n if EUDIf()(length >= 1):\n # An EUDArray's own value is where it is, as an EPD already.\n f_repmovsd_epd(cells + block, cells + ptr, length)\n EUDEndIf()\n give(ptr, k)\n EUDEndIf()\n EUDReturn(block, rooms[nk], nk, 1)\n\n @EUDFunc\n def push(ptr, length, room, k, value):\n \"\"\"One more cell at the end; the handle as it is afterwards. Nothing is pushed when the heap is full.\"\"\"\n ptr, room, k, ok = grow(ptr, length, room, k, length + 1)\n if EUDIf()(ok >= 1):\n cells[ptr + length] = value\n length += 1\n if EUDElse()():\n if EUDIf()(said == 0):\n said << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - an array could not grow (the programs' arrays share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDEndIf()\n EUDReturn(ptr, length, room, k)\n\n _HEAP.update(cells=cells, rooms=rooms, take=take, give=give, grow=grow, push=push)\n return _HEAP\n\n\n# The stack of the functions that call themselves. A function's variables are cells of the program, one of each, so\n# a call that may come back into the function it is in (the IR's `saves`) puts what that function holds here first\n# and takes it back after, with where the function returns to. One stack serves every program and every player:\n# such a function never sleeps, so the stack is empty whenever a frame ends. It is as many frames of the largest\n# frame as the map's script settings allow calls deep (the IR file's `stack`), and the depth is what is counted -\n# compiler/simulateIr.ts counts the same - so a program stops at the same call in both.\nSTACK_DEPTH = min(1 << 16, max(16, int(IR.get(\"stack\", 1024))))\nSTACK_CELLS_MAX = 1 << 20\nHANDLE = (\"ptr\", \"len\", \"room\", \"k\")\n_STACK = {}\n\n\ndef frame_cells(saves, kinds):\n return 1 + 4 * len(saves.get(\"arrays\", [])) + sum(3 if kinds.get(v) in (\"unit\", \"text\") else 1 for v in saves.get(\"vars\", []))\n\n\ndef largest_frame():\n most = [0]\n\n def walk(node, kinds):\n if isinstance(node, list):\n for x in node:\n walk(x, kinds)\n elif isinstance(node, dict):\n if isinstance(node.get(\"saves\"), dict):\n most[0] = max(most[0], frame_cells(node[\"saves\"], kinds))\n for v in node.values():\n if isinstance(v, (dict, list)):\n walk(v, kinds)\n\n def kinds_of(node, into):\n if isinstance(node, list):\n for x in node:\n kinds_of(x, into)\n elif isinstance(node, dict):\n if isinstance(node.get(\"id\"), str) and node.get(\"kind\") in (\"number\", \"boolean\", \"unit\", \"text\") and \"shared\" in node:\n # A text kept as its id is one cell, as a number is; one that was made is three.\n into[node[\"id\"]] = \"number\" if node[\"kind\"] == \"text\" and node.get(\"text\") == \"id\" else node[\"kind\"]\n for v in node.values():\n if isinstance(v, (dict, list)):\n kinds_of(v, into)\n return into\n\n for program in IR.get(\"programs\", []):\n walk(program.get(\"functions\", []), kinds_of(program, {}))\n return most[0]\n\n\ndef stack():\n \"\"\"The stack's cells, where the next frame goes (an EPD, so a cell is one write) and how deep it is.\"\"\"\n if _STACK:\n return _STACK\n frame = largest_frame()\n if frame * STACK_DEPTH > STACK_CELLS_MAX:\n raise Fail(\"trigscript: the stack would be %d cells - %d calls deep, %d cells a call - and %d is the most: lower the recursion depth in the script's Settings, or keep fewer variables in the function that calls itself\" % (frame * STACK_DEPTH, STACK_DEPTH, frame, STACK_CELLS_MAX))\n cells = EUDArray(max(1, frame) * STACK_DEPTH)\n base = cells # an EUDArray's own value is where it is, as an EPD already\n at = EUDVariable(base) # initial: the stack's own state; it is back here whenever a frame ends\n depth = EUDVariable(0) # initial: the stack's own state; nothing is on it between frames\n _STACK.update(cells=cells, base=base, at=at, depth=depth)\n return _STACK\n\n\nclass ListStorage:\n \"\"\"An array that grows: a handle - where its block is among the heap's cells (0: none yet), how many cells are\n in use, how many the block has room for, and the block's size class - a cell each, or a row of twelve each in\n a per-player program. Reads and stores are bounded by the cells in use; a store at exactly the length is a push.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no block\n self.handle = {name: make() for name in (\"ptr\", \"len\", \"room\", \"k\")}\n self.heap = heap()\n\n def field(self, name):\n cell = self.handle[name]\n return cell[self.player_of()] if self.rowed else cell\n\n def put(self, name, value):\n if self.rowed:\n self.handle[name][self.player_of()] = value\n else:\n self.handle[name] << value\n\n def length(self):\n return self.field(\"len\")\n\n def get(self, index):\n out = fresh(0)\n if EUDIf()(as_var(index) < as_var(self.field(\"len\"))):\n out << self.heap[\"cells\"][self.field(\"ptr\") + index]\n EUDEndIf()\n return out\n\n def push(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n ptr, length, room, k = self.heap[\"push\"](self.field(\"ptr\"), self.field(\"len\"), self.field(\"room\"), self.field(\"k\"), value)\n for name, v in ((\"ptr\", ptr), (\"len\", length), (\"room\", room), (\"k\", k)):\n self.put(name, v)\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n i, v, n = as_var(index), fresh(value), as_var(self.field(\"len\"))\n if EUDIf()(i < n):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n if EUDElseIf()(i == n):\n self.push(v) # xs[xs.length] = v, as JavaScript has it\n EUDEndIf()\n\n def pop(self):\n out = fresh(0)\n n = fresh(self.field(\"len\"))\n if EUDIf()(n >= 1):\n n -= 1\n self.put(\"len\", n)\n out << self.heap[\"cells\"][self.field(\"ptr\") + n]\n EUDEndIf()\n return out\n\n def set_length(self, value):\n v = as_var(value)\n if EUDIf()(v < as_var(self.field(\"len\"))):\n self.put(\"len\", v)\n EUDEndIf()\n\n def declare(self, values):\n \"\"\"Declared (again): the block it held goes back, and it starts over with these values.\"\"\"\n ptr = fresh(self.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n self.heap[\"give\"](ptr, self.field(\"k\"))\n EUDEndIf()\n for name in (\"ptr\", \"len\", \"room\", \"k\"):\n self.put(name, 0)\n for v in values:\n self.push(v)\n\n def declare_filled(self, value, count):\n \"\"\"The same with one value `count` times over: a loop, where a push a cell would be a push a cell in the map.\"\"\"\n if count <= 8:\n self.declare([value] * count)\n return\n self.declare([])\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= count - 1):\n self.push(v)\n i += 1\n EUDEndWhile()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i < as_var(self.field(\"len\"))):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n i += 1\n EUDEndWhile()\n\n\nclass InnerListStorage(ListStorage):\n \"\"\"An array that grows inside another: its handle is cell `index` of four arrays the outer one keeps (where the\n block is, how many cells are in use, its room, its size class), `index` a variable of the program. Everything\n else is a growing array's, which only ever asks for its handle's fields and puts them back.\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = False\n self.lowering = lowering\n self.heap = heap()\n\n def at(self):\n return self.lowering.var(self.decl[\"through\"][\"index\"], self.decl).get()\n\n def field(self, name):\n return self.lowering.array(self.decl[\"through\"][name], self.decl).get(self.at())\n\n def put(self, name, value):\n self.lowering.array(self.decl[\"through\"][name], self.decl).set(self.at(), value)\n\n\n# ── texts ──\nTEXT_BYTES = 1023 # the most a made text holds; compiler/ir.ts has the same number\nTEXT_FIELD_BYTES = 255 # the room of a string kept for an action's field or a unit type's name\nUNIT_MAP_STRING = 0x660260 # units.dat: the id of the name a map gives a unit type, a word each\n_TEXT = {}\n_SLOTS = {}\n\n\ndef texts():\n \"\"\"The texts' part of the run time, made when the first text that is made is met.\"\"\"\n if _TEXT:\n return _TEXT\n h = heap()\n cells, rooms, take, give = h[\"cells\"], h[\"rooms\"], h[\"take\"], h[\"give\"]\n # A text being made. It may run one part past its most before it is cut, and a part is at most that long again.\n scratch = Db(TEXT_BYTES + 1 + TEXT_BYTES + 1 + 8)\n empty = Db(4)\n said_cut = EUDVariable(0) # initial: said once a game\n said_full = EUDVariable(0) # initial: said once a game\n said_field = EUDVariable(0) # initial: said once a game\n\n @EUDFunc\n def count(addr):\n \"\"\"How many characters the text at addr is: every byte but those that continue a character (10xxxxxx).\"\"\"\n n = EUDVariable()\n n << 0\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n n += 1\n EUDEndIf()\n EUDEndInfLoop()\n EUDReturn(n)\n\n @EUDFunc\n def append(pos, src):\n \"\"\"The text at src written at pos, ended by a 0; where that 0 is. A text of the table may be any length, so no\n more of it than leaves the scratch whole: what is past a text's most is cut by make() anyway.\"\"\"\n br1.seekoffset(src)\n bw1.seekoffset(pos)\n if EUDWhile()(pos <= scratch + TEXT_BYTES + TEXT_BYTES):\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n bw1.writebyte(b)\n pos += 1\n EUDEndWhile()\n bw1.writebyte(0)\n EUDReturn(pos)\n\n @EUDFunc\n def make(end):\n \"\"\"The text in the scratch, which ends at `end`, into a block of its own: where it is, the block, its\n length in characters. Past what a text holds it is cut, never inside a character.\"\"\"\n if EUDIf()(end >= scratch + TEXT_BYTES + 1):\n end << scratch + TEXT_BYTES\n if EUDWhile()(f_bread(end).ExactlyX(0x80, 0xC0)):\n end -= 1\n EUDEndWhile()\n f_bwrite(end, 0)\n if EUDIf()(said_cut == 0):\n said_cut << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - one that is made holds %d bytes.\" % TEXT_BYTES)\n EUDEndIf()\n EUDEndIf()\n need = f_div(end - scratch, 4)[0] + 2\n k = EUDVariable()\n k << 0\n if EUDWhile()(rooms[k] < need):\n k += 1\n EUDEndWhile()\n block = take(k)\n if EUDIf()(block == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be made (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n cells[block] = k\n f_repmovsd_epd(cells + block + 1, EPD(scratch), need - 1)\n addr = f_mul(cells + block + 1, 4) + 0x58A364\n EUDReturn(addr, block, count(addr))\n\n @EUDFunc\n def copy(addr, block, length):\n \"\"\"A text as something to keep: itself when it owns no block, else a block of the same size with the same cells.\"\"\"\n if EUDIf()(block == 0):\n EUDReturn(addr, 0, length)\n EUDEndIf()\n k = cells[block]\n mine = take(k)\n if EUDIf()(mine == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be copied (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n f_repmovsd_epd(cells + mine, cells + block, rooms[k])\n EUDReturn(f_mul(cells + mine + 1, 4) + 0x58A364, mine, length)\n\n @EUDFunc\n def release(block):\n if EUDIf()(block >= 1):\n give(block, cells[block])\n EUDEndIf()\n\n @EUDFunc\n def skip(addr, n):\n \"\"\"Where character n of the text at addr starts; its end when it has fewer.\"\"\"\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n EUDBreakIf(n == 0)\n n -= 1\n EUDEndIf()\n addr += 1\n EUDEndInfLoop()\n EUDReturn(addr)\n\n @EUDFunc\n def size(addr):\n \"\"\"The bytes of the character that starts at addr; 0 at the text's end.\"\"\"\n b = f_bread(addr)\n n = EUDVariable()\n n << 1\n if EUDIf()(b == 0):\n n << 0\n if EUDElseIf()(b >= 0xF0):\n n << 4\n if EUDElseIf()(b >= 0xE0):\n n << 3\n if EUDElseIf()(b >= 0xC0):\n n << 2\n EUDEndIf()\n EUDReturn(n)\n\n @EUDFunc\n def slice_(addr, start, end):\n \"\"\"Characters start … end - 1 of the text at addr, made.\"\"\"\n a = skip(addr, start)\n z = EUDVariable()\n z << a\n if EUDIf()(end >= start + 1):\n z << skip(a, end - start)\n EUDEndIf()\n # A text of the table may be longer than one that is made: no more of it than the scratch holds, and make() cuts.\n n = z - a\n if EUDIf()(n >= TEXT_BYTES + 2):\n n << TEXT_BYTES + 1\n EUDEndIf()\n f_memcpy(scratch, a, n)\n f_bwrite(scratch + n, 0)\n EUDReturn(*make(scratch + n))\n\n @EUDFunc\n def pad(addr, length, width, fill, at_start):\n \"\"\"The text with `fill` over and over before or after it until it is `width` characters; as it is when it is that long, or `fill` is empty.\"\"\"\n pos = EUDVariable()\n pos << scratch\n need = EUDVariable()\n need << 0\n if EUDIf()([width >= length + 1, width <= 0x7FFFFFFF, f_bread(fill) >= 1]):\n need << width - length\n EUDEndIf()\n if EUDIf()(at_start == 0):\n pos << append(pos, addr)\n EUDEndIf()\n src = EUDVariable()\n src << fill\n if EUDWhile()([need >= 1, pos <= scratch + TEXT_BYTES]):\n n = size(src)\n if EUDIf()(n == 0):\n src << fill\n n << size(src)\n EUDEndIf()\n f_memcpy(pos, src, n)\n pos += n\n src += n\n need -= 1\n EUDEndWhile()\n f_bwrite(pos, 0)\n if EUDIf()([at_start >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def repeat(addr, times):\n pos = EUDVariable()\n pos << scratch\n f_bwrite(pos, 0)\n if EUDIf()([times <= 0x7FFFFFFF, f_bread(addr) >= 1]):\n if EUDWhile()([times >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n times -= 1\n EUDEndWhile()\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def starts(addr, find):\n \"\"\"Whether the text at addr starts with the one at find.\"\"\"\n br1.seekoffset(addr)\n br2.seekoffset(find)\n if EUDInfLoop()():\n want = br2.readbyte()\n if EUDIf()(want == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDBreakIfNot(br1.readbyte() == want)\n EUDEndInfLoop()\n EUDReturn(0)\n\n @EUDFunc\n def find_from(addr, find, start):\n \"\"\"The place, in characters, of the first match at or after character `start`; 0xFFFFFFFF when there is none.\"\"\"\n if EUDIf()(start >= count(addr) + 1):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, start)\n place = EUDVariable()\n place << start\n if EUDInfLoop()():\n if EUDIf()(starts(at, find) >= 1):\n EUDReturn(place)\n EUDEndIf()\n n = size(at)\n EUDBreakIf(n == 0)\n at += n\n place += 1\n EUDEndInfLoop()\n EUDReturn(0xFFFFFFFF)\n\n @EUDFunc\n def ends(addr, find):\n a, b = f_strlen(addr), f_strlen(find)\n if EUDIf()(b >= a + 1):\n EUDReturn(0)\n EUDEndIf()\n if EUDIf()(f_strcmp(addr + (a - b), find) == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def code(addr, index):\n \"\"\"The number of character `index`; 0xFFFFFFFF past either end.\"\"\"\n if EUDIf()(index >= 0x80000000):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, index)\n n = size(at)\n if EUDIf()(n == 0):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n br1.seekoffset(at)\n v = EUDVariable()\n v << br1.readbyte()\n if EUDIf()(n == 2):\n v << (v & 0x1F)\n if EUDElseIf()(n == 3):\n v << (v & 0x0F)\n if EUDElseIf()(n == 4):\n v << (v & 0x07)\n EUDEndIf()\n if EUDWhile()(n >= 2):\n v << f_mul(v, 64) + (br1.readbyte() & 0x3F)\n n -= 1\n EUDEndWhile()\n EUDReturn(v)\n\n @EUDFunc\n def show(dst, src):\n \"\"\"At most TEXT_FIELD_BYTES bytes of the text at src over the string at dst, never half a character.\"\"\"\n used = EUDVariable()\n used << 0\n if EUDInfLoop()():\n n = size(src)\n EUDBreakIf(n == 0)\n if EUDIf()(used + n >= TEXT_FIELD_BYTES + 1):\n if EUDIf()(said_field == 0):\n said_field << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - an action's text and a unit's name show %d bytes of one that is made.\" % TEXT_FIELD_BYTES)\n EUDEndIf()\n EUDBreak()\n EUDEndIf()\n f_memcpy(dst + used, src, n)\n used += n\n src += n\n EUDEndInfLoop()\n f_bwrite(dst + used, 0)\n\n @EUDFunc\n def address(id_):\n \"\"\"Where the text of the table with this id is; an empty one for id 0.\"\"\"\n if EUDIf()(id_ == 0):\n EUDReturn(empty)\n EUDEndIf()\n EUDReturn(GetMapStringAddr(id_))\n\n _TEXT.update(scratch=scratch, empty=empty, append=append, count=count, make=make, copy=copy, release=release, slice=slice_, pad=pad, repeat=repeat,\n starts=starts, ends=ends, find=find_from, code=code, show=show, size=size, address=address)\n return _TEXT\n\n\ndef slot(kind):\n \"\"\"The string of the built map's table kept for one kind of field, made the first time one is asked for: its\n room in bytes that nothing else shares, which is what ForceAddString is for.\"\"\"\n if kind not in _SLOTS:\n text = \"(TrigScript: %s)\" % kind\n _SLOTS[kind] = ForceAddString(text + \" \" * (TEXT_FIELD_BYTES - len(text)))\n return _SLOTS[kind]\n\n\n# The actions whose text the game shows from a string it reads again whenever it draws (played 2026-09-19), by the\n# kind of field: a player has one of each at a time, so one string a kind is enough.\nSLOT_OF_ACTION = {12: \"objectives\", 7: \"transmission\"}\nfor _t in (17, 18, 19, 20, 21, 33, 34, 35, 36, 37, 40):\n SLOT_OF_ACTION[_t] = \"leaderboard\"\n\n\ndef text_id(text):\n \"\"\"The id of a text written in the script; 0 for the empty one, which the table does not hold.\"\"\"\n return EncodeString(text) if text else 0\n\n\nclass TextVal:\n \"\"\"A text as the lowering holds it: where it is, the block it owns (the int 0: none), and its length in\n characters - an int, a variable, or None when nobody has counted yet. `taken`: the block is the value's own, so\n what receives the value keeps the block or gives it back; a variable's is only looked at.\"\"\"\n\n def __init__(self, addr, block, length, taken):\n self.addr, self.block, self._length, self.taken = addr, block, length, taken\n\n def length(self):\n if self._length is None:\n self._length = texts()[\"count\"](self.addr)\n return self._length\n\n\nclass TextStorage:\n \"\"\"A text variable that is made: three cells, or three rows of twelve in a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no text yet, and no block\n self.addr, self.block, self.len = make(), make(), make()\n\n def cell(self, cell):\n return cell[self.player_of()] if self.rowed else cell\n\n def get(self):\n # A variable that was never given a text is an empty one: its address is 0 until then.\n addr = fresh(self.cell(self.addr))\n if EUDIf()(addr == 0):\n addr << texts()[\"empty\"]\n EUDEndIf()\n return TextVal(addr, self.cell(self.block), self.cell(self.len), False)\n\n def write(self, addr, block, length):\n for cell, value in ((self.addr, addr), (self.block, block), (self.len, length)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n def take_out(self):\n \"\"\"The text, and its block with it: the variable holds no block from here on.\"\"\"\n v = self.get()\n out = TextVal(v.addr, fresh(v.block), fresh(v.length()), True)\n self.write(out.addr, 0, out.length())\n return out\n\n\nclass UnitRef:\n \"\"\"A unit of the game as the lowering holds it: the pointer, its EPD, and the slot's uniqueness byte\n (as it sits in its dword, masked 0xFF00). `uid` None is the unit of a loop's turn, there by\n construction; a pointer that is the int 0 is no unit at all.\"\"\"\n\n def __init__(self, ptr, epd, uid=None):\n self.ptr, self.epd, self.uid = ptr, epd, uid\n\n @property\n def none(self):\n return isinstance(self.ptr, int) and self.ptr == 0\n\n\nNO_UNIT = UnitRef(0, 0, 0)\n\n\nclass UnitStorage:\n \"\"\"A unit variable: three cells, or three 12-slot rows of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no unit\n self.ptr, self.epd, self.uid = make(), make(), make()\n\n def get(self):\n if self.rowed:\n p = self.player_of()\n return UnitRef(self.ptr[p], self.epd[p], self.uid[p])\n return UnitRef(self.ptr, self.epd, self.uid)\n\n def set(self, ref):\n uid = ref.uid\n if uid is None:\n # The unit of a loop's turn is there now: this is when its slot's uniqueness byte is taken.\n uid = f_maskread_epd(ref.epd + OFF_UID, 0xFF00)\n for cell, value in ((self.ptr, ref.ptr), (self.epd, ref.epd), (self.uid, uid)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n\nclass LoopUnit:\n \"\"\"The variable of a loop over units: the scan's own pointer, never kept past the loop.\"\"\"\n\n def __init__(self, ref):\n self.ref = ref\n\n def get(self):\n return self.ref\n\n def set(self, ref):\n raise Fail(\"trigscript: the unit of a loop's turn cannot be assigned\")\n\n\ndef saturate(value, bits):\n top = (1 << bits) - 1\n if isinstance(value, int):\n return min(value, top)\n v = EUDVariable()\n v << value\n if EUDIf()(v >= top + 1):\n v << top\n EUDEndIf()\n return v\n\n\ndef as_var(value):\n if isinstance(value, int):\n return EUDVariable(value & U32) # initial: a constant, never written\n return value\n\n\ndef fresh(value=0):\n \"\"\"A temporary that starts from `value` every time the code runs. `EUDVariable(n)` is not\n that: n is the cell's value when the map loads, so a temporary built that way and then\n written keeps what the last run left in it — `ticks + 1` went 1, 2, 4, 8 (found in the\n first played probe, 2026-09-18). Anything the lowering writes to starts here.\"\"\"\n v = EUDVariable()\n v << value\n return v\n\n\nclass Lowering:\n def __init__(self, program):\n self.p = program\n self.per_player = bool(program.get(\"perPlayer\"))\n self.owner = int(program.get(\"owner\", 0))\n self.slots = owner_slots(program)\n self.player = None\n self.vars = {}\n self.arrays = {a[\"id\"]: SliceStorage(a, self) if a.get(\"slice\") else InnerListStorage(a, self) if a.get(\"through\") else (ListStorage if a.get(\"dynamic\") else ArrayStorage)(a, self.per_player, self.player_of) for a in program.get(\"arrays\", [])}\n self.state = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.wait = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.resumes = [] # (index, Forward) for every sleep\n self.frame_end = None\n self.latches = {}\n # The functions that are called, by id, and each one's EUDFunc once something has called it.\n self.functions = {f[\"id\"]: f for f in program.get(\"functions\", [])}\n self.made = {}\n self.in_function = 0\n # The function being lowered, when it is one that calls itself: whose return address a frame keeps.\n self.within = []\n\n # ── storage ──\n def player_of(self):\n if self.player is None:\n raise Fail(\"trigscript: a per-player variable outside the player loop\")\n return self.player\n\n def declare(self, decl):\n if decl.get(\"kind\") == \"unit\":\n s = UnitStorage(decl, self.per_player, self.player_of)\n elif decl.get(\"kind\") == \"text\" and decl.get(\"text\") != \"id\":\n s = TextStorage(decl, self.per_player, self.player_of)\n else:\n s = Storage(decl, self.per_player, self.player_of)\n self.vars[decl[\"id\"]] = s\n return s\n\n def array(self, id_, node=None):\n a = self.arrays.get(id_)\n if a is None:\n raise Fail(\"trigscript: unknown array %r%s\" % (id_, where(node)))\n return a\n\n def var(self, id_, node=None):\n s = self.vars.get(id_)\n if s is None:\n raise Fail(\"trigscript: unknown variable %r%s\" % (id_, where(node)))\n return s\n\n def get_state(self):\n return self.state[self.player] if self.per_player else self.state\n\n def set_state(self, value):\n if self.per_player:\n self.state[self.player] = value\n else:\n self.state << value\n\n def get_wait(self):\n return self.wait[self.player] if self.per_player else self.wait\n\n def set_wait(self, value):\n if self.per_player:\n self.wait[self.player] = value\n else:\n self.wait << value\n\n # ── numbers: an int (the 32 bits, 0 … 2^32 - 1) or an EUDVariable ──\n def num(self, e):\n k = e[\"kind\"]\n if k in (\"textLength\", \"textIndexOf\", \"textCode\"):\n return self.text_number(e)\n if k == \"input\":\n return INPUT.read(e[\"input\"], self, e)\n if k == \"const\":\n return int(e[\"value\"]) & U32\n if k == \"var\":\n return self.var(e[\"id\"], e).get()\n if k == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"length\":\n a = self.array(e[\"array\"], e)\n return a.length() if isinstance(a, ListStorage) else a.length\n if k == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if k == \"cast\":\n return self.num(e[\"expr\"])\n if k == \"unary\":\n x = self.num(e[\"expr\"])\n return (-x) & U32 if isinstance(x, int) else 0 - x\n if k == \"binary\":\n return self.binary(e)\n if k == \"read\":\n return self.read(e)\n if k == \"randomInt\":\n # 0 … n - 1; an n of 0 gives 0, as a 0 divisor does.\n n = self.num(e[\"bound\"])\n if isinstance(n, int):\n if n <= 1:\n return 0\n return f_div(f_dwrand(), n)[1]\n out = fresh(0)\n if EUDIf()(n >= 1):\n out << f_div(f_dwrand(), n)[1]\n EUDEndIf()\n return out\n if k == \"unitField\":\n return self.unit_field(e)\n if k == \"unitPart\":\n return self.unit_part(e)\n if k == \"tableRead\":\n return self.table_read(e[\"cell\"], e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.num(e[\"whenTrue\"])\n if EUDElse()():\n t << self.num(e[\"whenFalse\"])\n EUDEndIf()\n return t\n if k == \"intrinsic\" and e[\"name\"] == \"abs\":\n x = self.num(e[\"args\"][0])\n if isinstance(x, int):\n return abs(signed(x)) & U32\n t = fresh(x)\n if EUDIf()(t >= SIGN):\n t << 0 - t\n EUDEndIf()\n return t\n if k == \"intrinsic\":\n a, b = [self.num(x) for x in e[\"args\"]]\n name = e[\"name\"]\n unsigned = bool(e.get(\"unsigned\"))\n if isinstance(a, int) and isinstance(b, int):\n key = (lambda v: v) if unsigned else signed\n return min(a, b, key=key) if name == \"min\" else max(a, b, key=key)\n t = EUDVariable()\n if EUDIf()(self.ordered(a, \"<=\" if name == \"min\" else \">=\", b, unsigned)):\n t << a\n if EUDElse()():\n t << b\n EUDEndIf()\n return t\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown expression %r%s\" % (k, where(e)))\n\n def binary(self, e):\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op = e[\"op\"]\n if op in BITWISE:\n return self.bitwise(op, a, b)\n both = isinstance(a, int) and isinstance(b, int)\n if op == \"+\":\n return (a + b) & U32 if both else a + b\n if op == \"-\":\n return (a - b) & U32 if both else a - b\n if op == \"*\":\n return (a * b) & U32 if both else f_mul(as_var(a), as_var(b))\n # / and %: towards zero, the remainder with the dividend's sign, unless both sides are u32s. A divisor of 0 gives 0.\n unsigned = bool(e.get(\"unsigned\"))\n pick = (lambda q, r: q) if op == \"/\" else (lambda q, r: r)\n if isinstance(b, int):\n if b == 0:\n raise Fail(\"trigscript: division by zero%s\" % where(e))\n if both:\n if unsigned:\n return pick(a // b, a % b)\n x, y = signed(a), signed(b)\n q = abs(x) // abs(y) * (-1 if (x < 0) != (y < 0) else 1)\n return pick(q, x - q * y) & U32\n return pick(*(f_div(a, b) if unsigned else f_div_towards_zero(fresh(a), signed(b))))\n out = fresh(0)\n if EUDIf()(b >= 1):\n q, r = f_div(as_var(a), b) if unsigned else f_div_towards_zero(fresh(a), fresh(b))\n out << pick(q, r)\n EUDEndIf()\n return out\n\n def ordered(self, a, op, b, unsigned):\n \"\"\"`a op b` as one condition, the two read as u32s (`unsigned` true), as signed numbers (false), or one of\n each (\"left\" / \"right\" names the u32) - exactly: a number below zero is smaller than any u32. At least one\n side is a variable. A signed order is the unsigned one with the top bit of both sides flipped.\"\"\"\n if unsigned in (\"left\", \"right\"):\n # s is the signed side, u the u32, and the comparison is turned to read s op u.\n s_, u_ = (b, a) if unsigned == \"left\" else (a, b)\n if unsigned == \"left\":\n op = FLIPPED[op]\n if isinstance(s_, int):\n if s_ >= SIGN:\n return always(op in (\"<\", \"<=\", \"!=\"))\n return self.ordered(s_, op, u_, True)\n sv = as_var(s_)\n if op in (\"<\", \"<=\", \"!=\"):\n return EUDOr(sv >= SIGN, relation(sv, op, u_))\n return EUDAnd(sv <= SIGN - 1, relation(sv, op, u_))\n if isinstance(a, int):\n a, b, op = b, a, FLIPPED[op]\n if unsigned or op in (\"==\", \"!=\"):\n return relation(as_var(a), op, b)\n return relation(a + SIGN, op, (b + SIGN) & U32 if isinstance(b, int) else b + SIGN)\n\n @staticmethod\n def bitwise(op, a, b):\n \"\"\"& | ^ << >> >>> over 32 bits. `>>` keeps the sign of what it shifts and `>>>` does not; a shift by 32 or\n more leaves nothing but that sign.\"\"\"\n if op in (\"<<\", \">>\", \">>>\"):\n if isinstance(a, int) and isinstance(b, int):\n if op == \"<<\":\n return 0 if b >= 32 else (a << b) & U32\n if op == \">>>\":\n return 0 if b >= 32 else a >> b\n return (signed(a) >> min(b, 31)) & U32\n if isinstance(b, int) and b == 0:\n return a\n if isinstance(b, int) and b >= 32 and op != \">>\":\n return 0\n if op == \"<<\":\n return f_bitlshift(fresh(a), b)\n if op == \">>>\":\n return f_bitrshift(fresh(a), b)\n # The sign kept: a number below zero is shifted as its complement, which has zeros where it has ones.\n x, below = fresh(a), fresh(0)\n if EUDIf()(x >= SIGN):\n below << 1\n x << ~x\n EUDEndIf()\n r = fresh(f_bitrshift(x, b)) if not (isinstance(b, int) and b >= 32) else fresh(0)\n if EUDIf()(below >= 1):\n r << ~r\n EUDEndIf()\n return r\n if isinstance(a, int) and isinstance(b, int):\n return {\"&\": a & b, \"|\": a | b, \"^\": a ^ b}[op]\n # Copies: eudplib computes in place into an operand nothing else refers to, and ours may be a variable's own cell.\n x, y = fresh(a), fresh(b)\n return x & y if op == \"&\" else x | y if op == \"|\" else x ^ y\n\n # ── reads: a value of the game ──\n def current(self):\n \"\"\"The player the program is running as: a slot, or the player loop's variable.\"\"\"\n return self.player if self.per_player else self.slots[0]\n\n def one_player(self, p, node):\n if p == CURRENT_PLAYER:\n return self.current()\n if 0 <= p < 12:\n return p\n raise Fail(\"trigscript: a read takes one player%s\" % where(node))\n\n def read(self, e):\n r = e[\"read\"]\n source = r.get(\"source\")\n if source == \"condition\":\n return self.read_condition(r[\"record\"], e)\n if source == \"player\" and r.get(\"fact\") == \"left\":\n return self.read_left(r, e)\n if source == \"player\":\n base = PLAYER_BYTES.get(r.get(\"fact\"))\n if base is None:\n raise Fail(\"trigscript: unknown player fact %r%s\" % (r.get(\"fact\"), where(e)))\n value = f_bread(base + self.one_player(r[\"player\"], e))\n if r.get(\"fact\") == \"slot\":\n # A computer of a Use Map Settings game keeps the map's own number, 5; a melee computer is 1\n # (the slice 2 probe read 5 for one). Both are \"a computer\": one number for the script.\n kind = fresh(value)\n if EUDIf()(kind == 5):\n kind << 1\n EUDEndIf()\n return kind\n return value\n if source == \"supply\":\n return self.read_supply(r, e)\n raise Fail(\"trigscript: unknown read %r%s\" % (source, where(e)))\n\n def read_left(self, r, e):\n \"\"\"1 once a player who was in the map's settings as a human or a computer is gone. Asked the way\n eudplib's f_playerexist asks (the player's trigger list), which the player loop already relies on.\n The byte table at 0x581D62 read 0 for players who were there, as it should, but nobody has seen it\n turn 1 in a game — a computer never leaves — so it is not what this rests on.\"\"\"\n p = self.one_player(r[\"player\"], e)\n if isinstance(p, int) and (p >= 8 or GetPlayerInfo(p).typestr not in (\"Human\", \"Computer\")):\n return 0\n gone = fresh(1)\n if EUDIf()(f_playerexist(p)):\n gone << 0\n EUDEndIf()\n return gone\n\n def read_supply(self, r, e):\n base = SUPPLY_TABLES.get(r.get(\"of\"))\n if base is None:\n raise Fail(\"trigscript: unknown supply %r%s\" % (r.get(\"of\"), where(e)))\n p = self.one_player(r[\"player\"], e)\n half = fresh(0)\n if r.get(\"race\") is None:\n # The race the player plays; a slot with none (neutral, empty) has no supply.\n race = f_bread(PLAYER_BYTES[\"race\"] + p)\n if EUDIf()(race <= 2):\n half << f_dwread_epd(EPD(base) + race * 36 + p)\n EUDEndIf()\n else:\n half << f_dwread_epd(EPD(base) + int(r[\"race\"]) * 36 + p)\n # As the top bar shows it: half a supply in use counts as one.\n if r[\"of\"] == \"used\":\n half += 1\n return half // 2\n\n def read_condition(self, rec, e):\n t, player, unit = rec[\"type\"], rec[\"player\"], rec[\"unitId\"]\n one = player == CURRENT_PLAYER or not 12 <= player <= 26 # not a group; beyond 26 is an EUD offset, read as it stands\n table = None\n if one and t in (COND_DEATHS, COND_KILL) and not 229 <= unit <= 232:\n table = (DEATHS_TABLE if t == COND_DEATHS else KILLS_TABLE, unit * 12)\n elif one and t == COND_ACCUMULATE and rec[\"resource\"] in (0, 1):\n table = (ORE_TABLE if rec[\"resource\"] == 0 else GAS_TABLE, 0)\n if table is not None:\n p = self.current() if player == CURRENT_PLAYER else player\n return f_dwread_epd(EPD(table[0]) + table[1] + p)\n # No table holds it (a group's sum, units counted at a location, a score, a clock): ask the\n # condition, a bit at a time from the top. A unit count stays under 2^12, a number of players under 2^4.\n bits = 12 if t in (COND_BRING, COND_COMMAND) else 4 if t == COND_OPPONENTS else 32\n found = fresh(0)\n for bit in reversed(range(bits)):\n probe = found + (1 << bit)\n if EUDIf()(Condition(rec[\"location\"], player, probe, unit, 0, t, rec[\"resource\"], rec[\"flags\"], eudx=rec.get(\"mask\", 0) or 0)):\n found << probe\n EUDEndIf()\n return found\n\n # ── booleans: an eudplib condition ──\n def cond(self, e):\n k = e[\"kind\"]\n if k == \"const\":\n return always(e[\"value\"])\n if k == \"cond\":\n return condition(e[\"record\"])\n if k == \"var\":\n return as_var(self.var(e[\"id\"], e).get()) >= 1\n if k == \"element\":\n return as_var(self.array(e[\"array\"], e).get(self.num(e[\"index\"]))) >= 1\n if k == \"pop\":\n return as_var(self.array(e[\"array\"], e).pop()) >= 1\n if k == \"test\":\n return as_var(self.num(e[\"expr\"])) >= 1\n if k == \"compare\":\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op, unsigned = e[\"op\"], e.get(\"unsigned\", False)\n if isinstance(a, int) and isinstance(b, int):\n x = a if unsigned in (True, \"left\") else signed(a)\n y = b if unsigned in (True, \"right\") else signed(b)\n return always(compare(x, op, y))\n # One comparison, built once: a comparison between variables writes into its own\n # condition, and one that is built and dropped is an orphan eudplib refuses.\n return self.ordered(a, op, b, unsigned)\n if k == \"and\":\n return EUDAnd(*[self.cond(c) for c in e[\"items\"]])\n if k == \"or\":\n return EUDOr(*[self.cond(c) for c in e[\"items\"]])\n if k == \"not\":\n return EUDNot(self.cond(e[\"expr\"]))\n if k == \"random\":\n return (f_rand() & 1) >= 1\n if k in (\"unitAlive\", \"unitFlag\", \"unitSame\"):\n return as_var(self.truth(e)) >= 1\n if k == \"edge\":\n return self.edge(e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.truth(e[\"whenTrue\"])\n if EUDElse()():\n t << self.truth(e[\"whenFalse\"])\n EUDEndIf()\n return t >= 1\n if k == \"call\":\n return as_var(self.call(e[\"call\"])) >= 1\n if k in (\"textCompare\", \"textTest\"):\n return as_var(self.text_truth(e)) >= 1\n raise Fail(\"trigscript: unknown condition %r%s\" % (k, where(e)))\n\n def truth(self, e):\n \"\"\"A boolean expression as 0 / 1.\"\"\"\n if e[\"kind\"] == \"const\":\n return 1 if e[\"value\"] else 0\n if e[\"kind\"] == \"var\":\n return self.var(e[\"id\"], e).get()\n if e[\"kind\"] == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if e[\"kind\"] == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if e[\"kind\"] == \"unitAlive\":\n ref = self.unit(e[\"unit\"])\n t = fresh(0)\n if not ref.none:\n self.when_alive(ref, lambda: t << 1)\n return t\n if e[\"kind\"] == \"unitFlag\":\n return self.unit_flag(e)\n if e[\"kind\"] == \"unitSame\":\n a, b = self.unit(e[\"left\"]), self.unit(e[\"right\"])\n t = fresh(0)\n if not a.none and not b.none:\n ap, bp = as_var(a.ptr), as_var(b.ptr)\n if EUDIf()([ap >= 1, ap == bp]):\n t << 1\n EUDEndIf()\n return t\n t = fresh(0)\n if EUDIf()(self.cond(e)):\n t << 1\n EUDEndIf()\n return t\n\n def edge(self, e):\n \"\"\"rose(c): true on the frame c becomes true; once(c): true the first time it holds.\"\"\"\n key = id(e)\n if key not in self.latches:\n self.latches[key] = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: the latch\n latch = self.latches[key]\n\n def get():\n return latch[self.player] if self.per_player else latch\n\n def put(v):\n if self.per_player:\n latch[self.player] = v\n else:\n latch << v\n\n fired = fresh(0)\n held = self.truth(e[\"cond\"])\n if EUDIf()(as_var(held) >= 1):\n if EUDIf()(as_var(get()) == 0):\n fired << 1\n put(1)\n EUDEndIf()\n if EUDElse()():\n if e[\"edge\"] == \"rose\":\n put(0)\n EUDEndIf()\n return fired >= 1\n\n # ── units: a pointer into the game's unit table, checked before use ──\n def unit(self, e):\n k = e[\"kind\"]\n if k == \"unitNull\":\n return NO_UNIT\n if k == \"unitVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"pick\":\n return self.pick(e)\n if k == \"unitAt\":\n # A unit kept as three numbers of the program (a cell each of an array of units): re-checked like any kept unit.\n ptr = self.num(e[\"ptr\"])\n if isinstance(ptr, int) and ptr == 0:\n return NO_UNIT\n return UnitRef(fresh(ptr), fresh(self.num(e[\"epd\"])), fresh(self.num(e[\"uid\"])))\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown unit expression %r%s\" % (k, where(e)))\n\n def unit_part(self, e):\n \"\"\"One of the three numbers a unit is kept as. The unit of a loop's turn has no uniqueness byte taken yet: it is read here.\"\"\"\n ref = self.unit(e[\"unit\"])\n if ref.none:\n return 0\n part = e[\"part\"]\n if part == \"ptr\":\n return ref.ptr\n if part == \"epd\":\n return ref.epd\n return f_maskread_epd(ref.epd + OFF_UID, 0xFF00) if ref.uid is None else ref.uid\n\n def when_alive(self, ref, body):\n \"\"\"`body()` when the unit is still the one that was kept: the slot has a sprite, its order is not\n \"die\", and its uniqueness byte is the one taken with the pointer.\"\"\"\n if ref.none:\n return\n if ref.uid is None:\n body()\n return\n ptr, epd = as_var(ref.ptr), as_var(ref.epd)\n if EUDIf()([ptr >= 1, MemoryEPD(epd + OFF_SPRITE, AtLeast, 1), MemoryXEPD(epd + OFF_OWNER_ORDER, AtLeast, 0x100, 0xFF00), MemoryXEPD(epd + OFF_UID, Exactly, ref.uid, 0xFF00)]):\n body()\n EUDEndIf()\n\n @staticmethod\n def cunit(ref):\n return CUnit(ref.epd, ptr=ref.ptr)\n\n def unit_field(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n self.when_alive(ref, lambda: out << self.field_of(self.cunit(ref), e[\"field\"], e))\n return out\n\n def field_of(self, cu, field, node):\n \"\"\"A unit's number, in the script's units: whole points for hit points (as the game shows them,\n a started point counting), shields and energy.\"\"\"\n if field == \"hp\":\n return f_div(cu.hp + 255, 256)[0]\n if field == \"maxHp\":\n return f_div(f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType), 256)[0]\n if field == \"shields\":\n return f_div(cu.shield, 256)[0]\n if field == \"maxShields\":\n return f_wread(UNITS_MAX_SHIELDS + cu.unitType * 2)\n if field == \"energy\":\n return f_div(cu.energy, 256)[0]\n if field in UNIT_TIMERS:\n return getattr(cu, UNIT_TIMERS[field])\n name = {\"owner\": \"owner\", \"type\": \"unitType\", \"x\": \"posX\", \"y\": \"posY\", \"kills\": \"killCount\", \"orderId\": \"orderID\", \"cooldown\": \"groundWeaponCooldown\", \"resources\": \"resourceAmount\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: unknown unit field %r%s\" % (field, where(node)))\n return getattr(cu, name)\n\n def unit_flag(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n\n def body():\n cu = self.cunit(ref)\n flag = e[\"flag\"]\n if flag == \"underAttack\":\n held = cu.attackNotifyTimer >= 1\n else:\n mask = {\"hallucinated\": STATUS_HALLUCINATION, \"cloaked\": STATUS_CLOAKED, \"burrowed\": STATUS_BURROWED, \"invincible\": STATUS_INVINCIBLE}.get(flag)\n if mask is None:\n raise Fail(\"trigscript: unknown unit flag %r%s\" % (flag, where(e)))\n held = cu.check_status_flag(mask)\n if EUDIf()(held):\n out << 1\n EUDEndIf()\n\n self.when_alive(ref, body)\n return out\n\n def location_bounds(self, number):\n base = EPD(MRGN + (int(number) - 1) * 20)\n return [f_dwread_epd(base + i) for i in range(4)]\n\n def scan(self, flt, node, body):\n \"\"\"The game's unit table, slot by slot: `body(ref, next_, exit_)` for every unit on the map the\n filter matches. Every test is one condition whose address moves on with the slot, so a slot\n that does not match costs a trigger or two.\"\"\"\n ptr, epd = EUDVariable(), EUDVariable()\n empty = MemoryEPD(0, Exactly, 0)\n dying = MemoryXEPD(0, Exactly, 0, 0xFF00)\n moving = [(empty, OFF_SPRITE), (dying, OFF_OWNER_ORDER)]\n match, values = [], []\n owner = flt.get(\"owner\")\n if owner is not None:\n who = self.current() if owner == CURRENT_PLAYER else int(owner)\n c = MemoryXEPD(0, Exactly, who if isinstance(who, int) else 0, 0xFF)\n match.append(c)\n moving.append((c, OFF_OWNER_ORDER))\n if not isinstance(who, int):\n values.append((c, who))\n kind = flt.get(\"type\")\n if kind is not None and kind < 228:\n c = MemoryXEPD(0, Exactly, int(kind), 0xFFFF)\n match.append(c)\n moving.append((c, OFF_TYPE))\n elif kind is not None and kind not in GROUP_BITS:\n raise Fail(\"trigscript: unknown unit type %r%s\" % (kind, where(node)))\n if flt.get(\"at\"):\n left, top, right, bottom = self.location_bounds(flt[\"at\"])\n # A location reaching past the map's edge starts below zero, which a unit's position never is.\n for v in (left, top):\n if EUDIf()(v >= 0x80000000):\n v << 0\n EUDEndIf()\n for comparison, mask, value in ((AtLeast, 0xFFFF, left), (AtMost, 0xFFFF, right), (AtLeast, 0xFFFF0000, f_bitlshift(top, 16)), (AtMost, 0xFFFF0000, f_bitlshift(bottom, 16))):\n c = MemoryXEPD(0, comparison, 0, mask)\n match.append(c)\n moving.append((c, OFF_POS))\n values.append((c, value))\n DoActions([ptr.SetNumber(UNIT_TABLE), epd.SetNumber(EPD(UNIT_TABLE))] + [SetMemory(c + 4, SetTo, EPD(UNIT_TABLE) + off) for c, off in moving])\n for c, value in values:\n f_dwwrite_epd(EPD(c + 8), value)\n head, next_, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n EUDJumpIf(ptr >= UNIT_END, exit_)\n EUDJumpIf(empty, next_)\n EUDJumpIf(dying, next_)\n if match:\n matched = Forward()\n EUDJumpIf(match, matched)\n EUDJump(next_)\n matched << NextTrigger()\n if kind in GROUP_BITS:\n group = fresh(f_bread(UNITS_GROUP + f_maskread_epd(epd + OFF_TYPE, 0xFFFF)))\n EUDJumpIf((group & GROUP_BITS[kind]) == 0, next_)\n body(UnitRef(ptr, epd, None), next_, exit_)\n next_ << NextTrigger()\n DoActions([SetMemory(c + 4, Add, UNIT_SIZE // 4) for c, _ in moving] + [ptr.AddNumber(UNIT_SIZE), epd.AddNumber(UNIT_SIZE // 4)])\n EUDJump(head)\n exit_ << NextTrigger()\n\n def pick(self, e):\n \"\"\"One unit among the matching: the first, the nearest to a location's centre, or one at random.\"\"\"\n by, flt = e[\"by\"], e.get(\"filter\", {})\n found_ptr, found_epd = fresh(0), fresh(0)\n\n def take(ref):\n found_ptr << ref.ptr\n found_epd << ref.epd\n\n if by == \"first\":\n def body(ref, next_, exit_):\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n elif by == \"nearest\":\n if e.get(\"mouse\") is not None:\n # A player's mouse, and nothing farther from it than `within`.\n cx = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"x\", \"player\": e[\"mouse\"]}, self, e))\n cy = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"y\", \"player\": e[\"mouse\"]}, self, e))\n least = fresh(int(e.get(\"within\", 48)) + 1)\n else:\n left, top, right, bottom = self.location_bounds(e[\"near\"])\n cx, cy = f_div(left + right, 2)[0], f_div(top + bottom, 2)[0]\n least = fresh(U32)\n\n def body(ref, next_, exit_):\n cu = self.cunit(ref)\n # |dx| + |dy| is enough to say which is nearest, and never overflows.\n d = distance(cu.posX, cx) + distance(cu.posY, cy)\n if EUDIf()(d < least):\n least << d\n take(ref)\n EUDEndIf()\n self.scan(flt, e, body)\n elif by == \"random\":\n # Count the matching, draw one, take the drawn one on a second pass.\n count = fresh(0)\n self.scan(flt, e, lambda ref, next_, exit_: count.__iadd__(1))\n none = Forward()\n EUDJumpIf(count == 0, none)\n drawn = f_div(f_dwrand(), count)[1]\n i = fresh(0)\n\n def body(ref, next_, exit_):\n hit = Forward()\n EUDJumpIf(i == drawn, hit)\n i.__iadd__(1)\n EUDJump(next_)\n hit << NextTrigger()\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n none << NextTrigger()\n else:\n raise Fail(\"trigscript: unknown pick %r%s\" % (by, where(e)))\n uid = fresh(0)\n if EUDIf()(found_ptr >= 1):\n uid << f_maskread_epd(found_epd + OFF_UID, 0xFF00)\n EUDEndIf()\n return UnitRef(found_ptr, found_epd, uid)\n\n def unit_loop(self, st, ctx):\n def body(ref, next_, exit_):\n self.vars[st[\"decl\"][\"id\"]] = LoopUnit(ref)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": next_}))\n self.scan(st.get(\"filter\", {}), st, body)\n\n def unit_write(self, st):\n ref = self.unit(st[\"unit\"])\n field = st[\"field\"]\n if field == \"invincible\":\n on = self.truth(st[\"value\"])\n\n def flag():\n cu = self.cunit(ref)\n if isinstance(on, int):\n cu.set_invincible() if on else cu.clear_invincible()\n return\n if EUDIf()(as_var(on) >= 1):\n cu.set_invincible()\n if EUDElse()():\n cu.clear_invincible()\n EUDEndIf()\n self.when_alive(ref, flag)\n return\n value = self.num(st[\"value\"])\n\n def write():\n cu = self.cunit(ref)\n if field == \"hp\":\n # Hit points at 0 are a dead unit, so that is what the write makes of it.\n if isinstance(value, int):\n if value == 0:\n cu.die()\n else:\n cu.hp = min(value, 0xFFFFFF) * 256\n return\n if EUDIf()(value == 0):\n cu.die()\n if EUDElse()():\n cu.hp = f_mul(saturate(value, 24), 256)\n EUDEndIf()\n elif field == \"shields\":\n cu.shield = points(value, 24)\n elif field == \"energy\":\n cu.energy = points(value, 8)\n elif field == \"kills\":\n cu.killCount = saturate(value, 8)\n elif field == \"resources\":\n cu.resourceAmount = saturate(value, 16)\n elif field == \"cooldown\":\n frames = saturate(value, 8)\n cu.groundWeaponCooldown = frames\n cu.airWeaponCooldown = frames\n cu.spellCooldown = frames\n elif field in UNIT_TIMERS:\n setattr(cu, UNIT_TIMERS[field], saturate(value, 8))\n else:\n raise Fail(\"trigscript: a unit's %s takes no write%s\" % (field, where(st)))\n self.when_alive(ref, write)\n\n def unit_do(self, st):\n ref = self.unit(st[\"unit\"])\n verb = st[\"verb\"]\n do = verb[\"do\"]\n amount = self.num(verb[\"amount\"]) if do in (\"damage\", \"heal\") else 0\n\n def act():\n cu = self.cunit(ref)\n if do == \"kill\":\n cu.die()\n elif do == \"remove\":\n cu.remove()\n elif do == \"give\":\n to = int(verb[\"to\"])\n cu.cgive(self.current() if to == CURRENT_PLAYER else to)\n elif do == \"order\":\n self.order(cu, verb, st)\n elif do == \"locate\":\n self.locate(cu, int(verb[\"location\"]))\n elif do in (\"damage\", \"heal\"):\n self.adjust(cu, do, amount, bool(verb.get(\"percent\")))\n else:\n raise Fail(\"trigscript: unknown unit verb %r%s\" % (do, where(st)))\n self.when_alive(ref, act)\n\n def order(self, cu, verb, node):\n \"\"\"The game's own Order, reaching this unit alone: a location is made a small box around the unit\n for the length of the action (the game did nothing with a box of no size), then put back.\"\"\"\n kind = {\"move\": Move, \"patrol\": Patrol, \"attack\": Attack}.get(verb[\"order\"])\n if kind is None:\n raise Fail(\"trigscript: unknown order %r%s\" % (verb[\"order\"], where(node)))\n base = EPD(MRGN + (SCRATCH_LOCATION - 1) * 20)\n kept = [f_dwread_epd(base + i) for i in range(5)]\n x, y = cu.posX, cu.posY\n for i, v in enumerate((x - 2, y - 2, x + 2, y + 2, 0)):\n f_dwwrite_epd(base + i, v)\n DoActions(Order(cu.unitType, cu.owner, SCRATCH_LOCATION, kind, int(verb[\"target\"])))\n for i, v in enumerate(kept):\n f_dwwrite_epd(base + i, v)\n\n def locate(self, cu, number):\n \"\"\"A location centred on the unit, its size kept.\"\"\"\n base = EPD(MRGN + (number - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n x, y = cu.posX - f_div(width, 2)[0], cu.posY - f_div(height, 2)[0]\n for i, v in enumerate((x, y, x + width, y + height)):\n f_dwwrite_epd(base + i, v)\n\n def center_location(self, st):\n \"\"\"A location centred on a point, its size kept.\"\"\"\n x, y = self.num(st[\"x\"]), self.num(st[\"y\"])\n base = EPD(MRGN + (int(st[\"location\"]) - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n nx, ny = x - f_div(width, 2)[0], y - f_div(height, 2)[0]\n for i, v in enumerate((nx, ny, nx + width, ny + height)):\n f_dwwrite_epd(base + i, v)\n\n def adjust(self, cu, do, amount, percent):\n \"\"\"Hit points down — at 0 the unit dies — or up to the type's maximum, in the game's own units (256 to a point).\"\"\"\n now = fresh(cu.hp)\n top = f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType)\n if percent:\n step = f_div(f_mul(top, as_var(amount)), 100)[0]\n else:\n step = amount * 256 if isinstance(amount, int) else f_mul(saturate(amount, 24), 256)\n if do == \"damage\":\n if EUDIf()(now <= step):\n cu.die()\n if EUDElse()():\n cu.hp = now - step\n EUDEndIf()\n return\n new = fresh(now + step)\n if EUDIf()(new >= top):\n new << top\n EUDEndIf()\n cu.hp = new\n\n # ── the game's tables ──\n def cell_address(self, c):\n index = c[\"index\"]\n if c.get(\"player\") and index == CURRENT_PLAYER:\n index = self.current()\n base = int(c[\"base\"]) + int(c.get(\"key\") or 0)\n return base + index * int(c[\"stride\"])\n\n def table_read(self, c, node):\n if c.get(\"special\"):\n raise Fail(\"trigscript: %s cannot be read%s\" % (c.get(\"name\"), where(node)))\n addr = self.cell_address(c)\n width = c[\"width\"]\n if width == \"bit\":\n v = fresh(0)\n if EUDIf()(MemoryX(addr, AtLeast, 1, 1 << int(c[\"bit\"]))):\n v << 1\n EUDEndIf()\n return v\n v = f_dwread(addr) if width == 4 else f_wread(addr) if width == 2 else f_bread(addr)\n scale = int(c.get(\"scale\") or 1)\n return f_div(v, scale)[0] if scale > 1 else v\n\n def table_write(self, st):\n c = st[\"cell\"]\n addr = self.cell_address(c)\n special = c.get(\"special\")\n if special == \"name\":\n if self.has_id(st[\"value\"]):\n f_wwrite(addr, self.text_id_of(st[\"value\"]))\n return\n # A name that was made: over the string kept for this unit type, on every computer - a name is nobody's in particular.\n made = self.text(st[\"value\"])\n sid = slot(\"the name of unit type %d\" % int(c[\"index\"]))\n texts()[\"show\"](GetMapStringAddr(sid), made.addr)\n self.used(made)\n f_wwrite(addr, sid)\n return\n width = c[\"width\"]\n if st.get(\"boolean\"):\n value = self.truth(st[\"value\"])\n else:\n value = self.num(st[\"value\"])\n scale = int(c.get(\"scale\") or 1)\n if scale > 1 and not st.get(\"scaled\"):\n value = value * scale if isinstance(value, int) else f_mul(value, scale)\n top = 1 if width == \"bit\" else (1 << (8 * width)) - 1\n if isinstance(value, int):\n value = min(value, top)\n elif width != 4 and width != \"bit\":\n value = saturate(value, 8 * width)\n if special == \"speed\":\n self.write_speed(int(c[\"index\"]), value)\n elif special == \"color\":\n f_bwrite(addr, value)\n f_bwrite(addr + MINIMAP_COLOR_OFFSET, value)\n elif width == \"bit\":\n mask = 1 << int(c[\"bit\"])\n if isinstance(value, int):\n DoActions(SetMemoryX(addr, SetTo, mask if value else 0, mask))\n else:\n if EUDIf()(value >= 1):\n DoActions(SetMemoryX(addr, SetTo, mask, mask))\n if EUDElse()():\n DoActions(SetMemoryX(addr, SetTo, 0, mask))\n EUDEndIf()\n elif width == 4:\n f_dwwrite(addr, value)\n elif width == 2:\n f_wwrite(addr, value)\n else:\n f_bwrite(addr, value)\n\n def write_speed(self, unit, speed):\n \"\"\"A unit type's top speed is its flingy's: the flingy is switched to table control and given the\n speed, with acceleration about a seventeenth of it and the braking distance v² / 2a to match\n (the Vulture's own figures), as Magenta writes it. Units made afterwards move at the new speed.\"\"\"\n flingy = f_bread(UNITS_FLINGY + unit)\n if isinstance(speed, int):\n acceleration = max(1, round(speed / 17))\n halt = max(1, round(speed * speed / (2 * acceleration)))\n else:\n acceleration = fresh(f_div(speed, 17)[0])\n if EUDIf()(acceleration == 0):\n acceleration << 1\n EUDEndIf()\n halt = fresh(f_div(f_mul(speed, speed), acceleration * 2)[0])\n if EUDIf()(halt == 0):\n halt << 1\n EUDEndIf()\n acceleration = saturate(acceleration, 16)\n f_bwrite(FLINGY_CONTROL + flingy, 0)\n f_dwwrite(FLINGY_SPEED + flingy * 4, speed)\n f_wwrite(FLINGY_ACCELERATION + flingy * 2, acceleration)\n f_dwwrite(FLINGY_HALT + flingy * 4, halt)\n\n # ── statements ──\n def block(self, statements, ctx):\n for st in statements:\n self.statement(st, ctx)\n\n def statement(self, st, ctx):\n k = st[\"kind\"]\n if k == \"declare\":\n s = self.declare(st[\"decl\"])\n if not st.get(\"failed\"):\n self.put(s, st[\"init\"], st[\"decl\"][\"kind\"])\n elif k == \"assignText\":\n self.put(self.var(st[\"target\"], st), st[\"value\"], \"text\")\n elif k == \"storeText\":\n self.store_text(st)\n elif k == \"releaseText\":\n block, i = self.array(st[\"block\"], st), fresh(self.num(st[\"index\"]))\n texts()[\"release\"](as_var(block.get(i)))\n block.set(i, 0)\n elif k == \"textLoop\":\n self.text_loop(st, ctx)\n elif k == \"assignUnit\":\n self.var(st[\"target\"], st).set(self.unit(st[\"value\"]))\n elif k == \"unitLoop\":\n self.unit_loop(st, ctx)\n elif k == \"unitWrite\":\n self.unit_write(st)\n elif k == \"unitDo\":\n self.unit_do(st)\n elif k == \"tableWrite\":\n self.table_write(st)\n elif k == \"assign\":\n self.var(st[\"target\"], st).set(self.num(st[\"value\"]))\n elif k == \"assignBool\":\n self.var(st[\"target\"], st).set(self.truth(st[\"value\"]))\n elif k == \"declareArray\":\n a = self.array(st[\"array\"], st)\n cell = self.num if a.decl[\"kind\"] == \"number\" else self.truth\n if isinstance(a, ListStorage):\n if st.get(\"fill\") is not None:\n v = cell(st[\"fill\"])\n a.declare_filled(v if isinstance(v, int) else fresh(v), int(a.decl[\"length\"]))\n else:\n values = [cell(v) for v in st.get(\"init\", [])]\n a.declare([v if isinstance(v, int) else fresh(v) for v in values])\n elif st.get(\"fill\") is not None:\n a.fill(cell(st[\"fill\"]))\n else:\n # Every value first, then the stores: [b, a] of two cells of the array itself is a swap.\n values = [cell(v) for v in st.get(\"init\", [])]\n values = [v if isinstance(v, int) else fresh(v) for v in values]\n for i, v in enumerate(values):\n a.set(i, v)\n elif k == \"push\":\n a = self.array(st[\"array\"], st)\n a.push((self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"]))\n elif k == \"pop\":\n self.array(st[\"array\"], st).pop()\n elif k == \"setLength\":\n self.array(st[\"array\"], st).set_length(self.num(st[\"value\"]))\n elif k == \"store\":\n a = self.array(st[\"array\"], st)\n value = (self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"])\n a.set(self.num(st[\"index\"]), value)\n elif k == \"if\":\n self.if_(st, ctx)\n elif k == \"while\":\n self.while_(st, ctx)\n elif k == \"do\":\n self.do_(st, ctx)\n elif k == \"for\":\n self.for_(st, ctx)\n elif k == \"unrolled\":\n self.unrolled(st, ctx)\n elif k == \"switch\":\n self.switch(st, ctx)\n elif k == \"break\":\n if \"break\" not in ctx:\n raise Fail(\"trigscript: break outside a loop%s\" % where(st))\n EUDJump(ctx[\"break\"])\n raise Leave()\n elif k == \"continue\":\n if \"continue\" not in ctx:\n raise Fail(\"trigscript: continue outside a loop%s\" % where(st))\n EUDJump(ctx[\"continue\"])\n raise Leave()\n elif k == \"return\":\n fn = ctx.get(\"fn\")\n if fn is None:\n raise Fail(\"trigscript: return outside a function%s\" % where(st))\n if st.get(\"value\") is not None and fn[\"result\"] is not None:\n self.put(fn[\"result\"], st[\"value\"], fn[\"kind\"])\n EUDJump(fn[\"end\"])\n raise Leave()\n elif k == \"sleep\":\n self.sleep(st)\n elif k == \"action\":\n self.action(st)\n elif k == \"print\":\n self.print_(st)\n elif k == \"centerLocation\":\n self.center_location(st)\n elif k == \"call\":\n self.call(st[\"call\"])\n elif k == \"block\":\n self.block(st[\"body\"], ctx)\n elif k == \"remark\":\n pass\n else:\n raise Fail(\"trigscript: unknown statement %r%s\" % (k, where(st)))\n\n def straight(self, statements, ctx):\n \"\"\"A statement list whose end may not be reached (a break inside): Leave stops it. True when the end was reached.\"\"\"\n try:\n self.block(statements, ctx)\n return True\n except Leave:\n return False\n\n def if_(self, st, ctx):\n else_l, end = Forward(), Forward()\n EUDJumpIfNot(self.cond(st[\"cond\"]), else_l)\n if self.straight(st[\"then\"], ctx):\n EUDJump(end)\n else_l << NextTrigger()\n if st.get(\"else\"):\n self.straight(st[\"else\"], ctx)\n end << NextTrigger()\n\n def while_(self, st, ctx):\n head, exit_ = Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n if self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": head})):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def do_(self, st, ctx):\n body, check, exit_ = Forward(), Forward(), Forward()\n body << NextTrigger()\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": check}))\n check << NextTrigger()\n EUDJumpIf(self.cond(st[\"cond\"]), body)\n exit_ << NextTrigger()\n\n def for_(self, st, ctx):\n head, update, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": update}))\n update << NextTrigger()\n if self.straight(st[\"update\"], ctx):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def unrolled(self, st, ctx):\n exit_ = Forward()\n for statements in st[\"iterations\"]:\n nxt = Forward()\n self.straight(statements, dict(ctx, **{\"break\": exit_, \"continue\": nxt}))\n nxt << NextTrigger()\n exit_ << NextTrigger()\n\n def switch(self, st, ctx):\n v = as_var(self.num(st[\"value\"]))\n exit_ = Forward()\n labels = [Forward() for _ in st[\"cases\"]]\n default = None\n for c, label in zip(st[\"cases\"], labels):\n if c[\"value\"] is None:\n default = label\n continue\n n = c[\"value\"]\n if n != n or n < -SIGN or n > U32: # NaN, or a value the variable never holds\n continue\n EUDJumpIf(v == int(n) & U32, label)\n EUDJump(default if default is not None else exit_)\n for c, label in zip(st[\"cases\"], labels):\n label << NextTrigger()\n self.straight(c[\"body\"], dict(ctx, **{\"break\": exit_}))\n exit_ << NextTrigger()\n\n def sleep(self, st):\n if self.in_function:\n raise Fail(\"trigscript: sleep inside a function that is called%s\" % where(st))\n if st.get(\"cycles\") is not None:\n frames = int(st[\"cycles\"])\n else:\n frames = max(1, int(round(float(st.get(\"ms\", 0)) * FRAMES_PER_SECOND / 1000)))\n index = len(self.resumes) + 1\n resume = Forward()\n self.resumes.append((index, resume))\n self.set_state(index)\n # The frame after this one is one frame later: sleep(frames(1)) goes on in the next frame, as the\n # simulator has it. (Until 3.4 the wait was one frame longer, so a loop sleeping a frame ran every other.)\n self.set_wait(frames - 1)\n EUDJump(self.frame_end)\n resume << NextTrigger()\n\n def action(self, st):\n r = st[\"record\"]\n fields = dict(locid1=r[\"location\"], strid=string_of(r[\"text\"]), wavid=string_of(r[\"wav\"]), time=r[\"time\"], player1=r[\"player\"], player2=r[\"target\"], unitid=r[\"unitId\"], acttype=r[\"type\"], amount=r[\"modifier\"], flags=r[\"flags\"])\n shown = None\n if st.get(\"text\") is not None:\n if self.has_id(st[\"text\"]):\n fields[\"strid\"] = self.text_id_of(st[\"text\"])\n else:\n kind = SLOT_OF_ACTION.get(int(r[\"type\"]))\n if kind is None:\n raise Fail(\"trigscript: this action's text cannot be one that is made%s\" % where(st))\n # Over the string kept for this kind of field - on the computer of the player the action is for, which is\n # the one the program is running as: the game reads the string again whenever it draws, and another\n # player's text must not be there when it does. Nothing of the script can read the string back.\n shown = self.text(st[\"text\"])\n if EUDIf()(IsUserCP()):\n texts()[\"show\"](GetMapStringAddr(slot(kind)), shown.addr)\n EUDEndIf()\n self.used(shown)\n fields[\"strid\"] = slot(kind)\n count = None\n for variable in st.get(\"variables\") or []:\n value = as_var(self.num(variable[\"expr\"]))\n field = variable[\"field\"]\n if field == \"modifier\":\n # A unit count: the byte field is not a variable's place, so the action is done once per\n # unit — as many as the variable says, 0 being none (in the record, 0 means \"all\").\n count = value\n continue\n name = {\"target\": \"player2\", \"time\": \"time\", \"player\": \"player1\", \"location\": \"locid1\", \"text\": \"strid\", \"wav\": \"wavid\", \"unitId\": \"unitid\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: no variable can stand in the %s field%s\" % (field, where(st)))\n if name == \"unitid\":\n # A unit type the game has: past the table, an action reads what is not a unit.\n value = fresh(value)\n if EUDIf()(value >= 228):\n value << 228\n EUDEndIf()\n fields[name] = value\n if count is None:\n DoActions(Action(**fields))\n return\n fields[\"amount\"] = 1\n for _ in EUDLoopRange(0, count):\n DoActions(Action(**fields))\n\n def print_(self, st):\n \"\"\"Text with values in it: every value first, then the text, shown only on the screen of the player it is for.\"\"\"\n args = []\n temps = []\n for part in st[\"parts\"]:\n k = part[\"kind\"]\n if k == \"text\":\n args.append(part[\"text\"])\n elif k == \"value\":\n v = self.text(part[\"text\"])\n temps.append(v)\n args.append(ptr2s(v.addr))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n args.append(str(v if part.get(\"unsigned\") else signed(v)))\n elif part.get(\"unsigned\"):\n args.append(v)\n else:\n # A number below zero: its minus sign, then how far below it is.\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n args.append(ptr2s(sign))\n args.append(size)\n elif k == \"name\":\n args.append(PName(self.one_player(part[\"player\"], st)))\n elif k == \"color\":\n args.append(PColor(self.one_player(part[\"player\"], st)))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n if not args:\n return\n # eudplib prints for the current player, and only on that player's own computer.\n show = (lambda: f_eprintln(*args)) if st.get(\"position\") == \"center\" else (lambda: GetGlobalStringBuffer().print(*args))\n to = int(st.get(\"to\", CURRENT_PLAYER))\n if to == CURRENT_PLAYER:\n show()\n self.used(*temps)\n return\n for p in group_slots(to, st):\n f_setcurpl(p)\n show()\n f_setcurpl(self.current())\n self.used(*temps)\n\n # ── texts ──\n def has_id(self, e):\n \"\"\"Whether a text is one of the built map's table whatever happens in the game (compiler/ir.ts#textHasId).\"\"\"\n k = e[\"kind\"]\n if k in (\"text\", \"textOf\"):\n return True\n if k == \"textVar\":\n return not isinstance(self.var(e[\"id\"], e), TextStorage)\n if k == \"textTernary\":\n return self.has_id(e[\"whenTrue\"]) and self.has_id(e[\"whenFalse\"])\n return False\n\n def text_id_of(self, e):\n \"\"\"The id of a text that has one: an int, or a variable.\"\"\"\n k = e[\"kind\"]\n if k == \"text\":\n return text_id(e[\"text\"])\n if k == \"textVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"textOf\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"textTernary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.text_id_of(e[\"whenTrue\"])\n if EUDElse()():\n t << self.text_id_of(e[\"whenFalse\"])\n EUDEndIf()\n return t\n raise Fail(\"trigscript: this text has no id%s\" % where(e))\n\n def used(self, *values):\n \"\"\"Values that have been used: a block that was the value's own goes back to the heap.\"\"\"\n for v in values:\n if v.taken and not isinstance(v.block, int):\n texts()[\"release\"](v.block)\n\n def texts_of(self, exprs):\n \"\"\"Several texts in the order they are written. A variable's text is only looked at - unless working a later\n one out runs a call, which may give that variable another text and its block back to the heap: then it is a copy.\"\"\"\n out = []\n for i, e in enumerate(exprs):\n v = self.text(e)\n if not v.taken and any(has_call(x) for x in exprs[i + 1:]):\n v = self.own(v)\n out.append(v)\n return out\n\n def own(self, v):\n \"\"\"A value as something to keep: its own block as it is, a copy of a variable's.\"\"\"\n if v.taken:\n return v\n addr, block, length = texts()[\"copy\"](v.addr, v.block, v.length())\n return TextVal(addr, block, length, True)\n\n def store_text(self, st):\n \"\"\"A text into cell i of three arrays (a row's): worked out first - it may be made from what the cells hold -\n then the block they held goes back, then they take the text, a copy of one that was only looked at.\"\"\"\n v = self.own(self.text(st[\"value\"]))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n i = fresh(self.num(st[\"index\"]))\n held = self.array(st[\"block\"], st)\n texts()[\"release\"](as_var(held.get(i)))\n self.array(st[\"addr\"], st).set(i, addr)\n held.set(i, block)\n self.array(st[\"chars\"], st).set(i, length)\n\n def put(self, storage, e, kind):\n \"\"\"A value into a variable. A text is worked out first - it may be made from what the variable holds - and only then does the block the variable held go back.\"\"\"\n if kind != \"text\":\n storage.set(self.value(e, kind))\n return\n if not isinstance(storage, TextStorage):\n storage.set(self.text_id_of(e))\n return\n v = self.own(self.text(e))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n texts()[\"release\"](storage.cell(storage.block))\n storage.write(addr, block, length)\n\n def written(self, parts, st):\n \"\"\"The parts of a text in the scratch, and where it ends. Every value is worked out first, since working one\n out may use the scratch itself; then the parts are written a group at a time, a group being what cannot\n come to more than a text holds, so that checking where the writing has got to between groups keeps it inside.\"\"\"\n T = texts()\n items, temps = [], []\n for part in parts:\n k = part[\"kind\"]\n if k == \"text\":\n data = part[\"text\"].encode(\"utf-8\")[:TEXT_BYTES].decode(\"utf-8\", \"ignore\")\n items.append(([data], len(data.encode(\"utf-8\")), None))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n data = str(v if part.get(\"unsigned\") else signed(v))\n items.append(([data], len(data), None))\n elif part.get(\"unsigned\"):\n items.append(([fresh(v)], 10, None))\n else:\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n items.append(([ptr2s(sign), size], 11, None))\n elif k == \"name\":\n items.append(([PName(self.one_player(part[\"player\"], st))], 25, None))\n elif k == \"color\":\n items.append(([PColor(self.one_player(part[\"player\"], st))], 1, None))\n elif k == \"value\":\n v = self.text(part[\"text\"])\n if not v.taken and has_call(parts[parts.index(part) + 1:]):\n v = self.own(v)\n temps.append(v)\n items.append((None, TEXT_BYTES, v))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n pos = fresh(T[\"scratch\"])\n f_bwrite(pos, 0)\n group, room, first = [], 0, [True]\n\n def flush():\n if not group:\n return\n if first[0]:\n pos << f_dbstr_print(pos, *group)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << f_dbstr_print(pos, *group)\n EUDEndIf()\n first[0] = False\n del group[:]\n\n for args, size, value in items:\n if value is not None:\n flush()\n room = 0\n if first[0]:\n pos << T[\"append\"](pos, value.addr)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << T[\"append\"](pos, value.addr)\n EUDEndIf()\n first[0] = False\n continue\n if room + size > TEXT_BYTES + 1:\n flush()\n room = 0\n group.extend(args)\n room += size\n flush()\n return pos, temps\n\n def text(self, e):\n \"\"\"A text: where it is, its block, its length (TextVal).\"\"\"\n T = texts()\n k = e[\"kind\"]\n if k == \"text\":\n return TextVal(fresh(GetMapStringAddr(e[\"text\"]) if e[\"text\"] else T[\"empty\"]), 0, len(e[\"text\"]), True)\n if k == \"textVar\" and isinstance(self.var(e[\"id\"], e), TextStorage):\n return self.var(e[\"id\"], e).get()\n if k == \"textAt\":\n # Cells that were never given a text are 0: the empty one, as a variable's are.\n i = fresh(self.num(e[\"index\"]))\n addr = fresh(self.array(e[\"addr\"], e).get(i))\n if EUDIf()(addr == 0):\n addr << T[\"empty\"]\n EUDEndIf()\n return TextVal(addr, fresh(self.array(e[\"block\"], e).get(i)), fresh(self.array(e[\"chars\"], e).get(i)), False)\n if self.has_id(e):\n return TextVal(T[\"address\"](as_var(self.text_id_of(e))), 0, None, True)\n if k == \"template\":\n pos, temps = self.written(e[\"parts\"], e)\n addr, block, length = T[\"make\"](pos)\n self.used(*temps)\n return TextVal(addr, block, length, True)\n if k == \"textTernary\":\n addr, block, length = EUDVariable(), EUDVariable(), EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n v = self.own(self.text(e[\"whenTrue\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n if EUDElse()():\n v = self.own(self.text(e[\"whenFalse\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n EUDEndIf()\n return TextVal(addr, block, length, True)\n if k == \"textSlice\":\n of = self.text(e[\"of\"])\n start = self.num(e[\"start\"]) if e.get(\"start\") is not None else 0\n end = self.num(e[\"end\"]) if e.get(\"end\") is not None else 0x7FFFFFFF\n addr, block, length = T[\"slice\"](of.addr, start, end)\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textPad\":\n of, fill = self.texts_of([e[\"of\"], e[\"with\"]])\n width = self.num(e[\"width\"])\n addr, block, length = T[\"pad\"](of.addr, of.length(), width, fill.addr, 1 if e[\"side\"] == \"start\" else 0)\n self.used(of, fill)\n return TextVal(addr, block, length, True)\n if k == \"textRepeat\":\n of = self.text(e[\"of\"])\n addr, block, length = T[\"repeat\"](of.addr, self.num(e[\"count\"]))\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textCall\":\n self.call(e[\"call\"])\n result = e[\"call\"].get(\"result\")\n if result is None:\n return TextVal(fresh(T[\"empty\"]), 0, 0, True)\n return self.var(result[\"decl\"][\"id\"], e).take_out()\n raise Fail(\"trigscript: unknown text %r%s\" % (k, where(e)))\n\n def text_number(self, e):\n T = texts()\n k = e[\"kind\"]\n if k == \"textIndexOf\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n start = self.num(e[\"from\"]) if e.get(\"from\") is not None else 0\n out = T[\"find\"](of.addr, find.addr, start)\n self.used(of, find)\n return out\n of = self.text(e[\"of\"])\n if k == \"textLength\":\n n = of.length()\n out = n if isinstance(n, int) else fresh(n)\n self.used(of)\n return out\n out = T[\"code\"](of.addr, self.num(e[\"index\"]))\n self.used(of)\n return out\n\n def text_truth(self, e):\n \"\"\"A comparison of two texts, or a test of one, as 0 / 1.\"\"\"\n T = texts()\n t = fresh(0)\n if e[\"kind\"] == \"textTest\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n if e[\"test\"] == \"startsWith\":\n t << T[\"starts\"](of.addr, find.addr)\n elif e[\"test\"] == \"endsWith\":\n t << T[\"ends\"](of.addr, find.addr)\n else:\n if EUDIfNot()(T[\"find\"](of.addr, find.addr, 0) == 0xFFFFFFFF):\n t << 1\n EUDEndIf()\n self.used(of, find)\n return t\n op = e[\"op\"]\n if op in (\"==\", \"!=\") and self.has_id(e[\"left\"]) and self.has_id(e[\"right\"]):\n # Two texts of the table: the same text is the same id.\n a, b = self.text_id_of(e[\"left\"]), self.text_id_of(e[\"right\"])\n if isinstance(a, int) and isinstance(b, int):\n return 1 if (a == b) == (op == \"==\") else 0\n if EUDIf()(as_var(a) == b):\n t << 1\n EUDEndIf()\n else:\n a, b = self.texts_of([e[\"left\"], e[\"right\"]])\n # The difference of the first bytes that differ, as a number of 32 bits: 0, below zero, or above.\n d = f_strcmp(a.addr, b.addr)\n self.used(a, b)\n if op in (\"==\", \"!=\"):\n if EUDIf()(d == 0):\n t << 1\n EUDEndIf()\n else:\n below = (lambda: d >= SIGN)\n if op in (\"<\", \">=\"):\n if EUDIf()(below()):\n t << 1\n EUDEndIf()\n else: # \"<=\" and \">\": whether it is not above\n if EUDIf()(EUDOr(d == 0, below())):\n t << 1\n EUDEndIf()\n return flipped(t) if op in (\">=\", \">\") else t\n return flipped(t) if op == \"!=\" else t\n\n def text_loop(self, st, ctx):\n \"\"\"`for (const ch of s)`: the text walked once, a character a turn, each a made text of its own.\"\"\"\n T = texts()\n turn = self.declare(st[\"decl\"])\n over = self.own(self.text(st[\"of\"]))\n held = fresh(over.block)\n at = fresh(over.addr)\n head, done = Forward(), Forward()\n inner = dict(ctx)\n inner[\"break\"], inner[\"continue\"] = done, head\n leave = None\n if ctx.get(\"fn\") is not None:\n # A return inside the loop leaves through here, so that the text the loop holds goes back first.\n leave = Forward()\n inner[\"fn\"] = dict(ctx[\"fn\"], end=leave)\n head << NextTrigger()\n n = T[\"size\"](at)\n EUDJumpIf(n == 0, done)\n f_memcpy(T[\"scratch\"], at, n)\n f_bwrite(T[\"scratch\"] + n, 0)\n addr, block, length = T[\"make\"](T[\"scratch\"] + n)\n T[\"release\"](turn.cell(turn.block))\n turn.write(addr, block, length)\n at += n\n if self.straight(st[\"body\"], inner):\n EUDJump(head)\n done << NextTrigger()\n T[\"release\"](held)\n if leave is not None:\n after = Forward()\n EUDJump(after)\n leave << NextTrigger()\n T[\"release\"](held)\n EUDJump(ctx[\"fn\"][\"end\"])\n after << NextTrigger()\n\n def value(self, e, kind):\n \"\"\"An expression as what a variable of `kind` holds.\"\"\"\n return self.num(e) if kind == \"number\" else self.unit(e) if kind == \"unit\" else self.truth(e)\n\n def function(self, id_, node):\n \"\"\"A called function: its parameters' cells, its result's, and its one body — an EUDFunc of no\n arguments, since the cells are the program's own (a row a player, as any variable is).\"\"\"\n made = self.made.get(id_)\n if made is not None:\n return made\n f = self.functions.get(id_)\n if f is None:\n raise Fail(\"trigscript: unknown function %r%s\" % (id_, where(node)))\n params = [self.declare(d) for d in f[\"params\"]]\n kind = f[\"result\"][\"kind\"] if f.get(\"result\") else \"void\"\n result = self.declare(f[\"result\"][\"decl\"]) if f.get(\"result\") else None\n lowering = self\n if f.get(\"recursive\"):\n return self.recursive_function(id_, f, params, kind, result)\n\n @EUDFunc\n def body():\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n lowering.in_function += 1\n try:\n lowering.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n lowering.in_function -= 1\n end << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n return made\n\n def recursive_function(self, id_, f, params, kind, result):\n \"\"\"A function that calls itself. eudplib's EUDFunc keeps one return address and is not there to be called\n until its body is whole, so this one is triggers of our own: the body in a scope apart, ended by a trigger\n whose next-trigger field is the return address - a cell a call writes, and a frame keeps. It is on record\n before its body is lowered, so a call of it met in that body finds it.\"\"\"\n start, tail = Forward(), Forward()\n # The return address a second time, in a variable: a frame keeps it from there, since reading a cell of\n # the game's memory back costs some thirty triggers and writing a variable out costs two.\n back = EUDVariable()\n\n def body():\n after = Forward()\n DoActions([SetNextPtr(tail, after), back.SetNumber(after)])\n EUDJump(start)\n after << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n entry = {\"f\": f, \"tail\": tail, \"back\": back}\n PushTriggerScope()\n start << NextTrigger()\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n self.in_function += 1\n self.within.append(entry)\n try:\n self.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n self.within.pop()\n self.in_function -= 1\n end << NextTrigger()\n tail << RawTrigger()\n PopTriggerScope()\n return made\n\n def saved_cells(self, saves, node):\n \"\"\"What a frame keeps, as (read, write) pairs a cell: the variables, a unit three cells, an array's handle four.\"\"\"\n pairs = []\n for id_ in saves.get(\"vars\", []):\n s = self.vars.get(id_)\n if s is None:\n # Declared further down the body: it has no cell yet, and the declaration that makes it sets it.\n continue\n if isinstance(s, UnitStorage):\n for cell in (s.ptr, s.epd, s.uid):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, TextStorage):\n for cell in (s.addr, s.block, s.len):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, Storage):\n pairs.append(self.cell_pair(s.store, s.rowed))\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, node)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n pairs.append(self.cell_pair(a.handle[name], a.rowed))\n return pairs\n\n def saved_texts(self, saves):\n \"\"\"The made texts among what a frame keeps: each owns a block, which the frame keeps for it.\"\"\"\n return [s for s in (self.vars.get(id_) for id_ in saves.get(\"vars\", [])) if isinstance(s, TextStorage)]\n\n def cell_pair(self, cell, rowed):\n if rowed:\n return (lambda: cell[self.player_of()]), (lambda v: cell.__setitem__(self.player_of(), v))\n return (lambda: cell), (lambda v: cell << v)\n\n def keep(self, call):\n \"\"\"Before a call that may come back into the function it is in: that function's cells and its return\n address onto the stack, the handles of its arrays set to \"no block\". Past the depth the map allows the\n program says so and stops for good.\"\"\"\n saves = call[\"saves\"]\n st = stack()\n if not self.within:\n raise Fail(\"trigscript: a call keeps a frame outside a function that calls itself%s\" % where(call))\n mine = self.within[-1]\n if EUDIf()(st[\"depth\"] >= STACK_DEPTH):\n GetGlobalStringBuffer().print(\"\\x06TrigScript: stack overflow in %s, line %s - %d calls deep. The program has stopped.\" % (saves.get(\"within\", \"a function\"), (call.get(\"at\") or {}).get(\"line\", \"?\"), STACK_DEPTH))\n st[\"depth\"] << 0\n st[\"at\"] << st[\"base\"]\n self.set_state(DONE)\n EUDJump(self.frame_end)\n EUDEndIf()\n pairs = self.saved_cells(saves, call)\n at = st[\"at\"]\n f_dwwrite_epd(at, mine[\"back\"])\n at += 1\n for read, _ in pairs:\n f_dwwrite_epd(at, read())\n at += 1\n st[\"depth\"] += 1\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n a.put(name, 0)\n # A text the function holds keeps its block on the stack; the variable holds none for the length of the call,\n # so that the inner run's first text does not give the outer run's block back.\n held = self.saved_texts(saves)\n for s in held:\n s.write(0, 0, 0)\n return pairs, mine, held\n\n def bring_back(self, call, kept):\n pairs, mine, held = kept\n st = stack()\n # The block the inner run left in a text goes back to the heap before the text is the outer run's again.\n for s in held:\n texts()[\"release\"](s.cell(s.block))\n # The block the inner run left in a handle goes back to the heap before the handle is the outer run's again.\n for id_ in call[\"saves\"].get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n ptr = fresh(a.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n a.heap[\"give\"](ptr, a.field(\"k\"))\n EUDEndIf()\n at = st[\"at\"]\n for _, write in reversed(pairs):\n at -= 1\n write(f_dwread_epd(at))\n at -= 1\n mine[\"back\"] << f_dwread_epd(at)\n f_dwwrite_epd(EPD(mine[\"tail\"]) + 1, mine[\"back\"])\n st[\"depth\"] -= 1\n\n def call_function(self, call, result):\n body, params, returned = self.function(call[\"fn\"], call)\n if len(params) != len(call[\"params\"]):\n raise Fail(\"trigscript: %s takes %d values, not %d%s\" % (call[\"fn\"], len(params), len(call[\"params\"]), where(call)))\n # Every argument first, then the parameters: an argument may be a call of this same function. A variable's\n # own cell is copied when a later argument holds a call, which may be of a function that writes that variable.\n values = []\n for i, p in enumerate(call[\"params\"]):\n if p[\"decl\"][\"kind\"] == \"text\":\n # A text is a copy of its own by the time the parameter takes it: three numbers, read now.\n t = self.own(self.text(p[\"init\"]))\n values.append((fresh(t.addr), fresh(t.block), fresh(t.length())))\n continue\n v = self.value(p[\"init\"], p[\"decl\"][\"kind\"])\n later = any(has_call(q[\"init\"]) for q in call[\"params\"][i + 1:])\n if later and p[\"decl\"][\"kind\"] != \"unit\" and not isinstance(v, int):\n v = fresh(v)\n values.append(v)\n # A call that may come back here keeps this function's frame: after the arguments are worked out (they read\n # it), before the parameters are set (they may be this function's own).\n kept = None\n if call.get(\"saves\"):\n # Copies: an argument may be one of this function's own parameters, which the ones set before it write over.\n values = [v if isinstance(v, (int, tuple)) else UnitRef(v.ptr if isinstance(v.ptr, int) else fresh(v.ptr), v.epd if isinstance(v.epd, int) else fresh(v.epd), v.uid if v.uid is None or isinstance(v.uid, int) else fresh(v.uid)) if isinstance(v, UnitRef) else fresh(v) for v in values]\n kept = self.keep(call)\n for s, v in zip(params, values):\n if isinstance(v, tuple):\n # What the parameter held from the call before goes back; then it holds this call's text.\n texts()[\"release\"](s.cell(s.block))\n s.write(*v)\n else:\n s.set(v)\n body()\n if kept is not None:\n self.bring_back(call, kept)\n if result is None:\n return 0\n if isinstance(result, TextStorage):\n # The function's text moves to the call's own result, from where a textCall takes it.\n v = returned.take_out()\n texts()[\"release\"](result.cell(result.block))\n result.write(v.addr, v.block, v.length())\n return 0\n result.set(returned.get())\n return result.get()\n\n def call(self, call):\n result = self.declare(call[\"result\"][\"decl\"]) if call.get(\"result\") else None\n if call.get(\"fn\") is not None:\n return self.call_function(call, result)\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and call[\"result\"][\"kind\"] != \"text\":\n result.set(NO_UNIT if call[\"result\"][\"kind\"] == \"unit\" else 0)\n for p in call[\"params\"]:\n s = self.declare(p[\"decl\"])\n self.put(s, p[\"init\"], p[\"decl\"][\"kind\"])\n end = Forward()\n self.straight(call[\"body\"], {\"fn\": {\"result\": result, \"kind\": call[\"result\"][\"kind\"] if call.get(\"result\") else \"void\", \"end\": end}})\n end << NextTrigger()\n return result.get() if result is not None else 0\n\n # ── one frame ──\n def frame(self):\n self.frame_end = Forward()\n start = Forward()\n skip = Forward()\n # Still waiting: count down and leave.\n EUDJumpIfNot(as_var(self.get_wait()) >= 1, skip)\n self.set_wait(as_var(self.get_wait()) - 1)\n EUDJump(self.frame_end)\n skip << NextTrigger()\n # The body once, resumes recorded as sleeps are met; the jump table is filled in after.\n table = Forward()\n EUDJump(table)\n start << NextTrigger()\n try:\n self.block(self.p[\"body\"], {})\n except Leave:\n pass\n # The body ended: the program stops for good, as the classic backend's does.\n self.set_state(DONE)\n EUDJump(self.frame_end)\n table << NextTrigger()\n EUDJumpIf(as_var(self.get_state()) == DONE, self.frame_end)\n for index, resume in self.resumes:\n EUDJumpIf(as_var(self.get_state()) == index, resume)\n EUDJump(start)\n self.frame_end << NextTrigger()\n\n def run(self):\n if self.per_player:\n for p in loop_players(self.slots):\n self.player = p\n f_setcurpl(p)\n self.frame()\n self.player = None\n else:\n # As a trigger owned by that player would: not at all once the player has left.\n if EUDIf()(f_playerexist(self.slots[0])):\n f_setcurpl(self.slots[0])\n self.frame()\n EUDEndIf()\n\n\ndef flipped(t):\n \"\"\"1 for 0 and 0 for anything else, in a variable of its own.\"\"\"\n out = fresh(0)\n if EUDIf()(t == 0):\n out << 1\n EUDEndIf()\n return out\n\n\ndef has_call(node):\n \"\"\"Whether a call is anywhere in a piece of the IR.\"\"\"\n if isinstance(node, list):\n return any(has_call(x) for x in node)\n if not isinstance(node, dict):\n return False\n if node.get(\"kind\") in (\"call\", \"textCall\") and isinstance(node.get(\"call\"), dict):\n return True\n return any(has_call(v) for v in node.values() if isinstance(v, (dict, list)))\n\n\ndef string_of(value):\n \"\"\"An action's text or sound: the string itself (eudplib adds it to the map), or an index the script named.\"\"\"\n if isinstance(value, str):\n return EncodeString(value) if value else 0\n return int(value or 0)\n\n\ndef group_slots(group, node):\n \"\"\"The player slots a player number stands for: a slot is itself, All Players and a force their human and computer players.\"\"\"\n playing = lambda p: GetPlayerInfo(p).typestr in (\"Human\", \"Computer\")\n if 0 <= group < 8:\n return [group]\n if group == 17:\n return [p for p in range(8) if playing(p)]\n if 18 <= group <= 21:\n return [p for p in range(8) if playing(p) and GetPlayerInfo(p).force == group - 18]\n raise Fail(\"trigscript: expected players 1 to 8, All Players or a force%s\" % where(node))\n\n\ndef owner_slots(program):\n \"\"\"The player slots a program runs for, from its owners and the map's player settings: a slot is\n itself, All Players and a force are their human and computer players — who a trigger runs for.\"\"\"\n slots = []\n for o in program.get(\"owners\", [program.get(\"owner\", 0)]):\n for p in group_slots(int(o), program):\n if p not in slots:\n slots.append(p)\n if not slots:\n raise Fail(\"trigscript: no human or computer player of this map is among the program's owners%s\" % where(program))\n return slots\n\n\ndef loop_players(slots):\n \"\"\"Each of `slots` who is in the game, as an EUDVariable: EUDLoopPlayer, for a list of our own.\"\"\"\n start, end = min(slots), max(slots)\n v = EUDVariable()\n v << start\n if EUDWhile()(v <= end):\n for i in range(start, end):\n if i not in slots:\n EUDContinueIf(v == i)\n EUDContinueIfNot(f_playerexist(v))\n yield v\n EUDSetContinuePoint()\n v += 1\n EUDEndWhile()\n\n\nBITWISE = (\"&\", \"|\", \"^\", \"<<\", \">>\", \">>>\")\nFLIPPED = {\"<\": \">\", \"<=\": \">=\", \">\": \"<\", \">=\": \"<=\", \"==\": \"==\", \"!=\": \"!=\"}\n\n\ndef points(value, bits):\n \"\"\"Whole points as the game stores them, 256 to a point; `bits` is how many bits of points the cell holds.\"\"\"\n if isinstance(value, int):\n return min(value, (1 << bits) - 1) * 256\n return f_mul(saturate(value, bits), 256)\n\n\ndef uses_random(node):\n if isinstance(node, dict):\n return node.get(\"kind\") in (\"random\", \"randomInt\") or (node.get(\"kind\") == \"pick\" and node.get(\"by\") == \"random\") or any(uses_random(v) for v in node.values())\n return isinstance(node, list) and any(uses_random(v) for v in node)\n\n\ndef distance(a, b):\n \"\"\"|a - b| of two places on the map, both from 0 up.\"\"\"\n d = fresh(a)\n d -= b\n if EUDIf()(d >= SIGN):\n d << 0 - d\n EUDEndIf()\n return d\n\n\ndef signed(v):\n \"\"\"The 32 bits as a signed number.\"\"\"\n v &= U32\n return v - (1 << 32) if v >= SIGN else v\n\n\ndef always(truth):\n \"\"\"A condition that is known when the map is built.\"\"\"\n return EUDVariable(1 if truth else 0) >= 1 # initial: a constant, never written\n\n\ndef relation(av, op, b):\n \"\"\"`av op b` between a variable and a variable or an int, both read from 0 up.\"\"\"\n if op == \"==\":\n return av == b\n if op == \"!=\":\n return av != b\n if op == \"<\":\n return av < b\n if op == \"<=\":\n return av <= b\n if op == \">\":\n return av > b\n return av >= b\n\n\ndef compare(a, op, b):\n return {\"==\": a == b, \"!=\": a != b, \"<\": a < b, \"<=\": a <= b, \">\": a > b, \">=\": a >= b}[op]\n\n\ndef condition(r):\n return Condition(r[\"location\"], r[\"player\"], r[\"amount\"], r[\"unitId\"], r[\"comparison\"], r[\"type\"], r[\"resource\"], r[\"flags\"], eudx=r.get(\"mask\", 0) or 0)\n\n\nclass Input:\n \"\"\"What the players do, as it reaches every computer: the cells chatEvent and MSQC write, by the\n names the editor put in their settings (compiler/input.ts, INPUT_NAMES), and the reads of them.\"\"\"\n\n MAX_NUMBER = 0xFFFFF\n\n def __init__(self, plan):\n self.plan = plan or {}\n self.keys = list(self.plan.get(\"keys\", []))\n self.buttons = list(self.plan.get(\"buttons\", []))\n self.chats = list(self.plan.get(\"chats\", []))\n self.mouse_base = self.plan.get(\"mouseBase\")\n # A person can sit in the map's human slots, which is where MSQC counts the mouse locations from.\n self.humans = [p for p in range(8) if GetPlayerInfo(p).typestr == \"Human\"]\n if plan and not self.humans:\n raise Fail(\"trigscript: a program reads keys, clicks, the mouse or chat, and the map has no human player to give any\")\n self.key_cells = [self.register(\"tsin_key%d\" % i, EUDArray(12)) for i in range(len(self.keys))]\n self.button_cells = [self.register(\"tsin_button%d\" % i, EUDArray(12)) for i in range(len(self.buttons))]\n self.captures = max([len(c[\"captures\"]) for c in self.chats] or [0])\n if self.chats:\n # Local: what chatEvent found on this computer, and what the patterns made of it.\n self.heard = self.register(\"tsin_heard\", EUDVariable())\n self.pointer = self.register(\"tsin_pointer\", EUDVariable())\n self.length = self.register(\"tsin_length\", EUDVariable())\n self.register(\"tsin_pattern\", EUDVariable())\n self.chat_local = self.register(\"tsin_chat\", EUDVariable())\n self.capture_local = [self.register(\"tsin_capture%d\" % i, EUDVariable()) for i in range(self.captures)]\n # Everyone's: what MSQC delivered this frame, by player; 0xFFFFFFFF on a frame with nothing.\n self.chat_in = self.register(\"tsin_chat_in\", EUDArray(12))\n self.capture_in = [self.register(\"tsin_capture%d_in\" % i, EUDArray(12)) for i in range(self.captures)]\n self.unit_table = None\n\n @staticmethod\n def register(name, cell):\n EUDRegisterObjectToNamespace(name, cell)\n return cell\n\n # ── reads ──\n def read(self, i, low, node):\n source = i.get(\"source\")\n p = low.one_player(i[\"player\"], node)\n if source == \"key\":\n return self.cell(self.key_cells, self.keys, i[\"key\"], node)[p]\n if source == \"click\":\n return self.cell(self.button_cells, self.buttons, i[\"button\"], node)[p]\n if source == \"mouse\":\n return self.mouse(p, 0 if i[\"axis\"] == \"x\" else 1, node)\n if source == \"chat\":\n number = self.chat_number(i[\"pattern\"], node)\n out = fresh(0)\n if EUDIf()(self.chat_in[p] == number):\n out << (1 if i.get(\"capture\") is None else self.capture_in[int(i[\"capture\"])][p])\n EUDEndIf()\n return out\n raise Fail(\"trigscript: unknown input %r%s\" % (source, where(node)))\n\n @staticmethod\n def cell(cells, names, name, node):\n if name not in names:\n raise Fail(\"trigscript: the IR's input plan has no %r%s\" % (name, where(node)))\n return cells[names.index(name)]\n\n def chat_number(self, pattern, node):\n for index, c in enumerate(self.chats):\n if c[\"pattern\"] == pattern:\n return index + 1\n raise Fail(\"trigscript: the IR's input plan has no pattern %r%s\" % (pattern, where(node)))\n\n def mouse(self, p, axis, node):\n \"\"\"MSQC keeps the mouse of the map's first human slot in location `mouseBase`, the next slot's in the next.\"\"\"\n if self.mouse_base is None:\n raise Fail(\"trigscript: the IR's input plan keeps no mouse%s\" % where(node))\n first = min(self.humans)\n cell = lambda h: EPD(MRGN + (int(self.mouse_base) - 1 + h - first) * 20) + axis\n if isinstance(p, int):\n return f_dwread_epd(cell(p)) if p in self.humans else 0\n out = fresh(0)\n for h in self.humans:\n if EUDIf()(p == h):\n out << f_dwread_epd(cell(h))\n EUDEndIf()\n return out\n\n # ── the typed line, on the computer it was typed on ──\n @staticmethod\n def hash_of(data):\n \"\"\"What `hashed` makes of the same bytes: capitals A to Z as small letters, h = h × 31 + byte.\"\"\"\n h = 0\n for b in data:\n if 65 <= b <= 90:\n b += 32\n h = (h * 31 + b) & U32\n return h\n\n def hashed(self, pos, stop_at_space):\n \"\"\"The hash of the line from `pos` to its end or, with `stop_at_space`, to the next space; `pos` moves past it.\"\"\"\n h = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = fresh(f_bread(self.pointer + pos))\n if stop_at_space:\n EUDBreakIf(ch == 32)\n if EUDIf()([ch >= 65, ch <= 90]):\n ch += 32\n EUDEndIf()\n h << f_mul(h, 31) + ch\n pos += 1\n EUDEndWhile()\n return h\n\n def units(self):\n \"\"\"Unit names by hash, in hash order, for a search by halves: two arrays side by side.\"\"\"\n if self.unit_table is None:\n by_hash = {}\n for name, unit in self.plan.get(\"unitNames\", []):\n by_hash.setdefault(self.hash_of(name.encode(\"utf-8\")), int(unit))\n ordered = sorted(by_hash.items())\n self.unit_table = (EUDArray([h for h, _ in ordered] or [0]), EUDArray([u for _, u in ordered] or [0]), len(ordered))\n return self.unit_table\n\n def capture(self, c, pos, fail):\n \"\"\"One value out of the line at `pos`, or a jump to `fail`.\"\"\"\n value = fresh(0)\n if c[\"kind\"] == \"number\":\n digits = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = f_bread(self.pointer + pos)\n EUDBreakIf(ch <= 47)\n EUDBreakIf(ch >= 58)\n value << f_mul(value, 10) + ch - 48\n if EUDIf()(value >= self.MAX_NUMBER + 1):\n value << self.MAX_NUMBER\n EUDEndIf()\n pos += 1\n digits += 1\n EUDEndWhile()\n EUDJumpIf(digits == 0, fail)\n elif c[\"kind\"] == \"word\":\n h = self.hashed(pos, True)\n found = fresh(0)\n for index, word in enumerate(c[\"words\"]):\n if EUDIf()(h == self.hash_of(word.encode(\"utf-8\"))):\n value << index\n found << 1\n EUDEndIf()\n EUDJumpIf(found == 0, fail)\n elif c[\"kind\"] == \"unit\":\n h = self.hashed(pos, False)\n hashes, units, count = self.units()\n lo, hi = fresh(0), fresh(count)\n if EUDWhile()(lo < hi):\n mid = f_div(lo + hi, 2)[0]\n if EUDIf()(hashes[mid] < h):\n lo << mid + 1\n if EUDElse()():\n hi << mid\n EUDEndIf()\n EUDEndWhile()\n EUDJumpIf(lo >= count, fail)\n EUDJumpIfNot(hashes[lo] == h, fail)\n value << units[lo]\n else:\n raise Fail(\"trigscript: unknown chat capture %r\" % (c[\"kind\"],))\n return value\n\n def match_line(self):\n \"\"\"The line the local player typed against every pattern, in order: the first that fits the whole\n line gives its number and values to the cells MSQC sends from. Nothing fits: nothing is sent.\"\"\"\n DoActions([self.chat_local.SetNumber(0)] + [c.SetNumber(0) for c in self.capture_local])\n if EUDIf()(self.heard >= 1):\n done = Forward()\n for index, chat in enumerate(self.chats):\n fail = Forward()\n pos = fresh(0)\n values = []\n for seg in chat[\"segments\"]:\n if isinstance(seg, str):\n data = seg.encode(\"utf-8\")\n EUDJumpIf(pos + len(data) >= self.length + 1, fail)\n EUDJumpIfNot(f_memcmp(self.pointer + pos, Db(data + b\"\\0\"), len(data)) == 0, fail)\n pos += len(data)\n else:\n values.append(self.capture(chat[\"captures\"][int(seg)], pos, fail))\n EUDJumpIfNot(pos == self.length, fail)\n DoActions(self.chat_local.SetNumber(index + 1))\n for cell, value in zip(self.capture_local, values):\n cell << value\n EUDJump(done)\n fail << NextTrigger()\n done << NextTrigger()\n EUDEndIf()\n\n\n# Where each trigger came from, for a run in the lifted game to name the line a trigger belongs to (TrigScript 4,\n# A1). Only when the build asks for it (the `triggerMap` setting): every RawTrigger made is tagged with the\n# statement being lowered, or with the eudplib function whose body it is, and once the payload is laid out one\n# header line and lines of rows say where each trigger sits in it:\n# TRIGSCRIPT-MAP {\"payload\": , \"count\": }\n# TRIGSCRIPT-ROWS [[offset, conditions, actions, origin], ...] (400 a line, as many lines as it takes)\n# An origin is \"line:column\" (or \"file:line:column\"), \"fn:\" for a function of eudplib's, \"\" for what no\n# statement made (a program's frame, its sleeps), or \"eudplib\" for what eudplib made before this file was read. The payload sits at the end of the strings section, so a run\n# finds it at the section's address plus its length less the payload's.\nif settings.get(\"triggerMap\"):\n import eudplib.core.allocator.payload as _payload\n from eudplib.core.eudfunc.eudfuncn import EUDFuncN as _EUDFuncN\n from eudplib.core.rawtrigger.rawtriggerdef import RawTrigger as _RawTrigger\n\n _ORIGINS = []\n _MADE = {}\n\n def _origin_of(at):\n if not at:\n return \"\"\n if at.get(\"file\"):\n return \"%s:%s:%s\" % (at.get(\"file\"), at.get(\"line\"), at.get(\"column\"))\n return \"%s:%s\" % (at.get(\"line\"), at.get(\"column\"))\n\n _trigger_init = _RawTrigger.__init__\n\n def _tagged_trigger(self, *args, **kwargs):\n _trigger_init(self, *args, **kwargs)\n _MADE[self] = _ORIGINS[-1] if _ORIGINS else \"\"\n\n _RawTrigger.__init__ = _tagged_trigger\n\n _func_body = _EUDFuncN._create_func_body\n\n def _tagged_body(self):\n _ORIGINS.append(\"fn:\" + getattr(self._bodyfunc, \"__name__\", \"?\"))\n try:\n return _func_body(self)\n finally:\n _ORIGINS.pop()\n\n _EUDFuncN._create_func_body = _tagged_body\n\n _statement = Lowering.statement\n\n def _tagged_statement(self, st, ctx):\n _ORIGINS.append(_origin_of(st.get(\"at\")) or (_ORIGINS[-1] if _ORIGINS else \"\"))\n try:\n return _statement(self, st, ctx)\n finally:\n _ORIGINS.pop()\n\n Lowering.statement = _tagged_statement\n\n _construct = _payload._construct_payload\n\n def _construct_and_say():\n built = _construct()\n found = _payload._found_objects_dict\n rows = []\n # (Every trigger in the payload, also those eudplib made before this file was read, at its own import:\n # those say \"eudplib\".)\n for trg, i in found.items():\n if not isinstance(trg, _RawTrigger):\n continue\n origin = _MADE.get(trg, \"eudplib\")\n rows.append([_payload._payload_builder.get_object_addr(i).offset, len(trg._conditions), len(trg._actions), origin])\n rows.sort()\n # (In parts, so that no one line of the log runs to megabytes.)\n print(\"TRIGSCRIPT-MAP \" + json.dumps({\"payload\": len(built.data), \"count\": len(rows)}, separators=(\",\", \":\")))\n for k in range(0, len(rows), 400):\n print(\"TRIGSCRIPT-ROWS \" + json.dumps(rows[k:k + 400], separators=(\",\", \":\")))\n return built\n\n _payload._construct_payload = _construct_and_say\n\n\nINPUT = Input(IR.get(\"input\"))\nPROGRAMS = [Lowering(p) for p in IR.get(\"programs\", [])]\n\n\ndef onPluginStart():\n # eudplib's generator starts from the same seed in every game; the game's own randomness (a switch randomized) seeds it.\n if uses_random(IR.get(\"programs\", [])):\n f_randomize()\n\n\ndef beforeTriggerExec():\n # After chatEvent has looked for a typed line, before MSQC sends what it was.\n if INPUT.chats:\n INPUT.match_line()\n\n\ndef afterTriggerExec():\n for prog in PROGRAMS:\n prog.run()\n"; +export const TRIGSCRIPT_PY: string = "\"\"\"\n[trigscript]\nir : /work/files/trigscript.json\n\nTrigScript's programs: the euddraft plugin that lowers the compiler's IR — data, never\ncode — into eudplib. The IR file is what the plugin's compiler wrote (docs/ir.md);\nthis module is handed to the eudplib library plugin as a source with every build, so the\ntwo halves of the IR version are always the same build.\n\nA program is a coroutine the game runs every frame. Its body is lowered to straight-line\neudplib triggers with jumps between labels: `if` / `while` / `switch` become conditional\njumps, and `sleep` stores the label to resume at in a state variable, sets a frame counter,\nand leaves the frame. The frame's entry counts the wait down, then jumps to the stored\nlabel. A program runs for the players it is owned by, as a trigger would: one owner runs\nit as that player while that player is in the game; several owners, All Players or a force\nrun it once each frame for every such player who is in the game, CurrentPlayer set, its\nvariables and its state as 12-slot arrays indexed by the player.\n\nText is written out in the IR: an action's `text` or `wav` is the string itself, which\neudplib adds to the built map's string table, or a number when the script named an index\nof the map's own. The map the user edits never holds a program's strings.\n\nA read is a value of the game taken when the expression is evaluated. What a condition compares\nis read from the game's tables where the table is that value (a player's deaths, kills, ore, gas)\nand otherwise found by asking the condition itself — \"at least 2^31? at least that plus 2^30? …\" —\nso a read means what the condition means, for a force's minerals or the units at a location alike.\nPlayer facts are bytes of the player tables. Text with values in it is printed through eudplib's\nstring buffer, for the player it is for and nobody else.\n\nA unit of the game is a pointer into the game's unit table — 1700 slots of 336 bytes — with the slot's\nuniqueness byte kept beside it: the game gives a dead unit's slot to the next unit made, so a unit kept\nin a variable is checked before every use (a sprite, an order other than \"die\", the same uniqueness\nbyte) and reads 0, and takes no write, once it is gone. A loop over units and a pick walk the whole\ntable with conditions whose address is moved on a slot at a time, as eudplib's own EUDLoopUnit2 does;\na dying unit is passed over. What a unit can be asked and told is what Magenta's probe maps saw\nworking in Remastered: no position, cloak or speed writes. The game's tables (`stats()`) are plain\ncells at addresses the IR carries; a speed is four flingy records, a colour two bytes, a name a string.\n\nWhat the players do — keys, clicks, the mouse, what they type — happens on one computer, and two\nplugins the build adds beside this one bring it to all of them in step. chatEvent (before this\nplugin) finds the line the local player typed; this plugin matches it against the programs'\npatterns, there and then, into a number for the pattern and up to three values; MSQC (after this\nplugin) sends those, the keys and the clicks to every computer as the player they came from, and\nkeeps each player's mouse in a location. They land in arrays registered by name — which is how the\nother two plugins' settings reach them — a cell per player, fresh every frame: an input reads 1 on\nthe frame it arrives. The IR's `input` lists what is asked for; the editor wrote the settings from it.\n\nNumbers keep one contract with the simulator: 32 bits, a `number` signed and a `u32` not, wrapping at\neither end. + - * and the bitwise operators are the same bits whichever way they are read; what reads\nthem one way or the other - a comparison, a division, a shift right, min and max, a printed number -\nsays which in the IR (`unsigned`), so nothing here works a type out. Where the game takes nothing below\nzero the compiler has already written max(v, 0), and a store keeps stopping at the top of what it holds.\nIn here a number known when the map is built is a Python int holding the 32 bits, 0 to 2^32 - 1.\n\nA text is kept one of two ways, a variable at a time (the IR says which). One that only ever holds texts written in\nthe script is the text's id in the built map's string table: a number. One that is made while the map is played is\nthree cells - where its bytes are (UTF-8, ended by a 0), the block of the heap it owns (0: none, the bytes are a\nstring of the table) and its length in characters. A made text is written into one scratch buffer, measured, and\ncopied into a block of just that size, whose first cell says which size; what holds the text owns the block, so\nassigning copies it, a value that was just made is moved, and whatever a variable held goes back to the heap once\nits new value is worked out. compiler/simulateIr.ts takes and gives blocks in the same order, so both run out at\nthe same text. A made text in an action's field goes over a string the build keeps for that kind of field, on the\ncomputer of the player the action is for and nowhere else: the game reads such a string again whenever it draws.\n\"\"\"\nimport json\n\nfrom eudplib import *\nfrom eudplib.core.mapdata.stringmap import ForceAddString\nfrom eudplib.memio.rwcommon import br1, br2, bw1\n\nIR_VERSION = 14\nFRAMES_PER_SECOND = 24\n# Where the game keeps what a read reads (1.16.1 addresses, which Remastered emulates). The player\n# tables are the ones Magenta's probes 5 and 8 read in the game.\nDEATHS_TABLE = 0x58A364\nKILLS_TABLE = 0x5878A4\nORE_TABLE = 0x57F0F0\nGAS_TABLE = 0x57F120\nPLAYER_BYTES = {\"slot\": 0x57F1B4, \"race\": 0x57F1C0}\n# Per race (144 bytes apart: Zerg, Terran, Protoss), a dword per player, in half supplies.\nSUPPLY_TABLES = {\"provided\": 0x582144, \"used\": 0x582174, \"max\": 0x5821A4}\n# The unit table, and a unit's dwords the scans test (as EPD offsets from the unit's own).\nUNIT_TABLE = 0x59CCA8\nUNIT_SIZE = 336\nUNIT_SLOTS = 1700\nUNIT_END = UNIT_TABLE + UNIT_SIZE * UNIT_SLOTS\nOFF_SPRITE, OFF_POS, OFF_OWNER_ORDER, OFF_TYPE, OFF_UID = 0x0C // 4, 0x28 // 4, 0x4C // 4, 0x64 // 4, 0xA4 // 4\n# units.dat: max hit points (dword, 256 to a point), max shields (word), the group flags a trigger's\n# Men / Buildings / Factories go by (byte), the flingy a type moves as (byte).\nUNITS_MAX_HP = 0x662350\nUNITS_MAX_SHIELDS = 0x660E00\nUNITS_GROUP = 0x6637A0\nUNITS_FLINGY = 0x6644F8\nGROUP_BITS = {230: 0x08, 231: 0x10, 232: 0x20}\n# flingy.dat, by flingy id: movement control (byte), top speed (dword), acceleration (word), halt distance (dword).\nFLINGY_CONTROL, FLINGY_SPEED, FLINGY_ACCELERATION, FLINGY_HALT = 0x6C9858, 0x6C9EF8, 0x6C9C78, 0x6C9930\nMINIMAP_COLOR_OFFSET = 0x581DD6 - 0x581D76\n# The location table, and the one an order borrows for the length of one action (put back after).\nMRGN = 0x58DC60\nSCRATCH_LOCATION = 255\nUNIT_TIMERS = {\"stim\": \"stimTimer\", \"ensnare\": \"ensnareTimer\", \"plague\": \"plagueTimer\", \"lockdown\": \"lockdownTimer\", \"maelstrom\": \"maelstromTimer\", \"irradiate\": \"irradiateTimer\", \"stasis\": \"stasisTimer\"}\nSTATUS_INVINCIBLE, STATUS_HALLUCINATION, STATUS_BURROWED, STATUS_CLOAKED = 0x04000000, 0x40000000, 0x00000010, 0x00000300\nCOND_COMMAND, COND_BRING, COND_ACCUMULATE, COND_KILL, COND_OPPONENTS, COND_DEATHS = 2, 3, 4, 5, 14, 15\nCURRENT_PLAYER = 13\n# The state of a program whose body ended: nothing resumes it.\nDONE = 0xFFFFFFFF\nU32 = 0xFFFFFFFF\nSIGN = 0x80000000\n\nMINUS_SIGN = Db(b\"-\\0\\0\\0\")\nNO_SIGN = Db(b\"\\0\\0\\0\\0\")\n\nwith open(settings[\"ir\"], encoding=\"utf-8\") as _f:\n IR = json.load(_f)\nif IR.get(\"version\") != IR_VERSION:\n raise RuntimeError(\"trigscript: the IR is version %r; this plugin reads version %d\" % (IR.get(\"version\"), IR_VERSION))\n\n\ndef where(node):\n \"\"\"\" at main.ts:12:5\" for a node with a position — what the editor parses back into a marker.\"\"\"\n at = node.get(\"at\") if isinstance(node, dict) else None\n if not at:\n return \"\"\n if at.get(\"file\"):\n return \" at %s:%s:%s\" % (at.get(\"file\"), at.get(\"line\"), at.get(\"column\"))\n return \" at %s:%s\" % (at.get(\"line\"), at.get(\"column\"))\n\n\nclass Fail(RuntimeError):\n pass\n\n\nclass Leave(Exception):\n \"\"\"Raised through the lowering when a statement ends the straight line (break / continue / return).\"\"\"\n\n\nclass Storage:\n \"\"\"A variable's cell: plain, or a 12-slot row of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n self.store = EUDArray([0] * 12) if self.rowed else EUDVariable(0) # initial: the variable's own cell\n\n def get(self):\n return self.store[self.player_of()] if self.rowed else self.store\n\n def set(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n if self.rowed:\n self.store[self.player_of()] = value\n else:\n self.store << value\n\n\nclass ArrayStorage:\n \"\"\"An array's cells: `length` of them, twelve rows of that in a per-player program, or - `values` - a list\n the script computed when it was built, which is in the map as it loads and which nothing writes. An index\n past either end (read from 0 up, so one below zero too) reads 0 and stores nothing.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.bits = decl.get(\"bits\")\n values = decl.get(\"values\")\n self.rowed = per_player and not decl.get(\"shared\") and values is None\n self.player_of = player_of\n if decl.get(\"texts\") is not None:\n # A list of texts the script has: a cell is the text's id in the built map's table.\n values = [text_id(t) for t in decl[\"texts\"]]\n self.store = EUDArray([int(v) & U32 for v in values] if values is not None else [0] * (self.length * (12 if self.rowed else 1))) # initial: a table's values, or cells declareArray sets\n\n def at(self, index):\n \"\"\"The cell's place among the store's: the player's row first.\"\"\"\n if not self.rowed:\n return index\n p = self.player_of()\n row = p * self.length if isinstance(p, int) else f_mul(p, self.length)\n return row + index\n\n def get(self, index):\n if isinstance(index, int):\n return self.store[self.at(index)] if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.store[self.at(index)]\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n if isinstance(index, int):\n if index < self.length:\n self.store[self.at(index)] = value\n return\n if EUDIf()(index <= self.length - 1):\n self.store[self.at(index)] = value\n EUDEndIf()\n\n def fill(self, value):\n if self.length <= 16:\n for i in range(self.length):\n self.set(i, value)\n return\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\nclass SliceStorage:\n \"\"\"A window on another array's cells - a row of a grid: cell i is cell `offset + i` of the array it is a window\n on, `offset` a variable of the program. Past its own end it reads 0 and stores nothing, so a row never reaches\n into the next; what it is a window on keeps its own ends (and its own width, and its row a player).\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.length = int(decl[\"length\"])\n self.lowering = lowering\n\n def of(self):\n return self.lowering.array(self.decl[\"slice\"][\"of\"], self.decl)\n\n def offset(self):\n return self.lowering.var(self.decl[\"slice\"][\"offset\"], self.decl).get()\n\n def get(self, index):\n if isinstance(index, int):\n return self.of().get(self.offset() + index) if index < self.length else 0\n out = fresh(0)\n if EUDIf()(index <= self.length - 1):\n out << self.of().get(self.offset() + index)\n EUDEndIf()\n return out\n\n def set(self, index, value):\n if isinstance(index, int):\n if index < self.length:\n self.of().set(self.offset() + index, value)\n return\n if EUDIf()(index <= self.length - 1):\n self.of().set(self.offset() + index, value)\n EUDEndIf()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= self.length - 1):\n self.set(i, v)\n i += 1\n EUDEndWhile()\n\n\n# The heap the programs' growing arrays share. A block is a power of two of cells, four at least; one given back\n# waits in its size's list (its first cell links the next) for whoever wants that size next; new ground is taken\n# from the bottom up. Cell 0 is never handed out: 0 is \"no block\". compiler/simulateIr.ts counts the same way, so\n# both run out at the same push. (Recursion's stack is an array of its own, below: how deep a function may go is\n# then the same whatever the arrays hold.)\nHEAP_CELLS = min(1 << 20, max(1024, int(IR.get(\"heap\", 16384)))) # the map's script settings, or the default\nHEAP_SMALLEST = 4\nHEAP_ROOMS = []\nwhile HEAP_SMALLEST << len(HEAP_ROOMS) <= HEAP_CELLS:\n HEAP_ROOMS.append(HEAP_SMALLEST << len(HEAP_ROOMS))\n_HEAP = {}\n\n\ndef heap():\n \"\"\"The heap's cells and its functions, made when the first growing array is met.\"\"\"\n if _HEAP:\n return _HEAP\n cells = EUDArray(HEAP_CELLS)\n rooms = EUDArray(HEAP_ROOMS + [0xFFFFFFFF]) # initial: the sizes, never written\n free = EUDArray(len(HEAP_ROOMS)) # initial: no block waits\n top = EUDVariable(1) # initial: the heap's own state, for the whole game\n said = EUDVariable(0) # initial: the heap's own state\n\n @EUDFunc\n def take(k):\n \"\"\"A block of size class k: its place among the cells, or 0 when there is none.\"\"\"\n at = EUDVariable()\n at << free[k]\n if EUDIf()(at >= 1):\n free[k] = cells[at]\n EUDReturn(at)\n EUDEndIf()\n end = top + rooms[k]\n if EUDIf()(end <= HEAP_CELLS):\n at << top\n top << end\n EUDReturn(at)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def give(at, k):\n cells[at] = free[k]\n free[k] = at\n\n @EUDFunc\n def grow(ptr, length, room, k, need):\n \"\"\"Room for `need` cells: a larger block, the cells copied over, the old block given back. Returns the\n handle as it is now and whether there is room; when the heap has no block left the handle is unchanged.\"\"\"\n if EUDIf()(need <= room):\n EUDReturn(ptr, room, k, 1)\n EUDEndIf()\n nk = EUDVariable()\n nk << 0\n if EUDIf()(ptr >= 1):\n nk << k + 1\n EUDEndIf()\n if EUDWhile()(rooms[nk] < need):\n nk += 1\n EUDEndWhile()\n if EUDIf()(nk >= len(HEAP_ROOMS)):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n block = take(nk)\n if EUDIf()(block == 0):\n EUDReturn(ptr, room, k, 0)\n EUDEndIf()\n if EUDIf()(ptr >= 1):\n if EUDIf()(length >= 1):\n # An EUDArray's own value is where it is, as an EPD already.\n f_repmovsd_epd(cells + block, cells + ptr, length)\n EUDEndIf()\n give(ptr, k)\n EUDEndIf()\n EUDReturn(block, rooms[nk], nk, 1)\n\n @EUDFunc\n def push(ptr, length, room, k, value):\n \"\"\"One more cell at the end; the handle as it is afterwards. Nothing is pushed when the heap is full.\"\"\"\n ptr, room, k, ok = grow(ptr, length, room, k, length + 1)\n if EUDIf()(ok >= 1):\n cells[ptr + length] = value\n length += 1\n if EUDElse()():\n if EUDIf()(said == 0):\n said << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - an array could not grow (the programs' arrays share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDEndIf()\n EUDReturn(ptr, length, room, k)\n\n _HEAP.update(cells=cells, rooms=rooms, take=take, give=give, grow=grow, push=push)\n return _HEAP\n\n\n# The stack of the functions that call themselves. A function's variables are cells of the program, one of each, so\n# a call that may come back into the function it is in (the IR's `saves`) puts what that function holds here first\n# and takes it back after, with where the function returns to. One stack serves every program and every player:\n# such a function never sleeps, so the stack is empty whenever a frame ends. It is as many frames of the largest\n# frame as the map's script settings allow calls deep (the IR file's `stack`), and the depth is what is counted -\n# compiler/simulateIr.ts counts the same - so a program stops at the same call in both.\nSTACK_DEPTH = min(1 << 16, max(16, int(IR.get(\"stack\", 1024))))\nSTACK_CELLS_MAX = 1 << 20\nHANDLE = (\"ptr\", \"len\", \"room\", \"k\")\n_STACK = {}\n\n\ndef frame_cells(saves, kinds):\n return 1 + 4 * len(saves.get(\"arrays\", [])) + sum(3 if kinds.get(v) in (\"unit\", \"text\") else 1 for v in saves.get(\"vars\", []))\n\n\ndef largest_frame():\n most = [0]\n\n def walk(node, kinds):\n if isinstance(node, list):\n for x in node:\n walk(x, kinds)\n elif isinstance(node, dict):\n if isinstance(node.get(\"saves\"), dict):\n most[0] = max(most[0], frame_cells(node[\"saves\"], kinds))\n for v in node.values():\n if isinstance(v, (dict, list)):\n walk(v, kinds)\n\n def kinds_of(node, into):\n if isinstance(node, list):\n for x in node:\n kinds_of(x, into)\n elif isinstance(node, dict):\n if isinstance(node.get(\"id\"), str) and node.get(\"kind\") in (\"number\", \"boolean\", \"unit\", \"text\") and \"shared\" in node:\n # A text kept as its id is one cell, as a number is; one that was made is three.\n into[node[\"id\"]] = \"number\" if node[\"kind\"] == \"text\" and node.get(\"text\") == \"id\" else node[\"kind\"]\n for v in node.values():\n if isinstance(v, (dict, list)):\n kinds_of(v, into)\n return into\n\n for program in IR.get(\"programs\", []):\n walk(program.get(\"functions\", []), kinds_of(program, {}))\n return most[0]\n\n\ndef stack():\n \"\"\"The stack's cells, where the next frame goes (an EPD, so a cell is one write) and how deep it is.\"\"\"\n if _STACK:\n return _STACK\n frame = largest_frame()\n if frame * STACK_DEPTH > STACK_CELLS_MAX:\n raise Fail(\"trigscript: the stack would be %d cells - %d calls deep, %d cells a call - and %d is the most: lower the recursion depth in the script's Settings, or keep fewer variables in the function that calls itself\" % (frame * STACK_DEPTH, STACK_DEPTH, frame, STACK_CELLS_MAX))\n cells = EUDArray(max(1, frame) * STACK_DEPTH)\n base = cells # an EUDArray's own value is where it is, as an EPD already\n at = EUDVariable(base) # initial: the stack's own state; it is back here whenever a frame ends\n depth = EUDVariable(0) # initial: the stack's own state; nothing is on it between frames\n _STACK.update(cells=cells, base=base, at=at, depth=depth)\n return _STACK\n\n\nclass ListStorage:\n \"\"\"An array that grows: a handle - where its block is among the heap's cells (0: none yet), how many cells are\n in use, how many the block has room for, and the block's size class - a cell each, or a row of twelve each in\n a per-player program. Reads and stores are bounded by the cells in use; a store at exactly the length is a push.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no block\n self.handle = {name: make() for name in (\"ptr\", \"len\", \"room\", \"k\")}\n self.heap = heap()\n\n def field(self, name):\n cell = self.handle[name]\n return cell[self.player_of()] if self.rowed else cell\n\n def put(self, name, value):\n if self.rowed:\n self.handle[name][self.player_of()] = value\n else:\n self.handle[name] << value\n\n def length(self):\n return self.field(\"len\")\n\n def get(self, index):\n out = fresh(0)\n if EUDIf()(as_var(index) < as_var(self.field(\"len\"))):\n out << self.heap[\"cells\"][self.field(\"ptr\") + index]\n EUDEndIf()\n return out\n\n def push(self, value):\n if self.bits:\n value = saturate(value, self.bits)\n ptr, length, room, k = self.heap[\"push\"](self.field(\"ptr\"), self.field(\"len\"), self.field(\"room\"), self.field(\"k\"), value)\n for name, v in ((\"ptr\", ptr), (\"len\", length), (\"room\", room), (\"k\", k)):\n self.put(name, v)\n\n def set(self, index, value):\n if self.bits:\n value = saturate(value, self.bits)\n i, v, n = as_var(index), fresh(value), as_var(self.field(\"len\"))\n if EUDIf()(i < n):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n if EUDElseIf()(i == n):\n self.push(v) # xs[xs.length] = v, as JavaScript has it\n EUDEndIf()\n\n def pop(self):\n out = fresh(0)\n n = fresh(self.field(\"len\"))\n if EUDIf()(n >= 1):\n n -= 1\n self.put(\"len\", n)\n out << self.heap[\"cells\"][self.field(\"ptr\") + n]\n EUDEndIf()\n return out\n\n def set_length(self, value):\n v = as_var(value)\n if EUDIf()(v < as_var(self.field(\"len\"))):\n self.put(\"len\", v)\n EUDEndIf()\n\n def declare(self, values):\n \"\"\"Declared (again): the block it held goes back, and it starts over with these values.\"\"\"\n ptr = fresh(self.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n self.heap[\"give\"](ptr, self.field(\"k\"))\n EUDEndIf()\n for name in (\"ptr\", \"len\", \"room\", \"k\"):\n self.put(name, 0)\n for v in values:\n self.push(v)\n\n def declare_filled(self, value, count):\n \"\"\"The same with one value `count` times over: a loop, where a push a cell would be a push a cell in the map.\"\"\"\n if count <= 8:\n self.declare([value] * count)\n return\n self.declare([])\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i <= count - 1):\n self.push(v)\n i += 1\n EUDEndWhile()\n\n def fill(self, value):\n v, i = as_var(value), fresh(0)\n if EUDWhile()(i < as_var(self.field(\"len\"))):\n self.heap[\"cells\"][self.field(\"ptr\") + i] = v\n i += 1\n EUDEndWhile()\n\n\nclass InnerListStorage(ListStorage):\n \"\"\"An array that grows inside another: its handle is cell `index` of four arrays the outer one keeps (where the\n block is, how many cells are in use, its room, its size class), `index` a variable of the program. Everything\n else is a growing array's, which only ever asks for its handle's fields and puts them back.\"\"\"\n\n def __init__(self, decl, lowering):\n self.decl = decl\n self.bits = decl.get(\"bits\")\n self.rowed = False\n self.lowering = lowering\n self.heap = heap()\n\n def at(self):\n return self.lowering.var(self.decl[\"through\"][\"index\"], self.decl).get()\n\n def field(self, name):\n return self.lowering.array(self.decl[\"through\"][name], self.decl).get(self.at())\n\n def put(self, name, value):\n self.lowering.array(self.decl[\"through\"][name], self.decl).set(self.at(), value)\n\n\n# ── texts ──\nTEXT_BYTES = 1023 # the most a made text holds; compiler/ir.ts has the same number\nTEXT_FIELD_BYTES = 255 # the room of a string kept for an action's field or a unit type's name\nUNIT_MAP_STRING = 0x660260 # units.dat: the id of the name a map gives a unit type, a word each\n_TEXT = {}\n_SLOTS = {}\n\n\ndef texts():\n \"\"\"The texts' part of the run time, made when the first text that is made is met.\"\"\"\n if _TEXT:\n return _TEXT\n h = heap()\n cells, rooms, take, give = h[\"cells\"], h[\"rooms\"], h[\"take\"], h[\"give\"]\n # A text being made. It may run one part past its most before it is cut, and a part is at most that long again.\n scratch = Db(TEXT_BYTES + 1 + TEXT_BYTES + 1 + 8)\n empty = Db(4)\n said_cut = EUDVariable(0) # initial: said once a game\n said_full = EUDVariable(0) # initial: said once a game\n said_field = EUDVariable(0) # initial: said once a game\n\n @EUDFunc\n def count(addr):\n \"\"\"How many characters the text at addr is: every byte but those that continue a character (10xxxxxx).\"\"\"\n n = EUDVariable()\n n << 0\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n n += 1\n EUDEndIf()\n EUDEndInfLoop()\n EUDReturn(n)\n\n @EUDFunc\n def append(pos, src):\n \"\"\"The text at src written at pos, ended by a 0; where that 0 is. A text of the table may be any length, so no\n more of it than leaves the scratch whole: what is past a text's most is cut by make() anyway.\"\"\"\n br1.seekoffset(src)\n bw1.seekoffset(pos)\n if EUDWhile()(pos <= scratch + TEXT_BYTES + TEXT_BYTES):\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n bw1.writebyte(b)\n pos += 1\n EUDEndWhile()\n bw1.writebyte(0)\n EUDReturn(pos)\n\n @EUDFunc\n def make(end):\n \"\"\"The text in the scratch, which ends at `end`, into a block of its own: where it is, the block, its\n length in characters. Past what a text holds it is cut, never inside a character.\"\"\"\n if EUDIf()(end >= scratch + TEXT_BYTES + 1):\n end << scratch + TEXT_BYTES\n if EUDWhile()(f_bread(end).ExactlyX(0x80, 0xC0)):\n end -= 1\n EUDEndWhile()\n f_bwrite(end, 0)\n if EUDIf()(said_cut == 0):\n said_cut << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - one that is made holds %d bytes.\" % TEXT_BYTES)\n EUDEndIf()\n EUDEndIf()\n need = f_div(end - scratch, 4)[0] + 2\n k = EUDVariable()\n k << 0\n if EUDWhile()(rooms[k] < need):\n k += 1\n EUDEndWhile()\n block = take(k)\n if EUDIf()(block == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be made (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n cells[block] = k\n f_repmovsd_epd(cells + block + 1, EPD(scratch), need - 1)\n addr = f_mul(cells + block + 1, 4) + 0x58A364\n EUDReturn(addr, block, count(addr))\n\n @EUDFunc\n def copy(addr, block, length):\n \"\"\"A text as something to keep: itself when it owns no block, else a block of the same size with the same cells.\"\"\"\n if EUDIf()(block == 0):\n EUDReturn(addr, 0, length)\n EUDEndIf()\n k = cells[block]\n mine = take(k)\n if EUDIf()(mine == 0):\n if EUDIf()(said_full == 0):\n said_full << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: out of memory - a text could not be copied (the programs' arrays and texts share %d cells).\" % HEAP_CELLS)\n EUDEndIf()\n EUDReturn(empty, 0, 0)\n EUDEndIf()\n f_repmovsd_epd(cells + mine, cells + block, rooms[k])\n EUDReturn(f_mul(cells + mine + 1, 4) + 0x58A364, mine, length)\n\n @EUDFunc\n def release(block):\n if EUDIf()(block >= 1):\n give(block, cells[block])\n EUDEndIf()\n\n @EUDFunc\n def skip(addr, n):\n \"\"\"Where character n of the text at addr starts; its end when it has fewer.\"\"\"\n br1.seekoffset(addr)\n if EUDInfLoop()():\n b = br1.readbyte()\n EUDBreakIf(b == 0)\n if EUDIfNot()(b.ExactlyX(0x80, 0xC0)):\n EUDBreakIf(n == 0)\n n -= 1\n EUDEndIf()\n addr += 1\n EUDEndInfLoop()\n EUDReturn(addr)\n\n @EUDFunc\n def size(addr):\n \"\"\"The bytes of the character that starts at addr; 0 at the text's end.\"\"\"\n b = f_bread(addr)\n n = EUDVariable()\n n << 1\n if EUDIf()(b == 0):\n n << 0\n if EUDElseIf()(b >= 0xF0):\n n << 4\n if EUDElseIf()(b >= 0xE0):\n n << 3\n if EUDElseIf()(b >= 0xC0):\n n << 2\n EUDEndIf()\n EUDReturn(n)\n\n @EUDFunc\n def slice_(addr, start, end):\n \"\"\"Characters start … end - 1 of the text at addr, made.\"\"\"\n a = skip(addr, start)\n z = EUDVariable()\n z << a\n if EUDIf()(end >= start + 1):\n z << skip(a, end - start)\n EUDEndIf()\n # A text of the table may be longer than one that is made: no more of it than the scratch holds, and make() cuts.\n n = z - a\n if EUDIf()(n >= TEXT_BYTES + 2):\n n << TEXT_BYTES + 1\n EUDEndIf()\n f_memcpy(scratch, a, n)\n f_bwrite(scratch + n, 0)\n EUDReturn(*make(scratch + n))\n\n @EUDFunc\n def pad(addr, length, width, fill, at_start):\n \"\"\"The text with `fill` over and over before or after it until it is `width` characters; as it is when it is that long, or `fill` is empty.\"\"\"\n pos = EUDVariable()\n pos << scratch\n need = EUDVariable()\n need << 0\n if EUDIf()([width >= length + 1, width <= 0x7FFFFFFF, f_bread(fill) >= 1]):\n need << width - length\n EUDEndIf()\n if EUDIf()(at_start == 0):\n pos << append(pos, addr)\n EUDEndIf()\n src = EUDVariable()\n src << fill\n if EUDWhile()([need >= 1, pos <= scratch + TEXT_BYTES]):\n n = size(src)\n if EUDIf()(n == 0):\n src << fill\n n << size(src)\n EUDEndIf()\n f_memcpy(pos, src, n)\n pos += n\n src += n\n need -= 1\n EUDEndWhile()\n f_bwrite(pos, 0)\n if EUDIf()([at_start >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def repeat(addr, times):\n pos = EUDVariable()\n pos << scratch\n f_bwrite(pos, 0)\n if EUDIf()([times <= 0x7FFFFFFF, f_bread(addr) >= 1]):\n if EUDWhile()([times >= 1, pos <= scratch + TEXT_BYTES]):\n pos << append(pos, addr)\n times -= 1\n EUDEndWhile()\n EUDEndIf()\n EUDReturn(*make(pos))\n\n @EUDFunc\n def starts(addr, find):\n \"\"\"Whether the text at addr starts with the one at find.\"\"\"\n br1.seekoffset(addr)\n br2.seekoffset(find)\n if EUDInfLoop()():\n want = br2.readbyte()\n if EUDIf()(want == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDBreakIfNot(br1.readbyte() == want)\n EUDEndInfLoop()\n EUDReturn(0)\n\n @EUDFunc\n def find_from(addr, find, start):\n \"\"\"The place, in characters, of the first match at or after character `start`; 0xFFFFFFFF when there is none.\"\"\"\n if EUDIf()(start >= count(addr) + 1):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, start)\n place = EUDVariable()\n place << start\n if EUDInfLoop()():\n if EUDIf()(starts(at, find) >= 1):\n EUDReturn(place)\n EUDEndIf()\n n = size(at)\n EUDBreakIf(n == 0)\n at += n\n place += 1\n EUDEndInfLoop()\n EUDReturn(0xFFFFFFFF)\n\n @EUDFunc\n def ends(addr, find):\n a, b = f_strlen(addr), f_strlen(find)\n if EUDIf()(b >= a + 1):\n EUDReturn(0)\n EUDEndIf()\n if EUDIf()(f_strcmp(addr + (a - b), find) == 0):\n EUDReturn(1)\n EUDEndIf()\n EUDReturn(0)\n\n @EUDFunc\n def code(addr, index):\n \"\"\"The number of character `index`; 0xFFFFFFFF past either end.\"\"\"\n if EUDIf()(index >= 0x80000000):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n at = skip(addr, index)\n n = size(at)\n if EUDIf()(n == 0):\n EUDReturn(0xFFFFFFFF)\n EUDEndIf()\n br1.seekoffset(at)\n v = EUDVariable()\n v << br1.readbyte()\n if EUDIf()(n == 2):\n v << (v & 0x1F)\n if EUDElseIf()(n == 3):\n v << (v & 0x0F)\n if EUDElseIf()(n == 4):\n v << (v & 0x07)\n EUDEndIf()\n if EUDWhile()(n >= 2):\n v << f_mul(v, 64) + (br1.readbyte() & 0x3F)\n n -= 1\n EUDEndWhile()\n EUDReturn(v)\n\n @EUDFunc\n def show(dst, src):\n \"\"\"At most TEXT_FIELD_BYTES bytes of the text at src over the string at dst, never half a character.\"\"\"\n used = EUDVariable()\n used << 0\n if EUDInfLoop()():\n n = size(src)\n EUDBreakIf(n == 0)\n if EUDIf()(used + n >= TEXT_FIELD_BYTES + 1):\n if EUDIf()(said_field == 0):\n said_field << 1\n GetGlobalStringBuffer().print(\"\\x06TrigScript: a text was cut off - an action's text and a unit's name show %d bytes of one that is made.\" % TEXT_FIELD_BYTES)\n EUDEndIf()\n EUDBreak()\n EUDEndIf()\n f_memcpy(dst + used, src, n)\n used += n\n src += n\n EUDEndInfLoop()\n f_bwrite(dst + used, 0)\n\n @EUDFunc\n def address(id_):\n \"\"\"Where the text of the table with this id is; an empty one for id 0.\"\"\"\n if EUDIf()(id_ == 0):\n EUDReturn(empty)\n EUDEndIf()\n EUDReturn(GetMapStringAddr(id_))\n\n _TEXT.update(scratch=scratch, empty=empty, append=append, count=count, make=make, copy=copy, release=release, slice=slice_, pad=pad, repeat=repeat,\n starts=starts, ends=ends, find=find_from, code=code, show=show, size=size, address=address)\n return _TEXT\n\n\ndef slot(kind):\n \"\"\"The string of the built map's table kept for one kind of field, made the first time one is asked for: its\n room in bytes that nothing else shares, which is what ForceAddString is for.\"\"\"\n if kind not in _SLOTS:\n text = \"(TrigScript: %s)\" % kind\n _SLOTS[kind] = ForceAddString(text + \" \" * (TEXT_FIELD_BYTES - len(text)))\n return _SLOTS[kind]\n\n\n# The actions whose text the game shows from a string it reads again whenever it draws (played 2026-09-19), by the\n# kind of field: a player has one of each at a time, so one string a kind is enough.\nSLOT_OF_ACTION = {12: \"objectives\", 7: \"transmission\"}\nfor _t in (17, 18, 19, 20, 21, 33, 34, 35, 36, 37, 40):\n SLOT_OF_ACTION[_t] = \"leaderboard\"\n\n\ndef text_id(text):\n \"\"\"The id of a text written in the script; 0 for the empty one, which the table does not hold.\"\"\"\n return EncodeString(text) if text else 0\n\n\nclass TextVal:\n \"\"\"A text as the lowering holds it: where it is, the block it owns (the int 0: none), and its length in\n characters - an int, a variable, or None when nobody has counted yet. `taken`: the block is the value's own, so\n what receives the value keeps the block or gives it back; a variable's is only looked at.\"\"\"\n\n def __init__(self, addr, block, length, taken):\n self.addr, self.block, self._length, self.taken = addr, block, length, taken\n\n def length(self):\n if self._length is None:\n self._length = texts()[\"count\"](self.addr)\n return self._length\n\n\nclass TextStorage:\n \"\"\"A text variable that is made: three cells, or three rows of twelve in a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player and not decl.get(\"shared\")\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no text yet, and no block\n self.addr, self.block, self.len = make(), make(), make()\n\n def cell(self, cell):\n return cell[self.player_of()] if self.rowed else cell\n\n def get(self):\n # A variable that was never given a text is an empty one: its address is 0 until then.\n addr = fresh(self.cell(self.addr))\n if EUDIf()(addr == 0):\n addr << texts()[\"empty\"]\n EUDEndIf()\n return TextVal(addr, self.cell(self.block), self.cell(self.len), False)\n\n def write(self, addr, block, length):\n for cell, value in ((self.addr, addr), (self.block, block), (self.len, length)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n def take_out(self):\n \"\"\"The text, and its block with it: the variable holds no block from here on.\"\"\"\n v = self.get()\n out = TextVal(v.addr, fresh(v.block), fresh(v.length()), True)\n self.write(out.addr, 0, out.length())\n return out\n\n\nclass UnitRef:\n \"\"\"A unit of the game as the lowering holds it: the pointer, its EPD, and the slot's uniqueness byte\n (as it sits in its dword, masked 0xFF00). `uid` None is the unit of a loop's turn, there by\n construction; a pointer that is the int 0 is no unit at all.\"\"\"\n\n def __init__(self, ptr, epd, uid=None):\n self.ptr, self.epd, self.uid = ptr, epd, uid\n\n @property\n def none(self):\n return isinstance(self.ptr, int) and self.ptr == 0\n\n\nNO_UNIT = UnitRef(0, 0, 0)\n\n\nclass UnitStorage:\n \"\"\"A unit variable: three cells, or three 12-slot rows of a per-player program.\"\"\"\n\n def __init__(self, decl, per_player, player_of):\n self.decl = decl\n self.rowed = per_player\n self.player_of = player_of\n make = (lambda: EUDArray([0] * 12)) if self.rowed else (lambda: EUDVariable(0)) # initial: no unit\n self.ptr, self.epd, self.uid = make(), make(), make()\n\n def get(self):\n if self.rowed:\n p = self.player_of()\n return UnitRef(self.ptr[p], self.epd[p], self.uid[p])\n return UnitRef(self.ptr, self.epd, self.uid)\n\n def set(self, ref):\n uid = ref.uid\n if uid is None:\n # The unit of a loop's turn is there now: this is when its slot's uniqueness byte is taken.\n uid = f_maskread_epd(ref.epd + OFF_UID, 0xFF00)\n for cell, value in ((self.ptr, ref.ptr), (self.epd, ref.epd), (self.uid, uid)):\n if self.rowed:\n cell[self.player_of()] = value\n else:\n cell << value\n\n\nclass LoopUnit:\n \"\"\"The variable of a loop over units: the scan's own pointer, never kept past the loop.\"\"\"\n\n def __init__(self, ref):\n self.ref = ref\n\n def get(self):\n return self.ref\n\n def set(self, ref):\n raise Fail(\"trigscript: the unit of a loop's turn cannot be assigned\")\n\n\ndef saturate(value, bits):\n top = (1 << bits) - 1\n if isinstance(value, int):\n return min(value, top)\n v = EUDVariable()\n v << value\n if EUDIf()(v >= top + 1):\n v << top\n EUDEndIf()\n return v\n\n\ndef as_var(value):\n if isinstance(value, int):\n return EUDVariable(value & U32) # initial: a constant, never written\n return value\n\n\ndef fresh(value=0):\n \"\"\"A temporary that starts from `value` every time the code runs. `EUDVariable(n)` is not\n that: n is the cell's value when the map loads, so a temporary built that way and then\n written keeps what the last run left in it — `ticks + 1` went 1, 2, 4, 8 (found in the\n first played probe, 2026-09-18). Anything the lowering writes to starts here.\"\"\"\n v = EUDVariable()\n v << value\n return v\n\n\nclass Lowering:\n def __init__(self, program):\n self.p = program\n self.per_player = bool(program.get(\"perPlayer\"))\n self.owner = int(program.get(\"owner\", 0))\n self.slots = owner_slots(program)\n self.player = None\n self.vars = {}\n self.arrays = {a[\"id\"]: SliceStorage(a, self) if a.get(\"slice\") else InnerListStorage(a, self) if a.get(\"through\") else (ListStorage if a.get(\"dynamic\") else ArrayStorage)(a, self.per_player, self.player_of) for a in program.get(\"arrays\", [])}\n self.state = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.wait = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: program state\n self.resumes = [] # (index, Forward) for every sleep\n self.frame_end = None\n self.latches = {}\n # The functions that are called, by id, and each one's EUDFunc once something has called it.\n self.functions = {f[\"id\"]: f for f in program.get(\"functions\", [])}\n self.made = {}\n self.in_function = 0\n # The function being lowered, when it is one that calls itself: whose return address a frame keeps.\n self.within = []\n\n # ── storage ──\n def player_of(self):\n if self.player is None:\n raise Fail(\"trigscript: a per-player variable outside the player loop\")\n return self.player\n\n def declare(self, decl):\n if decl.get(\"kind\") == \"unit\":\n s = UnitStorage(decl, self.per_player, self.player_of)\n elif decl.get(\"kind\") == \"text\" and decl.get(\"text\") != \"id\":\n s = TextStorage(decl, self.per_player, self.player_of)\n else:\n s = Storage(decl, self.per_player, self.player_of)\n self.vars[decl[\"id\"]] = s\n return s\n\n def array(self, id_, node=None):\n a = self.arrays.get(id_)\n if a is None:\n raise Fail(\"trigscript: unknown array %r%s\" % (id_, where(node)))\n return a\n\n def var(self, id_, node=None):\n s = self.vars.get(id_)\n if s is None:\n raise Fail(\"trigscript: unknown variable %r%s\" % (id_, where(node)))\n return s\n\n def get_state(self):\n return self.state[self.player] if self.per_player else self.state\n\n def set_state(self, value):\n if self.per_player:\n self.state[self.player] = value\n else:\n self.state << value\n\n def get_wait(self):\n return self.wait[self.player] if self.per_player else self.wait\n\n def set_wait(self, value):\n if self.per_player:\n self.wait[self.player] = value\n else:\n self.wait << value\n\n # ── numbers: an int (the 32 bits, 0 … 2^32 - 1) or an EUDVariable ──\n def num(self, e):\n k = e[\"kind\"]\n if k in (\"textLength\", \"textIndexOf\", \"textCode\"):\n return self.text_number(e)\n if k == \"input\":\n return INPUT.read(e[\"input\"], self, e)\n if k == \"const\":\n return int(e[\"value\"]) & U32\n if k == \"var\":\n return self.var(e[\"id\"], e).get()\n if k == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"length\":\n a = self.array(e[\"array\"], e)\n return a.length() if isinstance(a, ListStorage) else a.length\n if k == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if k == \"cast\":\n return self.num(e[\"expr\"])\n if k == \"unary\":\n x = self.num(e[\"expr\"])\n return (-x) & U32 if isinstance(x, int) else 0 - x\n if k == \"binary\":\n return self.binary(e)\n if k == \"read\":\n return self.read(e)\n if k == \"randomInt\":\n # 0 … n - 1; an n of 0 gives 0, as a 0 divisor does.\n n = self.num(e[\"bound\"])\n if isinstance(n, int):\n if n <= 1:\n return 0\n return f_div(f_dwrand(), n)[1]\n out = fresh(0)\n if EUDIf()(n >= 1):\n out << f_div(f_dwrand(), n)[1]\n EUDEndIf()\n return out\n if k == \"unitField\":\n return self.unit_field(e)\n if k == \"unitPart\":\n return self.unit_part(e)\n if k == \"tableRead\":\n return self.table_read(e[\"cell\"], e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.num(e[\"whenTrue\"])\n if EUDElse()():\n t << self.num(e[\"whenFalse\"])\n EUDEndIf()\n return t\n if k == \"intrinsic\" and e[\"name\"] == \"abs\":\n x = self.num(e[\"args\"][0])\n if isinstance(x, int):\n return abs(signed(x)) & U32\n t = fresh(x)\n if EUDIf()(t >= SIGN):\n t << 0 - t\n EUDEndIf()\n return t\n if k == \"intrinsic\":\n a, b = [self.num(x) for x in e[\"args\"]]\n name = e[\"name\"]\n unsigned = bool(e.get(\"unsigned\"))\n if isinstance(a, int) and isinstance(b, int):\n key = (lambda v: v) if unsigned else signed\n return min(a, b, key=key) if name == \"min\" else max(a, b, key=key)\n t = EUDVariable()\n if EUDIf()(self.ordered(a, \"<=\" if name == \"min\" else \">=\", b, unsigned)):\n t << a\n if EUDElse()():\n t << b\n EUDEndIf()\n return t\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown expression %r%s\" % (k, where(e)))\n\n def binary(self, e):\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op = e[\"op\"]\n if op in BITWISE:\n return self.bitwise(op, a, b)\n both = isinstance(a, int) and isinstance(b, int)\n if op == \"+\":\n return (a + b) & U32 if both else a + b\n if op == \"-\":\n return (a - b) & U32 if both else a - b\n if op == \"*\":\n return (a * b) & U32 if both else f_mul(as_var(a), as_var(b))\n # / and %: towards zero, the remainder with the dividend's sign, unless both sides are u32s. A divisor of 0 gives 0.\n unsigned = bool(e.get(\"unsigned\"))\n pick = (lambda q, r: q) if op == \"/\" else (lambda q, r: r)\n if isinstance(b, int):\n if b == 0:\n raise Fail(\"trigscript: division by zero%s\" % where(e))\n if both:\n if unsigned:\n return pick(a // b, a % b)\n x, y = signed(a), signed(b)\n q = abs(x) // abs(y) * (-1 if (x < 0) != (y < 0) else 1)\n return pick(q, x - q * y) & U32\n return pick(*(f_div(a, b) if unsigned else f_div_towards_zero(fresh(a), signed(b))))\n out = fresh(0)\n if EUDIf()(b >= 1):\n q, r = f_div(as_var(a), b) if unsigned else f_div_towards_zero(fresh(a), fresh(b))\n out << pick(q, r)\n EUDEndIf()\n return out\n\n def ordered(self, a, op, b, unsigned):\n \"\"\"`a op b` as one condition, the two read as u32s (`unsigned` true), as signed numbers (false), or one of\n each (\"left\" / \"right\" names the u32) - exactly: a number below zero is smaller than any u32. At least one\n side is a variable. A signed order is the unsigned one with the top bit of both sides flipped.\"\"\"\n if unsigned in (\"left\", \"right\"):\n # s is the signed side, u the u32, and the comparison is turned to read s op u.\n s_, u_ = (b, a) if unsigned == \"left\" else (a, b)\n if unsigned == \"left\":\n op = FLIPPED[op]\n if isinstance(s_, int):\n if s_ >= SIGN:\n return always(op in (\"<\", \"<=\", \"!=\"))\n return self.ordered(s_, op, u_, True)\n sv = as_var(s_)\n if op in (\"<\", \"<=\", \"!=\"):\n return EUDOr(sv >= SIGN, relation(sv, op, u_))\n return EUDAnd(sv <= SIGN - 1, relation(sv, op, u_))\n if isinstance(a, int):\n a, b, op = b, a, FLIPPED[op]\n if unsigned or op in (\"==\", \"!=\"):\n return relation(as_var(a), op, b)\n return relation(a + SIGN, op, (b + SIGN) & U32 if isinstance(b, int) else b + SIGN)\n\n @staticmethod\n def bitwise(op, a, b):\n \"\"\"& | ^ << >> >>> over 32 bits. `>>` keeps the sign of what it shifts and `>>>` does not; a shift by 32 or\n more leaves nothing but that sign.\"\"\"\n if op in (\"<<\", \">>\", \">>>\"):\n if isinstance(a, int) and isinstance(b, int):\n if op == \"<<\":\n return 0 if b >= 32 else (a << b) & U32\n if op == \">>>\":\n return 0 if b >= 32 else a >> b\n return (signed(a) >> min(b, 31)) & U32\n if isinstance(b, int) and b == 0:\n return a\n if isinstance(b, int) and b >= 32 and op != \">>\":\n return 0\n if op == \"<<\":\n return f_bitlshift(fresh(a), b)\n if op == \">>>\":\n return f_bitrshift(fresh(a), b)\n # The sign kept: a number below zero is shifted as its complement, which has zeros where it has ones.\n x, below = fresh(a), fresh(0)\n if EUDIf()(x >= SIGN):\n below << 1\n x << ~x\n EUDEndIf()\n r = fresh(f_bitrshift(x, b)) if not (isinstance(b, int) and b >= 32) else fresh(0)\n if EUDIf()(below >= 1):\n r << ~r\n EUDEndIf()\n return r\n if isinstance(a, int) and isinstance(b, int):\n return {\"&\": a & b, \"|\": a | b, \"^\": a ^ b}[op]\n # Copies: eudplib computes in place into an operand nothing else refers to, and ours may be a variable's own cell.\n x, y = fresh(a), fresh(b)\n return x & y if op == \"&\" else x | y if op == \"|\" else x ^ y\n\n # ── reads: a value of the game ──\n def current(self):\n \"\"\"The player the program is running as: a slot, or the player loop's variable.\"\"\"\n return self.player if self.per_player else self.slots[0]\n\n def one_player(self, p, node):\n if p == CURRENT_PLAYER:\n return self.current()\n if 0 <= p < 12:\n return p\n raise Fail(\"trigscript: a read takes one player%s\" % where(node))\n\n def read(self, e):\n r = e[\"read\"]\n source = r.get(\"source\")\n if source == \"condition\":\n return self.read_condition(r[\"record\"], e)\n if source == \"player\" and r.get(\"fact\") == \"left\":\n return self.read_left(r, e)\n if source == \"player\":\n base = PLAYER_BYTES.get(r.get(\"fact\"))\n if base is None:\n raise Fail(\"trigscript: unknown player fact %r%s\" % (r.get(\"fact\"), where(e)))\n value = f_bread(base + self.one_player(r[\"player\"], e))\n if r.get(\"fact\") == \"slot\":\n # A computer of a Use Map Settings game keeps the map's own number, 5; a melee computer is 1\n # (the slice 2 probe read 5 for one). Both are \"a computer\": one number for the script.\n kind = fresh(value)\n if EUDIf()(kind == 5):\n kind << 1\n EUDEndIf()\n return kind\n return value\n if source == \"supply\":\n return self.read_supply(r, e)\n raise Fail(\"trigscript: unknown read %r%s\" % (source, where(e)))\n\n def read_left(self, r, e):\n \"\"\"1 once a player who was in the map's settings as a human or a computer is gone. Asked the way\n eudplib's f_playerexist asks (the player's trigger list), which the player loop already relies on.\n The byte table at 0x581D62 read 0 for players who were there, as it should, but nobody has seen it\n turn 1 in a game — a computer never leaves — so it is not what this rests on.\"\"\"\n p = self.one_player(r[\"player\"], e)\n if isinstance(p, int) and (p >= 8 or GetPlayerInfo(p).typestr not in (\"Human\", \"Computer\")):\n return 0\n gone = fresh(1)\n if EUDIf()(f_playerexist(p)):\n gone << 0\n EUDEndIf()\n return gone\n\n def read_supply(self, r, e):\n base = SUPPLY_TABLES.get(r.get(\"of\"))\n if base is None:\n raise Fail(\"trigscript: unknown supply %r%s\" % (r.get(\"of\"), where(e)))\n p = self.one_player(r[\"player\"], e)\n half = fresh(0)\n if r.get(\"race\") is None:\n # The race the player plays; a slot with none (neutral, empty) has no supply.\n race = f_bread(PLAYER_BYTES[\"race\"] + p)\n if EUDIf()(race <= 2):\n half << f_dwread_epd(EPD(base) + race * 36 + p)\n EUDEndIf()\n else:\n half << f_dwread_epd(EPD(base) + int(r[\"race\"]) * 36 + p)\n # As the top bar shows it: half a supply in use counts as one.\n if r[\"of\"] == \"used\":\n half += 1\n return half // 2\n\n def read_condition(self, rec, e):\n t, player, unit = rec[\"type\"], rec[\"player\"], rec[\"unitId\"]\n one = player == CURRENT_PLAYER or not 12 <= player <= 26 # not a group; beyond 26 is an EUD offset, read as it stands\n table = None\n if one and t in (COND_DEATHS, COND_KILL) and not 229 <= unit <= 232:\n table = (DEATHS_TABLE if t == COND_DEATHS else KILLS_TABLE, unit * 12)\n elif one and t == COND_ACCUMULATE and rec[\"resource\"] in (0, 1):\n table = (ORE_TABLE if rec[\"resource\"] == 0 else GAS_TABLE, 0)\n if table is not None:\n p = self.current() if player == CURRENT_PLAYER else player\n return f_dwread_epd(EPD(table[0]) + table[1] + p)\n # No table holds it (a group's sum, units counted at a location, a score, a clock): ask the\n # condition, a bit at a time from the top. A unit count stays under 2^12, a number of players under 2^4.\n bits = 12 if t in (COND_BRING, COND_COMMAND) else 4 if t == COND_OPPONENTS else 32\n found = fresh(0)\n for bit in reversed(range(bits)):\n probe = found + (1 << bit)\n if EUDIf()(Condition(rec[\"location\"], player, probe, unit, 0, t, rec[\"resource\"], rec[\"flags\"], eudx=rec.get(\"mask\", 0) or 0)):\n found << probe\n EUDEndIf()\n return found\n\n # ── booleans: an eudplib condition ──\n def cond(self, e):\n k = e[\"kind\"]\n if k == \"const\":\n return always(e[\"value\"])\n if k == \"cond\":\n return condition(e[\"record\"])\n if k == \"var\":\n return as_var(self.var(e[\"id\"], e).get()) >= 1\n if k == \"element\":\n return as_var(self.array(e[\"array\"], e).get(self.num(e[\"index\"]))) >= 1\n if k == \"pop\":\n return as_var(self.array(e[\"array\"], e).pop()) >= 1\n if k == \"test\":\n return as_var(self.num(e[\"expr\"])) >= 1\n if k == \"compare\":\n a, b = self.num(e[\"left\"]), self.num(e[\"right\"])\n op, unsigned = e[\"op\"], e.get(\"unsigned\", False)\n if isinstance(a, int) and isinstance(b, int):\n x = a if unsigned in (True, \"left\") else signed(a)\n y = b if unsigned in (True, \"right\") else signed(b)\n return always(compare(x, op, y))\n # One comparison, built once: a comparison between variables writes into its own\n # condition, and one that is built and dropped is an orphan eudplib refuses.\n return self.ordered(a, op, b, unsigned)\n if k == \"and\":\n return EUDAnd(*[self.cond(c) for c in e[\"items\"]])\n if k == \"or\":\n return EUDOr(*[self.cond(c) for c in e[\"items\"]])\n if k == \"not\":\n return EUDNot(self.cond(e[\"expr\"]))\n if k == \"random\":\n return (f_rand() & 1) >= 1\n if k in (\"unitAlive\", \"unitFlag\", \"unitSame\"):\n return as_var(self.truth(e)) >= 1\n if k == \"edge\":\n return self.edge(e)\n if k == \"ternary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.truth(e[\"whenTrue\"])\n if EUDElse()():\n t << self.truth(e[\"whenFalse\"])\n EUDEndIf()\n return t >= 1\n if k == \"call\":\n return as_var(self.call(e[\"call\"])) >= 1\n if k in (\"textCompare\", \"textTest\"):\n return as_var(self.text_truth(e)) >= 1\n raise Fail(\"trigscript: unknown condition %r%s\" % (k, where(e)))\n\n def truth(self, e):\n \"\"\"A boolean expression as 0 / 1.\"\"\"\n if e[\"kind\"] == \"const\":\n return 1 if e[\"value\"] else 0\n if e[\"kind\"] == \"var\":\n return self.var(e[\"id\"], e).get()\n if e[\"kind\"] == \"element\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if e[\"kind\"] == \"pop\":\n return self.array(e[\"array\"], e).pop()\n if e[\"kind\"] == \"unitAlive\":\n ref = self.unit(e[\"unit\"])\n t = fresh(0)\n if not ref.none:\n self.when_alive(ref, lambda: t << 1)\n return t\n if e[\"kind\"] == \"unitFlag\":\n return self.unit_flag(e)\n if e[\"kind\"] == \"unitSame\":\n a, b = self.unit(e[\"left\"]), self.unit(e[\"right\"])\n t = fresh(0)\n if not a.none and not b.none:\n ap, bp = as_var(a.ptr), as_var(b.ptr)\n if EUDIf()([ap >= 1, ap == bp]):\n t << 1\n EUDEndIf()\n return t\n t = fresh(0)\n if EUDIf()(self.cond(e)):\n t << 1\n EUDEndIf()\n return t\n\n def edge(self, e):\n \"\"\"rose(c): true on the frame c becomes true; once(c): true the first time it holds.\"\"\"\n key = id(e)\n if key not in self.latches:\n self.latches[key] = EUDArray([0] * 12) if self.per_player else EUDVariable(0) # initial: the latch\n latch = self.latches[key]\n\n def get():\n return latch[self.player] if self.per_player else latch\n\n def put(v):\n if self.per_player:\n latch[self.player] = v\n else:\n latch << v\n\n fired = fresh(0)\n held = self.truth(e[\"cond\"])\n if EUDIf()(as_var(held) >= 1):\n if EUDIf()(as_var(get()) == 0):\n fired << 1\n put(1)\n EUDEndIf()\n if EUDElse()():\n if e[\"edge\"] == \"rose\":\n put(0)\n EUDEndIf()\n return fired >= 1\n\n # ── units: a pointer into the game's unit table, checked before use ──\n def unit(self, e):\n k = e[\"kind\"]\n if k == \"unitNull\":\n return NO_UNIT\n if k == \"unitVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"pick\":\n return self.pick(e)\n if k == \"unitAt\":\n # A unit kept as three numbers of the program (a cell each of an array of units): re-checked like any kept unit.\n ptr = self.num(e[\"ptr\"])\n if isinstance(ptr, int) and ptr == 0:\n return NO_UNIT\n return UnitRef(fresh(ptr), fresh(self.num(e[\"epd\"])), fresh(self.num(e[\"uid\"])))\n if k == \"call\":\n return self.call(e[\"call\"])\n raise Fail(\"trigscript: unknown unit expression %r%s\" % (k, where(e)))\n\n def unit_part(self, e):\n \"\"\"One of the three numbers a unit is kept as. The unit of a loop's turn has no uniqueness byte taken yet: it is read here.\"\"\"\n ref = self.unit(e[\"unit\"])\n if ref.none:\n return 0\n part = e[\"part\"]\n if part == \"ptr\":\n return ref.ptr\n if part == \"epd\":\n return ref.epd\n return f_maskread_epd(ref.epd + OFF_UID, 0xFF00) if ref.uid is None else ref.uid\n\n def when_alive(self, ref, body):\n \"\"\"`body()` when the unit is still the one that was kept: the slot has a sprite, its order is not\n \"die\", and its uniqueness byte is the one taken with the pointer.\"\"\"\n if ref.none:\n return\n if ref.uid is None:\n body()\n return\n ptr, epd = as_var(ref.ptr), as_var(ref.epd)\n if EUDIf()([ptr >= 1, MemoryEPD(epd + OFF_SPRITE, AtLeast, 1), MemoryXEPD(epd + OFF_OWNER_ORDER, AtLeast, 0x100, 0xFF00), MemoryXEPD(epd + OFF_UID, Exactly, ref.uid, 0xFF00)]):\n body()\n EUDEndIf()\n\n @staticmethod\n def cunit(ref):\n return CUnit(ref.epd, ptr=ref.ptr)\n\n def unit_field(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n self.when_alive(ref, lambda: out << self.field_of(self.cunit(ref), e[\"field\"], e))\n return out\n\n def field_of(self, cu, field, node):\n \"\"\"A unit's number, in the script's units: whole points for hit points (as the game shows them,\n a started point counting), shields and energy.\"\"\"\n if field == \"hp\":\n return f_div(cu.hp + 255, 256)[0]\n if field == \"maxHp\":\n return f_div(f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType), 256)[0]\n if field == \"shields\":\n return f_div(cu.shield, 256)[0]\n if field == \"maxShields\":\n return f_wread(UNITS_MAX_SHIELDS + cu.unitType * 2)\n if field == \"energy\":\n return f_div(cu.energy, 256)[0]\n if field in UNIT_TIMERS:\n return getattr(cu, UNIT_TIMERS[field])\n name = {\"owner\": \"owner\", \"type\": \"unitType\", \"x\": \"posX\", \"y\": \"posY\", \"kills\": \"killCount\", \"orderId\": \"orderID\", \"cooldown\": \"groundWeaponCooldown\", \"resources\": \"resourceAmount\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: unknown unit field %r%s\" % (field, where(node)))\n return getattr(cu, name)\n\n def unit_flag(self, e):\n ref = self.unit(e[\"unit\"])\n out = fresh(0)\n\n def body():\n cu = self.cunit(ref)\n flag = e[\"flag\"]\n if flag == \"underAttack\":\n held = cu.attackNotifyTimer >= 1\n else:\n mask = {\"hallucinated\": STATUS_HALLUCINATION, \"cloaked\": STATUS_CLOAKED, \"burrowed\": STATUS_BURROWED, \"invincible\": STATUS_INVINCIBLE}.get(flag)\n if mask is None:\n raise Fail(\"trigscript: unknown unit flag %r%s\" % (flag, where(e)))\n held = cu.check_status_flag(mask)\n if EUDIf()(held):\n out << 1\n EUDEndIf()\n\n self.when_alive(ref, body)\n return out\n\n def location_bounds(self, number):\n base = EPD(MRGN + (int(number) - 1) * 20)\n return [f_dwread_epd(base + i) for i in range(4)]\n\n def scan(self, flt, node, body):\n \"\"\"The game's unit table, slot by slot: `body(ref, next_, exit_)` for every unit on the map the\n filter matches. Every test is one condition whose address moves on with the slot, so a slot\n that does not match costs a trigger or two.\"\"\"\n ptr, epd = EUDVariable(), EUDVariable()\n empty = MemoryEPD(0, Exactly, 0)\n dying = MemoryXEPD(0, Exactly, 0, 0xFF00)\n moving = [(empty, OFF_SPRITE), (dying, OFF_OWNER_ORDER)]\n match, values = [], []\n owner = flt.get(\"owner\")\n if owner is not None:\n who = self.current() if owner == CURRENT_PLAYER else int(owner)\n c = MemoryXEPD(0, Exactly, who if isinstance(who, int) else 0, 0xFF)\n match.append(c)\n moving.append((c, OFF_OWNER_ORDER))\n if not isinstance(who, int):\n values.append((c, who))\n kind = flt.get(\"type\")\n if kind is not None and kind < 228:\n c = MemoryXEPD(0, Exactly, int(kind), 0xFFFF)\n match.append(c)\n moving.append((c, OFF_TYPE))\n elif kind is not None and kind not in GROUP_BITS:\n raise Fail(\"trigscript: unknown unit type %r%s\" % (kind, where(node)))\n if flt.get(\"at\"):\n left, top, right, bottom = self.location_bounds(flt[\"at\"])\n # A location reaching past the map's edge starts below zero, which a unit's position never is.\n for v in (left, top):\n if EUDIf()(v >= 0x80000000):\n v << 0\n EUDEndIf()\n for comparison, mask, value in ((AtLeast, 0xFFFF, left), (AtMost, 0xFFFF, right), (AtLeast, 0xFFFF0000, f_bitlshift(top, 16)), (AtMost, 0xFFFF0000, f_bitlshift(bottom, 16))):\n c = MemoryXEPD(0, comparison, 0, mask)\n match.append(c)\n moving.append((c, OFF_POS))\n values.append((c, value))\n DoActions([ptr.SetNumber(UNIT_TABLE), epd.SetNumber(EPD(UNIT_TABLE))] + [SetMemory(c + 4, SetTo, EPD(UNIT_TABLE) + off) for c, off in moving])\n for c, value in values:\n f_dwwrite_epd(EPD(c + 8), value)\n head, next_, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n EUDJumpIf(ptr >= UNIT_END, exit_)\n EUDJumpIf(empty, next_)\n EUDJumpIf(dying, next_)\n if match:\n matched = Forward()\n EUDJumpIf(match, matched)\n EUDJump(next_)\n matched << NextTrigger()\n if kind in GROUP_BITS:\n group = fresh(f_bread(UNITS_GROUP + f_maskread_epd(epd + OFF_TYPE, 0xFFFF)))\n EUDJumpIf((group & GROUP_BITS[kind]) == 0, next_)\n body(UnitRef(ptr, epd, None), next_, exit_)\n next_ << NextTrigger()\n DoActions([SetMemory(c + 4, Add, UNIT_SIZE // 4) for c, _ in moving] + [ptr.AddNumber(UNIT_SIZE), epd.AddNumber(UNIT_SIZE // 4)])\n EUDJump(head)\n exit_ << NextTrigger()\n\n def pick(self, e):\n \"\"\"One unit among the matching: the first, the nearest to a location's centre, or one at random.\"\"\"\n by, flt = e[\"by\"], e.get(\"filter\", {})\n found_ptr, found_epd = fresh(0), fresh(0)\n\n def take(ref):\n found_ptr << ref.ptr\n found_epd << ref.epd\n\n if by == \"first\":\n def body(ref, next_, exit_):\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n elif by == \"nearest\":\n if e.get(\"mouse\") is not None:\n # A player's mouse, and nothing farther from it than `within`.\n cx = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"x\", \"player\": e[\"mouse\"]}, self, e))\n cy = as_var(INPUT.read({\"source\": \"mouse\", \"axis\": \"y\", \"player\": e[\"mouse\"]}, self, e))\n least = fresh(int(e.get(\"within\", 48)) + 1)\n else:\n left, top, right, bottom = self.location_bounds(e[\"near\"])\n cx, cy = f_div(left + right, 2)[0], f_div(top + bottom, 2)[0]\n least = fresh(U32)\n\n def body(ref, next_, exit_):\n cu = self.cunit(ref)\n # |dx| + |dy| is enough to say which is nearest, and never overflows.\n d = distance(cu.posX, cx) + distance(cu.posY, cy)\n if EUDIf()(d < least):\n least << d\n take(ref)\n EUDEndIf()\n self.scan(flt, e, body)\n elif by == \"random\":\n # Count the matching, draw one, take the drawn one on a second pass.\n count = fresh(0)\n self.scan(flt, e, lambda ref, next_, exit_: count.__iadd__(1))\n none = Forward()\n EUDJumpIf(count == 0, none)\n drawn = f_div(f_dwrand(), count)[1]\n i = fresh(0)\n\n def body(ref, next_, exit_):\n hit = Forward()\n EUDJumpIf(i == drawn, hit)\n i.__iadd__(1)\n EUDJump(next_)\n hit << NextTrigger()\n take(ref)\n EUDJump(exit_)\n self.scan(flt, e, body)\n none << NextTrigger()\n else:\n raise Fail(\"trigscript: unknown pick %r%s\" % (by, where(e)))\n uid = fresh(0)\n if EUDIf()(found_ptr >= 1):\n uid << f_maskread_epd(found_epd + OFF_UID, 0xFF00)\n EUDEndIf()\n return UnitRef(found_ptr, found_epd, uid)\n\n def unit_loop(self, st, ctx):\n def body(ref, next_, exit_):\n self.vars[st[\"decl\"][\"id\"]] = LoopUnit(ref)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": next_}))\n self.scan(st.get(\"filter\", {}), st, body)\n\n def unit_write(self, st):\n ref = self.unit(st[\"unit\"])\n field = st[\"field\"]\n if field == \"invincible\":\n on = self.truth(st[\"value\"])\n\n def flag():\n cu = self.cunit(ref)\n if isinstance(on, int):\n cu.set_invincible() if on else cu.clear_invincible()\n return\n if EUDIf()(as_var(on) >= 1):\n cu.set_invincible()\n if EUDElse()():\n cu.clear_invincible()\n EUDEndIf()\n self.when_alive(ref, flag)\n return\n value = self.num(st[\"value\"])\n\n def write():\n cu = self.cunit(ref)\n if field == \"hp\":\n # Hit points at 0 are a dead unit, so that is what the write makes of it.\n if isinstance(value, int):\n if value == 0:\n cu.die()\n else:\n cu.hp = min(value, 0xFFFFFF) * 256\n return\n if EUDIf()(value == 0):\n cu.die()\n if EUDElse()():\n cu.hp = f_mul(saturate(value, 24), 256)\n EUDEndIf()\n elif field == \"shields\":\n cu.shield = points(value, 24)\n elif field == \"energy\":\n cu.energy = points(value, 8)\n elif field == \"kills\":\n cu.killCount = saturate(value, 8)\n elif field == \"resources\":\n cu.resourceAmount = saturate(value, 16)\n elif field == \"cooldown\":\n frames = saturate(value, 8)\n cu.groundWeaponCooldown = frames\n cu.airWeaponCooldown = frames\n cu.spellCooldown = frames\n elif field in UNIT_TIMERS:\n setattr(cu, UNIT_TIMERS[field], saturate(value, 8))\n else:\n raise Fail(\"trigscript: a unit's %s takes no write%s\" % (field, where(st)))\n self.when_alive(ref, write)\n\n def unit_do(self, st):\n ref = self.unit(st[\"unit\"])\n verb = st[\"verb\"]\n do = verb[\"do\"]\n amount = self.num(verb[\"amount\"]) if do in (\"damage\", \"heal\") else 0\n\n def act():\n cu = self.cunit(ref)\n if do == \"kill\":\n cu.die()\n elif do == \"remove\":\n cu.remove()\n elif do == \"give\":\n to = int(verb[\"to\"])\n cu.cgive(self.current() if to == CURRENT_PLAYER else to)\n elif do == \"order\":\n self.order(cu, verb, st)\n elif do == \"locate\":\n self.locate(cu, int(verb[\"location\"]))\n elif do in (\"damage\", \"heal\"):\n self.adjust(cu, do, amount, bool(verb.get(\"percent\")))\n else:\n raise Fail(\"trigscript: unknown unit verb %r%s\" % (do, where(st)))\n self.when_alive(ref, act)\n\n def order(self, cu, verb, node):\n \"\"\"The game's own Order, reaching this unit alone: a location is made a small box around the unit\n for the length of the action (the game did nothing with a box of no size), then put back.\"\"\"\n kind = {\"move\": Move, \"patrol\": Patrol, \"attack\": Attack}.get(verb[\"order\"])\n if kind is None:\n raise Fail(\"trigscript: unknown order %r%s\" % (verb[\"order\"], where(node)))\n base = EPD(MRGN + (SCRATCH_LOCATION - 1) * 20)\n kept = [f_dwread_epd(base + i) for i in range(5)]\n x, y = cu.posX, cu.posY\n for i, v in enumerate((x - 2, y - 2, x + 2, y + 2, 0)):\n f_dwwrite_epd(base + i, v)\n DoActions(Order(cu.unitType, cu.owner, SCRATCH_LOCATION, kind, int(verb[\"target\"])))\n for i, v in enumerate(kept):\n f_dwwrite_epd(base + i, v)\n\n def locate(self, cu, number):\n \"\"\"A location centred on the unit, its size kept.\"\"\"\n base = EPD(MRGN + (number - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n x, y = cu.posX - f_div(width, 2)[0], cu.posY - f_div(height, 2)[0]\n for i, v in enumerate((x, y, x + width, y + height)):\n f_dwwrite_epd(base + i, v)\n\n def center_location(self, st):\n \"\"\"A location centred on a point, its size kept.\"\"\"\n x, y = self.num(st[\"x\"]), self.num(st[\"y\"])\n base = EPD(MRGN + (int(st[\"location\"]) - 1) * 20)\n left, top, right, bottom = [f_dwread_epd(base + i) for i in range(4)]\n width, height = right - left, bottom - top\n nx, ny = x - f_div(width, 2)[0], y - f_div(height, 2)[0]\n for i, v in enumerate((nx, ny, nx + width, ny + height)):\n f_dwwrite_epd(base + i, v)\n\n def adjust(self, cu, do, amount, percent):\n \"\"\"Hit points down — at 0 the unit dies — or up to the type's maximum, in the game's own units (256 to a point).\"\"\"\n now = fresh(cu.hp)\n top = f_dwread_epd(EPD(UNITS_MAX_HP) + cu.unitType)\n if percent:\n step = f_div(f_mul(top, as_var(amount)), 100)[0]\n else:\n step = amount * 256 if isinstance(amount, int) else f_mul(saturate(amount, 24), 256)\n if do == \"damage\":\n if EUDIf()(now <= step):\n cu.die()\n if EUDElse()():\n cu.hp = now - step\n EUDEndIf()\n return\n new = fresh(now + step)\n if EUDIf()(new >= top):\n new << top\n EUDEndIf()\n cu.hp = new\n\n # ── the game's tables ──\n def cell_address(self, c):\n index = c[\"index\"]\n if c.get(\"player\") and index == CURRENT_PLAYER:\n index = self.current()\n base = int(c[\"base\"]) + int(c.get(\"key\") or 0)\n return base + index * int(c[\"stride\"])\n\n def table_read(self, c, node):\n if c.get(\"special\"):\n raise Fail(\"trigscript: %s cannot be read%s\" % (c.get(\"name\"), where(node)))\n addr = self.cell_address(c)\n width = c[\"width\"]\n if width == \"bit\":\n v = fresh(0)\n if EUDIf()(MemoryX(addr, AtLeast, 1, 1 << int(c[\"bit\"]))):\n v << 1\n EUDEndIf()\n return v\n v = f_dwread(addr) if width == 4 else f_wread(addr) if width == 2 else f_bread(addr)\n scale = int(c.get(\"scale\") or 1)\n return f_div(v, scale)[0] if scale > 1 else v\n\n def table_write(self, st):\n c = st[\"cell\"]\n addr = self.cell_address(c)\n special = c.get(\"special\")\n if special == \"name\":\n if self.has_id(st[\"value\"]):\n f_wwrite(addr, self.text_id_of(st[\"value\"]))\n return\n # A name that was made: over the string kept for this unit type, on every computer - a name is nobody's in particular.\n made = self.text(st[\"value\"])\n sid = slot(\"the name of unit type %d\" % int(c[\"index\"]))\n texts()[\"show\"](GetMapStringAddr(sid), made.addr)\n self.used(made)\n f_wwrite(addr, sid)\n return\n width = c[\"width\"]\n if st.get(\"boolean\"):\n value = self.truth(st[\"value\"])\n else:\n value = self.num(st[\"value\"])\n scale = int(c.get(\"scale\") or 1)\n if scale > 1 and not st.get(\"scaled\"):\n value = value * scale if isinstance(value, int) else f_mul(value, scale)\n top = 1 if width == \"bit\" else (1 << (8 * width)) - 1\n if isinstance(value, int):\n value = min(value, top)\n elif width != 4 and width != \"bit\":\n value = saturate(value, 8 * width)\n if special == \"speed\":\n self.write_speed(int(c[\"index\"]), value)\n elif special == \"color\":\n f_bwrite(addr, value)\n f_bwrite(addr + MINIMAP_COLOR_OFFSET, value)\n elif width == \"bit\":\n mask = 1 << int(c[\"bit\"])\n if isinstance(value, int):\n DoActions(SetMemoryX(addr, SetTo, mask if value else 0, mask))\n else:\n if EUDIf()(value >= 1):\n DoActions(SetMemoryX(addr, SetTo, mask, mask))\n if EUDElse()():\n DoActions(SetMemoryX(addr, SetTo, 0, mask))\n EUDEndIf()\n elif width == 4:\n f_dwwrite(addr, value)\n elif width == 2:\n f_wwrite(addr, value)\n else:\n f_bwrite(addr, value)\n\n def write_speed(self, unit, speed):\n \"\"\"A unit type's top speed is its flingy's: the flingy is switched to table control and given the\n speed, with acceleration about a seventeenth of it and the braking distance v² / 2a to match\n (the Vulture's own figures), as Magenta writes it. Units made afterwards move at the new speed.\"\"\"\n flingy = f_bread(UNITS_FLINGY + unit)\n if isinstance(speed, int):\n acceleration = max(1, round(speed / 17))\n halt = max(1, round(speed * speed / (2 * acceleration)))\n else:\n acceleration = fresh(f_div(speed, 17)[0])\n if EUDIf()(acceleration == 0):\n acceleration << 1\n EUDEndIf()\n halt = fresh(f_div(f_mul(speed, speed), acceleration * 2)[0])\n if EUDIf()(halt == 0):\n halt << 1\n EUDEndIf()\n acceleration = saturate(acceleration, 16)\n f_bwrite(FLINGY_CONTROL + flingy, 0)\n f_dwwrite(FLINGY_SPEED + flingy * 4, speed)\n f_wwrite(FLINGY_ACCELERATION + flingy * 2, acceleration)\n f_dwwrite(FLINGY_HALT + flingy * 4, halt)\n\n # ── statements ──\n def block(self, statements, ctx):\n for st in statements:\n self.statement(st, ctx)\n\n def statement(self, st, ctx):\n k = st[\"kind\"]\n if k == \"declare\":\n s = self.declare(st[\"decl\"])\n if not st.get(\"failed\"):\n self.put(s, st[\"init\"], st[\"decl\"][\"kind\"])\n elif k == \"assignText\":\n self.put(self.var(st[\"target\"], st), st[\"value\"], \"text\")\n elif k == \"storeText\":\n self.store_text(st)\n elif k == \"releaseText\":\n block, i = self.array(st[\"block\"], st), fresh(self.num(st[\"index\"]))\n texts()[\"release\"](as_var(block.get(i)))\n block.set(i, 0)\n elif k == \"textLoop\":\n self.text_loop(st, ctx)\n elif k == \"assignUnit\":\n self.var(st[\"target\"], st).set(self.unit(st[\"value\"]))\n elif k == \"unitLoop\":\n self.unit_loop(st, ctx)\n elif k == \"unitWrite\":\n self.unit_write(st)\n elif k == \"unitDo\":\n self.unit_do(st)\n elif k == \"tableWrite\":\n self.table_write(st)\n elif k == \"assign\":\n self.var(st[\"target\"], st).set(self.num(st[\"value\"]))\n elif k == \"assignBool\":\n self.var(st[\"target\"], st).set(self.truth(st[\"value\"]))\n elif k == \"declareArray\":\n a = self.array(st[\"array\"], st)\n cell = self.num if a.decl[\"kind\"] == \"number\" else self.truth\n if isinstance(a, ListStorage):\n if st.get(\"fill\") is not None:\n v = cell(st[\"fill\"])\n a.declare_filled(v if isinstance(v, int) else fresh(v), int(a.decl[\"length\"]))\n else:\n values = [cell(v) for v in st.get(\"init\", [])]\n a.declare([v if isinstance(v, int) else fresh(v) for v in values])\n elif st.get(\"fill\") is not None:\n a.fill(cell(st[\"fill\"]))\n else:\n # Every value first, then the stores: [b, a] of two cells of the array itself is a swap.\n values = [cell(v) for v in st.get(\"init\", [])]\n values = [v if isinstance(v, int) else fresh(v) for v in values]\n for i, v in enumerate(values):\n a.set(i, v)\n elif k == \"push\":\n a = self.array(st[\"array\"], st)\n a.push((self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"]))\n elif k == \"pop\":\n self.array(st[\"array\"], st).pop()\n elif k == \"setLength\":\n self.array(st[\"array\"], st).set_length(self.num(st[\"value\"]))\n elif k == \"store\":\n a = self.array(st[\"array\"], st)\n value = (self.num if a.decl[\"kind\"] == \"number\" else self.truth)(st[\"value\"])\n a.set(self.num(st[\"index\"]), value)\n elif k == \"if\":\n self.if_(st, ctx)\n elif k == \"while\":\n self.while_(st, ctx)\n elif k == \"do\":\n self.do_(st, ctx)\n elif k == \"for\":\n self.for_(st, ctx)\n elif k == \"unrolled\":\n self.unrolled(st, ctx)\n elif k == \"switch\":\n self.switch(st, ctx)\n elif k == \"break\":\n if \"break\" not in ctx:\n raise Fail(\"trigscript: break outside a loop%s\" % where(st))\n EUDJump(ctx[\"break\"])\n raise Leave()\n elif k == \"continue\":\n if \"continue\" not in ctx:\n raise Fail(\"trigscript: continue outside a loop%s\" % where(st))\n EUDJump(ctx[\"continue\"])\n raise Leave()\n elif k == \"return\":\n fn = ctx.get(\"fn\")\n if fn is None:\n raise Fail(\"trigscript: return outside a function%s\" % where(st))\n if st.get(\"value\") is not None and fn[\"result\"] is not None:\n self.put(fn[\"result\"], st[\"value\"], fn[\"kind\"])\n EUDJump(fn[\"end\"])\n raise Leave()\n elif k == \"sleep\":\n self.sleep(st)\n elif k == \"action\":\n self.action(st)\n elif k == \"print\":\n self.print_(st)\n elif k == \"centerLocation\":\n self.center_location(st)\n elif k == \"call\":\n self.call(st[\"call\"])\n elif k == \"block\":\n self.block(st[\"body\"], ctx)\n elif k == \"remark\":\n pass\n else:\n raise Fail(\"trigscript: unknown statement %r%s\" % (k, where(st)))\n\n def straight(self, statements, ctx):\n \"\"\"A statement list whose end may not be reached (a break inside): Leave stops it. True when the end was reached.\"\"\"\n try:\n self.block(statements, ctx)\n return True\n except Leave:\n return False\n\n def if_(self, st, ctx):\n else_l, end = Forward(), Forward()\n EUDJumpIfNot(self.cond(st[\"cond\"]), else_l)\n if self.straight(st[\"then\"], ctx):\n EUDJump(end)\n else_l << NextTrigger()\n if st.get(\"else\"):\n self.straight(st[\"else\"], ctx)\n end << NextTrigger()\n\n def while_(self, st, ctx):\n head, exit_ = Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n if self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": head})):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def do_(self, st, ctx):\n body, check, exit_ = Forward(), Forward(), Forward()\n body << NextTrigger()\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": check}))\n check << NextTrigger()\n EUDJumpIf(self.cond(st[\"cond\"]), body)\n exit_ << NextTrigger()\n\n def for_(self, st, ctx):\n head, update, exit_ = Forward(), Forward(), Forward()\n head << NextTrigger()\n if st.get(\"cond\") is not None:\n EUDJumpIfNot(self.cond(st[\"cond\"]), exit_)\n self.straight(st[\"body\"], dict(ctx, **{\"break\": exit_, \"continue\": update}))\n update << NextTrigger()\n if self.straight(st[\"update\"], ctx):\n EUDJump(head)\n exit_ << NextTrigger()\n\n def unrolled(self, st, ctx):\n exit_ = Forward()\n for statements in st[\"iterations\"]:\n nxt = Forward()\n self.straight(statements, dict(ctx, **{\"break\": exit_, \"continue\": nxt}))\n nxt << NextTrigger()\n exit_ << NextTrigger()\n\n def switch(self, st, ctx):\n v = as_var(self.num(st[\"value\"]))\n exit_ = Forward()\n labels = [Forward() for _ in st[\"cases\"]]\n default = None\n for c, label in zip(st[\"cases\"], labels):\n if c[\"value\"] is None:\n default = label\n continue\n n = c[\"value\"]\n if n != n or n < -SIGN or n > U32: # NaN, or a value the variable never holds\n continue\n EUDJumpIf(v == int(n) & U32, label)\n EUDJump(default if default is not None else exit_)\n for c, label in zip(st[\"cases\"], labels):\n label << NextTrigger()\n self.straight(c[\"body\"], dict(ctx, **{\"break\": exit_}))\n exit_ << NextTrigger()\n\n def sleep(self, st):\n if self.in_function:\n raise Fail(\"trigscript: sleep inside a function that is called%s\" % where(st))\n if st.get(\"cycles\") is not None:\n frames = int(st[\"cycles\"])\n else:\n frames = max(1, int(round(float(st.get(\"ms\", 0)) * FRAMES_PER_SECOND / 1000)))\n index = len(self.resumes) + 1\n resume = Forward()\n self.resumes.append((index, resume))\n self.set_state(index)\n # The frame after this one is one frame later: sleep(frames(1)) goes on in the next frame, as the\n # simulator has it. (Until 3.4 the wait was one frame longer, so a loop sleeping a frame ran every other.)\n self.set_wait(frames - 1)\n EUDJump(self.frame_end)\n resume << NextTrigger()\n\n def action(self, st):\n r = st[\"record\"]\n fields = dict(locid1=r[\"location\"], strid=string_of(r[\"text\"]), wavid=string_of(r[\"wav\"]), time=r[\"time\"], player1=r[\"player\"], player2=r[\"target\"], unitid=r[\"unitId\"], acttype=r[\"type\"], amount=r[\"modifier\"], flags=r[\"flags\"])\n shown = None\n if st.get(\"text\") is not None:\n if self.has_id(st[\"text\"]):\n fields[\"strid\"] = self.text_id_of(st[\"text\"])\n else:\n kind = SLOT_OF_ACTION.get(int(r[\"type\"]))\n if kind is None:\n raise Fail(\"trigscript: this action's text cannot be one that is made%s\" % where(st))\n # Over the string kept for this kind of field - on the computer of the player the action is for, which is\n # the one the program is running as: the game reads the string again whenever it draws, and another\n # player's text must not be there when it does. Nothing of the script can read the string back.\n shown = self.text(st[\"text\"])\n if EUDIf()(IsUserCP()):\n texts()[\"show\"](GetMapStringAddr(slot(kind)), shown.addr)\n EUDEndIf()\n self.used(shown)\n fields[\"strid\"] = slot(kind)\n count = None\n for variable in st.get(\"variables\") or []:\n value = as_var(self.num(variable[\"expr\"]))\n field = variable[\"field\"]\n if field == \"modifier\":\n # A unit count: the byte field is not a variable's place, so the action is done once per\n # unit — as many as the variable says, 0 being none (in the record, 0 means \"all\").\n count = value\n continue\n name = {\"target\": \"player2\", \"time\": \"time\", \"player\": \"player1\", \"location\": \"locid1\", \"text\": \"strid\", \"wav\": \"wavid\", \"unitId\": \"unitid\"}.get(field)\n if name is None:\n raise Fail(\"trigscript: no variable can stand in the %s field%s\" % (field, where(st)))\n if name == \"unitid\":\n # A unit type the game has: past the table, an action reads what is not a unit.\n value = fresh(value)\n if EUDIf()(value >= 228):\n value << 228\n EUDEndIf()\n fields[name] = value\n if count is None:\n DoActions(Action(**fields))\n return\n fields[\"amount\"] = 1\n for _ in EUDLoopRange(0, count):\n DoActions(Action(**fields))\n\n def print_(self, st):\n \"\"\"Text with values in it: every value first, then the text, shown only on the screen of the player it is for.\"\"\"\n args = []\n temps = []\n for part in st[\"parts\"]:\n k = part[\"kind\"]\n if k == \"text\":\n args.append(part[\"text\"])\n elif k == \"value\":\n v = self.text(part[\"text\"])\n temps.append(v)\n args.append(ptr2s(v.addr))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n args.append(str(v if part.get(\"unsigned\") else signed(v)))\n elif part.get(\"unsigned\"):\n args.append(v)\n else:\n # A number below zero: its minus sign, then how far below it is.\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n args.append(ptr2s(sign))\n args.append(size)\n elif k == \"name\":\n args.append(PName(self.one_player(part[\"player\"], st)))\n elif k == \"color\":\n args.append(PColor(self.one_player(part[\"player\"], st)))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n if not args:\n return\n # eudplib prints for the current player, and only on that player's own computer.\n show = (lambda: f_eprintln(*args)) if st.get(\"position\") == \"center\" else (lambda: GetGlobalStringBuffer().print(*args))\n to = int(st.get(\"to\", CURRENT_PLAYER))\n if to == CURRENT_PLAYER:\n show()\n self.used(*temps)\n return\n for p in group_slots(to, st):\n f_setcurpl(p)\n show()\n f_setcurpl(self.current())\n self.used(*temps)\n\n # ── texts ──\n def has_id(self, e):\n \"\"\"Whether a text is one of the built map's table whatever happens in the game (compiler/ir.ts#textHasId).\"\"\"\n k = e[\"kind\"]\n if k in (\"text\", \"textOf\"):\n return True\n if k == \"textVar\":\n return not isinstance(self.var(e[\"id\"], e), TextStorage)\n if k == \"textTernary\":\n return self.has_id(e[\"whenTrue\"]) and self.has_id(e[\"whenFalse\"])\n return False\n\n def text_id_of(self, e):\n \"\"\"The id of a text that has one: an int, or a variable.\"\"\"\n k = e[\"kind\"]\n if k == \"text\":\n return text_id(e[\"text\"])\n if k == \"textVar\":\n return self.var(e[\"id\"], e).get()\n if k == \"textOf\":\n return self.array(e[\"array\"], e).get(self.num(e[\"index\"]))\n if k == \"textTernary\":\n t = EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n t << self.text_id_of(e[\"whenTrue\"])\n if EUDElse()():\n t << self.text_id_of(e[\"whenFalse\"])\n EUDEndIf()\n return t\n raise Fail(\"trigscript: this text has no id%s\" % where(e))\n\n def used(self, *values):\n \"\"\"Values that have been used: a block that was the value's own goes back to the heap.\"\"\"\n for v in values:\n if v.taken and not isinstance(v.block, int):\n texts()[\"release\"](v.block)\n\n def texts_of(self, exprs):\n \"\"\"Several texts in the order they are written. A variable's text is only looked at - unless working a later\n one out runs a call, which may give that variable another text and its block back to the heap: then it is a copy.\"\"\"\n out = []\n for i, e in enumerate(exprs):\n v = self.text(e)\n if not v.taken and any(has_call(x) for x in exprs[i + 1:]):\n v = self.own(v)\n out.append(v)\n return out\n\n def own(self, v):\n \"\"\"A value as something to keep: its own block as it is, a copy of a variable's.\"\"\"\n if v.taken:\n return v\n addr, block, length = texts()[\"copy\"](v.addr, v.block, v.length())\n return TextVal(addr, block, length, True)\n\n def store_text(self, st):\n \"\"\"A text into cell i of three arrays (a row's): worked out first - it may be made from what the cells hold -\n then the block they held goes back, then they take the text, a copy of one that was only looked at.\"\"\"\n v = self.own(self.text(st[\"value\"]))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n i = fresh(self.num(st[\"index\"]))\n held = self.array(st[\"block\"], st)\n texts()[\"release\"](as_var(held.get(i)))\n self.array(st[\"addr\"], st).set(i, addr)\n held.set(i, block)\n self.array(st[\"chars\"], st).set(i, length)\n\n def put(self, storage, e, kind):\n \"\"\"A value into a variable. A text is worked out first - it may be made from what the variable holds - and only then does the block the variable held go back.\"\"\"\n if kind != \"text\":\n storage.set(self.value(e, kind))\n return\n if not isinstance(storage, TextStorage):\n storage.set(self.text_id_of(e))\n return\n v = self.own(self.text(e))\n addr, block, length = fresh(v.addr), fresh(v.block), fresh(v.length())\n texts()[\"release\"](storage.cell(storage.block))\n storage.write(addr, block, length)\n\n def written(self, parts, st):\n \"\"\"The parts of a text in the scratch, and where it ends. Every value is worked out first, since working one\n out may use the scratch itself; then the parts are written a group at a time, a group being what cannot\n come to more than a text holds, so that checking where the writing has got to between groups keeps it inside.\"\"\"\n T = texts()\n items, temps = [], []\n for part in parts:\n k = part[\"kind\"]\n if k == \"text\":\n data = part[\"text\"].encode(\"utf-8\")[:TEXT_BYTES].decode(\"utf-8\", \"ignore\")\n items.append(([data], len(data.encode(\"utf-8\")), None))\n elif k == \"number\":\n v = self.num(part[\"expr\"])\n if isinstance(v, int):\n data = str(v if part.get(\"unsigned\") else signed(v))\n items.append(([data], len(data), None))\n elif part.get(\"unsigned\"):\n items.append(([fresh(v)], 10, None))\n else:\n sign, size = fresh(NO_SIGN), fresh(v)\n if EUDIf()(size >= SIGN):\n sign << MINUS_SIGN\n size << 0 - size\n EUDEndIf()\n items.append(([ptr2s(sign), size], 11, None))\n elif k == \"name\":\n items.append(([PName(self.one_player(part[\"player\"], st))], 25, None))\n elif k == \"color\":\n items.append(([PColor(self.one_player(part[\"player\"], st))], 1, None))\n elif k == \"value\":\n v = self.text(part[\"text\"])\n if not v.taken and has_call(parts[parts.index(part) + 1:]):\n v = self.own(v)\n temps.append(v)\n items.append((None, TEXT_BYTES, v))\n else:\n raise Fail(\"trigscript: unknown text part %r%s\" % (k, where(st)))\n pos = fresh(T[\"scratch\"])\n f_bwrite(pos, 0)\n group, room, first = [], 0, [True]\n\n def flush():\n if not group:\n return\n if first[0]:\n pos << f_dbstr_print(pos, *group)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << f_dbstr_print(pos, *group)\n EUDEndIf()\n first[0] = False\n del group[:]\n\n for args, size, value in items:\n if value is not None:\n flush()\n room = 0\n if first[0]:\n pos << T[\"append\"](pos, value.addr)\n else:\n if EUDIf()(pos <= T[\"scratch\"] + TEXT_BYTES):\n pos << T[\"append\"](pos, value.addr)\n EUDEndIf()\n first[0] = False\n continue\n if room + size > TEXT_BYTES + 1:\n flush()\n room = 0\n group.extend(args)\n room += size\n flush()\n return pos, temps\n\n def text(self, e):\n \"\"\"A text: where it is, its block, its length (TextVal).\"\"\"\n T = texts()\n k = e[\"kind\"]\n if k == \"text\":\n return TextVal(fresh(GetMapStringAddr(e[\"text\"]) if e[\"text\"] else T[\"empty\"]), 0, len(e[\"text\"]), True)\n if k == \"textVar\" and isinstance(self.var(e[\"id\"], e), TextStorage):\n return self.var(e[\"id\"], e).get()\n if k == \"textAt\":\n # Cells that were never given a text are 0: the empty one, as a variable's are.\n i = fresh(self.num(e[\"index\"]))\n addr = fresh(self.array(e[\"addr\"], e).get(i))\n if EUDIf()(addr == 0):\n addr << T[\"empty\"]\n EUDEndIf()\n return TextVal(addr, fresh(self.array(e[\"block\"], e).get(i)), fresh(self.array(e[\"chars\"], e).get(i)), False)\n if self.has_id(e):\n return TextVal(T[\"address\"](as_var(self.text_id_of(e))), 0, None, True)\n if k == \"template\":\n pos, temps = self.written(e[\"parts\"], e)\n addr, block, length = T[\"make\"](pos)\n self.used(*temps)\n return TextVal(addr, block, length, True)\n if k == \"textTernary\":\n addr, block, length = EUDVariable(), EUDVariable(), EUDVariable()\n if EUDIf()(self.cond(e[\"cond\"])):\n v = self.own(self.text(e[\"whenTrue\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n if EUDElse()():\n v = self.own(self.text(e[\"whenFalse\"]))\n addr << v.addr\n block << v.block\n length << v.length()\n EUDEndIf()\n return TextVal(addr, block, length, True)\n if k == \"textSlice\":\n of = self.text(e[\"of\"])\n start = self.num(e[\"start\"]) if e.get(\"start\") is not None else 0\n end = self.num(e[\"end\"]) if e.get(\"end\") is not None else 0x7FFFFFFF\n addr, block, length = T[\"slice\"](of.addr, start, end)\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textPad\":\n of, fill = self.texts_of([e[\"of\"], e[\"with\"]])\n width = self.num(e[\"width\"])\n addr, block, length = T[\"pad\"](of.addr, of.length(), width, fill.addr, 1 if e[\"side\"] == \"start\" else 0)\n self.used(of, fill)\n return TextVal(addr, block, length, True)\n if k == \"textRepeat\":\n of = self.text(e[\"of\"])\n addr, block, length = T[\"repeat\"](of.addr, self.num(e[\"count\"]))\n self.used(of)\n return TextVal(addr, block, length, True)\n if k == \"textCall\":\n self.call(e[\"call\"])\n result = e[\"call\"].get(\"result\")\n if result is None:\n return TextVal(fresh(T[\"empty\"]), 0, 0, True)\n return self.var(result[\"decl\"][\"id\"], e).take_out()\n raise Fail(\"trigscript: unknown text %r%s\" % (k, where(e)))\n\n def text_number(self, e):\n T = texts()\n k = e[\"kind\"]\n if k == \"textIndexOf\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n start = self.num(e[\"from\"]) if e.get(\"from\") is not None else 0\n out = T[\"find\"](of.addr, find.addr, start)\n self.used(of, find)\n return out\n of = self.text(e[\"of\"])\n if k == \"textLength\":\n n = of.length()\n out = n if isinstance(n, int) else fresh(n)\n self.used(of)\n return out\n out = T[\"code\"](of.addr, self.num(e[\"index\"]))\n self.used(of)\n return out\n\n def text_truth(self, e):\n \"\"\"A comparison of two texts, or a test of one, as 0 / 1.\"\"\"\n T = texts()\n t = fresh(0)\n if e[\"kind\"] == \"textTest\":\n of, find = self.texts_of([e[\"of\"], e[\"find\"]])\n if e[\"test\"] == \"startsWith\":\n t << T[\"starts\"](of.addr, find.addr)\n elif e[\"test\"] == \"endsWith\":\n t << T[\"ends\"](of.addr, find.addr)\n else:\n if EUDIfNot()(T[\"find\"](of.addr, find.addr, 0) == 0xFFFFFFFF):\n t << 1\n EUDEndIf()\n self.used(of, find)\n return t\n op = e[\"op\"]\n if op in (\"==\", \"!=\") and self.has_id(e[\"left\"]) and self.has_id(e[\"right\"]):\n # Two texts of the table: the same text is the same id.\n a, b = self.text_id_of(e[\"left\"]), self.text_id_of(e[\"right\"])\n if isinstance(a, int) and isinstance(b, int):\n return 1 if (a == b) == (op == \"==\") else 0\n if EUDIf()(as_var(a) == b):\n t << 1\n EUDEndIf()\n else:\n a, b = self.texts_of([e[\"left\"], e[\"right\"]])\n # The difference of the first bytes that differ, as a number of 32 bits: 0, below zero, or above.\n d = f_strcmp(a.addr, b.addr)\n self.used(a, b)\n if op in (\"==\", \"!=\"):\n if EUDIf()(d == 0):\n t << 1\n EUDEndIf()\n else:\n below = (lambda: d >= SIGN)\n if op in (\"<\", \">=\"):\n if EUDIf()(below()):\n t << 1\n EUDEndIf()\n else: # \"<=\" and \">\": whether it is not above\n if EUDIf()(EUDOr(d == 0, below())):\n t << 1\n EUDEndIf()\n return flipped(t) if op in (\">=\", \">\") else t\n return flipped(t) if op == \"!=\" else t\n\n def text_loop(self, st, ctx):\n \"\"\"`for (const ch of s)`: the text walked once, a character a turn, each a made text of its own.\"\"\"\n T = texts()\n turn = self.declare(st[\"decl\"])\n over = self.own(self.text(st[\"of\"]))\n held = fresh(over.block)\n at = fresh(over.addr)\n head, done = Forward(), Forward()\n inner = dict(ctx)\n inner[\"break\"], inner[\"continue\"] = done, head\n leave = None\n if ctx.get(\"fn\") is not None:\n # A return inside the loop leaves through here, so that the text the loop holds goes back first.\n leave = Forward()\n inner[\"fn\"] = dict(ctx[\"fn\"], end=leave)\n head << NextTrigger()\n n = T[\"size\"](at)\n EUDJumpIf(n == 0, done)\n f_memcpy(T[\"scratch\"], at, n)\n f_bwrite(T[\"scratch\"] + n, 0)\n addr, block, length = T[\"make\"](T[\"scratch\"] + n)\n T[\"release\"](turn.cell(turn.block))\n turn.write(addr, block, length)\n at += n\n if self.straight(st[\"body\"], inner):\n EUDJump(head)\n done << NextTrigger()\n T[\"release\"](held)\n if leave is not None:\n after = Forward()\n EUDJump(after)\n leave << NextTrigger()\n T[\"release\"](held)\n EUDJump(ctx[\"fn\"][\"end\"])\n after << NextTrigger()\n\n def value(self, e, kind):\n \"\"\"An expression as what a variable of `kind` holds.\"\"\"\n return self.num(e) if kind == \"number\" else self.unit(e) if kind == \"unit\" else self.truth(e)\n\n def function(self, id_, node):\n \"\"\"A called function: its parameters' cells, its result's, and its one body — an EUDFunc of no\n arguments, since the cells are the program's own (a row a player, as any variable is).\"\"\"\n made = self.made.get(id_)\n if made is not None:\n return made\n f = self.functions.get(id_)\n if f is None:\n raise Fail(\"trigscript: unknown function %r%s\" % (id_, where(node)))\n params = [self.declare(d) for d in f[\"params\"]]\n kind = f[\"result\"][\"kind\"] if f.get(\"result\") else \"void\"\n result = self.declare(f[\"result\"][\"decl\"]) if f.get(\"result\") else None\n lowering = self\n if f.get(\"recursive\"):\n return self.recursive_function(id_, f, params, kind, result)\n\n @EUDFunc\n def body():\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n lowering.in_function += 1\n try:\n lowering.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n lowering.in_function -= 1\n end << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n return made\n\n def recursive_function(self, id_, f, params, kind, result):\n \"\"\"A function that calls itself. eudplib's EUDFunc keeps one return address and is not there to be called\n until its body is whole, so this one is triggers of our own: the body in a scope apart, ended by a trigger\n whose next-trigger field is the return address - a cell a call writes, and a frame keeps. It is on record\n before its body is lowered, so a call of it met in that body finds it.\"\"\"\n start, tail = Forward(), Forward()\n # The return address a second time, in a variable: a frame keeps it from there, since reading a cell of\n # the game's memory back costs some thirty triggers and writing a variable out costs two.\n back = EUDVariable()\n\n def body():\n after = Forward()\n DoActions([SetNextPtr(tail, after), back.SetNumber(after)])\n EUDJump(start)\n after << NextTrigger()\n\n made = self.made[id_] = (body, params, result)\n entry = {\"f\": f, \"tail\": tail, \"back\": back}\n PushTriggerScope()\n start << NextTrigger()\n if result is not None and kind != \"text\":\n result.set(NO_UNIT if kind == \"unit\" else 0)\n end = Forward()\n self.in_function += 1\n self.within.append(entry)\n try:\n self.straight(f[\"body\"], {\"fn\": {\"result\": result, \"kind\": kind, \"end\": end}})\n finally:\n self.within.pop()\n self.in_function -= 1\n end << NextTrigger()\n tail << RawTrigger()\n PopTriggerScope()\n return made\n\n def saved_cells(self, saves, node):\n \"\"\"What a frame keeps, as (read, write) pairs a cell: the variables, a unit three cells, an array's handle four.\"\"\"\n pairs = []\n for id_ in saves.get(\"vars\", []):\n s = self.vars.get(id_)\n if s is None:\n # Declared further down the body: it has no cell yet, and the declaration that makes it sets it.\n continue\n if isinstance(s, UnitStorage):\n for cell in (s.ptr, s.epd, s.uid):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, TextStorage):\n for cell in (s.addr, s.block, s.len):\n pairs.append(self.cell_pair(cell, s.rowed))\n elif isinstance(s, Storage):\n pairs.append(self.cell_pair(s.store, s.rowed))\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, node)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n pairs.append(self.cell_pair(a.handle[name], a.rowed))\n return pairs\n\n def saved_texts(self, saves):\n \"\"\"The made texts among what a frame keeps: each owns a block, which the frame keeps for it.\"\"\"\n return [s for s in (self.vars.get(id_) for id_ in saves.get(\"vars\", [])) if isinstance(s, TextStorage)]\n\n def cell_pair(self, cell, rowed):\n if rowed:\n return (lambda: cell[self.player_of()]), (lambda v: cell.__setitem__(self.player_of(), v))\n return (lambda: cell), (lambda v: cell << v)\n\n def keep(self, call):\n \"\"\"Before a call that may come back into the function it is in: that function's cells and its return\n address onto the stack, the handles of its arrays set to \"no block\". Past the depth the map allows the\n program says so and stops for good.\"\"\"\n saves = call[\"saves\"]\n st = stack()\n if not self.within:\n raise Fail(\"trigscript: a call keeps a frame outside a function that calls itself%s\" % where(call))\n mine = self.within[-1]\n if EUDIf()(st[\"depth\"] >= STACK_DEPTH):\n GetGlobalStringBuffer().print(\"\\x06TrigScript: stack overflow in %s, line %s - %d calls deep. The program has stopped.\" % (saves.get(\"within\", \"a function\"), (call.get(\"at\") or {}).get(\"line\", \"?\"), STACK_DEPTH))\n st[\"depth\"] << 0\n st[\"at\"] << st[\"base\"]\n self.set_state(DONE)\n EUDJump(self.frame_end)\n EUDEndIf()\n pairs = self.saved_cells(saves, call)\n at = st[\"at\"]\n f_dwwrite_epd(at, mine[\"back\"])\n at += 1\n for read, _ in pairs:\n f_dwwrite_epd(at, read())\n at += 1\n st[\"depth\"] += 1\n for id_ in saves.get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n for name in HANDLE:\n a.put(name, 0)\n # A text the function holds keeps its block on the stack; the variable holds none for the length of the call,\n # so that the inner run's first text does not give the outer run's block back.\n held = self.saved_texts(saves)\n for s in held:\n s.write(0, 0, 0)\n return pairs, mine, held\n\n def bring_back(self, call, kept):\n pairs, mine, held = kept\n st = stack()\n # The block the inner run left in a text goes back to the heap before the text is the outer run's again.\n for s in held:\n texts()[\"release\"](s.cell(s.block))\n # The block the inner run left in a handle goes back to the heap before the handle is the outer run's again.\n for id_ in call[\"saves\"].get(\"arrays\", []):\n a = self.array(id_, call)\n if isinstance(a, ListStorage):\n ptr = fresh(a.field(\"ptr\"))\n if EUDIf()(ptr >= 1):\n a.heap[\"give\"](ptr, a.field(\"k\"))\n EUDEndIf()\n at = st[\"at\"]\n for _, write in reversed(pairs):\n at -= 1\n write(f_dwread_epd(at))\n at -= 1\n mine[\"back\"] << f_dwread_epd(at)\n f_dwwrite_epd(EPD(mine[\"tail\"]) + 1, mine[\"back\"])\n st[\"depth\"] -= 1\n\n def call_function(self, call, result):\n body, params, returned = self.function(call[\"fn\"], call)\n if len(params) != len(call[\"params\"]):\n raise Fail(\"trigscript: %s takes %d values, not %d%s\" % (call[\"fn\"], len(params), len(call[\"params\"]), where(call)))\n # Every argument first, then the parameters: an argument may be a call of this same function. A variable's\n # own cell is copied when a later argument holds a call, which may be of a function that writes that variable.\n values = []\n for i, p in enumerate(call[\"params\"]):\n if p[\"decl\"][\"kind\"] == \"text\":\n # A text is a copy of its own by the time the parameter takes it: three numbers, read now.\n t = self.own(self.text(p[\"init\"]))\n values.append((fresh(t.addr), fresh(t.block), fresh(t.length())))\n continue\n v = self.value(p[\"init\"], p[\"decl\"][\"kind\"])\n later = any(has_call(q[\"init\"]) for q in call[\"params\"][i + 1:])\n if later and p[\"decl\"][\"kind\"] != \"unit\" and not isinstance(v, int):\n v = fresh(v)\n values.append(v)\n # A call that may come back here keeps this function's frame: after the arguments are worked out (they read\n # it), before the parameters are set (they may be this function's own).\n kept = None\n if call.get(\"saves\"):\n # Copies: an argument may be one of this function's own parameters, which the ones set before it write over.\n values = [v if isinstance(v, (int, tuple)) else UnitRef(v.ptr if isinstance(v.ptr, int) else fresh(v.ptr), v.epd if isinstance(v.epd, int) else fresh(v.epd), v.uid if v.uid is None or isinstance(v.uid, int) else fresh(v.uid)) if isinstance(v, UnitRef) else fresh(v) for v in values]\n kept = self.keep(call)\n for s, v in zip(params, values):\n if isinstance(v, tuple):\n # What the parameter held from the call before goes back; then it holds this call's text.\n texts()[\"release\"](s.cell(s.block))\n s.write(*v)\n else:\n s.set(v)\n body()\n if kept is not None:\n self.bring_back(call, kept)\n if result is None:\n return 0\n if isinstance(result, TextStorage):\n # The function's text moves to the call's own result, from where a textCall takes it.\n v = returned.take_out()\n texts()[\"release\"](result.cell(result.block))\n result.write(v.addr, v.block, v.length())\n return 0\n result.set(returned.get())\n return result.get()\n\n def call(self, call):\n result = self.declare(call[\"result\"][\"decl\"]) if call.get(\"result\") else None\n if call.get(\"fn\") is not None:\n return self.call_function(call, result)\n # A text result keeps what it held until `return` puts the next one in it, which is when that block goes back.\n if result is not None and call[\"result\"][\"kind\"] != \"text\":\n result.set(NO_UNIT if call[\"result\"][\"kind\"] == \"unit\" else 0)\n for p in call[\"params\"]:\n s = self.declare(p[\"decl\"])\n self.put(s, p[\"init\"], p[\"decl\"][\"kind\"])\n end = Forward()\n self.straight(call[\"body\"], {\"fn\": {\"result\": result, \"kind\": call[\"result\"][\"kind\"] if call.get(\"result\") else \"void\", \"end\": end}})\n end << NextTrigger()\n return result.get() if result is not None else 0\n\n # ── one frame ──\n def frame(self):\n self.frame_end = Forward()\n start = Forward()\n skip = Forward()\n # Still waiting: count down and leave.\n EUDJumpIfNot(as_var(self.get_wait()) >= 1, skip)\n self.set_wait(as_var(self.get_wait()) - 1)\n EUDJump(self.frame_end)\n skip << NextTrigger()\n # The body once, resumes recorded as sleeps are met; the jump table is filled in after.\n table = Forward()\n EUDJump(table)\n start << NextTrigger()\n try:\n self.block(self.p[\"body\"], {})\n except Leave:\n pass\n # The body ended: the program stops for good, as the classic backend's does.\n self.set_state(DONE)\n EUDJump(self.frame_end)\n table << NextTrigger()\n EUDJumpIf(as_var(self.get_state()) == DONE, self.frame_end)\n for index, resume in self.resumes:\n EUDJumpIf(as_var(self.get_state()) == index, resume)\n EUDJump(start)\n self.frame_end << NextTrigger()\n\n def run(self):\n if self.per_player:\n for p in loop_players(self.slots):\n self.player = p\n f_setcurpl(p)\n self.frame()\n self.player = None\n else:\n # As a trigger owned by that player would: not at all once the player has left.\n if EUDIf()(f_playerexist(self.slots[0])):\n f_setcurpl(self.slots[0])\n self.frame()\n EUDEndIf()\n\n\ndef flipped(t):\n \"\"\"1 for 0 and 0 for anything else, in a variable of its own.\"\"\"\n out = fresh(0)\n if EUDIf()(t == 0):\n out << 1\n EUDEndIf()\n return out\n\n\ndef has_call(node):\n \"\"\"Whether a call is anywhere in a piece of the IR.\"\"\"\n if isinstance(node, list):\n return any(has_call(x) for x in node)\n if not isinstance(node, dict):\n return False\n if node.get(\"kind\") in (\"call\", \"textCall\") and isinstance(node.get(\"call\"), dict):\n return True\n return any(has_call(v) for v in node.values() if isinstance(v, (dict, list)))\n\n\ndef string_of(value):\n \"\"\"An action's text or sound: the string itself (eudplib adds it to the map), or an index the script named.\"\"\"\n if isinstance(value, str):\n return EncodeString(value) if value else 0\n return int(value or 0)\n\n\ndef group_slots(group, node):\n \"\"\"The player slots a player number stands for: a slot is itself, All Players and a force their human and computer players.\"\"\"\n playing = lambda p: GetPlayerInfo(p).typestr in (\"Human\", \"Computer\")\n if 0 <= group < 8:\n return [group]\n if group == 17:\n return [p for p in range(8) if playing(p)]\n if 18 <= group <= 21:\n return [p for p in range(8) if playing(p) and GetPlayerInfo(p).force == group - 18]\n raise Fail(\"trigscript: expected players 1 to 8, All Players or a force%s\" % where(node))\n\n\ndef owner_slots(program):\n \"\"\"The player slots a program runs for, from its owners and the map's player settings: a slot is\n itself, All Players and a force are their human and computer players — who a trigger runs for.\"\"\"\n slots = []\n for o in program.get(\"owners\", [program.get(\"owner\", 0)]):\n for p in group_slots(int(o), program):\n if p not in slots:\n slots.append(p)\n if not slots:\n raise Fail(\"trigscript: no human or computer player of this map is among the program's owners%s\" % where(program))\n return slots\n\n\ndef loop_players(slots):\n \"\"\"Each of `slots` who is in the game, as an EUDVariable: EUDLoopPlayer, for a list of our own.\"\"\"\n start, end = min(slots), max(slots)\n v = EUDVariable()\n v << start\n if EUDWhile()(v <= end):\n for i in range(start, end):\n if i not in slots:\n EUDContinueIf(v == i)\n EUDContinueIfNot(f_playerexist(v))\n yield v\n EUDSetContinuePoint()\n v += 1\n EUDEndWhile()\n\n\nBITWISE = (\"&\", \"|\", \"^\", \"<<\", \">>\", \">>>\")\nFLIPPED = {\"<\": \">\", \"<=\": \">=\", \">\": \"<\", \">=\": \"<=\", \"==\": \"==\", \"!=\": \"!=\"}\n\n\ndef points(value, bits):\n \"\"\"Whole points as the game stores them, 256 to a point; `bits` is how many bits of points the cell holds.\"\"\"\n if isinstance(value, int):\n return min(value, (1 << bits) - 1) * 256\n return f_mul(saturate(value, bits), 256)\n\n\ndef uses_random(node):\n if isinstance(node, dict):\n return node.get(\"kind\") in (\"random\", \"randomInt\") or (node.get(\"kind\") == \"pick\" and node.get(\"by\") == \"random\") or any(uses_random(v) for v in node.values())\n return isinstance(node, list) and any(uses_random(v) for v in node)\n\n\ndef distance(a, b):\n \"\"\"|a - b| of two places on the map, both from 0 up.\"\"\"\n d = fresh(a)\n d -= b\n if EUDIf()(d >= SIGN):\n d << 0 - d\n EUDEndIf()\n return d\n\n\ndef signed(v):\n \"\"\"The 32 bits as a signed number.\"\"\"\n v &= U32\n return v - (1 << 32) if v >= SIGN else v\n\n\ndef always(truth):\n \"\"\"A condition that is known when the map is built.\"\"\"\n return EUDVariable(1 if truth else 0) >= 1 # initial: a constant, never written\n\n\ndef relation(av, op, b):\n \"\"\"`av op b` between a variable and a variable or an int, both read from 0 up.\"\"\"\n if op == \"==\":\n return av == b\n if op == \"!=\":\n return av != b\n if op == \"<\":\n return av < b\n if op == \"<=\":\n return av <= b\n if op == \">\":\n return av > b\n return av >= b\n\n\ndef compare(a, op, b):\n return {\"==\": a == b, \"!=\": a != b, \"<\": a < b, \"<=\": a <= b, \">\": a > b, \">=\": a >= b}[op]\n\n\ndef condition(r):\n return Condition(r[\"location\"], r[\"player\"], r[\"amount\"], r[\"unitId\"], r[\"comparison\"], r[\"type\"], r[\"resource\"], r[\"flags\"], eudx=r.get(\"mask\", 0) or 0)\n\n\nclass Input:\n \"\"\"What the players do, as it reaches every computer: the cells chatEvent and MSQC write, by the\n names the editor put in their settings (compiler/input.ts, INPUT_NAMES), and the reads of them.\"\"\"\n\n MAX_NUMBER = 0xFFFFF\n\n def __init__(self, plan):\n self.plan = plan or {}\n self.keys = list(self.plan.get(\"keys\", []))\n self.buttons = list(self.plan.get(\"buttons\", []))\n self.chats = list(self.plan.get(\"chats\", []))\n self.mouse_base = self.plan.get(\"mouseBase\")\n # A person can sit in the map's human slots, which is where MSQC counts the mouse locations from.\n self.humans = [p for p in range(8) if GetPlayerInfo(p).typestr == \"Human\"]\n if plan and not self.humans:\n raise Fail(\"trigscript: a program reads keys, clicks, the mouse or chat, and the map has no human player to give any\")\n self.key_cells = [self.register(\"tsin_key%d\" % i, EUDArray(12)) for i in range(len(self.keys))]\n self.button_cells = [self.register(\"tsin_button%d\" % i, EUDArray(12)) for i in range(len(self.buttons))]\n self.captures = max([len(c[\"captures\"]) for c in self.chats] or [0])\n if self.chats:\n # Local: what chatEvent found on this computer, and what the patterns made of it.\n self.heard = self.register(\"tsin_heard\", EUDVariable())\n self.pointer = self.register(\"tsin_pointer\", EUDVariable())\n self.length = self.register(\"tsin_length\", EUDVariable())\n self.register(\"tsin_pattern\", EUDVariable())\n self.chat_local = self.register(\"tsin_chat\", EUDVariable())\n self.capture_local = [self.register(\"tsin_capture%d\" % i, EUDVariable()) for i in range(self.captures)]\n # Everyone's: what MSQC delivered this frame, by player; 0xFFFFFFFF on a frame with nothing.\n self.chat_in = self.register(\"tsin_chat_in\", EUDArray(12))\n self.capture_in = [self.register(\"tsin_capture%d_in\" % i, EUDArray(12)) for i in range(self.captures)]\n self.unit_table = None\n\n @staticmethod\n def register(name, cell):\n EUDRegisterObjectToNamespace(name, cell)\n return cell\n\n # ── reads ──\n def read(self, i, low, node):\n source = i.get(\"source\")\n p = low.one_player(i[\"player\"], node)\n if source == \"key\":\n return self.cell(self.key_cells, self.keys, i[\"key\"], node)[p]\n if source == \"click\":\n return self.cell(self.button_cells, self.buttons, i[\"button\"], node)[p]\n if source == \"mouse\":\n return self.mouse(p, 0 if i[\"axis\"] == \"x\" else 1, node)\n if source == \"chat\":\n number = self.chat_number(i[\"pattern\"], node)\n out = fresh(0)\n if EUDIf()(self.chat_in[p] == number):\n out << (1 if i.get(\"capture\") is None else self.capture_in[int(i[\"capture\"])][p])\n EUDEndIf()\n return out\n raise Fail(\"trigscript: unknown input %r%s\" % (source, where(node)))\n\n @staticmethod\n def cell(cells, names, name, node):\n if name not in names:\n raise Fail(\"trigscript: the IR's input plan has no %r%s\" % (name, where(node)))\n return cells[names.index(name)]\n\n def chat_number(self, pattern, node):\n for index, c in enumerate(self.chats):\n if c[\"pattern\"] == pattern:\n return index + 1\n raise Fail(\"trigscript: the IR's input plan has no pattern %r%s\" % (pattern, where(node)))\n\n def mouse(self, p, axis, node):\n \"\"\"MSQC keeps the mouse of the map's first human slot in location `mouseBase`, the next slot's in the next.\"\"\"\n if self.mouse_base is None:\n raise Fail(\"trigscript: the IR's input plan keeps no mouse%s\" % where(node))\n first = min(self.humans)\n cell = lambda h: EPD(MRGN + (int(self.mouse_base) - 1 + h - first) * 20) + axis\n if isinstance(p, int):\n return f_dwread_epd(cell(p)) if p in self.humans else 0\n out = fresh(0)\n for h in self.humans:\n if EUDIf()(p == h):\n out << f_dwread_epd(cell(h))\n EUDEndIf()\n return out\n\n # ── the typed line, on the computer it was typed on ──\n @staticmethod\n def hash_of(data):\n \"\"\"What `hashed` makes of the same bytes: capitals A to Z as small letters, h = h × 31 + byte.\"\"\"\n h = 0\n for b in data:\n if 65 <= b <= 90:\n b += 32\n h = (h * 31 + b) & U32\n return h\n\n def hashed(self, pos, stop_at_space):\n \"\"\"The hash of the line from `pos` to its end or, with `stop_at_space`, to the next space; `pos` moves past it.\"\"\"\n h = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = fresh(f_bread(self.pointer + pos))\n if stop_at_space:\n EUDBreakIf(ch == 32)\n if EUDIf()([ch >= 65, ch <= 90]):\n ch += 32\n EUDEndIf()\n h << f_mul(h, 31) + ch\n pos += 1\n EUDEndWhile()\n return h\n\n def units(self):\n \"\"\"Unit names by hash, in hash order, for a search by halves: two arrays side by side.\"\"\"\n if self.unit_table is None:\n by_hash = {}\n for name, unit in self.plan.get(\"unitNames\", []):\n by_hash.setdefault(self.hash_of(name.encode(\"utf-8\")), int(unit))\n ordered = sorted(by_hash.items())\n self.unit_table = (EUDArray([h for h, _ in ordered] or [0]), EUDArray([u for _, u in ordered] or [0]), len(ordered))\n return self.unit_table\n\n def capture(self, c, pos, fail):\n \"\"\"One value out of the line at `pos`, or a jump to `fail`.\"\"\"\n value = fresh(0)\n if c[\"kind\"] == \"number\":\n digits = fresh(0)\n if EUDWhile()(pos < self.length):\n ch = f_bread(self.pointer + pos)\n EUDBreakIf(ch <= 47)\n EUDBreakIf(ch >= 58)\n value << f_mul(value, 10) + ch - 48\n if EUDIf()(value >= self.MAX_NUMBER + 1):\n value << self.MAX_NUMBER\n EUDEndIf()\n pos += 1\n digits += 1\n EUDEndWhile()\n EUDJumpIf(digits == 0, fail)\n elif c[\"kind\"] == \"word\":\n h = self.hashed(pos, True)\n found = fresh(0)\n for index, word in enumerate(c[\"words\"]):\n if EUDIf()(h == self.hash_of(word.encode(\"utf-8\"))):\n value << index\n found << 1\n EUDEndIf()\n EUDJumpIf(found == 0, fail)\n elif c[\"kind\"] == \"unit\":\n h = self.hashed(pos, False)\n hashes, units, count = self.units()\n lo, hi = fresh(0), fresh(count)\n if EUDWhile()(lo < hi):\n mid = f_div(lo + hi, 2)[0]\n if EUDIf()(hashes[mid] < h):\n lo << mid + 1\n if EUDElse()():\n hi << mid\n EUDEndIf()\n EUDEndWhile()\n EUDJumpIf(lo >= count, fail)\n EUDJumpIfNot(hashes[lo] == h, fail)\n value << units[lo]\n else:\n raise Fail(\"trigscript: unknown chat capture %r\" % (c[\"kind\"],))\n return value\n\n def match_line(self):\n \"\"\"The line the local player typed against every pattern, in order: the first that fits the whole\n line gives its number and values to the cells MSQC sends from. Nothing fits: nothing is sent.\"\"\"\n DoActions([self.chat_local.SetNumber(0)] + [c.SetNumber(0) for c in self.capture_local])\n if EUDIf()(self.heard >= 1):\n done = Forward()\n for index, chat in enumerate(self.chats):\n fail = Forward()\n pos = fresh(0)\n values = []\n for seg in chat[\"segments\"]:\n if isinstance(seg, str):\n data = seg.encode(\"utf-8\")\n EUDJumpIf(pos + len(data) >= self.length + 1, fail)\n EUDJumpIfNot(f_memcmp(self.pointer + pos, Db(data + b\"\\0\"), len(data)) == 0, fail)\n pos += len(data)\n else:\n values.append(self.capture(chat[\"captures\"][int(seg)], pos, fail))\n EUDJumpIfNot(pos == self.length, fail)\n DoActions(self.chat_local.SetNumber(index + 1))\n for cell, value in zip(self.capture_local, values):\n cell << value\n EUDJump(done)\n fail << NextTrigger()\n done << NextTrigger()\n EUDEndIf()\n\n\n# Where each trigger came from, for a run in the lifted game to name the line a trigger belongs to (TrigScript 4,\n# A1). Only when the build asks for it (the `triggerMap` setting): every RawTrigger made is tagged with the\n# statement being lowered, or with the eudplib function whose body it is, and once the payload is laid out one\n# header line and lines of rows say where each trigger sits in it:\n# TRIGSCRIPT-MAP {\"payload\": , \"count\": }\n# TRIGSCRIPT-ROWS [[offset, conditions, actions, origin], ...] (400 a line, as many lines as it takes)\n# An origin is \"line:column\" (or \"file:line:column\"), \"fn:\" for a function of eudplib's, \"\" for what no\n# statement made (a program's frame, its sleeps), or \"eudplib\" for what eudplib made before this file was read. The payload sits at the end of the strings section, so a run\n# finds it at the section's address plus its length less the payload's.\nif settings.get(\"triggerMap\"):\n import eudplib.core.allocator.payload as _payload\n from eudplib.core.eudfunc.eudfuncn import EUDFuncN as _EUDFuncN\n from eudplib.core.rawtrigger.rawtriggerdef import RawTrigger as _RawTrigger\n\n _ORIGINS = []\n _MADE = {}\n\n def _origin_of(at):\n if not at:\n return \"\"\n if at.get(\"file\"):\n return \"%s:%s:%s\" % (at.get(\"file\"), at.get(\"line\"), at.get(\"column\"))\n return \"%s:%s\" % (at.get(\"line\"), at.get(\"column\"))\n\n _trigger_init = _RawTrigger.__init__\n\n def _tagged_trigger(self, *args, **kwargs):\n _trigger_init(self, *args, **kwargs)\n _MADE[self] = _ORIGINS[-1] if _ORIGINS else \"\"\n\n _RawTrigger.__init__ = _tagged_trigger\n\n _func_body = _EUDFuncN._create_func_body\n\n def _tagged_body(self):\n _ORIGINS.append(\"fn:\" + getattr(self._bodyfunc, \"__name__\", \"?\"))\n try:\n return _func_body(self)\n finally:\n _ORIGINS.pop()\n\n _EUDFuncN._create_func_body = _tagged_body\n\n _statement = Lowering.statement\n\n def _tagged_statement(self, st, ctx):\n _ORIGINS.append(_origin_of(st.get(\"at\")) or (_ORIGINS[-1] if _ORIGINS else \"\"))\n try:\n return _statement(self, st, ctx)\n finally:\n _ORIGINS.pop()\n\n Lowering.statement = _tagged_statement\n\n _construct = _payload._construct_payload\n\n def _construct_and_say():\n built = _construct()\n found = _payload._found_objects_dict\n rows = []\n # (Every trigger in the payload, also those eudplib made before this file was read, at its own import:\n # those say \"eudplib\".)\n for trg, i in found.items():\n if not isinstance(trg, _RawTrigger):\n continue\n origin = _MADE.get(trg, \"eudplib\")\n rows.append([_payload._payload_builder.get_object_addr(i).offset, len(trg._conditions), len(trg._actions), origin])\n rows.sort()\n # (In parts, so that no one line of the log runs to megabytes.)\n print(\"TRIGSCRIPT-MAP \" + json.dumps({\"payload\": len(built.data), \"count\": len(rows)}, separators=(\",\", \":\")))\n for k in range(0, len(rows), 400):\n print(\"TRIGSCRIPT-ROWS \" + json.dumps(rows[k:k + 400], separators=(\",\", \":\")))\n return built\n\n _payload._construct_payload = _construct_and_say\n\n\n# Where each program's variables are, for a test in the lifted game to read them as `sim.program()` reads the\n# simulator's (TrigScript 4, A2). Only when the build asks for it (the `variableMap` setting): once the payload is\n# placed, a header line and a line a program give the address of every cell:\n# TRIGSCRIPT-VARS {\"programs\": , \"heap\":
, \"strings\":
}\n# TRIGSCRIPT-PROGRAM {\"index\", \"name\", \"perPlayer\", \"owners\", \"vars\": [...], \"arrays\": [...]}\n# A variable is its id (what the simulator keys it by), its kind, and its cells: `value` (a number, a boolean, a text\n# of the table by its id), `addr` + `len` (a text that is made), `ptr` + `uid` (a unit). An array is its cells' `at`,\n# or, growing, its handle's `ptr` + `len` among the heap's cells. A cell of a per-player program's own (`rowed`) is a\n# row of twelve, one a player; an address is null for what nothing in the map uses, which eudplib leaves out.\nif settings.get(\"variableMap\"):\n import eudplib.core.allocator.payload as _vpayload\n import eudplib.maprw.injector.apply_injector as _injector\n from eudplib.core.allocator.constexpr import Evaluate as _evaluate\n from eudplib.core.mapdata.stringmap import get_string_section_name as _strings_name\n from eudplib.core.variable.vbuf import get_current_varbuffer as _varbuffer\n from eudplib.utils import unProxy as _unproxy\n\n _PLACES = {}\n\n def _offset(expr):\n \"\"\"A payload-relative address as a byte offset into the payload: eudplib's own (relocated whole) or an EPD\n (relocated a quarter at a time, as EPD(x) = (x - 0x58A364) / 4 is).\"\"\"\n r = _evaluate(expr)\n if r.rlocmode == 4:\n return r.offset\n if r.rlocmode == 1:\n return r.offset * 4 + 0x58A364\n return None\n\n def _place(obj):\n \"\"\"Where a variable's value or an array's first cell is in the payload; None for what it left out.\"\"\"\n if isinstance(obj, EUDVariable):\n vb = _varbuffer()\n if vb is None or obj.GetVTable() not in vb._vdict:\n return None\n return _offset(obj.getValueAddr())\n # An EUDArray is a proxy of its data's EPD (or of the data itself), the data an object of its own.\n data = _unproxy(obj)\n while not isinstance(data, EUDObject) and hasattr(data, \"_value\"):\n data = _unproxy(data)\n try:\n return _offset(data)\n except Exception:\n return None # (nothing in the map uses it, so eudplib did not place it)\n\n def _cells_of(s):\n if isinstance(s, UnitStorage):\n return {\"ptr\": s.ptr, \"uid\": s.uid}\n if isinstance(s, TextStorage):\n return {\"addr\": s.addr, \"len\": s.len}\n return {\"value\": s.store}\n\n def _program_places(k, low):\n p = low.p\n out = {\"index\": k, \"name\": p.get(\"name\"), \"perPlayer\": low.per_player, \"owners\": p.get(\"owners\", [low.owner]), \"vars\": [], \"arrays\": []}\n for id_, s in low.vars.items():\n d = s.decl\n if d.get(\"temp\"):\n continue\n out[\"vars\"].append({\"id\": id_, \"kind\": d.get(\"kind\"), \"text\": d.get(\"text\"), \"bits\": d.get(\"bits\"), \"unsigned\": bool(d.get(\"unsigned\")),\n \"rowed\": bool(s.rowed), \"cells\": {name: _place(cell) for name, cell in _cells_of(s).items()}})\n for id_, a in low.arrays.items():\n if isinstance(a, (InnerListStorage, SliceStorage)):\n continue # (a row of another's cells, or a list inside one: read through what it is in, not here)\n d = a.decl\n row = {\"id\": id_, \"kind\": d.get(\"kind\"), \"bits\": d.get(\"bits\"), \"unsigned\": bool(d.get(\"unsigned\")), \"length\": int(d.get(\"length\", 0)), \"rowed\": bool(a.rowed)}\n if isinstance(a, ListStorage):\n row.update(dynamic=True, ptr=_place(a.handle[\"ptr\"]), len=_place(a.handle[\"len\"]))\n else:\n row.update(at=_place(a.store))\n out[\"arrays\"].append(row)\n return out\n\n _vconstruct = _vpayload._construct_payload\n\n def _construct_and_place():\n built = _vconstruct()\n # (eudplib works addresses out only while it writes the payload: for as long as this takes, it is again.)\n was = _vpayload.phase\n _vpayload.phase = _vpayload.PHASE_WRITING\n try:\n _PLACES[\"programs\"] = [_program_places(k, low) for k, low in enumerate(PROGRAMS)]\n _PLACES[\"heap\"] = _place(_HEAP[\"cells\"]) if _HEAP else None\n finally:\n _vpayload.phase = was\n _PLACES[\"bytes\"] = len(built.data)\n return built\n\n _vpayload._construct_payload = _construct_and_place\n\n _initialize = _injector.initialize_payload\n\n def _initialize_and_say(chkt, payload, *args, **kwargs):\n out = _initialize(chkt, payload, *args, **kwargs)\n # The payload is the end of the strings section, which Remastered keeps at 0x191943C8.\n base = 0x191943C8 + len(chkt.getsection(_strings_name())) - _PLACES[\"bytes\"]\n\n def at(o):\n return None if o is None else base + o\n\n print(\"TRIGSCRIPT-VARS \" + json.dumps({\"programs\": len(_PLACES[\"programs\"]), \"heap\": at(_PLACES[\"heap\"]), \"strings\": 0x191943C8}, separators=(\",\", \":\")))\n for prog in _PLACES[\"programs\"]:\n for v in prog[\"vars\"]:\n v[\"cells\"] = {name: at(o) for name, o in v[\"cells\"].items()}\n for a in prog[\"arrays\"]:\n for key in (\"at\", \"ptr\", \"len\"):\n if key in a:\n a[key] = at(a[key])\n print(\"TRIGSCRIPT-PROGRAM \" + json.dumps(prog, separators=(\",\", \":\")))\n return out\n\n _injector.initialize_payload = _initialize_and_say\n\n\nINPUT = Input(IR.get(\"input\"))\nPROGRAMS = [Lowering(p) for p in IR.get(\"programs\", [])]\n\n\ndef onPluginStart():\n # eudplib's generator starts from the same seed in every game; the game's own randomness (a switch randomized) seeds it.\n if uses_random(IR.get(\"programs\", [])):\n f_randomize()\n\n\ndef beforeTriggerExec():\n # After chatEvent has looked for a typed line, before MSQC sends what it was.\n if INPUT.chats:\n INPUT.match_line()\n\n\ndef afterTriggerExec():\n for prog in PROGRAMS:\n prog.run()\n"; diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 424ac46..b752ce5 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -288,8 +288,8 @@ stays JavaScript and is handed a world backed by the game, and it runs where the in play, and MSQC sends it on. - A key and a mouse button are the local machine's memory (0x596A18, 0x6CDDC0), held for one frame. - - Not yet: `program()` (a program's variables; the build would have to say where each lives), - and what another player was shown. + - `program()` reads a program's variables where the build put them (below). Not yet: what + another player was shown. - **The same tests, twice.** `TestRunOptions.makeWorld` hands each test a world other than the simulator's. A compile also returns the script as JavaScript (`CompileResult.modules`). `compiler/gameTests.ts`, bundled as `dist/gametest.js` (198 KB, no TypeScript in it), runs those @@ -334,10 +334,34 @@ stays JavaScript and is handed a world backed by the game, and it runs where the 4. The status bar says "In the game: 4 of 5 tests passed. The simulator disagrees on 1 test." That came about 9 seconds after the click. - The fifth test reads `sim.program()`, which the game cannot answer yet, and it shows as + The fifth test reads `sim.program()`, which the game could not answer yet, and it showed as meant: a ringed red mark beside the simulator's green one, and the game's reason on its own line in Test Results. The game's mark sits beside the simulator's icon rather than at the row's end, where a narrow sidebar cut it off in the first run. +- **`sim.program()` in the game (2026-10-16).** + - The build says where every variable is when asked (the `variableMap` setting, which + **Run All Tests in the Game** and `scripts/test-game.mts` set). Once eudplib has laid the + payload out, the lowering works out each cell's address, and it prints them once + `initialize_payload` has placed the payload at the end of STRx (0x191943C8 plus the + section's length): a `TRIGSCRIPT-VARS` line, then a `TRIGSCRIPT-PROGRAM` line a program. + The editor keeps these lines out of Output. + - `compiler/gameVariables.ts` reads them, and reads a program's variables out of the game's + memory by the same rules as the simulator's lookup. That covers numbers (signed, `u8`/`u16`, + `u32`), booleans, texts of the table and texts that are made, units (null once the slot's + uniqueness byte has moved on), fixed and growing arrays, records and lists of records, and a + per-player program's row by player. + - Two differences, both because the game has every cell from the start. A variable not yet + declared reads as its zero. Of two declarations with one name, the later in the source wins. + A list inside a list and a row of a grid are not read. + - Traps: an `EUDArray` is a proxy of its data's EPD, not of its address (relocation mode 1, so + `4·offset + 0x58A364`). eudplib works addresses out only while it writes the payload, so the + lowering sets that phase again for as long as it reads them. + - Checked: `probes/game-program.ts` (every kind above, a unit, a per-player program by player) + passes in the game and in the simulator under Node, and so does `probes/game-tests.ts`. In a + built editor the five tests of the end-to-end run now pass in the game: "In the game: 5 of 5 + tests passed.", about 10 seconds after the click. + - plugin-scm-sim released 0.9.0 with the Replay view's zoom in the meantime, so `test()` + ships in 0.10, and TrigScript's message now asks for that. ### A3. The desync detector (4.1.0) diff --git a/editor.ts b/editor.ts index 6840b73..ad124cc 100644 --- a/editor.ts +++ b/editor.ts @@ -42,6 +42,7 @@ import { } from "./monaco"; import { renamedKeys, renamesInUse, replaceReferences, type Renamed } from "./refs"; import { FILE_NAME } from "./script"; +import { isMachineLine, readVariableMap } from "./compiler/gameVariables"; import { isTestFile, type TestInfo, type TestReport, type TestResult, type TestRunOptions } from "./compiler/testing"; import { countTests, foldPlayers, idsUnder, marksOf, mergeReport, NO_TESTS, notesOf, playersLabel, stateOf, stateOfCounts, summary, testTree, warningsOf, type TestNode, type TestState, type TestStateName } from "./testState"; import { testName } from "./service"; @@ -108,6 +109,8 @@ const OWNER_COMPUTER = 5; const OWNER_HUMAN = 6; /** How many of a simulation's events the list shows before it says how many more there were. */ const SIMULATE_ROWS = 200; +/** A build's log as a person reads it: without the lines the lowering says for a run in the lifted game. */ +const withoutMachineLines = (text: string) => text.split("\n").filter((l) => !isMachineLine(l)).join("\n"); /** The most lines with a fault the Simulate view lists. */ const SIMULATE_FAULTS = 20; @@ -736,7 +739,7 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const runTestsInGame = async (filter: Pick = {}) => { if (!editor || testsRunning || building || testing || !ready) return; const engine = svc.engine(); - if (!engine?.present || !engine.test) { setStatus("error", t("Running tests in the game needs the scm-sim plugin (0.9 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….")); return; } + if (!engine?.present || !engine.test) { setStatus("error", t("Running tests in the game needs the scm-sim plugin (0.10 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….")); return; } testsRunning = true; renderTesting(); try { @@ -752,13 +755,17 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa const names = svc.names()?.names; if (!names) { setStatus("error", t("No map is open.")); return; } setStatus("busy", t("Building the map…")); - // (Whole: a bare .chk has no room for what the build steps make, so the engine's page reads the scenario out of it.) - const file = await api.document.export({ format: "scx" }); + // (Whole: a bare .chk has no room for what the build steps make, so the engine's page reads the scenario out of + // it. The build says in its log where it put the programs' variables, for sim.program().) + const logged: string[] = []; + const heard = svc.onBuild((e) => { if (e.kind === "log") logged.push(e.line); else if (e.kind === "done" && !logged.length) logged.push(...e.log.split("\n")); }); + const file = await svc.withVariableMap(() => api.document.export({ format: "scx" })).finally(() => heard.dispose()); if (!file || cancelled) { if (!file) setStatus("error", t("No map is open.")); return; } + const variables = readVariableMap(logged); gametest ??= fetch(gametestUrl(svc.dist())).then((r) => { if (!r.ok) throw new Error(`gametest.js: ${r.status}`); return r.text(); }); const module = await gametest.catch((err) => { gametest = null; throw err; }); setStatus("busy", t("Running the tests in the game…")); - const report = await engine.test(new Uint8Array(await file.arrayBuffer()), module, { modules: a.compiled.modules, names, files: filter.files, ids: filter.ids }) as TestReport & { error?: string }; + const report = await engine.test(new Uint8Array(await file.arrayBuffer()), module, { modules: a.compiled.modules, names, files: filter.files, ids: filter.ids, variables }) as TestReport & { error?: string }; if (cancelled) return; if (report.error) { setStatus("error", t("Not run in the game: {message}", { message: report.error })); return; } const ran = report.results.filter((x) => x.status !== "skipped"); @@ -1184,14 +1191,15 @@ function createWorkspace(svc: ScriptService, options: OpenOptions, mode: Workspa if (failing) log(t("{n, plural, one {A test fails} other {# tests fail}}: see the Testing view.", { n: failing }), false); } else if (e.kind === "log") { streamed++; - log(e.line, false); + // (What the lowering says for a run in the lifted game is not for a person to read.) + if (!isMachineLine(e.line)) log(e.line, false); } else if (e.kind === "done") { - if (!streamed && e.log) log(e.log, false); + 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 })); } else { - if (!streamed && e.log) log(e.log, false); + if (!streamed && e.log) log(withoutMachineLines(e.log), false); // Save goes on without the step: the file is the map and its script, with no programs in it. const saved = e.purpose === "save"; buildState = { diff --git a/ko.ts b/ko.ts index f0c149e..b5bd2d7 100644 --- a/ko.ts +++ b/ko.ts @@ -275,7 +275,7 @@ export const KO: Record = { "passed": "통과", "In the game: {message}": "게임에서: {message}", "In the game: passed": "게임에서: 통과", - "Running tests in the game needs the scm-sim plugin (0.9 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….": "테스트를 게임에서 실행하려면 scmjs.dev에서 scm-sim 플러그인(0.9 이상)이 필요합니다. 플러그인 ▸ 플러그인 관리…에서 설치하거나 켜세요.", + "Running tests in the game needs the scm-sim plugin (0.10 or newer) on scmjs.dev: install or turn it on under Plugins ▸ Manage Plugins….": "테스트를 게임에서 실행하려면 scmjs.dev에서 scm-sim 플러그인(0.10 이상)이 필요합니다. 플러그인 ▸ 플러그인 관리…에서 설치하거나 켜세요.", "Not run in the game: {message}": "게임에서 실행하지 못함: {message}", "the engine cannot run a game now": "엔진이 지금 게임을 실행할 수 없습니다", "Not run in the game: the engine's page is from before tests in the game. Reload the editor and try again.": "게임에서 실행하지 못함: 엔진 페이지가 게임 속 테스트보다 오래된 판입니다. 편집기를 새로 고친 뒤 다시 시도하세요.", diff --git a/probes/game-program.ts b/probes/game-program.ts new file mode 100644 index 0000000..dd31016 --- /dev/null +++ b/probes/game-program.ts @@ -0,0 +1,77 @@ +// TrigScript 4, A2: sim.program() in the lifted game — every kind of variable a program keeps, read back out of the +// game's memory where the build said it put them, and the same test in the simulator beside it. +// npx tsx scripts/test-game.mts probes/game-program.ts +// Built like a probe (Player 1 Terran, Player 2 a Zerg computer, location 1 "Home" by Player 1's base). +import { test, expect, program, sleep, frames, shared, first, createUnit, units, P1, P2, CurrentPlayer, type Location, type Unit, type u8, type u32 } from "trigscript"; + +const HOME = 1 as Location; + +program(() => { + let ticks = 0; + let small: u8 = 0; + let big: u32 = 0; + let minus = 0; + let even = false; + let title = "start"; + let said = ""; + let pos = { x: 1, y: 2 }; + const seen = [0, 0, 0]; + const queue: number[] = []; + let marine: Unit | null = null; + createUnit(P1, units.TerranMarine, 1, HOME); + while (true) { + ticks += 1; + small = ticks * 100; + big = 4000000000; + minus = -ticks; + even = ticks % 2 == 0; + if (ticks == 3) title = "three"; + said = `tick ${ticks}`; + pos.x = ticks * 10; + seen[ticks % 3] = ticks; + if (queue.length < 4) queue.push(ticks); + if (!marine) marine = first({ owner: P1, type: units.TerranMarine }); + sleep(frames(1)); + } +}, { owner: P1, name: "kinds" }); + +program(() => { + let mine = 0; + let all = shared(0); + while (true) { mine += 1; all += 1; sleep(frames(1)); } +}, { owner: [P1, P2], name: "each" }); + +test("numbers, booleans, texts, a record and arrays", (sim) => { + sim.frames(5); + const k = sim.program("kinds"); + const ticks = k.ticks as number; + expect(ticks).toBeGreaterThan(2); + expect(k.small).toBe(255); + expect(k.big).toBe(4000000000); + expect(k.minus).toBe(-ticks); + expect(k.even).toBe(ticks % 2 == 0); + expect(k.title).toBe("three"); + expect(k.said).toBe(`tick ${ticks}`); + expect(k.pos).toEqual({ x: ticks * 10, y: 2 }); + expect(k.queue).toEqual([1, 2, 3, 4]); + expect((k.seen as number[])[ticks % 3]).toBe(ticks); + expect(k.nothing).toBeUndefined(); +}); + +test("a unit a program holds", (sim) => { + sim.frames(5); + const marine = sim.program("kinds").marine as { alive: boolean; type: number; owner: number } | null; + expect(marine).not.toBeNull(); + expect(marine!.type).toBe(units.TerranMarine); + expect(marine!.owner).toBe(P1); +}); + +test("a per-player program, by player", (sim) => { + sim.frames(4); + const one = sim.program("each", P1).mine as number, two = sim.program("each", P2).mine as number; + expect(one).toBeGreaterThan(0); + expect(two).toBe(one); + // (Each player's run declares the shared cell again when it starts, so it counts from the later start.) + expect(sim.program("each").all).toBeGreaterThanOrEqual(one); + expect(() => sim.program("each", CurrentPlayer + 5)).toThrow(/runs for P/); +}); diff --git a/python/trigscript.py b/python/trigscript.py index 2dcd061..7888737 100644 --- a/python/trigscript.py +++ b/python/trigscript.py @@ -3173,6 +3173,120 @@ def _construct_and_say(): _payload._construct_payload = _construct_and_say +# Where each program's variables are, for a test in the lifted game to read them as `sim.program()` reads the +# simulator's (TrigScript 4, A2). Only when the build asks for it (the `variableMap` setting): once the payload is +# placed, a header line and a line a program give the address of every cell: +# TRIGSCRIPT-VARS {"programs": , "heap":
, "strings":
} +# TRIGSCRIPT-PROGRAM {"index", "name", "perPlayer", "owners", "vars": [...], "arrays": [...]} +# A variable is its id (what the simulator keys it by), its kind, and its cells: `value` (a number, a boolean, a text +# of the table by its id), `addr` + `len` (a text that is made), `ptr` + `uid` (a unit). An array is its cells' `at`, +# or, growing, its handle's `ptr` + `len` among the heap's cells. A cell of a per-player program's own (`rowed`) is a +# row of twelve, one a player; an address is null for what nothing in the map uses, which eudplib leaves out. +if settings.get("variableMap"): + import eudplib.core.allocator.payload as _vpayload + import eudplib.maprw.injector.apply_injector as _injector + from eudplib.core.allocator.constexpr import Evaluate as _evaluate + from eudplib.core.mapdata.stringmap import get_string_section_name as _strings_name + from eudplib.core.variable.vbuf import get_current_varbuffer as _varbuffer + from eudplib.utils import unProxy as _unproxy + + _PLACES = {} + + def _offset(expr): + """A payload-relative address as a byte offset into the payload: eudplib's own (relocated whole) or an EPD + (relocated a quarter at a time, as EPD(x) = (x - 0x58A364) / 4 is).""" + r = _evaluate(expr) + if r.rlocmode == 4: + return r.offset + if r.rlocmode == 1: + return r.offset * 4 + 0x58A364 + return None + + def _place(obj): + """Where a variable's value or an array's first cell is in the payload; None for what it left out.""" + if isinstance(obj, EUDVariable): + vb = _varbuffer() + if vb is None or obj.GetVTable() not in vb._vdict: + return None + return _offset(obj.getValueAddr()) + # An EUDArray is a proxy of its data's EPD (or of the data itself), the data an object of its own. + data = _unproxy(obj) + while not isinstance(data, EUDObject) and hasattr(data, "_value"): + data = _unproxy(data) + try: + return _offset(data) + except Exception: + return None # (nothing in the map uses it, so eudplib did not place it) + + def _cells_of(s): + if isinstance(s, UnitStorage): + return {"ptr": s.ptr, "uid": s.uid} + if isinstance(s, TextStorage): + return {"addr": s.addr, "len": s.len} + return {"value": s.store} + + def _program_places(k, low): + p = low.p + out = {"index": k, "name": p.get("name"), "perPlayer": low.per_player, "owners": p.get("owners", [low.owner]), "vars": [], "arrays": []} + for id_, s in low.vars.items(): + d = s.decl + if d.get("temp"): + continue + out["vars"].append({"id": id_, "kind": d.get("kind"), "text": d.get("text"), "bits": d.get("bits"), "unsigned": bool(d.get("unsigned")), + "rowed": bool(s.rowed), "cells": {name: _place(cell) for name, cell in _cells_of(s).items()}}) + for id_, a in low.arrays.items(): + if isinstance(a, (InnerListStorage, SliceStorage)): + continue # (a row of another's cells, or a list inside one: read through what it is in, not here) + d = a.decl + row = {"id": id_, "kind": d.get("kind"), "bits": d.get("bits"), "unsigned": bool(d.get("unsigned")), "length": int(d.get("length", 0)), "rowed": bool(a.rowed)} + if isinstance(a, ListStorage): + row.update(dynamic=True, ptr=_place(a.handle["ptr"]), len=_place(a.handle["len"])) + else: + row.update(at=_place(a.store)) + out["arrays"].append(row) + return out + + _vconstruct = _vpayload._construct_payload + + def _construct_and_place(): + built = _vconstruct() + # (eudplib works addresses out only while it writes the payload: for as long as this takes, it is again.) + was = _vpayload.phase + _vpayload.phase = _vpayload.PHASE_WRITING + try: + _PLACES["programs"] = [_program_places(k, low) for k, low in enumerate(PROGRAMS)] + _PLACES["heap"] = _place(_HEAP["cells"]) if _HEAP else None + finally: + _vpayload.phase = was + _PLACES["bytes"] = len(built.data) + return built + + _vpayload._construct_payload = _construct_and_place + + _initialize = _injector.initialize_payload + + def _initialize_and_say(chkt, payload, *args, **kwargs): + out = _initialize(chkt, payload, *args, **kwargs) + # The payload is the end of the strings section, which Remastered keeps at 0x191943C8. + base = 0x191943C8 + len(chkt.getsection(_strings_name())) - _PLACES["bytes"] + + def at(o): + return None if o is None else base + o + + print("TRIGSCRIPT-VARS " + json.dumps({"programs": len(_PLACES["programs"]), "heap": at(_PLACES["heap"]), "strings": 0x191943C8}, separators=(",", ":"))) + for prog in _PLACES["programs"]: + for v in prog["vars"]: + v["cells"] = {name: at(o) for name, o in v["cells"].items()} + for a in prog["arrays"]: + for key in ("at", "ptr", "len"): + if key in a: + a[key] = at(a[key]) + print("TRIGSCRIPT-PROGRAM " + json.dumps(prog, separators=(",", ":"))) + return out + + _injector.initialize_payload = _initialize_and_say + + INPUT = Input(IR.get("input")) PROGRAMS = [Lowering(p) for p in IR.get("programs", [])] diff --git a/scripts/build-fixture.mts b/scripts/build-fixture.mts index 34ec6fd..6a455cf 100644 --- a/scripts/build-fixture.mts +++ b/scripts/build-fixture.mts @@ -20,6 +20,7 @@ import ts from "typescript"; import { compileScript } from "../compiler/compiler"; import { serializeIr } from "../compiler/eud"; import { buildPlugins } from "../compiler/input"; +import { readVariableMap } from "../compiler/gameVariables"; import { resolveStrings } from "../script"; import { defaultScriptNames } from "../compiler/names"; import { defaultLib } from "../bundle/lib.mjs"; @@ -98,6 +99,9 @@ const sections = buildPlugins(r.input, "/work/files/trigscript.json"); // TRIGGER_MAP=1: the Python says where each trigger it made sits in the payload (a TRIGSCRIPT-MAP line in the // log), and that line is written beside the map as .triggers.json, for scm-sim's run in the lifted game. if (process.env.TRIGGER_MAP) (sections.trigscript as Record).triggerMap = "1"; +// VARIABLE_MAP=1: the same for where each program's variables are (TRIGSCRIPT-VARS), as .variables.json, for a +// test in the lifted game to read them (`sim.program()`). +if (process.env.VARIABLE_MAP) (sections.trigscript as Record).variableMap = "1"; const { eudTurbo, ...before } = sections; writeFileSync(pluginsPath, JSON.stringify(extraName ? { ...before, [extraName]: {}, eudTurbo } : sections)); const extraArgs = extra && extraName ? [`${extraName}=${resolve(extra.slice(extraName.length + 1))}`] : []; @@ -114,5 +118,8 @@ if (mapLine) { if (triggers.length !== head.count) { console.error(`the map of triggers came out with ${triggers.length} of ${head.count} rows`); process.exit(1); } writeFileSync(resolve(outPath).replace(/\.scx$/, ".triggers.json"), JSON.stringify({ payload: head.payload, triggers })); } -process.stdout.write(res.stdout.split("\n").filter((l) => !l.startsWith("TRIGSCRIPT-MAP ") && !l.startsWith("TRIGSCRIPT-ROWS ")).join("\n")); +const variables = readVariableMap(logLines); +if (variables) writeFileSync(resolve(outPath).replace(/\.scx$/, ".variables.json"), JSON.stringify(variables)); +else if (process.env.VARIABLE_MAP && res.status === 0) { console.error("the build said nothing of where the variables are"); process.exit(1); } +process.stdout.write(res.stdout.split("\n").filter((l) => !/^TRIGSCRIPT-[A-Z]+ /.test(l)).join("\n")); if (res.status !== 0) { console.error(res.stderr); process.exit(1); } diff --git a/scripts/test-game.mts b/scripts/test-game.mts index a31afe9..36d6d46 100644 --- a/scripts/test-game.mts +++ b/scripts/test-game.mts @@ -31,7 +31,7 @@ const dir = mkdtempSync(join(tmpdir(), "trigscript-tests-")); try { // The map, built as Save builds it. const built = join(dir, basename(script, ".ts") + ".scx"); - const b = spawnSync("npx", ["tsx", join(root, "scripts", "build-fixture.mts"), resolve(script), built, ...(baseMap ? [resolve(baseMap)] : [])], { cwd: root, encoding: "utf8", maxBuffer: 256 << 20 }); + const b = spawnSync("npx", ["tsx", join(root, "scripts", "build-fixture.mts"), resolve(script), built, ...(baseMap ? [resolve(baseMap)] : [])], { cwd: root, encoding: "utf8", maxBuffer: 256 << 20, env: { ...process.env, VARIABLE_MAP: "1" } }); if (b.status !== 0) { console.error(b.stdout.split("\n").slice(-20).join("\n"), b.stderr); process.exit(1); } // The game, at frame 0, EUD let through. @@ -64,7 +64,9 @@ try { const bundle = join(dir, "gametest.js"); spawnSync("npx", ["esbuild", join(root, "compiler", "gameTestEntry.ts"), "--bundle", "--format=esm", "--target=es2022", "--platform=neutral", `--outfile=${bundle}`], { cwd: root }); const host = await import(`data:text/javascript;base64,${Buffer.from(readFileSync(bundle)).toString("base64")}`) as typeof import("../compiler/gameTestEntry"); - const inGame = host.runGameTests(handle, { modules: compiled.modules, names, locations }) as TestReport & { error?: string }; + // (Where the build put the programs' variables, for sim.program().) + const variables = JSON.parse(readFileSync(built.replace(/\.scx$/, ".variables.json"), "utf8")); + const inGame = host.runGameTests(handle, { modules: compiled.modules, names, locations, variables }) as TestReport & { error?: string }; if (inGame.error) { console.error(`the script did not run beside the game: ${inGame.error}`); process.exit(1); } const bySim = new Map((simulated?.results ?? []).map((r) => [r.id, r])); let failed = 0; diff --git a/service.ts b/service.ts index a237c3c..c8ee160 100644 --- a/service.ts +++ b/service.ts @@ -141,6 +141,8 @@ export class ScriptService { 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; + /** Builds under way that want the lowering to say where the programs' variables are (`withVariableMap`). */ + private variableMaps = 0; /** 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; @@ -304,14 +306,25 @@ export class ScriptService { 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 plugins = buildPlugins(artifact.compiled.input, "/work/files/trigscript.json"); + if (this.variableMaps) (plugins.trigscript as Record).variableMap = "1"; return { // chatEvent and MSQC join in, around the lowering, when a program reads what the players do. - plugins: buildPlugins(artifact.compiled.input, "/work/files/trigscript.json"), + plugins, sources: { trigscript: TRIGSCRIPT_PY }, files: { "trigscript.json": serializeIr(artifact.compiled.ir, artifact.compiled.strings, artifact.compiled.input, this.settings()) }, }; } + /** + * `run` with every build it starts asked to say where the programs' variables are — lines of the build's log + * (`compiler/gameVariables.ts`) — for tests in the lifted game to read them. + */ + async withVariableMap(run: () => Promise): Promise { + this.variableMaps++; + 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. */ onBuild(listener: (event: EudplibBuildEvent) => void): { dispose(): void } { let mine: { dispose(): void } | null = null; diff --git a/tests/game-variables.test.ts b/tests/game-variables.test.ts new file mode 100644 index 0000000..e910e0b --- /dev/null +++ b/tests/game-variables.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { isMachineLine, programVariables, readVariableMap, type VariableMap } from "../compiler/gameVariables"; + +// A memory of 64 KB standing in for the game's, with the cells where a map says they are. +const memory = () => { const u8 = new Uint8Array(1 << 16); return { u8, m: new DataView(u8.buffer) }; }; +const STRINGS = 0x8000, HEAP = 0xa000; + +const MAP: VariableMap = { + heap: HEAP, + strings: STRINGS, + programs: [ + { + index: 0, name: "kinds", perPlayer: false, owners: [0], + vars: [ + { id: "n#0", kind: "number", unsigned: false, rowed: false, cells: { value: 0x100 } }, + { id: "big#1", kind: "number", unsigned: true, rowed: false, cells: { value: 0x104 } }, + { id: "ok#2", kind: "boolean", unsigned: false, rowed: false, cells: { value: 0x108 } }, + { id: "title#3", kind: "text", text: "id", unsigned: false, rowed: false, cells: { value: 0x10c } }, + { id: "said#4", kind: "text", text: "made", unsigned: false, rowed: false, cells: { addr: 0x110, len: 0x114 } }, + { id: "pos.x#5", kind: "number", unsigned: false, rowed: false, cells: { value: 0x118 } }, + { id: "pos.y#6", kind: "number", unsigned: false, rowed: false, cells: { value: null } }, + { id: "who#7", kind: "unit", unsigned: false, rowed: false, cells: { ptr: 0x120, uid: 0x124 } }, + { id: "n#8", kind: "number", unsigned: false, rowed: false, cells: { value: 0x128 } }, + ], + arrays: [ + { id: "seen#0", kind: "number", unsigned: false, length: 3, rowed: false, at: 0x200 }, + { id: "queue#1", kind: "number", unsigned: false, length: 0, rowed: false, dynamic: true, ptr: 0x300, len: 0x304 }, + ], + }, + { + index: 1, name: "each", perPlayer: true, owners: [0, 2], + vars: [{ id: "mine#0", kind: "number", unsigned: false, rowed: true, cells: { value: 0x400 } }], + arrays: [{ id: "row#0", kind: "boolean", unsigned: false, length: 2, rowed: true, at: 0x500 }], + }, + ], +}; + +function filled() { + const g = memory(), { m, u8 } = g; + m.setInt32(0x100, -7, true); + m.setUint32(0x104, 4000000000, true); + m.setUint32(0x108, 1, true); + // String 2 of the table: "three". + m.setUint32(STRINGS + 8, 0x40, true); + u8.set(new TextEncoder().encode("three\0"), STRINGS + 0x40); + m.setUint32(0x10c, 2, true); + u8.set(new TextEncoder().encode("tick 4 ✓\0"), 0x600); + m.setUint32(0x110, 0x600, true); + m.setUint32(0x118, 40, true); + m.setUint32(0x120, 0x7000, true); + m.setUint32(0x124, 0x0500, true); + m.setInt32(0x128, 99, true); + [1, 2, 3].forEach((v, i) => m.setUint32(0x200 + 4 * i, v, true)); + m.setUint32(0x300, 8, true); m.setUint32(0x304, 2, true); + m.setUint32(HEAP + 4 * 8, 5, true); m.setUint32(HEAP + 4 * 9, 6, true); + m.setUint32(0x400 + 4 * 0, 3, true); m.setUint32(0x400 + 4 * 2, 4, true); + m.setUint32(0x500 + 4 * (2 * 2 + 1), 1, true); + const unitAt = (ptr: number, uid: number) => (ptr === 0x7000 && uid === 0x0500 ? ({ alive: true, type: 0 } as never) : null); + return { ...g, unitAt }; +} + +describe("a program's variables read out of the game", () => { + it("each kind, as the simulator gives it", () => { + const k = programVariables(filled(), MAP, "kinds"); + // (Of two with one name, the later.) + expect(k.n).toBe(99); + expect(k.big).toBe(4000000000); + expect(k.ok).toBe(true); + expect(k.title).toBe("three"); + expect(k.said).toBe("tick 4 ✓"); + expect(k.pos).toEqual({ x: 40, y: 0 }); + expect(k.who).toEqual({ alive: true, type: 0 }); + expect(k.seen).toEqual([1, 2, 3]); + expect(k.queue).toEqual([5, 6]); + expect(k.nothing).toBeUndefined(); + expect("seen" in k).toBe(true); + }); + + it("a per-player program's row, by player; the first owner's by default", () => { + const g = filled(); + expect(programVariables(g, MAP, "each").mine).toBe(3); + expect(programVariables(g, MAP, "each", 2).mine).toBe(4); + expect(programVariables(g, MAP, 1, 2).row).toEqual([false, true]); + expect(() => programVariables(g, MAP, "each", 1)).toThrow(/runs for P2/); + expect(() => programVariables(g, MAP, "waves")).toThrow(/Named: kinds, each/); + expect(() => programVariables(g, MAP, 5)).toThrow(/the script has 2/); + }); + + it("a unit that is gone is null; nothing held is null", () => { + const g = filled(); + g.m.setUint32(0x124, 0x0600, true); + expect(programVariables(g, MAP, "kinds").who).toBeNull(); + g.m.setUint32(0x120, 0, true); + expect(programVariables(g, MAP, "kinds").who).toBeNull(); + }); +}); + +describe("the build's lines", () => { + const lines = [ + "[Stage 3/3] ConstructPayload", + `TRIGSCRIPT-VARS {"programs":2,"heap":${HEAP},"strings":${STRINGS}}`, + `TRIGSCRIPT-PROGRAM ${JSON.stringify(MAP.programs[1])}`, + `TRIGSCRIPT-PROGRAM ${JSON.stringify(MAP.programs[0])}`, + ]; + it("make the map, in the programs' order", () => { expect(readVariableMap(lines)).toEqual(MAP); }); + it("cut short, make none", () => { expect(readVariableMap(lines.slice(0, 3))).toBeNull(); expect(readVariableMap(["hello"])).toBeNull(); }); + it("are not for a person", () => { expect(lines.map(isMachineLine)).toEqual([false, true, true, true]); expect(isMachineLine("TRIGSCRIPT-ROWS [[0,1,2,\"\"]]")).toBe(true); }); +}); diff --git a/vendor/scmSim.ts b/vendor/scmSim.ts index fbcc2ff..04b10c6 100644 --- a/vendor/scmSim.ts +++ b/vendor/scmSim.ts @@ -22,7 +22,7 @@ export interface ScmSimEngine { * A script's tests beside the game: the map as Save builds it (a whole .scx; the page reads * its scenario) or that scenario as eudplib built it, the text of a module * whose `runGameTests(game, payload)` runs them, and its payload; resolves with what the - * module returned. Absent from the plugin before 0.9.0. + * module returned. Absent from the plugin before 0.10.0. */ test?(scenario: Uint8Array, module: string, payload: unknown, options?: { signal?: AbortSignal; onProgress?: (p: { stage: "starting" }) => void }): Promise; }