From 80f32abc9da1da2b637a88e7629a16bebc5e2e8f Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 23 Oct 2026 12:15:43 -0600 Subject: [PATCH 1/4] Own backend, the map's own triggers as the game's nodes in the payload: step 13 The map's TRIG section and the script's trigger() records are laid out as the game's own nodes in the payload, a copy for each player whose list the game would copy them into, chained after the player's entry and before their gate; the TRIG section holds the start-up alone. The gate is turned around so the programs run from the last player's list still there, after every player's own triggers, as eudplib's build runs them. hyperTriggers() records are left out: the map's triggers run every frame as it is, and the game holds a list with hyper triggers in it to every second frame (measured). Found by the probe: the start-up left the current player at the EPD it wrote through, so the first frame's Wait took nobody's slot (the start-up now puts the player back); a deaths() read of a cell 12 to 26 dwords into the table is a player group's number as a player field, so such a cell is asked as the game's own Deaths condition of its player and unit. eudplib's build runs a trigger whose Disabled flag is set where the game does not. The ledger: step 13's 13 rows taken. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/emit.ts | 12 ++- compiler/backend/input.ts | 11 ++- compiler/backend/layout.ts | 45 +++++++-- compiler/backend/own.ts | 57 ++++++++++- docs/v4-plan.md | 80 +++++++++++++++- ledger/triggers/comment.ts | 2 +- ledger/triggers/disabled-action.ts | 2 +- ledger/triggers/disabled-condition.ts | 2 +- ledger/triggers/hyper-triggers.ts | 2 +- ledger/triggers/not-in-a-trigger.ts | 2 +- ledger/triggers/preserve-trigger.ts | 2 +- ledger/triggers/preserve.ts | 2 +- ledger/triggers/trigger-disabled-option.ts | 2 +- ledger/triggers/trigger-execution-flags.ts | 2 +- .../triggers/trigger-for-several-players.ts | 2 +- ledger/triggers/trigger-nested-lists.ts | 2 +- ledger/triggers/trigger-preserved-option.ts | 2 +- ledger/triggers/trigger.ts | 2 +- probes/own-triggers.ts | 48 ++++++++++ scripts/build-own.mts | 13 ++- tests/emitter.test.ts | 94 +++++++++++++++++-- tests/ownGame.test.ts | 75 ++++++++++++++- 22 files changed, 410 insertions(+), 51 deletions(-) create mode 100644 probes/own-triggers.ts diff --git a/compiler/backend/emit.ts b/compiler/backend/emit.ts index df420f2..ffbf6aa 100644 --- a/compiler/backend/emit.ts +++ b/compiler/backend/emit.ts @@ -2136,6 +2136,14 @@ class Emitter { const table = tableOf(rec, this.scope.owner); if (table !== undefined) { const out = `${key}.o`, d: Dword = { game: table.at }, whole = table.mask === U32 - 1; + // A cell of the deaths table whose number is a player group's (12 to 26: Player 2's Ghosts, Player 1's Vultures) + // cannot be named by its EPD — the game reads the group's cell for that player field — so it is asked as the + // game's own Deaths condition of that player and unit, the mask in its record (step 13; found by its probe). + const index = rec.type === DEATHS_TYPE && table.at >= DEATHS_TABLE && table.at < DEATHS_TABLE + 4 * 12 * 228 ? (table.at - DEATHS_TABLE) / 4 : -1; + const byRecord = index >= 12 && index <= LAST_GROUP; + const cond = (compare: Compare, value: number, mask?: number): Cond => (byRecord + ? { dword: { game: 0 }, compare, value, game: { ...rec, player: index % 12, unitId: Math.floor(index / 12), comparison: compare === "atLeast" ? 0 : 1, amount: value, location: mask ?? 0, mask: mask === undefined ? 0 : MASKED_MARK } } + : { dword: d, compare, value, ...(mask === undefined ? {} : { mask }) }); this.push({ conds: [], items: [{ act: { dword: { temp: out }, modifier: "set", value: 0 } }], at }); // (The bits of the mask from the top, each moved down `shift`; entered at the highest group of eight from the // lowest that has a bit set: below 2^8, 2^16, 2^24 of the field — the masked dword at most that — from its bit 7, @@ -2143,10 +2151,10 @@ class Emitter { const bits = Array.from({ length: 32 }, (_, b) => 31 - b).filter((b) => table.mask & 2 ** b); const lo = bits[bits.length - 1], cuts = [8, 16, 24].map((g) => lo + g).filter((c) => c <= bits[0]); const starts = new Map(cuts.map((c) => [c - 1, this.label("rgfrom")])); - for (const c of cuts) this.branch([{ dword: d, compare: "atMost", value: u(2 ** c - 2 ** lo), ...(whole ? {} : { mask: table.mask }) }], starts.get(c - 1)!, at); + for (const c of cuts) this.branch([cond("atMost", u(2 ** c - 2 ** lo), whole ? undefined : table.mask)], starts.get(c - 1)!, at); for (const b of bits) { if (starts.has(b)) this.land(starts.get(b)!); - this.push({ conds: [{ dword: d, compare: "atLeast", value: 1, mask: u(2 ** b) }], items: [{ act: { dword: { temp: out }, modifier: "add", value: u(2 ** (b - shift)) } }], at }); + this.push({ conds: [cond("atLeast", 1, u(2 ** b))], items: [{ act: { dword: { temp: out }, modifier: "add", value: u(2 ** (b - shift)) } }], at }); } this.open().items.push({ fill: { temp: out, target } }); return; diff --git a/compiler/backend/input.ts b/compiler/backend/input.ts index 3f41f42..eb92a1d 100644 --- a/compiler/backend/input.ts +++ b/compiler/backend/input.ts @@ -36,11 +36,16 @@ * frame and its values are in cells. */ import type { ArrayDecl, At, BoolExpr, NumExpr, Program, Stmt, VarDecl } from "../ir"; -import type { ActionRecord } from "../../vendor/triggers"; +import type { ActionRecord, TriggerRecord } from "../../vendor/triggers"; import { MAX_CHAT_BYTES, MAX_CHAT_NUMBER, MOUSE_BUTTONS, inputsOf, parseChatPattern, virtualKey, type ChatPattern, type InputSource, type MouseButton } from "../input"; -/** What the build says of the map: the human players' slots, the map in pixels, the unit-properties slot (1-based) it wrote as "in transit", and the carrier type. */ -export interface OwnMap { humans: number[]; width: number; height: number; cuwp: number; carrier?: number } +/** + * What the build says of the map: the human players' slots, the map in pixels, the unit-properties slot (1-based) it + * wrote as "in transit", and the carrier type. `triggers`: the map's own triggers as its TRIG section holds them before + * the script's records (TrigScript 4, step 13) — laid out with the records, before them; their texts and sound files + * are the map's strings already. + */ +export interface OwnMap { humans: number[]; width: number; height: number; cuwp: number; carrier?: number; triggers?: TriggerRecord[] } export interface OwnInputPlan { humans: number[]; diff --git a/compiler/backend/layout.ts b/compiler/backend/layout.ts index 907e8a4..4de0387 100644 --- a/compiler/backend/layout.ts +++ b/compiler/backend/layout.ts @@ -33,6 +33,7 @@ import { AFTER_END, END, NOW, PASS_END, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, type Act, type Cond, type Dword, type Graph, type Value } from "./graph"; import { TRIGGER_LISTS, WAKE_AT, WAKE_NEXT, WAKE_TICK } from "./emit"; import type { ScriptString } from "../runtime"; +import type { TriggerRecord } from "../../vendor/triggers"; export const STRINGS = 0x191943c8; const DEATHS = 0x58a364; @@ -180,8 +181,18 @@ export interface LayoutInput { * game, among the triggers stacked in each other's room). */ free?: number; + /** + * The map's own triggers (TrigScript 4, step 13), in the order the map's TRIG section would hold them, each with the + * players (0-based slots among `players`) whose lists the game would copy it into. Laid out as the game's nodes in + * the payload — a copy a player, chained after the player's entry and before their gate — in place of the TRIG + * section, which holds the start-up alone. + */ + triggers?: OwnTriggerInput[]; } +/** A trigger of the map's own for the layout: its record, with each condition and action as the graph has them (`game`, `record`). */ +export interface OwnTriggerInput { record: TriggerRecord; conds: Cond[]; acts: Act[]; slots: number[] } + /** * The dwords of a variable's cell trigger (below) a routing writes — its action's destination, where it goes after — * and the label the walk is sent to it by. @@ -291,7 +302,18 @@ export function layout(input: LayoutInput): OwnMap { // (Where each run's graph ends: the next run.) const ends = new Map(runs.flatMap((_, i): [string, string][] => [[i ? `${END}#${i}` : END, after(i)], [i ? `${PASS_END}#${i}` : PASS_END, wake(i)], [i ? `${AFTER_END}#${i}` : AFTER_END, stop]])); const endOf = (l: string) => ends.get(l) ?? l; - type T = { label: string; conds: Cond[]; acts: Act[]; next?: string | number; prev?: number; off?: boolean }; + // (`flags`: a node's execution flags as written, a trigger of the map's own; else preserved, or `off`.) + type T = { label: string; conds: Cond[]; acts: Act[]; next?: string | number; prev?: number; off?: boolean; flags?: number }; + // The map's own triggers (step 13): a player's copies chained in order after their entry, the last going on to their + // gate. They are the game's nodes, so the game runs them as it runs a TRIG section's — a trigger that is not + // preserved is switched off once it has run, a Wait holds it in the player's wait slot — and they are reachable only + // from the entry while the walk is in the list, so a leaver's list empties as before. + const own = (p: number, k: number) => `@trig${k}.p${p}`; + const chains = new Map(players.map((p) => [p, (input.triggers ?? []).flatMap((t, k) => (t.slots.includes(p) ? [own(p, k)] : []))])); + const chainTriggers: T[] = (input.triggers ?? []).flatMap((t, k) => t.slots.filter((p) => players.includes(p)).map((p): T => { + const chain = chains.get(p)!, i = chain.indexOf(own(p, k)); + return { label: own(p, k), prev: 0, conds: t.conds, acts: t.acts, next: chain[i + 1] ?? gate(p), flags: t.record.flags }; + })); const triggers: T[] = runs.flatMap(({ graph, owner }, i) => [ { label: wake(i), @@ -307,7 +329,8 @@ export function layout(input: LayoutInput): OwnMap { // triggers run every frame and not every few seconds.) { label: stop, conds: [], acts: [{ dword: { game: TRIGGER_WAIT }, modifier: "set", value: 0 }], next: 0x80000000 }, // Each player's entry, their list's one node: the list made it alone (the start-up's triggers dropped), and the - // walk sent on to the gate — the next pointer goes back to the list's end at `@run`, or the next frame's. + // walk sent on to the player's own triggers and then the gate — the next pointer goes back to the list's end at + // `@run`, or the next frame's. ...players.map((p): T => ({ label: entry(p), prev: list(p) + 4, @@ -315,14 +338,17 @@ export function layout(input: LayoutInput): OwnMap { acts: [ { dword: { game: list(p) + 8 }, modifier: "set", value: { label: entry(p) } }, { dword: { game: list(p) + 4 }, modifier: "set", value: { label: entry(p) } }, - { dword: { at: entry(p), field: "next" }, modifier: "set", value: { label: gate(p) } }, + { dword: { at: entry(p), field: "next" }, modifier: "set", value: { label: chains.get(p)![0] ?? gate(p) } }, ], next: listEnd(p), })), - // The gate: into the programs when each player before this one has an empty list; otherwise the walk ends. + ...chainTriggers, + // The gate: into the programs when each player after this one has an empty list — so the programs run from the + // last player's list still there, after every player's own triggers, as eudplib's build runs them (its + // RunTrigTrigger before the programs); otherwise the walk ends. ...players.map((p): T => ({ label: gate(p), - conds: players.filter((q) => q < p).map((q): Cond => ({ dword: { game: list(q) + 8 }, compare: "atLeast", value: 0x80000000 })), + conds: players.filter((q) => q > p).map((q): Cond => ({ dword: { game: list(q) + 8 }, compare: "atLeast", value: 0x80000000 })), acts: [{ dword: { at: gate(p), field: "next" }, modifier: "set", value: { label: run } }], next: listEnd(p), })), @@ -527,7 +553,8 @@ export function layout(input: LayoutInput): OwnMap { dv.setUint8(pad + o + 28 - base, 20); if (a.mask !== undefined) dv.setUint16(pad + o + 30 - base, MASKED, true); }); - w32(n + 2376, t.off ? TRIGGER_OFF : TRIGGER_ON); // preserved (and done, one passed by) + // (Preserved — and done, one passed by; a trigger of the map's own with its flags as written.) + w32(n + 2376, t.flags ?? (t.off ? TRIGGER_OFF : TRIGGER_ON)); }); for (const l of listData) l.values.forEach((v, i) => w32(l.at + 4 * l.stride * i, v)); for (const t of graphs.flatMap((g) => g.templates ?? [])) if (!input.templateAt?.has(t.id) && t.at === undefined) t.bytes.forEach((b, i) => dv.setUint8(pad + templates.get(t.id)! + i - base, b)); @@ -586,8 +613,10 @@ function startUp(players: number[], entry: (p: number) => number): Uint8Array { // The current player: the EPD of that node's next pointer. record(players, [], [{ player: epd(CURRENT_PLAYER), modifier: 7, value: epd(4) }]); for (let e = 2; e < 32; e++) record(players, [{ player: SCRATCH, compare: 0, value: 1, mask: 2 ** e }], [{ player: epd(CURRENT_PLAYER), modifier: 8, value: 2 ** (e - 2) }]); - // The player's entry after it; the scratch dword back to 0. - for (const p of players) record([p], [], [{ player: CURRENT, modifier: 7, value: entry(p) }, { player: SCRATCH, modifier: 7, value: 0 }]); + // The player's entry after it; the scratch dword back to 0; the current player the player again — the game set it + // when it started on the list, and the map's own triggers after the start-up (step 13) read it (a Wait takes the + // current player's wait slot; Display Text shows to them). + for (const p of players) record([p], [], [{ player: CURRENT, modifier: 7, value: entry(p) }, { player: SCRATCH, modifier: 7, value: 0 }, { player: epd(CURRENT_PLAYER), modifier: 7, value: p }]); const out = new Uint8Array(2400 * records.length); records.forEach((r, i) => out.set(r, 2400 * i)); return out; diff --git a/compiler/backend/own.ts b/compiler/backend/own.ts index 1095846..cb0cae2 100644 --- a/compiler/backend/own.ts +++ b/compiler/backend/own.ts @@ -11,6 +11,8 @@ */ import type { CompileResult } from "../compiler"; import type { ArrayDecl, FuncDecl, Program, VarDecl } from "../ir"; +import type { ScriptString } from "../runtime"; +import { ActionType, PlayerGroup, TriggerFlag, type TriggerRecord } from "../../vendor/triggers"; import { ANY_PLAYERS, emitBody, groupSlots, type MapPlayers } from "./emit"; import { NOW, type Act, type Cond, type Dword, type Graph, type Num, type Trigger, type Value } from "./graph"; import { TextPool, knownTexts } from "./texts"; @@ -39,6 +41,50 @@ export interface Own { texts: string[]; /** The players' input the map carries itself (`input.ts`): what the build makes the carriers from; absent when no program reads the players. */ input?: OwnInputPlan; + /** + * The map's own triggers (TrigScript 4, step 13): the map's before the script's `trigger()` and `hyperTriggers()` + * records, in order, each with the players whose lists it runs in. The layout lays them out as the game's nodes, a + * copy a player, before the player's gate — so that each player's run them in turn, before the programs, as + * eudplib's build runs them. + */ + triggers: OwnTrigger[]; + /** How many hyper triggers (`hyperTriggers()`, or the map's own) were left out of them. */ + hyperTriggersLeftOut: number; +} + +/** A hyper trigger: preserved, most of its actions Wait(0) (`compiler/lower.ts`'s, or a map maker's own). */ +export function isHyperTrigger(t: TriggerRecord): boolean { + return (t.flags & TriggerFlag.Preserve) !== 0 && t.actions.filter((a) => a.type === ActionType.Wait && a.time === 0).length >= 32; +} + +/** + * A trigger of the map's own as a map of our own lays it out: its record as written, its conditions and actions as + * the graph's (`game` / `record`, with the strings a text or a sound file is), and the players whose lists hold a copy — + * the slots this map gives a human or a computer among those the record names, All Players and the forces resolved as + * the game resolves them when it loads the map. A record naming no such player is in no list, as in the game. + */ +export interface OwnTrigger { + record: TriggerRecord; + conds: Cond[]; + acts: Act[]; + slots: number[]; +} + +/** A trigger record as the layout takes it; `string` names a text or a sound file by the record's index. */ +function ownTrigger(record: TriggerRecord, players: MapPlayers, string: (i: number) => ScriptString | undefined): OwnTrigger { + const slots: number[] = []; + for (const [group, on] of record.players.entries()) { + if (!on || group > PlayerGroup.Force4) continue; + // (A player not in the game has no list: a trigger for them alone never runs, as in the game.) + for (const p of groupSlots(group, players) ?? []) if (players.slots.includes(p) && !slots.includes(p)) slots.push(p); + } + slots.sort((a, b) => a - b); + return { + record, + conds: record.conditions.map((c): Cond => ({ dword: { game: 0 }, compare: "atLeast", value: 0, game: c })), + acts: record.actions.map((a): Act => ({ dword: { game: 0 }, modifier: "set", value: 0, record: a, ...(a.text || a.wav ? { strings: { text: string(a.text), wav: string(a.wav) } } : {}) })), + slots, + }; } /** What `emitOwn` takes the map for when the build gave none: the players' slots all human, a 128 by 128 map, the last unit-properties slot. */ @@ -61,8 +107,13 @@ function ownerSlots(program: Program, players: MapPlayers): number[] | string { * no map). */ export function emitOwn(r: Pick & Partial>, players: MapPlayers = ANY_PLAYERS, textBase = 1, map: OwnMap = { ...ANY_MAP, humans: players === ANY_PLAYERS ? ANY_MAP.humans : players.slots }): Own | string { - // (A map of our own is built without the map's triggers, so `trigger()` records would be lost without a word.) - if (r.triggers.length) return `a map of our own keeps no trigger() or hyperTriggers() records yet; this script has ${r.triggers.length}`; + // The map's own triggers, then the script's records (step 13): a record's text or sound file is the script's string + // it names, the map's the map's own string already. Hyper triggers are left out: a map of our own goes through its + // triggers every frame as it is, and under that pass the game holds a list with hyper triggers in it to every second + // frame (measured in the lifted game; eudplib's build is held so too). + const kept = [...(map.triggers ?? []).map((t) => [t, true] as const), ...r.triggers.map((t) => [t, false] as const)].filter(([t]) => !isHyperTrigger(t)); + const hyperTriggersLeftOut = (map.triggers?.length ?? 0) + r.triggers.length - kept.length; + const triggers = kept.map(([t, ofMap]) => ownTrigger(t, players, ofMap ? (i) => (i ? { index: i } : undefined) : (i) => (i ? r.strings[i - 1] : undefined))); const runs: OwnRun[] = []; const values = new Map(); // Every program's texts known when built first: the pool is numbered once they are all in, before any is lowered. @@ -120,7 +171,7 @@ export function emitOwn(r: Pick & } } if (!runs.length) return "a script with no program"; - return { programs, runs, values, texts, ...(input ? { input } : {}) }; + return { programs, runs, values, texts, triggers, hyperTriggersLeftOut, ...(input ? { input } : {}) }; } /** diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 62028c2..c8002cc 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -3415,9 +3415,10 @@ No relocation, no scrambling, no flags to flip (the map has no triggers of its o - **Played in Remastered (2026-10-19):** `trigscript-own-loader.scx` printed all eight lines, every one "ok" — the first TrigScript map Remastered has run with no eudplib in it. Quitting was clean: the recording ends on the Single Player menu, no error — the end of a game, when the owner's list still holds nodes outside the game's heap, is fine. -- Not yet: anything the emitter leaves to the Python side (texts with values, units, arrays it does not read, calls, - tasks, per-player programs, `trigger()` records of the map's own), and the map's own triggers alongside (eudplib's - stage 3 keeps them; ours drops them). The probe map is `trigscript-own-loader.scx` in the Maps folder: played in +- Not yet (as of 2026-10-19; every one of these has since landed, the map's own triggers as step 13): anything the + emitter leaves to the Python side (texts with values, units, arrays it does not read, calls, tasks, per-player + programs, `trigger()` records of the map's own), and the map's own triggers alongside (eudplib's stage 3 keeps them; + ours drops them). The probe map is `trigscript-own-loader.scx` in the Maps folder: played in Remastered, it says whether the start-up holds there — and whether the end of a game does, the one moment where a list holding nodes outside the game's heap could matter (eudplib's lists hold them too). @@ -3573,8 +3574,10 @@ found what they left out; where a step moved, its old number is given. 12. **Input** (was 7; idea 13). Keys, clicks, the mouse, typed lines, sync channels, the `local.*` reads and the user's player id. The test harness types and presses through chatEvent's and MSQC's addresses (`GameSim`): it moves with them, and the map stops needing what MSQC takes (a location, the Valkyrie's entry, Player 12's units). -13. **The map's own triggers** (was 8). Kept beside the program, in eudplib's order (programs after every player's - triggers) or another one said; `trigger()` and `hyperTriggers()` records. +13. **The map's own triggers** (was 8) ✓ — "The map's own triggers on our own backend" (all 13 of the ledger's rows). + The map's TRIG section and the script's `trigger()` records laid out as the game's own nodes in the payload, a copy + a player, each player's run in turn before the programs, as eudplib's build runs them; `hyperTriggers()` records + left out, the map's triggers running every frame as it is. 14. **The build in the editor** (new). A build step of the plugin's own for Save, Test Map and export; the CHK and the archive written in the plugin (mopaq bundled, or an addition to the plugin API); eudplib's fixes to the map (MTXM's null tiles, a desync by its source's account; UNIT flags; UNIx/UPGx/TECx defaults); the script's settings @@ -4043,6 +4046,22 @@ the ledger is the list it can be written again from at 4.0. value is cut to that. The emitter folds a sum of bit-slices of one read with a negative constant wrongly (above, "Found on the way"): to find in the emitter's sliced copies and fix, with a random-program test that mixes `&`, `>>>` and a subtraction. +- **The map's own triggers on a map of our own (step 13), what is left:** the order is eudplib's — every player's + triggers, then the programs — and no other can be said yet (a program before the triggers would run from the first + player's list, before their entry's chain: a word in `program()`'s options). A trigger for a player group the game + gives no list (Players 9 to 12, Foes, Allies, Neutral, Non-Allied Victory Players) is in no list, as in the game, + without a word. The map's own triggers' Comments are kept as the game keeps them (a Comment action in the payload: 32 + bytes each). A map maker's own hyper triggers in the map's TRIG section are left out as the script's are, found by + their shape (preserved, 32 or more `Wait(0)`s) — said in the build's line, nowhere else yet. The chain's nodes are + stacked as the graph's triggers are, 2,408 bytes a node only when full. Open in eudplib's build, found by the probe: + it runs a trigger whose Disabled flag is set (the game does not), and with `hyperTriggers()` in the map it goes + through the triggers every second frame (the game's own doing under eudTurbo, measured: a list with hyper triggers in + it is held to every second frame). +- A `deaths(p, u)` read whose cell is 12 to 26 dwords into the deaths table (Player 2's Ghosts, Player 1's Vultures) + cannot be asked by its EPD — the game reads the player group of that number instead (13 the current player, 17 all + players, 18 to 21 the forces) — so a map of our own asks the game's own Deaths condition of that player and unit, the + mask in its record (fixed in step 13, found by its probe: the read gave the current player's Marines). The same holds + for `memory(0x58A394 … 0x58A3CC)` in both builds, which still read the group: in the Open list for the Python side. - A variable set from a constant less itself (`j = 1 - j`) is copied a bit at a time — 32 triggers, about 0.5 µs, where eudplib's is two actions: the game's subtract stops at 0, so the emitter copies a variable read times −1 (found weighing the errors probe, 2026-10-22). @@ -5254,6 +5273,57 @@ stood alone while their answers had faded). **Re-test played (recording 10.44.13 "held 56 frames", two quick taps two presses, `-help` answered. Step 12 played out. +### The map's own triggers on our own backend (2026-10-23) + +Step 13 (`compiler/backend/own.ts`: `OwnTrigger`, the records with the players whose lists hold a copy; +`compiler/backend/layout.ts`: the chains after each player's entry, the gate turned around; `scripts/build-own.mts`; +the start-up's current player). The ledger's 13 rows of `trigger()`, its options and `hyperTriggers()` are taken: step +13 is done, 13 of 13. + +- **The map's own triggers are the game's own nodes in the payload.** The map's TRIG section (as the editor holds it + before the script's records go in) and then the script's `trigger()` records, in order, each copied once for every + player whose list the game would copy it into — the slots named, All Players, a force, among the players the map + gives a human or a computer (a trigger for a player not in the game runs for nobody, as in the game). A copy is laid + out as a node of the game's — its conditions and actions byte for byte as the TRIG section would hold them, its + texts and sound files put among the map's strings, its execution flags as written — stacked as the graph's triggers + are, and chained: the player's entry sends the walk to the first copy, each goes on to the next, the last to the + player's gate. The game runs them as it runs a TRIG section's: a trigger that is not preserved is switched off once it + has run, a Wait holds it in the player's wait slot and the walk goes on, a disabled trigger, condition or action is + passed over. The TRIG section holds the start-up alone, as before. +- **In eudplib's order.** The gate is turned around: it lets the walk into the programs when every player *after* this + one has an empty list, so the programs run from the last player's list still there — after every player's own + triggers, as eudplib's build runs them (its RunTrigTrigger at the top of each frame, the programs after). Each + player's copies run in their list's walk, so the current player is theirs; a copy of Player 2's that reads what + Player 1's set in the same frame sees it. +- **Hyper triggers are left out.** A map of our own goes through its triggers every frame as it is (`@stop` sets the + game's wait between passes to 0), and under that pass the game holds a list with hyper triggers in it to every second + frame (measured in the lifted game with the probe's triggers as a plain classic map: passes on the odd frames, `Wait` + counting 84 ms a pass; eudplib's build with `hyperTriggers()` in the map is held so too). The build's line says how + many it left out. +- **Found on the way.** (1) The start-up left the current player at the EPD it wrote through, and the first frame's + chain ran with it: the `Wait` took a wait slot that was nobody's and ended at the next pass. The start-up's last + trigger now puts the current player back to the player whose list it is. (2) A `deaths(P2, units.TerranGhost)` + read gave the current player's Marines: a deaths cell 12 to 26 dwords in is a player group's number as a player + field (Open, above); such a cell is now asked as the game's own Deaths condition of that player and unit. (3) + eudplib's build runs a trigger whose Disabled flag is set; the game does not (the classic map, the lifted game): + ours follows the game. + +| Lifted game, Remastered's weights (`probes/own-triggers.ts`: eight triggers of the map's own beside a program) | Ours | eudplib | +| --- | ---: | ---: | +| a quiet frame (the triggers walked, the program asleep) | **0.4 µs, 10 triggers** | 3.3 µs, 111 triggers | +| the first frame | **6 µs** | 1,082 µs | +| the map | 60 KB | **54 KB** | + +Checked: `tests/ownGame.test.ts` ("a map of our own, the map's own triggers": the players a record is copied for; +each copy's bytes against `encodeTriggers` of the record, its flags, the chain's next pointers, the gate's conditions); +`tests/emitter.test.ts` (the same, walked from each player's entry frame after frame as the game walks the lists — a +trigger not preserved switched off after its run, Player 2's copy of an All Players trigger seeing the switch Player +1's set the same frame, the program after every player's triggers; a player not in the game left out); the ledger; +`probes/own-triggers.ts` both ways in the lifted game: every line "ok" and at the same frame as eudplib's build without +`hyperTriggers()` — but E, where eudplib runs the disabled trigger — and as the same triggers in a plain classic map. +Not played in Remastered yet: `Maps/TrigScript/own-step13-triggers.scx` is to settle whether Remastered runs the +payload's nodes as its TRIG section's (a Wait, a trigger switched off, a disabled condition). + ### Triggers stacked (2026-10-20) Brought forward from step 14 (`compiler/backend/layout.ts`). The game reads no more of a trigger's 2,408 bytes than diff --git a/ledger/triggers/comment.ts b/ledger/triggers/comment.ts index d8f5a03..50b5fab 100644 --- a/ledger/triggers/comment.ts +++ b/ledger/triggers/comment.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // The action Comment: a trigger's name in StarEdit's list, which does nothing in the game. trigger(P1, [always()], [comment("Starting minerals"), setResources(P1, "set", 50, "ore")]); program(() => { diff --git a/ledger/triggers/disabled-action.ts b/ledger/triggers/disabled-action.ts index f5bd160..fa562b8 100644 --- a/ledger/triggers/disabled-action.ts +++ b/ledger/triggers/disabled-action.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // An action kept in a trigger but switched off, as StarEdit can: `disabled(…)`. trigger(P1, [always()], [displayText("Shown."), disabled(defeat())]); program(() => { diff --git a/ledger/triggers/disabled-condition.ts b/ledger/triggers/disabled-condition.ts index b7d9c78..eb415cd 100644 --- a/ledger/triggers/disabled-condition.ts +++ b/ledger/triggers/disabled-condition.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // A condition kept in a trigger but switched off, as StarEdit can: `disabled(…)`. trigger(P1, [always(), disabled(never())], [displayText("Fires anyway.")]); program(() => { diff --git a/ledger/triggers/hyper-triggers.ts b/ledger/triggers/hyper-triggers.ts index 3103ea2..6754535 100644 --- a/ledger/triggers/hyper-triggers.ts +++ b/ledger/triggers/hyper-triggers.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 3 +// ledger: step 13, takes // `hyperTriggers()`: preserved triggers of Wait(0)s that make the game go through the trigger list every // 2 frames instead of every 31. hyperTriggers(P8); diff --git a/ledger/triggers/not-in-a-trigger.ts b/ledger/triggers/not-in-a-trigger.ts index 9e48985..d024f37 100644 --- a/ledger/triggers/not-in-a-trigger.ts +++ b/ledger/triggers/not-in-a-trigger.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // A condition turned around by `not(…)` in a trigger's conditions: a comparison flips, a switch test flips, // always becomes never. trigger(P1, [not(bring(P1, units.AnyUnit, locations.Anywhere, ">=", 1)), not(switchIs(switches.Switch1, "set"))], [defeat()]); diff --git a/ledger/triggers/preserve-trigger.ts b/ledger/triggers/preserve-trigger.ts index 18eefec..3669f13 100644 --- a/ledger/triggers/preserve-trigger.ts +++ b/ledger/triggers/preserve-trigger.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // The action Preserve Trigger in a trigger's actions: `preserveTrigger()`. trigger(P1, [always()], [setResources(P1, "add", 1, "ore"), preserveTrigger()]); program(() => { diff --git a/ledger/triggers/preserve.ts b/ledger/triggers/preserve.ts index 1c66497..541c1da 100644 --- a/ledger/triggers/preserve.ts +++ b/ledger/triggers/preserve.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // The action Preserve Trigger in a trigger's actions, by its short name: `preserve()`. trigger(P1, [always()], [setResources(P1, "add", 1, "ore"), preserve()]); program(() => { diff --git a/ledger/triggers/trigger-disabled-option.ts b/ledger/triggers/trigger-disabled-option.ts index a3e56cc..b4f3007 100644 --- a/ledger/triggers/trigger-disabled-option.ts +++ b/ledger/triggers/trigger-disabled-option.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // A trigger kept in the map but switched off: `{ disabled: true }`. trigger(P1, [always()], [victory()], { disabled: true }); program(() => { diff --git a/ledger/triggers/trigger-execution-flags.ts b/ledger/triggers/trigger-execution-flags.ts index 753715c..98aadcc 100644 --- a/ledger/triggers/trigger-execution-flags.ts +++ b/ledger/triggers/trigger-execution-flags.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // A trigger's other execution flags: ignore the game's end, ignore Display Text, conditions met, // paused, Wait skipping off, and raw flags ORed in. trigger(P1, [always()], [displayText("Still here.")], { ignoreGameEnd: true, ignoreDisplay: true, conditionsMet: false, paused: false, waitSkipDisabled: true, flags: 0 }); diff --git a/ledger/triggers/trigger-for-several-players.ts b/ledger/triggers/trigger-for-several-players.ts index 1e2d339..0addcb1 100644 --- a/ledger/triggers/trigger-for-several-players.ts +++ b/ledger/triggers/trigger-for-several-players.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // A trigger for several players at once: `trigger([P1, P2], …)`. trigger([P1, P2], [elapsedTime(">=", 10)], [displayText("Ten seconds in.")]); program(() => { diff --git a/ledger/triggers/trigger-nested-lists.ts b/ledger/triggers/trigger-nested-lists.ts index b3f3dc0..09002d5 100644 --- a/ledger/triggers/trigger-nested-lists.ts +++ b/ledger/triggers/trigger-nested-lists.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // A trigger's conditions and actions as nested lists, `null` entries left out: what a helper puts together. const hard: boolean = false; const reward = [setResources(P1, "add", 100, "ore"), hard ? setResources(P1, "subtract", 50, "gas") : null]; diff --git a/ledger/triggers/trigger-preserved-option.ts b/ledger/triggers/trigger-preserved-option.ts index ad08d39..6764444 100644 --- a/ledger/triggers/trigger-preserved-option.ts +++ b/ledger/triggers/trigger-preserved-option.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // A trigger that runs again every time its conditions hold: `{ preserve: true }`. trigger(P1, [always()], [setResources(P1, "add", 1, "ore")], { preserve: true }); program(() => { diff --git a/ledger/triggers/trigger.ts b/ledger/triggers/trigger.ts index 4debaf3..6341ee9 100644 --- a/ledger/triggers/trigger.ts +++ b/ledger/triggers/trigger.ts @@ -1,4 +1,4 @@ -// ledger: step 13, refused: a map of our own keeps no trigger() or hyperTriggers() records yet; this script has 1 +// ledger: step 13, takes // A trigger of the map's own, beside a program: `trigger(players, conditions, actions)`. trigger(P1, [bring(P1, units.TerranMarine, locations.Anywhere, ">=", 1)], [displayText("A Marine is here."), victory()]); program(() => { diff --git a/probes/own-triggers.ts b/probes/own-triggers.ts new file mode 100644 index 0000000..c7ddace --- /dev/null +++ b/probes/own-triggers.ts @@ -0,0 +1,48 @@ +// TrigScript 4, the backend: the map's own triggers in a map of our own (scripts/build-own.mts) — step 13 of the way off +// eudplib. The script's trigger() and hyperTriggers() records are laid out as the game's own nodes in the payload, a +// copy for each player whose list the game would copy them into, chained after the player's entry and before their +// gate: each player's run in turn before the programs, as eudplib's build runs a map's triggers (RunTrigTrigger) before +// its programs. A trigger that is not preserved is switched off once it has run, a Wait holds it in the player's wait +// slot, a disabled trigger, condition or action is passed over — all the game's own doing. +// Built both ways (through eudplib, and as a map of our own), the lifted game should print the same lines at the same +// frames; every line should end "ok", and a line ending "WRONG" names the check that failed. +// npx tsx scripts/build-own.mts probes/own-triggers.ts /tmp/own-triggers.scx +// npx tsx scripts/build-fixture.mts probes/own-triggers.ts /tmp/own-triggers-eud.scx +import { program, print, sleep, frames, trigger, hyperTriggers, always, never, disabled, deaths, setDeaths, displayText, wait, elapsedTime, preserve, units, P1, P2, P5, AllPlayers, CurrentPlayer } from "trigscript"; + +// A: preserved, Player 1's: one more each frame, before the program's turn. +trigger(P1, [always()], [setDeaths(P1, units.TerranMarine, "add", 1), preserve()]); +// B: not preserved: runs once, then is switched off. +trigger(P1, [always()], [setDeaths(P1, units.TerranFirebat, "add", 1)]); +// C: All Players: a copy in each player's list, each counting for the current player once A has counted to 3. +trigger(AllPlayers, [deaths(P1, units.TerranMarine, ">=", 3)], [setDeaths(CurrentPlayer, units.TerranGhost, "add", 1), preserve()]); +// D: a Wait of half a second, then a line: the game holds the trigger in Player 1's wait slot. +trigger(P1, [always()], [wait(500), displayText("D: waited half a second")]); +// E: a disabled trigger never runs; a disabled condition is passed over; a disabled action is skipped. +trigger(P1, [always()], [setDeaths(P1, units.TerranGoliath, "set", 99)], { disabled: true }); +trigger(P1, [always(), disabled(never())], [setDeaths(P1, units.TerranVulture, "set", 7), disabled(setDeaths(P1, units.TerranVulture, "set", 8))]); +// F: a trigger for a player not in the game never runs. +trigger(P5, [always()], [setDeaths(P1, units.TerranWraith, "set", 1), preserve()]); +// G: the game's own line, a second in. +trigger(P1, [elapsedTime(">=", 1)], [displayText("G: a second in")]); +// H: hyper triggers for the computer: nothing of the above should change. +hyperTriggers(P2); + +program(async () => { + print("Own triggers: the payload runs."); + // The program's first turn comes after every player's triggers of the frame: A and B have each run once. + const a1 = deaths(P1, units.TerranMarine), b1 = deaths(P1, units.TerranFirebat); + if (a1 == 1 && b1 == 1) print("A: the triggers run before the program ok"); else print(`A: WRONG ${a1} ${b1}`); + await sleep(frames(3)); + // Three frames on: A has counted 4, B is switched off at 1; C ran for both players when A read 3 and 4. + const a4 = deaths(P1, units.TerranMarine), b4 = deaths(P1, units.TerranFirebat), c1 = deaths(P1, units.TerranGhost), c2 = deaths(P2, units.TerranGhost); + if (a4 == 4 && b4 == 1) print("B: a trigger not preserved ran once ok"); else print(`B: WRONG ${a4} ${b4}`); + if (c1 == 2 && c2 == 2) print("C: All Players, a copy a player ok"); else print(`C: WRONG ${c1} ${c2}`); + const e1 = deaths(P1, units.TerranGoliath), e2 = deaths(P1, units.TerranVulture), f = deaths(P1, units.TerranWraith); + if (e1 == 0 && e2 == 7) print("E: disabled trigger, condition and action ok"); else print(`E: WRONG ${e1} ${e2}`); + if (f == 0) print("F: a player not in the game ok"); else print(`F: WRONG ${f}`); + await sleep(frames(40)); + const a44 = deaths(P1, units.TerranMarine); + if (a44 == 44) print("A2: counted every frame ok"); else print(`A2: WRONG ${a44}`); + print("Weighed"); +}); diff --git a/scripts/build-own.mts b/scripts/build-own.mts index 3bd8b59..ee4c365 100644 --- a/scripts/build-own.mts +++ b/scripts/build-own.mts @@ -66,7 +66,11 @@ const cuwpFree = cuwpUsage(scn) as number[]; const cuwp = 1 + Math.max(0, ...[...Array(64).keys()].filter((i) => !cuwpFree[i])); const used = new Set([...scn.units.map((u: { unitId: number }) => u.unitId), ...scn.triggers.flatMap((t: { conditions: { unitId: number }[]; actions: { unitId: number }[] }) => [...t.conditions.map((c) => c.unitId), ...t.actions.map((a) => a.unitId)])]); const carrier = used.has(58) ? [...Array(228).keys()].find((t) => !used.has(t))! : 58; -const lowered = emitOwn(r, players, textBase, { humans, width: scn.width * 32, height: scn.height * 32, cuwp, carrier }); +// The map's own triggers go into the payload with the script's records (step 13), and the TRIG section holds the +// start-up alone. The base is a melee map, whose stock triggers would end a Use Map Settings game at once: they are +// left out unless OWN_KEEP_TRIGGERS says otherwise. +const mapTriggers = process.env.OWN_KEEP_TRIGGERS ? scn.triggers.slice() : []; +const lowered = emitOwn(r, players, textBase, { humans, width: scn.width * 32, height: scn.height * 32, cuwp, carrier, triggers: mapTriggers }); if (typeof lowered === "string") { console.error(`the emitter cannot lower this program: ${lowered}`); process.exit(1); } lowered.texts.forEach((text, i) => { if (setString(scn.strings, 0, text) !== textBase + i) throw new Error("the pool of texts did not go into the map's strings one after another"); @@ -74,7 +78,6 @@ lowered.texts.forEach((text, i) => { const { runs } = lowered; const graphs: Graph[] = runs.map((run) => run.graph); const emitted = performance.now(); -// No trigger of the melee map's (they would end the game at once): the start-up is the only one. scn.triggers = []; const START_LOCATION = 214, COMMAND_CENTER = 106, HATCHERY = 131, MARINE = 0, ZERGLING = 37; const placed = []; @@ -89,7 +92,7 @@ applyUnitChanges(scn, addUnits(scn, placed)); // The texts the graph shows, in the strings first: the payload goes after them. const texts = new Map(); const intern = (text: string) => { if (!texts.has(text)) texts.set(text, internString(scn, text)); }; -for (const t of graphs.flatMap((g) => g.triggers)) for (const a of t.acts) { +for (const t of [...graphs.flatMap((g) => g.triggers), ...lowered.triggers]) for (const a of t.acts) { if (a.display !== undefined) intern(a.display); // (An action of the game's: its text and its sound file's name.) for (const s of [a.strings?.text, a.strings?.wav]) if (s && "text" in s) intern(s.text); @@ -139,7 +142,7 @@ for (const t of graphs.flatMap((g) => g.templates ?? [])) { offsets.setUint32(4 * index, at - STRINGS, true); templateAt.set(t.id, at); } -const own = layout({ runs: runs.map((run) => ({ graph: run.graph, owner: run.player })), players: players.slots, strings: strings.length, text: (t) => texts.get(t)!, arrays: values, template: (id) => templates.get(id)!, templateAt, ...(process.env.OWN_FREE ? { free: Number(process.env.OWN_FREE) } : {}) }); +const own = layout({ runs: runs.map((run) => ({ graph: run.graph, owner: run.player })), players: players.slots, triggers: lowered.triggers, strings: strings.length, text: (t) => texts.get(t)!, arrays: values, template: (id) => templates.get(id)!, templateAt, ...(process.env.OWN_FREE ? { free: Number(process.env.OWN_FREE) } : {}) }); const withPayload = new Uint8Array(strings.length + own.payload.length); withPayload.set(strings); withPayload.set(own.payload, strings.length); @@ -163,5 +166,5 @@ if (r.sounds.some((s) => s.vorbis)) finishSounds(r.sounds, vorbisEncoder(readFil for (const s of r.sounds) extras.set(s.path, s.bytes!); writeFileSync(resolve(outPath), await saveMap(out, { extras, compress: "pkware", listfile: true })); const done = performance.now(); -console.log(`${resolve(outPath)}: ${runs.length} run${runs.length === 1 ? "" : "s"} (${runs.map((run) => `program ${run.program + 1} as P${run.player + 1}`).join(", ")}), ${graphs.reduce((n, g) => n + g.triggers.length, 0)} triggers of the graphs, payload ${own.payload.length} bytes at 0x${own.address.toString(16)}, start-up ${own.trig.length / 2400} triggers`); +console.log(`${resolve(outPath)}: ${runs.length} run${runs.length === 1 ? "" : "s"} (${runs.map((run) => `program ${run.program + 1} as P${run.player + 1}`).join(", ")}), ${graphs.reduce((n, g) => n + g.triggers.length, 0)} triggers of the graphs, payload ${own.payload.length} bytes at 0x${own.address.toString(16)}, ${lowered.triggers.length} trigger${lowered.triggers.length === 1 ? "" : "s"} of the map's own (${lowered.triggers.reduce((n, t) => n + t.slots.length, 0)} copies${lowered.hyperTriggersLeftOut ? `, ${lowered.hyperTriggersLeftOut} hyper triggers left out` : ""}), start-up ${own.trig.length / 2400} triggers`); console.log(`built in ${(done - started).toFixed(0)} ms (compile ${(compiled - started).toFixed(0)}, emit ${(emitted - compiled).toFixed(0)}, map ${(done - emitted).toFixed(0)})`); diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index 3fb76a4..72e51bc 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -10,7 +10,7 @@ import type { ArrayDecl, BoolExpr, Call, FuncDecl, NumExpr, Stmt, TextPart, VarD import { emitBody, emitLoop, emitterCounts } from "../compiler/backend/emit"; import type { ConditionRecord } from "../vendor/triggers"; import { END, SWITCHES, TRIGGER_BYTES, type Dword, type Graph, type Num } from "../compiler/backend/graph"; -import { layout } from "../compiler/backend/layout"; +import { layout, type OwnTriggerInput } from "../compiler/backend/layout"; import { fixedAtan2, fixedDiv, fixedMul, fixedSin, fixedSqrt, fixedText } from "../compiler/fixedMath"; import { SIN_SCALE, atanTable, sinTable } from "../compiler/numbers"; import ts from "typescript"; @@ -1075,14 +1075,19 @@ const laidOut = new WeakMap; own: ReturnT * one more) when more than one, the memory kept from one to the next; each text shown noted with its frame. */ /** `between`: what the game does before each frame — memory written, as units come and go. */ -function runOwn(g: Graph, memory?: Map, arrays = new Map(), runs?: { graph: Graph; owner: number }[], frames = 1, between?: (frame: number, write: (addr: number, value: number) => void, read: (addr: number) => number) => void): Env { +/** + * `lists`: the map's own triggers and the players whose lists the payload is linked into (TrigScript 4, step 13): each + * frame is then walked as the game walks it, from each player's entry in turn with the current player theirs, instead + * of from `@run`. + */ +function runOwn(g: Graph, memory?: Map, arrays = new Map(), runs?: { graph: Graph; owner: number }[], frames = 1, between?: (frame: number, write: (addr: number, value: number) => void, read: (addr: number) => number) => void, lists?: { triggers: OwnTriggerInput[]; players: number[] }): Env { // (Laid out once a graph: a test may walk one many times.) let done = laidOut.get(g); if (!done || (done.arrays !== arrays && (done.arrays.size || arrays.size))) { // (Strings by number: a printed text's template 1000 on, a text known when built 5000 on.) const templates = (runs ? runs.flatMap((r) => r.graph.templates ?? []) : g.templates ?? []).map((t) => t.id), texts: string[] = []; // (Every dword no trigger or data holds all ones: nothing reads them.) - const own = layout({ ...(runs ? { runs } : { graph: g, owner: 0 }), strings: 0, text: (t) => 5000 + (texts.includes(t) ? texts.indexOf(t) : texts.push(t) - 1), arrays, template: (id) => 1000 + templates.indexOf(id), free: 0xffffffff }); + const own = layout({ ...(runs ? { runs } : { graph: g, owner: 0 }), ...(lists ? { triggers: lists.triggers, players: lists.players } : {}), strings: 0, text: (t) => 5000 + (texts.includes(t) ? texts.indexOf(t) : texts.push(t) - 1), arrays, template: (id) => 1000 + templates.indexOf(id), free: 0xffffffff }); done = { arrays, own, texts, templates }; laidOut.set(g, done); } @@ -1100,10 +1105,16 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map u(DEATHS + 4 * (epd === 13 ? rd(0x6509b0) : epd)); PRINTED.frames = []; + // (A switch's bit where the game keeps it.) + const switchAt = (sw: number): [number, number] => [SWITCHES + 4 * (sw >> 5), 2 ** (sw & 31)]; for (let frame = 1; frame <= frames; frame++) { between?.(frame, wr, rd); - let pc = frames > 1 ? own.triggers.get("@run")! : own.address; + // (With the lists: each player's list in turn, from their entry, the current player theirs, as the game walks them.) + const starts: number[] = lists ? lists.players.map((p) => own.triggers.get(`@entry${p}`)!) : [frames > 1 ? own.triggers.get("@run")! : own.address]; const shownBefore = PRINTED.got.length; + starts.forEach((start, w) => { + if (lists) wr(0x6509b0, lists.players[w]); + let pc = start; for (let steps = 0; !(pc & 0x80000000); steps++) { if (steps > Number(process.env.OWN_STEPS ?? 200_000_000)) throw new Error("the payload does not end"); if (!inside(pc)) throw new Error(`the walk left the payload: 0x${pc.toString(16)}`); @@ -1113,9 +1124,19 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map>> 0 : rd(at(rd(o + 4))), y = rd(o + 8), compare = byte(o + 14); + const x = masked ? (rd(d) & rd(o)) >>> 0 : rd(d), y = rd(o + 8), compare = byte(o + 14); holds = compare === 0 ? x >= y : compare === 1 ? x <= y : x === y; } for (let k = 0; holds && k < 64; k++) { @@ -1126,24 +1147,30 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map= 5000) { PRINTED.got.push(shown([...new TextEncoder().encode(texts[index - 5000])])); continue; } + // (A string of the map's own, by its number.) + if (index < 1000) { PRINTED.got.push(shown([...new TextEncoder().encode(`string ${index}`)])); continue; } const out: number[] = []; for (let a = own.templates.get(templates[index - 1000])!; byte(a) !== 0; a++) out.push(byte(a)); PRINTED.got.push(shown(out)); continue; } if (kind === 13) { - // Set Switch: to a random state (11), its bit where the game keeps it. - const sw = rd(o + 20), addr = SWITCHES + 4 * (sw >> 5), bit = 2 ** (sw & 31); - if (byte(o + 27) !== 11) throw new Error(`Set Switch ${byte(o + 27)}`); - wr(addr, randomBit() ? (rd(addr) | bit) >>> 0 : (rd(addr) & ~bit) >>> 0); + // Set Switch: set (4), clear (5), toggle (6), or to a random state (11), its bit where the game keeps it. + const [addr, bit] = switchAt(rd(o + 20)), modifier = byte(o + 27); + const on = modifier === 4 ? true : modifier === 5 ? false : modifier === 6 ? (rd(addr) & bit) === 0 : modifier === 11 ? randomBit() !== 0 : null; + if (on === null) throw new Error(`Set Switch ${modifier}`); + wr(addr, on ? (rd(addr) | bit) >>> 0 : (rd(addr) & ~bit) >>> 0); continue; } + // (Preserve Trigger and Comment, a trigger of the map's own: the trigger kept, and nothing.) + if (kind === 3) { wr(pc + 2376, rd(pc + 2376) | 4); continue; } + if (kind === 47) continue; // (Play WAV: noted as a sound played, which sound aside.) if (kind === 8) { PRINTED.got.push("wav"); continue; } // (Create Unit with Properties and Give Units: the game's, which a test's world plays — the units are its.) if (kind === 11 || kind === 48) { GAME.action?.(kind, (off) => (off === 27 ? byte(o + 27) : rd(o + off)), rd, wr); continue; } if (kind !== 45) throw new Error(`action kind ${kind}`); - const dest = at(rd(o + 16)), value = rd(o + 20), modifier = byte(o + 27), old = rd(dest); + const dest = at(rd(o + 16)) + 48 * dv.getUint16(o + 24 - base, true), value = rd(o + 20), modifier = byte(o + 27), old = rd(dest); if (dv.getUint16(o + 30 - base, true) === 0x4353) { // (A masked write as the game has it: the bits under the mask of both worked, a carry past them lost.) const m = rd(o), f = (old & m) >>> 0, w = (value & m) >>> 0; @@ -1153,8 +1180,11 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map [id, rd(a)])); @@ -2186,6 +2216,50 @@ describe("a map of our own, unit events", () => { }); }); +describe("a map of our own, the map's own triggers", () => { + /** + * The lists as the game keeps them: a player not in the game has an empty one (its last pointer the list's own end), + * and each frame is walked from each player's entry in turn. + */ + const LISTS = 0x51a280; + const run = (script: string, frames: number, slots: number[]): string[] => { + const r = compileScript(ts, { "main.ts": script }, defaultScriptNames(), { lib: defaultLib(), emitter: false }); + expect(r.diagnostics).toEqual([]); + const o = emitOwn(r, { slots, forces: [0, 1, 1, 0, 0, 0, 0, 0] }); + if (typeof o === "string") throw new Error(o); + const memory = new Map(); + for (let q = 0; q < 8; q++) if (!slots.includes(q)) memory.set(LISTS + 12 * q + 8, u(~(LISTS + 12 * q + 4))); + PRINTED.got = []; + runOwn(o.runs[0].graph, memory, o.values, o.runs.map((x) => ({ graph: x.graph, owner: x.player })), frames, undefined, { triggers: o.triggers, players: slots }); + return PRINTED.got.map((t, i) => `${PRINTED.frames[i]} ${Buffer.from(t, "hex").toString()}`); + }; + + it("runs each player's own triggers in turn before the programs, every frame, and switches one that is not preserved off once it has run", () => { + const got = run([ + `trigger(P1, [always()], [setDeaths(P1, units.TerranMarine, "add", 1), preserve()]);`, + `trigger(P2, [always()], [displayText("P2 once")]);`, + `trigger(AllPlayers, [switchIs(switches.Switch1, "set")], [displayText("switched"), comment("a name"), preserve()]);`, + `trigger(P1, [deaths(P1, units.TerranMarine, ">=", 3)], [displayText("three"), setSwitch(switches.Switch1, "set")]);`, + "program(async () => {", + " for (let i = 0; i < 4; i++) { print(`d ${deaths(P1, units.TerranMarine)}`); await sleep(frames(1)); }", + "});", + ].join("\n"), 4, [0, 1]); + // Frame 3: Player 1's third trigger fires once and sets the switch; Player 2's copy of the All Players trigger, + // walked after Player 1's list, sees it the same frame; the program, after every player's triggers, reads the count + // the first trigger added to that frame. + expect(got).toEqual(["1 P2 once", "1 d 1", "2 d 2", "3 three", "3 switched", "3 d 3", "4 switched", "4 switched", "4 d 4"]); + }); + + it("runs the programs from the last player's list that is there, after every player's triggers, and leaves a player not in the game out", () => { + const got = run([ + `trigger(P3, [always()], [displayText("P3"), preserve()]);`, + `trigger([P1, P2, P4], [always()], [displayText("P1 P2 P4"), preserve()]);`, + `program(() => { print("program"); });`, + ].join("\n"), 2, [0, 2, 3]); + expect(got).toEqual(["1 P1 P2 P4", "1 P3", "1 P1 P2 P4", "1 program", "2 P1 P2 P4", "2 P3", "2 P1 P2 P4"]); + }); +}); + describe("a map of our own, input", () => { /** * The game's side of the channel, played by the test: Player 12's list holds the carriers the start-up finds (six a diff --git a/tests/ownGame.test.ts b/tests/ownGame.test.ts index d54eebd..88bc51f 100644 --- a/tests/ownGame.test.ts +++ b/tests/ownGame.test.ts @@ -254,10 +254,11 @@ describe("a map of our own, programs, owners and players", () => { // Its actions: the list made the entry alone, the entry's next pointer the gate's. expect([0, 1].map((a) => (node(entry, 328 + 32 * a + 16) * 4 + 0x58a364) >>> 0)).toEqual([list + 8, list + 4]); expect([node(entry, 328 + 20), node(entry, 328 + 32 + 20)]).toEqual([entry, entry]); - // The gate: one condition for each player before this one, their list empty. + // The gate: one condition for each player after this one, their list empty — the programs run from the last + // player's list that is there, after every player's own triggers (step 13). const gate = node(entry, 328 + 64 + 20); const conds = [0, 1, 2, 3].filter((c) => pv.getUint8(gate - out.address + 8 + 20 * c + 15) === 15); - expect(conds.length).toBe(players.filter((q) => q < p).length); + expect(conds.length).toBe(players.filter((q) => q > p).length); } }); }); @@ -306,3 +307,73 @@ describe("a map of our own, texts known when built chosen while the game runs", expect(cases.map((c) => c.text).join("")).toBe("ran "); }); }); + +describe("a map of our own, the map's own triggers", () => { + /** A script of `trigger()` records beside a program, for a map whose humans and computers are `slots`. */ + const script = (source: string, slots: number[], forces = [0, 1, 1, 0, 0, 0, 0, 0]) => { + lib ??= defaultLib(); + const r = compileScript(ts, { "main.ts": source }, defaultScriptNames(), { lib, emitter: false }); + expect(r.diagnostics).toEqual([]); + const o = emitOwn(r, { slots, forces }); + if (typeof o === "string") throw new Error(o); + return { ...o, strings: r.strings }; + }; + const PROGRAM = `program(() => { print("started"); });`; + + it("keeps each record, and gives it the players whose lists the game would copy it into: the slots named, All Players, a force", () => { + const o = script([ + `trigger([P1, P2], [always()], [displayText("both")]);`, + `trigger(AllPlayers, [always()], [displayText("all")]);`, + `trigger(players.Force2, [always()], [displayText("force")]);`, + `trigger(P8, [always()], [displayText("nobody")]);`, + `hyperTriggers(P3);`, + PROGRAM, + ].join("\n"), [0, 1, 2]); + // (hyperTriggers: three preserved triggers of Player 3's, 63 Wait(0)s and Preserve Trigger each — left out of a + // map of our own, which goes through its triggers every frame as it is.) + expect(o.triggers.length).toBe(4); + expect(o.hyperTriggersLeftOut).toBe(3); + expect(o.triggers.map((t) => t.slots)).toEqual([[0, 1], [0, 1, 2], [1, 2], []]); + // (The records as the script wrote them.) + expect(o.triggers[0].record.actions[0]).toMatchObject({ type: 9 }); + expect(o.triggers[0].acts[0].strings?.text).toEqual({ text: "both" }); + }); + + it("lays a player's copies out as the game's nodes, chained from their entry to their gate, the record's bytes and flags as written", () => { + const o = script([ + `trigger([P1, P2], [bring(P1, units.TerranMarine, locations.Anywhere, ">=", 1), disabled(never())], [comment("a name"), displayText("A Marine."), disabled(victory())], { ignoreGameEnd: true });`, + `trigger(P1, [always()], [setResources(P1, "add", 1, "ore")], { preserve: true });`, + PROGRAM, + ].join("\n"), [0, 1]); + const texts = new Map([["a name", 7], ["A Marine.", 8], ["started", 9]]); + const out = layout({ runs: o.runs.map((r) => ({ graph: r.graph, owner: r.player })), players: [0, 1], triggers: o.triggers, strings: 0, text: (s) => texts.get(s)!, arrays: new Map() }); + const node = (label: string) => out.triggers.get(label)! - out.address; + const dword = (at: number) => new DataView(out.payload.buffer, out.payload.byteOffset).getUint32(at, true); + // Player 1's chain: both records; Player 2's: the first alone. The entry's third action sends the walk to the chain's + // head; the last node goes on to the gate. + for (const [p, chain] of [[0, ["@trig0.p0", "@trig1.p0"]], [1, ["@trig0.p1"]]] as const) { + const entry = node(`@entry${p}`); + expect(dword(entry + 328 + 32 * 2 + 20)).toBe(out.triggers.get(chain[0])!); + chain.forEach((label, i) => expect(dword(node(label) + 4)).toBe(out.triggers.get(chain[i + 1] ?? `@gate${p}`)!)); + } + // The bytes: the record as the map's TRIG section would hold it, its texts the map's strings. + const [first, second] = o.triggers; + const resolved = (t: typeof first) => ({ ...t.record, actions: t.record.actions.map((a) => ({ ...a, text: a.text ? texts.get((o.strings[a.text - 1] as { text: string }).text)! : 0 })) }); + for (const [label, t] of [["@trig0.p0", first], ["@trig0.p1", first], ["@trig1.p0", second]] as const) { + const record = encodeTriggers([resolved(t)]); + const n = node(label); + expect([...out.payload.subarray(n + 8, n + 8 + 20 * t.record.conditions.length)]).toEqual([...record.subarray(0, 20 * t.record.conditions.length)]); + expect([...out.payload.subarray(n + 328, n + 328 + 32 * t.record.actions.length)]).toEqual([...record.subarray(320, 320 + 32 * t.record.actions.length)]); + expect(dword(n + 2376)).toBe(t.record.flags); + } + expect(first.record.flags).toBe(2); + expect(second.record.flags).toBe(4); + // (A condition or an action switched off keeps its flag; a Comment is kept as the game keeps it, doing nothing.) + expect(first.record.conditions[1].flags & 2).toBe(2); + expect(first.record.actions.map((a) => [a.type, a.flags & 2])).toEqual([[47, 0], [9, 0], [1, 2]]); + // The gate: into the programs when every player after this one has an empty list. + const gate0 = node("@gate0"), gate1 = node("@gate1"); + expect(dword(gate0 + 8 + 4)).toBe((0x51a280 + 12 + 8 - 0x58a364) / 4 >>> 0); + expect(dword(gate1 + 8 + 15) & 0xff).toBe(0); + }); +}); From 808c4ada95431c115e133bee42c14f1271de7c07 Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 23 Oct 2026 12:31:30 -0600 Subject: [PATCH 2/4] Own backend, the events watch gated: a quiet frame passes the unit places by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a program's events are created, died and killed alone, four triggers before the walk over the unit places compare what the game shows with what the watch last read: the head of the free list and the place behind it (a unit made pops the head; a place freed goes in behind it), and each player's counts of units and of buildings (0x581DE4 and 0x581F34, taken off as a unit starts to die or is removed; read in the lifted game first). All holding, the watch is passed by; one failing says which group and the watch runs, after which the numbers are read again as far as the frame's changes reach. A wrong number only costs the next frames the watch. A quiet frame of the events probe without on.order: 1.9 µs against eudplib's 5.1 and 6.7 before, the gate 0.25 µs of it; every line at the same frame as before. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/emit.ts | 95 +++++++++++++++++++++++++++++++++++++-- compiler/backend/units.ts | 30 +++++++++---- compiler/ir.ts | 2 +- docs/v4-plan.md | 56 ++++++++++++++++++++--- tests/emitter.test.ts | 19 ++++++-- 5 files changed, 179 insertions(+), 23 deletions(-) diff --git a/compiler/backend/emit.ts b/compiler/backend/emit.ts index ffbf6aa..671392d 100644 --- a/compiler/backend/emit.ts +++ b/compiler/backend/emit.ts @@ -363,6 +363,8 @@ const MOVES_UNITS = new Set([11, 39, 44, 48]); * is asked a bit at a time from the top (`Emitter.readGame`). The Python side's `read_condition`. */ const DEATHS_TABLE = 0x58a364, KILLS_TABLE = 0x5878a4, ORE_TABLE = 0x57f0f0, GAS_TABLE = 0x57f120; +/** An address as the player field of a Deaths names it. */ +const epdOf = (addr: number) => u((addr - DEATHS_TABLE) / 4); const CURRENT_PLAYER = 13, DEATHS_TYPE = 15, KILL_TYPE = 5, ACCUMULATE_TYPE = 4, OPPONENTS_TYPE = 14; const gameRead = (r: ReadSource): r is Extract => r.source === "condition"; /** @@ -413,6 +415,15 @@ const CURRENT_PLAYER_ADDRESS = 0x6509b0; const UNIT_PLACES = 1700, UNIT_STRIDE = 336, UNIT_TABLE_ADDRESS = 0x59cca8, UNIT_TABLE_EPD = (UNIT_TABLE_ADDRESS - 0x58a364) / 4; /** The events pass's variables the watch writes (`units.ts`'s): the record's EPD, the trigger's first condition's number, the boundary. */ const EVENT_RECORD = "@ev.e", EVENT_FIELDS = "@ev.wf", EVENT_BOUNDARY = "@ev.be"; +/** The handler's word to the watch's gate (`units.ts`'s): how many units came on, whether any came on or went, and a bit for each player that had one. */ +const EVENT_MADE_N = "@ev.madeN", EVENT_TOUCHED = "@ev.touch", EVENT_OWNERS = "@ev.own"; +/** + * The game's counts the gate watches (TrigScript 4, step 11's quiet frames): a dword a player (0 to 11) of units and of + * buildings, counted as the unit is made and taken off as it starts to die or is removed — the "Units" and "Buildings" + * of the score screen (0x488CF6 in 1.16.1: men and every other unit but an egg, a cocoon and a lurker egg in the one, + * buildings in the other; incomplete ones counted). + */ +const UNIT_COUNTS = 0x581de4, BUILDING_COUNTS = 0x581f34, GATE_PLAYERS = 12; /** * The map's player settings, as a map of our own needs them (TrigScript 4, step 7): the slots a human or a computer * plays (0-based, P1 to P8), and each slot's force (0 to 3). Who a program runs for, who All Players and a force are, @@ -1491,9 +1502,9 @@ class Emitter { else this.push({ conds: [], items: [], next: to }); } - /** A trigger whose conditions all holding send the walk to `to`; otherwise it goes on. */ - branch(conds: Cond[], to: string, at?: At): void { - const label = this.label("b"), after = this.label("a"); + /** A trigger whose conditions all holding send the walk to `to`; otherwise it goes on. (`named`: its label, when its fields are places.) */ + branch(conds: Cond[], to: string, at?: At, named?: string): void { + const label = named ?? this.label("b"), after = this.label("a"); this.push({ label, conds, items: [{ act: { dword: { at: label, field: "next" }, modifier: "set", value: { label: to } } }], next: after, at }); let r = this.restores.get(to); if (!r) this.restores.set(to, (r = [])); @@ -2702,6 +2713,43 @@ class Emitter { watch(s: Extract): void { const W = (k: number) => `${this.key}:w${k}`, S = (k: number) => `${this.key}:s${k}`; const entry = this.label("ev"), handler = this.label("eh"), back = this.label("er"), exit = this.label("ex"), stays = this.label("eb"), move = this.label("emv"); + // The gate (`s.gate`: no orders or types watched): one trigger before the walk whose conditions hold when nothing + // can have changed since the watch last ran — the head of the game's list of free places and the place behind it + // (a place handed out moves one or the other: every unit made pops the head, a place freed goes in behind it), and + // each player's counts of units and of buildings (taken off as a unit starts to die or is removed). All holding, + // the walk passes the watch by; otherwise it goes through it, and afterwards the gate's numbers are read again + // from the game — the head and the place behind it when a unit came on, the counts of each player the handler + // marked, or every one of them when the gate opened and the handler found nothing (a unit given away, a morph). + // A number wrong only costs the next frames the watch: the gate holds only when all of them are right. + const gated = !!s.gate, skip = this.label("egs"); + const G = { head: `${this.key}.egH`, behind: `${this.key}.egQ`, next: `${this.key}.egN`, units: (p: number) => `${this.key}.egU${p}`, buildings: (p: number) => `${this.key}.egB${p}`, failed: `${this.key}.egw` }; + const players = Array.from({ length: GATE_PLAYERS }, (_, p) => p); + // (The gate in four triggers, each a group of its numbers — the head and the place behind it; the units of players + // 1 to 6; of 7 to 12; the buildings — so that when the handler finds nothing, the group that failed is read again + // and no other: a place freed moves the one behind the head, three reads, where every number would be twenty-seven.) + const groups: { label: string; conds: Cond[]; fields: [string, number, "value" | "player", number][] }[] = gated ? [ + { label: this.label("eg"), conds: [{ dword: { game: FIRST_FREE_UNIT }, compare: "exactly", value: 0 }, { dword: { game: DEATHS_TABLE }, compare: "exactly", value: 0 }], fields: [[G.head, 0, "value", 0], [G.behind, 1, "player", epdOf(4)], [G.next, 1, "value", 0]] }, + ...[players.slice(0, 6), players.slice(6)].map((ps) => ({ label: this.label("eg"), conds: ps.map((p): Cond => ({ dword: { game: UNIT_COUNTS + 4 * p }, compare: "exactly", value: 0 })), fields: ps.map((p, i): [string, number, "value" | "player", number] => [G.units(p), i, "value", 0]) })), + { label: this.label("eg"), conds: players.map((p): Cond => ({ dword: { game: BUILDING_COUNTS + 4 * p }, compare: "exactly", value: 0 })), fields: players.map((p): [string, number, "value" | "player", number] => [G.buildings(p), p, "value", 0]) }, + ] : []; + if (gated) { + this.own.add(G.failed); + groups.forEach((g, i) => { + // Its conditions all holding go on to the next group, or past the watch; one failing says which group and goes + // into the watch. + const on = i + 1 < groups.length ? groups[i + 1].label : skip; + if (i) this.land(g.label); + this.branch(g.conds, on, this.at, i ? g.label + "t" : g.label); + this.write(G.failed, { c: i + 1, terms: [] }); + this.goto(entry); + // (The group's numbers as variables of the watch's own, each a place of a condition's field and nothing else.) + for (const [id, cond, part, add] of g.fields) { + this.own.add(id); + this.cellless.add(id); + this.placesOf(id).push({ dword: { at: i ? g.label + "t" : g.label, field: { cond, part } }, scale: 1, add }); + } + }); + } // (Variables whose places are next pointers and numbers, their cells read by nothing: what a stub sets is the // places. Where the handler goes on to, and the place handed out after this one: the numbers of the two actions of // `move` that point the walk's start and the handler's way on at it.) @@ -2744,6 +2792,47 @@ class Emitter { this.land(stays); this.push({ label: back, conds: [], items: [], next: W(0), keep: true }); this.push({ label: exit, conds: [], items: [putBack(0)] }); + if (gated) { + // The gate's numbers read again from the game, as far as this frame's changes reach. + const WHOLE: ConditionRecord = { location: 0, player: 0, amount: 0, unitId: 0, comparison: 0, type: DEATHS_TYPE, resource: 0, flags: 0, mask: 0 }; + const COUNT: ConditionRecord = { ...WHOLE, location: 0xffff, mask: MASKED_MARK }; + const headAgain = () => { + this.readAt(G.head, WHOLE, { c: epdOf(FIRST_FREE_UNIT), terms: [] }, this.at); + // (The head's address over 4, into the player field with the EPD of 4 added: the EPD of its next pointer.) + this.readAt(G.behind, WHOLE, { c: epdOf(FIRST_FREE_UNIT), terms: [] }, this.at, 2); + this.readAt(G.next, WHOLE, { c: epdOf(4), terms: [{ id: G.behind, k: 1 }] }, this.at); + }; + const countsAgain = (p: number) => { + this.readAt(G.units(p), COUNT, { c: epdOf(UNIT_COUNTS + 4 * p), terms: [] }, this.at); + this.readAt(G.buildings(p), COUNT, { c: epdOf(BUILDING_COUNTS + 4 * p), terms: [] }, this.at); + }; + const found = this.label("egf"), rearmed = this.label("egr"); + this.branch([{ dword: { var: EVENT_TOUCHED }, compare: "atLeast", value: 1 }], found, this.at); + // Nothing found: the group that failed, read again. + const groupRead: (() => void)[] = [headAgain, () => players.slice(0, 6).forEach((p) => this.readAt(G.units(p), COUNT, { c: epdOf(UNIT_COUNTS + 4 * p), terms: [] }, this.at)), () => players.slice(6).forEach((p) => this.readAt(G.units(p), COUNT, { c: epdOf(UNIT_COUNTS + 4 * p), terms: [] }, this.at)), () => players.forEach((p) => this.readAt(G.buildings(p), COUNT, { c: epdOf(BUILDING_COUNTS + 4 * p), terms: [] }, this.at))]; + groupRead.forEach((read, i) => { + const yes = this.label("egg"), no = this.label("egn"); + this.branch([{ dword: { var: G.failed }, compare: "exactly", value: i + 1 }], yes, this.at); + this.goto(no); + this.land(yes); + read(); + this.land(no); + }); + this.goto(rearmed); + this.land(found); + const noneMade = this.label("egm"); + this.branch([{ dword: { var: EVENT_MADE_N }, compare: "atMost", value: 0 }], noneMade, this.at); + headAgain(); + this.land(noneMade); + for (const p of players) { + const other = this.label("ego"); + this.branch([{ dword: { var: EVENT_OWNERS }, compare: "atMost", value: 0, mask: 2 ** p }], other, this.at); + countsAgain(p); + this.land(other); + } + this.land(rearmed); + } + this.land(skip); } /** A trigger of a slot's or a branch's that says whether nothing runs in it (done) or something does. */ diff --git a/compiler/backend/units.ts b/compiler/backend/units.ts index 869442e..42cf07f 100644 --- a/compiler/backend/units.ts +++ b/compiler/backend/units.ts @@ -96,9 +96,14 @@ const MADE_N = "@ev.madeN", GONE_N = "@ev.goneN", TURNED_N = "@ev.turnedN", CHAN * ones five dwords on); the distance from place 0 of the place the game hands out next. */ const EV_RECORD = "@ev.e", EV_FIELDS = "@ev.wf", EV_BOUNDARY = "@ev.be"; +/** + * For the watch's gate (`emit.ts`): whether the handler found a unit come on or gone this frame, and a bit for each + * player (0 to 11) that had one — whose counts the gate reads again. + */ +const EV_TOUCHED = "@ev.touch", EV_OWNERS = "@ev.own"; const AT0 = { file: "", line: 0, column: 0 }; /** The pass's variables, one each for the map, which no program declares; and its lists, the map's (a program's events read them). */ -export const EVENT_GLOBALS: readonly VarDecl[] = [MADE_N, GONE_N, TURNED_N, CHANGED_N, EV_RECORD, EV_BOUNDARY, EV_FIELDS].map((id) => ({ id, name: "(events)", kind: "number", shared: true, ...(id === EV_FIELDS ? {} : { routed: true }), global: true, at: AT0 })); +export const EVENT_GLOBALS: readonly VarDecl[] = [MADE_N, GONE_N, TURNED_N, CHANGED_N, EV_RECORD, EV_BOUNDARY, EV_FIELDS, EV_TOUCHED, EV_OWNERS].map((id) => ({ id, name: "(events)", kind: "number", shared: true, ...(id === EV_FIELDS ? {} : { routed: true }), global: true, at: AT0 })); export const isEventGlobal = (id: string): boolean => id.startsWith("@ev."); /** A unit's hit points (256ths, 0 when it died in a fight) and the last player whose attack hurt it. */ const HP_DWORD = 0x08 >> 2, LAST_ATTACKER = word(0x90, 1); @@ -195,11 +200,11 @@ export function unitValues(program: Program, events?: { sources: Set ({ at: here, label: "" }); - const bin = (op: "+" | "-" | "*" | "&" | ">>>", a: NumExpr, b: NumExpr): NumExpr => { + const bin = (op: "+" | "-" | "*" | "&" | "|" | ">>>", a: NumExpr, b: NumExpr): NumExpr => { // (Of two constants, the constant: a field takes it as it is.) if (a.kind === "const" && b.kind === "const") { const [x, y] = [a.value >>> 0, b.value >>> 0]; - return k((op === "+" ? x + y : op === "-" ? x - y : op === "*" ? Number((BigInt(x) * BigInt(y)) & 0xffffffffn) : op === "&" ? x & y : x >>> y) >>> 0); + return k((op === "+" ? x + y : op === "-" ? x - y : op === "*" ? Number((BigInt(x) * BigInt(y)) & 0xffffffffn) : op === "&" ? x & y : op === "|" ? x | y : x >>> y) >>> 0); } return { kind: "binary", op, left: a, right: b, ...at() } as NumExpr; }; @@ -481,17 +486,22 @@ export function unitValues(program: Program, events?: { sources: Set): Stmt[] => { here = AT0; const died = sources.has("died") || sources.has("killed"), ordered = sources.has("ordered"), morphed = sources.has("morphed"); + // The gate (`emit.ts`): the watch passed by on a frame when no unit can have come on or gone — only when neither + // orders nor types are watched, which change with nothing else of the game's to show for it. + const gate = !ordered && !morphed; type Field = { name: string; dword: number; mask?: number; compare: "atLeast" | "exactly"; read: number }; // (The uniqueness byte first: the watch of a place whose unit is not alive ends there. The game hands a place's // first unit a byte above 0, and every unit dies through the order Die, its sprite still there.) const fields: Field[] = [{ name: "uid", dword: UID.dword, mask: 0xff00, compare: "exactly", read: 0xff00 }]; if (ordered) fields.push({ name: "order", dword: ORDER.dword, mask: 0xff00, compare: "exactly", read: 0xff00 }); - else if (died) fields.push({ name: "alive", dword: ORDER.dword, mask: 0xff00, compare: "atLeast", read: 0x100 }); + // (Whether it is alive is watched for the gate too: a unit gone marks its owner, listed or not.) + else if (died || gate) fields.push({ name: "alive", dword: ORDER.dword, mask: 0xff00, compare: "atLeast", read: 0x100 }); if (morphed) fields.push({ name: "type", dword: TYPE.dword, mask: 0xffff, compare: "exactly", read: 0xffff }); const old = (name: string) => `@ev.was.${name}`, now = (name: string) => `@ev.now.${name}`; const e = V(EV_RECORD); const step = (by: number): Stmt => ({ kind: "action", record: { location: 0, text: 0, wav: 0, time: 0, player: 0, target: by, unitId: 0, type: 45, modifier: 8, flags: 0, padding: 0, mask: 0 }, variables: [{ field: "player", bits: 32, name: "at", expr: k(CURRENT) }], ...at() } as Stmt); const out: Stmt[] = [set(MADE_N, k(0))]; + if (gate) out.push(set(EV_TOUCHED, k(0)), set(EV_OWNERS, k(0))); if (died) out.push(set(GONE_N, k(0))); if (ordered) out.push(set(TURNED_N, k(0))); if (morphed) out.push(set(CHANGED_N, k(0))); @@ -505,14 +515,18 @@ export function unitValues(program: Program, events?: { sources: Set=", peek(of(e, SPRITE)), k(1)), cmp(">=", ordered ? V(now("order")) : peek(of(e, ORDER.dword), 0xff00), k(0x100))]), [set(ALIVE, k(1))])); const listed = (list: string, n: string, extra: Stmt[] = []): Stmt[] => when(cmp("<", V(n), k(EVENT_CAP)), [store(list, V(n), e), ...extra, set(n, bin("+", V(n), k(1)))]); + // (Gated: a unit come on or gone marks the frame and its owner's bit, for the gate to read that player's counts again.) + const marked: Stmt[] = gate ? [set(EV_TOUCHED, k(1)), set(OWNERV, peek(of(e, OWNER.dword), OWNER.mask)), ...Array.from({ length: 12 }, (_, p) => when(cmp("==", V(OWNERV), k(p)), [set(EV_OWNERS, bin("|", V(EV_OWNERS), k(2 ** p)))])).flat()] : []; const same: Stmt[] = []; - if (died) same.push(...when(all([cmp(">=", V(old(ordered ? "order" : "alive")), k(0x100)), cmp("==", V(ALIVE), k(0))]), listed(GONE, GONE_N))); + if (died) same.push(...when(all([cmp(">=", V(old(ordered ? "order" : "alive")), k(0x100)), cmp("==", V(ALIVE), k(0))]), [...listed(GONE, GONE_N), ...marked])); + else if (gate) same.push(...when(all([cmp(">=", V(old("alive")), k(0x100)), cmp("==", V(ALIVE), k(0))]), marked)); if (ordered) same.push(...when(all([cmp("==", V(ALIVE), k(1)), cmp("!=", V(now("order")), V(old("order")))]), listed(TURNED, TURNED_N, [store(TURNED_WAS, V(TURNED_N), bin(">>>", V(old("order")), k(8)))]))); if (morphed) same.push(...when(all([cmp("==", V(ALIVE), k(1)), cmp("!=", V(now("type")), V(old("type")))]), listed(CHANGED, CHANGED_N, [store(CHANGED_WAS, V(CHANGED_N), V(old("type")))]))); - h.push(...when(cmp("!=", V(now("uid")), V(old("uid"))), when(cmp("==", V(ALIVE), k(1)), listed(MADE, MADE_N)), same)); + h.push(...when(cmp("!=", V(now("uid")), V(old("uid"))), when(cmp("==", V(ALIVE), k(1)), [...listed(MADE, MADE_N), ...marked]), same)); // What it sees now, into the watch's numbers: whether it is alive, or its order. For a unit not alive, only another // in its place is news: the second condition's kind (the top byte of its dword after the number) is 0 then, and // the game reads no further. @@ -524,7 +538,7 @@ export function unitValues(program: Program, events?: { sources: Set ({ dword, ...(mask !== undefined ? { mask } : {}), compare })), handler: h, ...at() } as Stmt); + out.push({ kind: "watch", fields: fields.map(({ dword, mask, compare }) => ({ dword, ...(mask !== undefined ? { mask } : {}), compare })), handler: h, ...(gate ? { gate } : {}), ...at() } as Stmt); return out; }; diff --git a/compiler/ir.ts b/compiler/ir.ts index 36b7456..39e4e92 100644 --- a/compiler/ir.ts +++ b/compiler/ir.ts @@ -701,7 +701,7 @@ export type Stmt = * trigger saw there last, its own numbers. A place as it was goes on to the next; one that changed runs `handler`, * with `@ev.e` the record's EPD and the current player at the trigger's first condition's number (`emit.ts`). */ - | { kind: "watch"; fields: { dword: number; mask?: number; compare: "atLeast" | "exactly" }[]; handler: Stmt[]; at: At; label: string } + | { kind: "watch"; fields: { dword: number; mask?: number; compare: "atLeast" | "exactly" }[]; handler: Stmt[]; gate?: boolean; at: At; label: string } /** A loop the emitter took (version 26): `graph` is made as it is; `original` is the loop, run where nothing is made (the simulator). */ | { kind: "graph"; graph: Graph; original: Stmt[]; at: At; label: string }; diff --git a/docs/v4-plan.md b/docs/v4-plan.md index c8002cc..78431ac 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -4026,13 +4026,16 @@ the ledger is the list it can be written again from at 4.0. is a condition that fails each frame (13 ns), one with nothing in it a trigger passed by as done: a timer wheel (idea 2) would make a sleeper cost nothing until its frame. "did not start" is said in red by the game, as the Python side's build says it, where the simulator notes a fault (both builds). -- **Unit events on a map of our own (step 11, part 3), what is left:** the watch costs every place the game has handed - out 48–60 ns a frame, so a frame where nothing comes on and nothing is dying grows with the map's units, where - eudplib's build without `on.order` looks at its lists' heads alone: 9.3 µs against 5.2 with 128 places, and at the - full table about 90 against 5. The way out is a gate that passes the watch by when nothing can have changed: the head - of the game's list of free places (it moves when a unit is handed a place or one is freed) and a count that moves - when a unit starts to die (a player's losses), a dozen conditions — to be read in both games first, since Remastered - links its lists otherwise. Without `on.order` or `on.morphed` only. +- **Unit events on a map of our own (step 11, part 3), what is left:** the watch is gated since 2026-10-23 ("The + watch's gate", below) when neither orders nor types are watched; with `on.order` or `on.morphed` every place is still + walked every frame (48–60 ns a place: 9.3 µs at 128 places, about 90 at the full table), and no cheap sign of an + order or a type changed is known. The gate's holes, each a unit whose change the game's counts do not show (reported + at the next frame the gate opens, or lost if its place is handed out again before that): a Zerg Egg, Cocoon or Lurker + Egg dying while another unit's death is in progress (the counts leave the three out, and the dying list's head moves + only when it was empty); a unit removed while hidden in a transport or a bunker (not counted). A frame on which a + place is freed costs the watch and three reads (about 15 µs at 80 places): the place behind the head moves, and + nothing else says so. The gate's numbers are not checked in Remastered yet (0x628438, 0x581DE4, 0x581F34 are among + what it lets a map read; whether its emulation moves them as 1.16.1 does, the probe will say). The handler reads a changed place's values a bit at a time, about 4 µs for a unit made: through the current player, pointed at the record once, instead of a copy of the record's EPD for every read. The 1,700 places are two triggers each (1.84 MB against eudplib's 1.27 for the probe without `on.order`): a stub of fewer actions packs tighter. @@ -5324,6 +5327,45 @@ trigger not preserved switched off after its run, Player 2's copy of an All Play Not played in Remastered yet: `Maps/TrigScript/own-step13-triggers.scx` is to settle whether Remastered runs the payload's nodes as its TRIG section's (a Wait, a trigger switched off, a disabled condition). +### The watch's gate (2026-10-23) + +Step 11's one loss to eudplib closed (`compiler/backend/units.ts`: the handler marks what it found; `compiler/backend/emit.ts`, +`watch`: the gate and its numbers read again). When a program's events are `created`, `died` and `killed` alone — no +orders, no types, which change with nothing else of the game's to show for it — the watch of the unit places is passed +by on a frame when nothing can have come on or gone. + +- **What the game shows, read first** (`~/trigscript-audit-2026-10-19/tools/step13/gate-research.mts`: a quiet map + in the lifted game, units made, killed and removed through the engine's own actions, the game's data diffed frame by + frame). A unit handed a place pops the head of the free list (0x628438); a place freed goes in *behind* the head, so + the head does not move for it, and a pop after it can bring the head back — but not the place behind the head as + well. A unit that starts to die or is removed is taken off its owner's count at once — 0x581DE4 + 4p units (men and + every other unit but an Egg, a Cocoon and a Lurker Egg; incomplete ones too), 0x581F34 + 4p buildings (0x488CF6 in + 1.16.1, the score screen's "Units" and "Buildings"), 12 players each, Player 12's included — where the deaths table + moves only for a death in a fight (not Kill Unit), the dying list's head (0x62842C) only from empty, the unit + finder's count (0x66FF74) is not Remastered's to read, and the free count (0x6283F0) moves a frame late. +- **The gate**: four triggers before the walk, each a group of the numbers compared with what the watch last read — + the head and the place behind it (two conditions, the second's player field the EPD of the head's next pointer); the + units of players 1 to 6; of 7 to 12; the buildings — each holding going on to the next, the last to past the watch; + one failing says which group and goes into the watch. About 0.25 µs when quiet. +- **Read again** after the walk, as far as the frame's changes reach: the head and the place behind it when a unit came + on (`@ev.madeN`); the counts of each player the handler marked as having a unit come on or gone (`@ev.own`, a bit + a player); when the gate opened and the handler found nothing (a place freed, a unit given to another player, a + morph: `@ev.touch` 0), the group that failed alone. A number wrong only costs the next frames the watch — the gate + holds only when every number is right — so the handler need not mirror the game's books; it marks owners and the + reads do the rest. The handler watches whether a unit is alive whenever gated, listed or not. + +| Lifted game, Remastered's weights, µs a frame (`probes/own-events.ts` without `on.order`, 80 places handed out) | Ours | eudplib | Ours before | +| --- | ---: | ---: | ---: | +| nothing coming or going (the whole frame, the listeners' tasks included) | **1.9** | 5.1 | 6.7 | +| of it the gate | **0.25** | | | +| a dead unit's place freed (the place behind the head moved: the watch and three reads) | 15 | 5.1 | 6.7 | +| the first frame (the listeners started, the gate's 27 numbers read) | 389 | 1,272 | 331 | + +Every line of the probe at the same frame as before, both ways. Checked: `tests/emitter.test.ts` ("a map of our +own, unit events": a unit put in a place with neither the head nor a count moved is not seen until one moves; the head +and Player 1's count move as the game moves them and the unit is handed on). Not played in Remastered yet +(`Maps/TrigScript/own-step11-events-gated.scx`). + ### Triggers stacked (2026-10-20) Brought forward from step 14 (`compiler/backend/layout.ts`). The game reads no more of a trigger's 2,408 bytes than diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index 72e51bc..20e2e89 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -2175,6 +2175,10 @@ describe("a map of our own, unit events", () => { }; unit(0, 0, 9); unit(1699, 37, 8); + // (The game's head of free places — the place it hands out next — and Player 1's count of units, which the watch's + // gate reads: both units are Player 1's.) + memory.set(0x628438, place(1698)); + memory.set(0x581de4, 2); PRINTED.got = []; runOwn(o.runs[0].graph, memory, o.values, undefined, frames, then); return PRINTED.got.map((t, i) => `${PRINTED.frames[i]} ${Buffer.from(t, "hex").toString()}`); @@ -2204,15 +2208,22 @@ describe("a map of our own, unit events", () => { expect(got).toEqual(["3 made 0", "5 order 0 6 3", "7 morphed 38 from 37", "9 died 0", "9 killed 0 by 0", "11 made 32"]); }); - it("starts a task for each unit a handler that waits is handed", () => { + it("starts a task for each unit a handler that waits is handed, and passes the watch by while the game's free list and counts stand still", () => { + // (No orders or types watched: the watch is gated. The game's head of free places (0x628438) and Player 1's count + // of units (0x581DE4) move as the game moves them when a unit is made; a unit put in a place with neither moved is + // not seen until one does.) + const FREE_HEAD = 0x628438, UNITS_P1 = 0x581de4; const got = run([ "program(async () => {", " on.created(async (u) => { await sleep(frames(2)); print(`late ${u.type}`); });", "});", - ].join("\n"), 8, (f, w) => { - if (f === 3) for (const [kk, uid] of [[1698, 7], [1697, 6]]) { w(place(kk) + 0x0c, 0x700001); w(place(kk) + 0x4c, 0x0300); w(place(kk) + 0x64, 5); w(place(kk) + 0xa4, uid * 256); } + ].join("\n"), 12, (f, w) => { + const unit = (kk: number, uid: number) => { w(place(kk) + 0x0c, 0x700001); w(place(kk) + 0x4c, 0x0300); w(place(kk) + 0x64, 5); w(place(kk) + 0xa4, uid * 256); }; + if (f === 3) { for (const [kk, uid] of [[1698, 7], [1697, 6]]) unit(kk, uid); w(FREE_HEAD, place(1696)); w(UNITS_P1, 4); } + if (f === 6) unit(1696, 5); + if (f === 8) w(UNITS_P1, 5); }); - expect(got).toEqual(["5 late 5", "5 late 5"]); + expect(got).toEqual(["5 late 5", "5 late 5", "10 late 5"]); }); }); From 1b008161e822dffd4d61f4ee0e1748638b8b5de7 Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 23 Oct 2026 12:41:31 -0600 Subject: [PATCH 3/4] Plan: step 13's probe played in Remastered Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- docs/v4-plan.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 78431ac..8856c6c 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -5324,8 +5324,10 @@ trigger not preserved switched off after its run, Player 2's copy of an All Play 1's set the same frame, the program after every player's triggers; a player not in the game left out); the ledger; `probes/own-triggers.ts` both ways in the lifted game: every line "ok" and at the same frame as eudplib's build without `hyperTriggers()` — but E, where eudplib runs the disabled trigger — and as the same triggers in a plain classic map. -Not played in Remastered yet: `Maps/TrigScript/own-step13-triggers.scx` is to settle whether Remastered runs the -payload's nodes as its TRIG section's (a Wait, a trigger switched off, a disabled condition). +Played in Remastered (2026-10-23, recording 12.37.25, `own-step13-triggers.scx`): all ten lines in order, each "ok"; +the first two groups on screen as the view fades in, D 10 game frames after them, G one after D, A2 31 after D — the +lifted game's spacing to the frame; a clean quit. Remastered runs the payload's nodes as its TRIG section's: a Wait in +the player's slot, a trigger switched off after its run, a disabled condition passed over, a disabled trigger not run. ### The watch's gate (2026-10-23) From 61b7e1f6bb69b223f76426865505f40867b63c11 Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 23 Oct 2026 12:43:55 -0600 Subject: [PATCH 4/4] Plan: the gated events probe played in Remastered Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- docs/v4-plan.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 8856c6c..0eced92 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -4034,8 +4034,7 @@ the ledger is the list it can be written again from at 4.0. Egg dying while another unit's death is in progress (the counts leave the three out, and the dying list's head moves only when it was empty); a unit removed while hidden in a transport or a bunker (not counted). A frame on which a place is freed costs the watch and three reads (about 15 µs at 80 places): the place behind the head moves, and - nothing else says so. The gate's numbers are not checked in Remastered yet (0x628438, 0x581DE4, 0x581F34 are among - what it lets a map read; whether its emulation moves them as 1.16.1 does, the probe will say). + nothing else says so. The gate's numbers hold in Remastered (played 2026-10-23: the probe's events came on the frame). The handler reads a changed place's values a bit at a time, about 4 µs for a unit made: through the current player, pointed at the record once, instead of a copy of the record's EPD for every read. The 1,700 places are two triggers each (1.84 MB against eudplib's 1.27 for the probe without `on.order`): a stub of fewer actions packs tighter. @@ -5365,8 +5364,10 @@ by on a frame when nothing can have come on or gone. Every line of the probe at the same frame as before, both ways. Checked: `tests/emitter.test.ts` ("a map of our own, unit events": a unit put in a place with neither the head nor a count moved is not seen until one moves; the head -and Player 1's count move as the game moves them and the unit is handed on). Not played in Remastered yet -(`Maps/TrigScript/own-step11-events-gated.scx`). +and Player 1's count move as the game moves them and the unit is handed on). Played in Remastered (2026-10-23, +recording 12.42.11, `own-step11-events-gated.scx`): all six lines "ok", each group at the lifted game's frame spacing +(B two frames after A, C six after B, W fourteen after Weighed) — Remastered moves the free list's head and the counts +as 1.16.1 does, and the gate opens on the frame a unit comes on or dies. ### Triggers stacked (2026-10-20)