From c22848bfa89d69945592cc8bcfc31de75b27fc28 Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 22 Oct 2026 21:55:10 -0600 Subject: [PATCH 1/3] =?UTF-8?q?Own=20backend:=20unit=20events=20=E2=80=94?= =?UTF-8?q?=20the=20map's=20events=20pass=20and=20on.*?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 11, part 3 of the way off eudplib: the ledger's 15 rows of on.created, on.died, on.killed, on.order and on.morphed are taken, and step 11 is done (50 of 50). The map's events pass, once before every program's turn as the Python side's: the units linked in behind the head of the game's list since the pass before, each not seen before marked and listed; the dying list both ways from its head; with on.order or on.morphed, every unit's order and type against the pass before's. What it keeps of a unit place is a table of plain dwords 336 bytes apart, as the places are, which the layout lays out before the triggers so that they fill the room between; the current player is pointed at a unit's dword of it (pointAt). A listener is a task looping over what the pass found. probes/own-events.ts, lifted game, Remastered's weights, µs a frame (ours / eudplib): nothing coming or going 4.7 / 5.1, forty units made 210 / 248, forty-six dying 146 / 89, with on.order 221 / 164; the map 1.05 / 1.78 MB. The walks are slower: the plan says how to be past eudplib at all of it (a trigger a unit place holding what it last saw). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/emit.ts | 40 ++++- compiler/backend/graph.ts | 17 ++- compiler/backend/heap.ts | 2 +- compiler/backend/layout.ts | 19 ++- compiler/backend/own.ts | 15 +- compiler/backend/texts.ts | 2 +- compiler/backend/units.ts | 202 ++++++++++++++++++++++++- compiler/ir.ts | 11 ++ compiler/simulateIr.ts | 2 +- docs/v4-plan.md | 56 ++++++- ledger/input/on-chat.ts | 2 +- ledger/input/on-key-from.ts | 2 +- ledger/input/on-key.ts | 2 +- ledger/programs/listener-at-the-top.ts | 2 +- ledger/units/on-created-at-the-top.ts | 2 +- ledger/units/on-created-filter.ts | 2 +- ledger/units/on-created-type.ts | 2 +- ledger/units/on-created.ts | 2 +- ledger/units/on-died-filter.ts | 2 +- ledger/units/on-died.ts | 2 +- ledger/units/on-event-async.ts | 2 +- ledger/units/on-event-stopped.ts | 2 +- ledger/units/on-killed-by.ts | 2 +- ledger/units/on-killed.ts | 2 +- ledger/units/on-morphed-type.ts | 2 +- ledger/units/on-morphed.ts | 2 +- ledger/units/on-order-of-one.ts | 2 +- ledger/units/on-order.ts | 2 +- probes/own-events.ts | 72 +++++++++ tests/emitter.test.ts | 68 ++++++++- tests/ledger.test.ts | 1 + 31 files changed, 494 insertions(+), 49 deletions(-) create mode 100644 probes/own-events.ts diff --git a/compiler/backend/emit.ts b/compiler/backend/emit.ts index a701302..7666afd 100644 --- a/compiler/backend/emit.ts +++ b/compiler/backend/emit.ts @@ -24,7 +24,7 @@ import type { Range } from "../ranges"; import { PlayerGroup, type ActionRecord, type ConditionRecord } from "../../vendor/triggers"; import { actionDef } from "../../vendor/triggerDefs"; import { OVERFLOW_FN, errorLine } from "./errors"; -import { END, MOST_ACTS, MOST_CONDS, NOW, STACK, SWITCHES, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, checkGraph, nextOf, type Act, type ActPart, type Compare, type Cond, type Dword, type FixedFn, type Graph, type Num, type Region, type Trigger } from "./graph"; +import { END, MOST_ACTS, MOST_CONDS, NOW, PASS_END, STACK, SWITCHES, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, checkGraph, nextOf, type Act, type ActPart, type Compare, type Cond, type Dword, type FixedFn, type Graph, type Num, type Region, type Trigger } from "./graph"; /** The game's tick (frames since the game started), and the fields of the trigger a map of our own wakes by. */ export const GAME_TICK = 0x57f23c; @@ -409,6 +409,8 @@ const CENTRE_LINE = 0x640b60 + 218 * 12, CENTRE_BYTES = 218, FIRST_FREE_UNIT = 0 export const TRIGGER_LISTS = 0x51a280; /** Where the game keeps the current player: whom Display Text shows to, and what "the current player" names. */ const CURRENT_PLAYER_ADDRESS = 0x6509b0; +/** The game's unit table: its places, how far apart, the first one's EPD (a table of the events pass's is laid out as it). */ +const UNIT_PLACES = 1700, UNIT_STRIDE = 336, UNIT_TABLE_EPD = (0x59cca8 - 0x58a364) / 4; /** * 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, @@ -490,7 +492,7 @@ function gameConds(r: ConditionRecord, when: boolean): Cond[] | "true" | "false" } /** The kinds of statement, told apart from the expressions of the same name (`pop`, `call`). */ -export const STATEMENT_KINDS = new Set(["declare", "assignText", "storeText", "releaseText", "textLoop", "assign", "declareArray", "store", "push", "setLength", "assignBool", "assignUnit", "unitLoop", "unitWrite", "unitDo", "tableWrite", "if", "while", "do", "for", "unrolled", "switch", "break", "continue", "return", "sleep", "parallel", "start", "cancel", "action", "syncSend", "select", "centerLocation", "print", "buildText", "block", "try", "throw", "uncaught", "remark", "graph"]); +export const STATEMENT_KINDS = new Set(["declare", "assignText", "storeText", "releaseText", "textLoop", "assign", "declareArray", "store", "push", "setLength", "assignBool", "assignUnit", "unitLoop", "unitWrite", "unitDo", "tableWrite", "if", "while", "do", "for", "unrolled", "switch", "break", "continue", "return", "sleep", "parallel", "pointAt", "start", "cancel", "action", "syncSend", "select", "centerLocation", "print", "buildText", "block", "try", "throw", "uncaught", "remark", "graph"]); // ── what the emitter takes ── @@ -1111,12 +1113,13 @@ class Scope { return true; } case "return": return (this.native && this.returning.length > 0 && this.returned(s)) || this.island(s); - // (A map of our own: the slots `planTasks` made; listening — `on.*` — is part 3's.) + // (A map of our own: the slots `planTasks` made — a listener's, `on.*`'s, as any other's.) case "start": { - const slots = this.native && !s.listen ? this.slots.get(s) : undefined; + const slots = this.native ? this.slots.get(s) : undefined; return (!!slots && slots.every((slot) => this.taskCall(slot.call))) || this.island(s); } case "cancel": return (this.native && this.lowers(s.task) && (this.num(s.task), true)) || this.island(s); + case "pointAt": return (this.native && this.lowers(s.of) && (this.num(s.of), true)) || this.island(s); case "parallel": return (this.native && s.branches.every((b) => this.stmts(b, false))) || this.island(s); // (Where a `throw` goes is the emitter's, `Emitter.handler`: the program keeps the error in flight.) case "try": return (this.native && !!this.errors && this.stmts(s.body, inLoop) && this.stmts(s.catch, inLoop)) || this.island(s); @@ -1892,6 +1895,7 @@ class Emitter { case "break": return this.goto(this.loops[this.loops.length - 1].exit); case "continue": return this.goto(this.loops[this.loops.length - 1].cont); case "start": return this.scope.islands.has(s) ? this.left(s) : this.start(s); + case "pointAt": return this.pointAt(s); case "cancel": return this.scope.islands.has(s) ? this.left(s) : this.cancel(s); case "parallel": return this.scope.islands.has(s) ? this.left(s) : this.parallel(s); case "try": { @@ -2673,6 +2677,20 @@ class Emitter { this.push({ label: resume, conds: [], items: [{ act: co.reset }], ...(then ? { next: then } : {}) }); } + /** + * The current player at a unit's dword of a table of the events pass's (`units.ts`): set to the unit's EPD (a place + * of it), then the distance from the unit table to the table added, as the layout has it. The table a region of + * plain dwords, one a unit place, 336 bytes apart. + */ + pointAt(s: Extract): void { + if (!this.regions.has(s.table)) this.regions.set(s.table, { id: s.table, cells: UNIT_PLACES, stride: UNIT_STRIDE }); + const l = linear(s.of)!; + const set: Act = { dword: { game: CURRENT_PLAYER_ADDRESS }, modifier: "set", value: u(l.c) }; + if (l.terms.length) this.actPlaces.set(set, { value: { id: l.terms[0].id, scale: l.terms[0].k, add: l.c } }); + // (A trigger of its own, its actions first: an action whose number is a place is found by its index there.) + this.push({ conds: [], items: [{ act: set }, { act: { dword: { game: CURRENT_PLAYER_ADDRESS }, modifier: "add", value: { epdOf: s.table, plus: u(-UNIT_TABLE_EPD) } } }] }); + } + /** A trigger of a slot's or a branch's that says whether nothing runs in it (done) or something does. */ private flags(check: string, on: boolean): Item { return { act: { dword: { at: check, field: "flags" }, modifier: "set", value: on ? TRIGGER_ON : TRIGGER_OFF } }; @@ -6093,7 +6111,7 @@ export const emitterCounts = { hoisted: 0, reciprocals: 0, jumps: 0, products: 0 * call of a function of the Python side's, and nothing read that the body did not write first (there is no one to * copy it in; a copy within the body is made of triggers by the layout). Returns the graph, or why it cannot be. */ -export function emitBody(body: Stmt[], decls: Map, key: string, functions = new Map(), arrays = new Map(), stackDepth = STACK_DEPTH, owner?: number, players: MapPlayers = ANY_PLAYERS, errors?: ProgramErrors): Graph | string { +export function emitBody(body: Stmt[], decls: Map, key: string, functions = new Map(), arrays = new Map(), stackDepth = STACK_DEPTH, owner?: number, players: MapPlayers = ANY_PLAYERS, errors?: ProgramErrors, pass?: Stmt[]): Graph | string { // (Every statement must be the emitter's here, so a rewriting it would not take is refused whichever way it goes: // no trial is needed.) const take = () => true; @@ -6141,10 +6159,14 @@ export function emitBody(body: Stmt[], decls: Map, key: string, const planned = planTasks(block.length === 1 ? block[0] : ({ kind: "block", body: block, at: body[0]?.at } as Stmt), key, known, arrays); if (typeof planned === "string") return planned; const s = planned.root; + // (The map's events pass, its statements as the program's: laid out apart, before the run's `@wake`.) + const passed = pass?.length ? rw.stmt({ kind: "block", body: pass, at: pass[0].at } as Stmt) : []; + const passStmt: Stmt | undefined = passed.length ? { kind: "block", body: passed, at: pass![0].at } : undefined; const scope = new Scope(known); rules(scope); scope.slots = planned.slots; scope.once = planned.once; + if (passStmt && !scope.stmt(passStmt, false)) return scope.why || "the events pass"; // (The map's own variables, which no program declares and no run's start sets: 0 as the map starts, one cell each.) for (const d of known.values()) if (d.global) scope.declared.add(d.id); if (!scope.stmt(s, false)) return scope.why || "?"; @@ -6188,6 +6210,13 @@ export function emitBody(body: Stmt[], decls: Map, key: string, if (!e.pending.length && !e.pendingTasks.length) break; } const tasks = e.turnOfTasks(); + // The events pass: in a trigger of its own, kept, which the layout sends the walk to before the run's `@wake`. + const passAt = `${key}:pass`; + if (passStmt) { + e.push({ label: passAt, conds: [], items: [], keep: true }); + e.stmt(passStmt); + e.goto(PASS_END); + } e.settleThrown(); e.emitDigits(); if (start) { @@ -6204,6 +6233,7 @@ export function emitBody(body: Stmt[], decls: Map, key: string, } if (typeof g !== "string" && e.templates.length) g.templates = e.templates; if (typeof g !== "string" && tasks) g.tasks = tasks; + if (typeof g !== "string" && passStmt) g.pass = passAt; if (typeof g !== "string" && e.regions.size) g.cells = [...e.regions.values(), ...(g.cells ?? [])]; const cells = typeof g === "string" ? 0 : (g.cells ?? []).reduce((n, r) => n + r.cells, 0); if (cells > STACK_CELLS_MOST) return { e, g: `arrays and stacks of ${cells} cells, past the ${STACK_CELLS_MOST} a map of our own lays out (72 bytes a cell)` }; diff --git a/compiler/backend/graph.ts b/compiler/backend/graph.ts index 3ee285a..3f89bea 100644 --- a/compiler/backend/graph.ts +++ b/compiler/backend/graph.ts @@ -199,8 +199,19 @@ export interface Entry { from: string; into: Dword; add: Num; scale?: number } * `tasks`: where a run's tasks have their turn (a map of our own): the walk goes there after the program's turn, and when * the program's wake does not hold; it ends at `END`. */ -export interface Graph { entries: Entry[]; triggers: Trigger[]; arrays?: { id: string; stride: number }[]; cells?: Region[]; templates?: { id: string; bytes: number[]; at?: number; data?: boolean }[]; rows?: string[][]; tasks?: string } -export interface Region { id: string; cells: number; values?: number[] } +/** + * `pass`: where the map's events pass starts (a map of our own), which the walk goes through before the run's `@wake`; + * it ends at `PASS_END`. + */ +export interface Graph { entries: Entry[]; triggers: Trigger[]; arrays?: { id: string; stride: number }[]; cells?: Region[]; templates?: { id: string; bytes: number[]; at?: number; data?: boolean }[]; rows?: string[][]; tasks?: string; pass?: string } +/** Where the events pass goes when it is done: its run's `@wake`. */ +export const PASS_END = "@passed"; +/** + * `stride`: a table of plain dwords this many bytes apart instead of cell triggers — a table of the events pass's, a + * dword for each of the game's unit places, 336 bytes apart as the places are (`epdOf` its first dword's EPD); the + * layout puts other things in between. + */ +export interface Region { id: string; cells: number; values?: number[]; stride?: number } /** * A region of cell triggers, as an array's id: `{ element }` is its first cell's number, `{ epdOf }` that number's EPD @@ -231,7 +242,7 @@ export const actValue = (label: string, k: number): Dword => ({ at: label, field export function checkGraph(g: Graph): string[] { const labels = new Set(g.triggers.map((t) => t.label)); const out: string[] = []; - const known = (l: string) => l === END || labels.has(l); + const known = (l: string) => l === END || l === PASS_END || labels.has(l); const dword = (d: Dword, where: string) => { if ("at" in d && !labels.has(d.at)) out.push(`${where}: no trigger ${d.at}`); }; diff --git a/compiler/backend/heap.ts b/compiler/backend/heap.ts index b824b7e..5e8fa64 100644 --- a/compiler/backend/heap.ts +++ b/compiler/backend/heap.ts @@ -840,7 +840,7 @@ export function growingArrays(program: Program, cells = HEAP_CELLS): Program { /** The statements whose bodies run again and again. */ const LOOPS = new Set(["while", "do", "for", "unitLoop", "textLoop", "unrolled"]); /** The kinds of statement (a `call` and a `pop` with a label: an expression of either has none). */ -const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "graph", "if", "parallel", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "while"]); +const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "graph", "if", "parallel", "pointAt", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "while"]); /** * The variables the closures count (`VarDecl.held`: a reference to an environment, a row of the front end's arrays — the diff --git a/compiler/backend/layout.ts b/compiler/backend/layout.ts index 2cd8269..d66ece8 100644 --- a/compiler/backend/layout.ts +++ b/compiler/backend/layout.ts @@ -30,7 +30,7 @@ * went on — a trigger of a few conditions and actions in a little over 200 bytes, not 2,408. A row a doubling trigger * walks (`Graph.rows`) is stacked as one, its triggers 2,408 bytes apart. */ -import { END, NOW, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, type Act, type Cond, type Dword, type Graph, type Value } from "./graph"; +import { 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"; @@ -281,11 +281,13 @@ export function layout(input: LayoutInput): OwnMap { // past the program once woken. const wake = (i: number) => (i ? `@wake#${i}` : "@wake"), init = (i: number) => (i ? `@init#${i}` : "@init"); const stop = "@stop", run = "@run", entry = (p: number) => `@entry${p}`, gate = (p: number) => `@gate${p}`; - const after = (i: number) => (i + 1 < runs.length ? wake(i + 1) : stop); + // (A run whose graph holds the map's events pass goes through it first.) + const first = (i: number) => runs[i].graph.pass ?? wake(i); + const after = (i: number) => (i + 1 < runs.length ? first(i + 1) : stop); const list = (p: number) => TRIGGER_LISTS + 12 * p; const listEnd = (p: number) => u(~(list(p) + 4)); // (Where each run's graph ends: the next run.) - const ends = new Map(runs.map((_, i) => [i ? `${END}#${i}` : END, after(i)])); + const ends = new Map(runs.flatMap((_, i): [string, string][] => [[i ? `${END}#${i}` : END, after(i)], [i ? `${PASS_END}#${i}` : PASS_END, wake(i)]])); const endOf = (l: string) => ends.get(l) ?? l; type T = { label: string; conds: Cond[]; acts: Act[]; next?: string | number; prev?: number; off?: boolean }; const triggers: T[] = runs.flatMap(({ graph, owner }, i) => [ @@ -323,11 +325,14 @@ export function layout(input: LayoutInput): OwnMap { next: listEnd(p), })), // Every entry and gate back to ending at its list's end, the count of frames one more, then the first run. - { label: run, conds: [], acts: [...players.flatMap((p): Act[] => [{ dword: { at: entry(p), field: "next" }, modifier: "set", value: listEnd(p) }, { dword: { at: gate(p), field: "next" }, modifier: "set", value: listEnd(p) }]), { dword: { var: NOW }, modifier: "add", value: 1 }], next: wake(0) }, + { label: run, conds: [], acts: [...players.flatMap((p): Act[] => [{ dword: { at: entry(p), field: "next" }, modifier: "set", value: listEnd(p) }, { dword: { at: gate(p), field: "next" }, modifier: "set", value: listEnd(p) }]), { dword: { var: NOW }, modifier: "add", value: 1 }], next: first(0) }, ); for (const t of runs.flatMap((r) => r.graph.triggers)) if (t.copy || t.island || t.call) throw new Error(`${t.label}: a copy, an island or a call has no place in a map of our own`); - // The triggers stacked, a row as one. + // The triggers stacked, a row as one — after the tables of plain dwords `stride` bytes apart, which they fill in + // between. const space = new Space(); + const strided = new Map(); + for (const r of new Map(runs.flatMap((x) => x.graph.cells ?? []).filter((x) => x.stride).map((x) => [x.id, x])).values()) strided.set(r.id, base + 4 * space.stack(Array.from({ length: r.cells }, (_, k) => (k * r.stride!) / 4))); const at = new Map(); const rowOf = new Map(); for (const row of runs.flatMap((r) => r.graph.rows ?? [])) for (const l of row) rowOf.set(l, row); @@ -345,7 +350,8 @@ export function layout(input: LayoutInput): OwnMap { const routes = [...routed]; const graphs = runs.map((r) => r.graph); // (A region every run names — the heap's — is laid out once.) - const regionList = [...new Map(graphs.flatMap((g) => g.cells ?? []).map((r) => [r.id, r])).values()]; + const allRegions = [...new Map(graphs.flatMap((g) => g.cells ?? []).map((r) => [r.id, r])).values()]; + const regionList = allRegions.filter((r) => !r.stride); const cellCount = routes.length + regionList.reduce((n, r) => n + r.cells, 0); const cellBase = cellCount ? base + 4 * space.stack(Array.from({ length: cellCount }, (_, k) => CELL_HOLDS.map((d) => d + CELL_DWORDS * k)).flat(), space.top) : base; routes.forEach((id, k) => at.set(ROUTE_CELL + id, cellBase + 72 * k)); @@ -435,6 +441,7 @@ export function layout(input: LayoutInput): OwnMap { if ("addrOf" in v && regionOf(v.addrOf) !== undefined) return u(regionOf(v.addrOf)! + v.plus); if ("addrOf" in v) throw new Error(`a jump into ${v.addrOf}'s cells, which a map of our own does not lay out`); if (regionOf(v.epdOf) !== undefined) return u(epd(regionOf(v.epdOf)! + 348) + v.plus); + if (strided.has(v.epdOf)) return u(epd(strided.get(v.epdOf)!) + v.plus); const l = lists.get(v.epdOf); if (l === undefined) throw new Error(`no list ${v.epdOf}`); return u(epd(l) + v.plus); diff --git a/compiler/backend/own.ts b/compiler/backend/own.ts index 17fd69c..46fe1c6 100644 --- a/compiler/backend/own.ts +++ b/compiler/backend/own.ts @@ -15,7 +15,7 @@ import { ANY_PLAYERS, emitBody, groupSlots, type MapPlayers } from "./emit"; import { NOW, type Act, type Cond, type Dword, type Graph, type Num, type Trigger, type Value } from "./graph"; import { TextPool, knownTexts } from "./texts"; import { HEAP_GLOBALS, growingArrays, heldVariables, isHeapGlobal, loopScoped } from "./heap"; -import { UNIT_GLOBALS, unitValues } from "./units"; +import { EVENT_GLOBALS, UNIT_GLOBALS, eventSources, isEventGlobal, unitValues } from "./units"; import { ownErrors } from "./errors"; export interface OwnRun { @@ -66,7 +66,10 @@ export function emitOwn(r: Pick, p const texts = pool.finish(); // Units as two numbers each (`units.ts`), then arrays that grow on the heap (`heap.ts`), once the texts' numbers are // in: those passes copy what they rewrite. - const programs = known.map((p) => loopScoped(growingArrays(heldVariables(unitValues(p))))); + // (The map's events pass is one, laid out by the first program that has events, for what all of them loop over.) + const sources = eventSources(known); + const passer = known.findIndex((p) => eventSources(p).size); + const programs = known.map((p, k) => loopScoped(growingArrays(heldVariables(unitValues(p, { sources, pass: k === passer }))))); for (const [k, program] of programs.entries()) { const slots = ownerSlots(program, players); if (typeof slots === "string") return slots; @@ -81,19 +84,20 @@ export function emitOwn(r: Pick, p }; collect(program); // (The heap's own variables, which no program declares, and the count of frames: one cell each for the map.) - for (const d of [...HEAP_GLOBALS, ...UNIT_GLOBALS, NOW_DECL]) decls.set(d.id, d); + for (const d of [...HEAP_GLOBALS, ...UNIT_GLOBALS, ...EVENT_GLOBALS, NOW_DECL]) decls.set(d.id, d); const functions = new Map((program.functions ?? []).map((f) => [f.id, f])); const arrays = new Map((program.arrays ?? []).map((a) => [a.id, a])); // The variables and arrays every run of the program has in common: one for every player of a per-player program. const shared = new Set(program.perPlayer ? [...[...decls.values()].filter((d) => d.shared).map((d) => d.id), ...[...arrays.values()].filter((a) => a.shared).map((a) => a.id)] : []); // A program for one player runs as the first of its owners (the Python side's `slots[0]`), one for each player // as each of them, in the order of the slots. + const firstRun = runs.length; for (const player of program.perPlayer ? [...slots].sort((a, b) => a - b) : slots.slice(0, 1)) { const n = runs.length; // (The first run keeps the emitter's names; each other one's are its own, but for what its program shares.) - const graph = emitBody(program.body, decls, n ? `own${n}` : "own", functions, arrays, undefined, player, players, program.errors); + const graph = emitBody(program.body, decls, n ? `own${n}` : "own", functions, arrays, undefined, player, players, program.errors, runs.length === firstRun ? program.pass : undefined); if (typeof graph === "string") return graph; - const name = (id: string) => (!n || isHeapGlobal(id) || id === NOW || UNIT_GLOBALS.some((d) => d.id === id) ? id : shared.has(id) ? (k ? `${id}#p${k}` : id) : `${id}#${n}`); + const name = (id: string) => (!n || isHeapGlobal(id) || id === NOW || isEventGlobal(id) || UNIT_GLOBALS.some((d) => d.id === id) ? id : shared.has(id) ? (k ? `${id}#p${k}` : id) : `${id}#${n}`); const g = n ? renamed(graph, `#${n}`, name) : graph; // An action of the game's names its text and its sound file by the script's strings: given here, for the layout. const of = (i: number) => (i ? r.strings[i - 1] : undefined); @@ -143,6 +147,7 @@ function renamed(g: Graph, suffix: string, name: (id: string) => string): Graph ...(g.templates ? { templates: g.templates.map((t) => ({ ...t, id: t.id + suffix })) } : {}), ...(g.rows ? { rows: g.rows.map((r) => r.map(label)) } : {}), ...(g.tasks ? { tasks: label(g.tasks) } : {}), + ...(g.pass ? { pass: label(g.pass) } : {}), }; } diff --git a/compiler/backend/texts.ts b/compiler/backend/texts.ts index 9797531..ecadddd 100644 --- a/compiler/backend/texts.ts +++ b/compiler/backend/texts.ts @@ -1470,7 +1470,7 @@ const TEXT_BYTES = 1023; const DISPLAY_TEXT = 9; // (`call` and `pop` are numbers too: a list of numbers that starts with one is walked as statements, which does to // each what walking it as a number would.) -const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "graph", "if", "parallel", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "while"]); +const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "graph", "if", "parallel", "pointAt", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "while"]); function isStmt(o: unknown): boolean { return !!o && typeof o === "object" && STMT_KINDS.has((o as { kind?: string }).kind ?? ""); } diff --git a/compiler/backend/units.ts b/compiler/backend/units.ts index 09702f8..d87b492 100644 --- a/compiler/backend/units.ts +++ b/compiler/backend/units.ts @@ -17,7 +17,7 @@ * What it does not take — a unit an event gives (step 11), a pick near the mouse and the selection read (step 12) — * it leaves as it was, for the emitter to refuse in a script's words. */ -import type { At, BoolExpr, Call, CompareOp, FuncDecl, NumExpr, Program, Stmt, UnitExpr, UnitFilter, UnitFlag, UnitNumField, VarDecl } from "../ir"; +import type { ArrayDecl, At, BoolExpr, Call, CompareOp, FuncDecl, NumExpr, Program, Stmt, UnitExpr, UnitFilter, UnitFlag, UnitNumField, VarDecl } from "../ir"; import type { ActionRecord, ConditionRecord } from "../../vendor/triggers"; import { STATEMENT_KINDS } from "./emit"; @@ -82,6 +82,41 @@ const ME_ID = "@u.me"; * whole map, as the heap's are, read and moved down by every walk (`scan`). */ export const UNIT_END = "@unit.end"; + +/** + * The frame's events pass (TrigScript 4, step 11, part 3), the map's one, before the programs' turns — the Python side's + * `Events`. Its tables, a dword a unit place 336 bytes apart as the places are (`pointAt`): the unit seen there (its + * uniqueness byte plus 1, and 256 more once its death has been handed on), and its order and type as the pass before + * found them (plus 1). Its lists of this frame's units, each an EPD: what came on, what died, what took up an order or + * turned into another type (with what it was), and how many of each. + */ +const SEEN = "@ev.seen", EV_ORDERS = "@ev.orders", EV_TYPES = "@ev.types"; +const MADE = "@ev.made", GONE = "@ev.gone", TURNED = "@ev.turned", TURNED_WAS = "@ev.turnedWas", CHANGED = "@ev.changed", CHANGED_WAS = "@ev.changedWas"; +const MADE_N = "@ev.madeN", GONE_N = "@ev.goneN", TURNED_N = "@ev.turnedN", CHANGED_N = "@ev.changedN"; +/** Where the pass before left the list of the units on the map: its head and the record behind it (each its pointer's bits above the lowest two), that one's uniqueness byte. */ +const HEAD_R = "@ev.hr", BEHIND_R = "@ev.br", BEHIND_UID = "@ev.bu"; +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, HEAD_R, BEHIND_R, BEHIND_UID].map((id) => ({ id, name: "(events)", kind: "number", shared: true, routed: true, global: true, at: AT0 })); +export const isEventGlobal = (id: string): boolean => id.startsWith("@ev."); +/** The game's lists: of the units on the map (the newest linked in behind its head), and of the dying ones. */ +const VISIBLE_HEAD = epd(0x628430), DYING_HEAD = epd(0x62842c); +/** 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), TYPE_LOW = word(0x64, 1); +/** The most units of each kind a pass hands on in a frame, as the Python side's. */ +const EVENT_CAP = 512; +/** A unit pointer's bits that can be set: the unit table is below 16 MB. */ +const POINTER_BITS = 0x00fffffc; +type EventSource = "created" | "died" | "killed" | "ordered" | "morphed"; +/** The sources a piece of the IR loops over the events pass's units of. */ +export function eventSources(x: unknown, out = new Set()): Set { + if (Array.isArray(x)) { x.forEach((y) => eventSources(y, out)); return out; } + if (!x || typeof x !== "object") return out; + const o = x as { kind?: string; source?: EventSource }; + if (o.kind === "unitLoop" && o.source) out.add(o.source); + for (const v of Object.values(o)) if (v && typeof v === "object") eventSources(v, out); + return out; +} export const UNIT_GLOBALS: readonly VarDecl[] = [{ id: UNIT_END, name: "(units)", kind: "number", shared: true, routed: true, global: true, at: { file: "", line: 0, column: 0 } }]; /** * Each player's list of units: its first unit (a pointer a player, 0 for none), and a unit's place in its owner's list — @@ -146,7 +181,11 @@ function isStmtList(x: unknown): x is Stmt[] { * The program with its units as numbers: `unitValues` (see the file's comment). `owner`: the player the program runs * as, when it is one player known when built (the current player of a filter); otherwise read where the game keeps it. */ -export function unitValues(program: Program): Program { +/** + * `events`: the sources the map's programs' events loop over, and whether this program lays out the map's one events + * pass (`Program.pass`) — the first program that has any. + */ +export function unitValues(program: Program, events?: { sources: Set; pass: boolean }): Program { if (!hasUnits(program)) return program; let here: At = program.at; let made = 0; @@ -374,6 +413,150 @@ export function unitValues(program: Program): Program { ]; }; + // ── the events pass's units ── + const pointAt = (u: NumExpr, table: string): Stmt => ({ kind: "pointAt", of: u, table, ...at() }); + const mark = () => peek(k(CURRENT_PLAYER)); + const markAs = (value: NumExpr): Stmt => poke(k(CURRENT_PLAYER), value); + const back = (): Stmt => poke(k(CURRENT), ME); + const store = (array: string, index: NumExpr, value: NumExpr): Stmt => ({ kind: "store", array, index, value, ...at() }); + const element = (array: string, index: NumExpr): NumExpr => ({ kind: "element", array, index, ...at() } as NumExpr); + /** + * A loop over the units this frame's events pass found (`on.*`'s), in the order it found them, the filter asked of + * each: one that came on, took up an order or turned into another type and is gone since is passed over; one that died + * is its record as it died (the game gives its place to no other unit before the next frame's dying pass). + */ + const eventLoop = (s: Extract): Stmt[] => { + const source = s.source as EventSource; + const list = { created: MADE, died: GONE, killed: GONE, ordered: TURNED, morphed: CHANGED }[source]; + const count = { created: MADE_N, died: GONE_N, killed: GONE_N, ordered: TURNED_N, morphed: CHANGED_N }[source]; + const was = source === "ordered" ? TURNED_WAS : source === "morphed" ? CHANGED_WAS : undefined; + const type = s.filter.type; + if (type !== undefined && type >= 228 && GROUP_BITS[type] === undefined) throw new Leave(); + const pre: Stmt[] = []; + const I = fresh("ei"), E = fresh("ee"); + const e = V(E); + const who = s.filter.owner !== undefined ? playerOf(s.filter.owner, pre) : undefined; + const box = s.filter.at ? sides(s.filter.at, pre) : undefined; + const by = s.event?.by !== undefined ? playerOf(s.event.by, pre) : undefined; + const asks: BoolExpr[] = []; + if (source !== "died" && source !== "killed") asks.push(cmp(">=", peek(of(e, SPRITE)), k(1)), cmp(">=", bits(e, ORDER), k(0x100))); + // (Died in a fight: no hit points, the last attack one of the eight players'.) + if (source === "killed") asks.push(cmp("==", peek(of(e, HP_DWORD)), k(0)), cmp("<=", read(e, LAST_ATTACKER), k(7))); + if (by) asks.push(cmp("==", read(e, LAST_ATTACKER), by)); + if (s.event?.order !== undefined) asks.push(cmp("==", bits(e, ORDER), k(s.event.order * 256))); + if (who) asks.push(cmp("==", read(e, OWNER), who)); + if (type !== undefined && type < 228) asks.push(cmp("==", read(e, TYPE), k(type))); + if (box) { + const [l, t, r, b] = box; + const xy = (mask: number) => peek(of(e, POSITION), mask); + asks.push(cmp(">=", xy(0xffff), l), cmp("<=", xy(0xffff), r), cmp(">=", xy(0xffff0000), ranged(bin("*", t, k(0x10000)), 0, 0xffff0000)), cmp("<=", xy(0xffff0000), ranged(bin("*", b, k(0x10000)), 0, 0xffff0000))); + } + let inner: Stmt[]; + turns.set(s.decl.id, { q: e, killed: mayKill(s.body) }); + try { + inner = stmts(s.body); + } finally { + turns.delete(s.decl.id); + } + if (s.was) inner = [{ kind: "declare", decl: s.was, init: source === "killed" ? k(0) : element(was!, V(I)), ...at() }, ...(source === "killed" ? readInto(s.was.id, e, LAST_ATTACKER) : []), ...inner]; + if (type !== undefined && GROUP_BITS[type] !== undefined) { + // (A trigger's class: the type's group byte, read where units.dat keeps it.) + const ty = fresh("ty"), qt = fresh("tq"), bit = GROUP_BITS[type], tyv = ranged(V(ty), 0, 0xffff); + inner = [dec(ty, k(0), 0xffff), ...readInto(ty, e, TYPE), dec(qt, bin(">>>", tyv, k(2)), 0x3fff), + ...when(any([0, 1, 2, 3].map((r) => all([cmp("==", bin("&", tyv, k(3)), k(r)), cmp(">=", peek(bin("+", k(GROUPS), V(qt)), (bit * 2 ** (8 * r)) >>> 0), k(1))]))), inner)]; + } + return [...pre, dec(I, k(0)), { kind: "for", cond: cmp("<", V(I), V(count)), update: [set(I, bin("+", V(I), k(1)))], body: [dec(E, element(list, V(I))), ...when(all(asks), inner)], ...at() } as Stmt]; + }; + + /** + * The frame's events pass, as the Python side's `Events.run`: the counts back to 0; with `on.order` or `on.morphed`, + * every unit on the map's order and type against what the pass before found; the list of the units on the map from + * its head to the record that was behind the head at the pass before — the units linked in since — each not seen + * before marked seen and listed; with `on.died` or `on.killed`, the dying list, both ways from its head, each unit + * seen and not yet handed on listed. Nothing of the game's is written. + */ + const pass = (sources: Set): Stmt[] => { + here = AT0; + const died = sources.has("died") || sources.has("killed"); + // A record is its pointer's bits above the lowest two (`R`, 0 for none): its EPD that less `D4`. A walk points the + // current player at the record's dwords and reads them there; the pointer is read once, into one variable. + const D4 = DEATHS / 4; + // (Each walk its own record and uniqueness byte: a variable read a bit at a time is copied into all its places, and + // one shared by the walks had places in each.) + let R = "", RR = "", U = ""; + const walk = (name: string) => { R = `@ev.${name}r`; RR = `@ev.${name}rr`; U = `@ev.${name}u`; return [dec(R, k(0)), dec(U, k(0), 255)]; }; + const F = "@ev.first", GO = "@ev.go", CAP = "@ev.full", S0 = "@ev.s0", FWD = "@ev.fwd", O = "@ev.o", C = "@ev.c"; + const u1 = (): NumExpr => bin("+", V(U), k(1)); + const epdOf = (r: string) => bin("-", V(r), k(D4)); + const cpAt = (r: string, off: number): Stmt => poke(k(CURRENT), bin("+", V(r), k((off - D4) >>> 0))); + const here13 = (mask = 0xffffffff, shift = 0) => peek(k(CURRENT_PLAYER), mask, shift); + /** The record after (`off` 1) or before (0) the walk's in its list. */ + const next = (off: number): Stmt[] => [cpAt(R, off), set(R, here13(POINTER_BITS, 2))]; + const uid = (r: string): Stmt[] => [cpAt(r, UID.dword), set(U, here13(UID.mask, UID.shift))]; + const out: Stmt[] = [dec(CAP, k(0), 1), set(MADE_N, k(0))]; + if (died) out.push(set(GONE_N, k(0))); + const watched: [string, string, string, string, Bits][] = []; + if (sources.has("ordered")) { out.push(set(TURNED_N, k(0))); watched.push([EV_ORDERS, TURNED, TURNED_WAS, TURNED_N, ORDER]); } + if (sources.has("morphed")) { out.push(set(CHANGED_N, k(0))); watched.push([EV_TYPES, CHANGED, CHANGED_WAS, CHANGED_N, TYPE_LOW]); } + // (What is done only when something is found reads the record from a copy: a place of the record in it would be one + // more that every record's pointer read writes.) + const listed = (list: string, n: string, extra: Stmt[] = []): Stmt[] => [dec(RR, V(R)), ...when(cmp("<", V(n), k(EVENT_CAP)), [store(list, V(n), epdOf(RR)), ...extra, set(n, bin("+", V(n), k(1)))])]; + if (watched.length) { + // (A unit not seen before only has them kept: the units that came on are taken after this.) + out.push(...walk("w"), dec(O, k(0), 0xffff), dec(C, k(0)), set(R, peek(k(VISIBLE_HEAD), POINTER_BITS, 2))); + const turn: Stmt[] = []; + for (const [table, list, wasList, n, b] of watched) { + turn.push(cpAt(R, b.dword), set(O, here13(b.mask, b.shift)), pointAt(epdOf(R), table), set(C, mark()), ...when(cmp("!=", V(C), bin("+", V(O), k(1))), [ + markAs(bin("+", V(O), k(1))), dec(RR, V(R)), ...uid(RR), pointAt(epdOf(RR), SEEN), + ...when(cmp("==", mark(), u1()), listed(list, n, [store(wasList, V(n), bin("-", V(C), k(1)))])), + ])); + } + out.push({ kind: "while", cond: cmp("!=", V(R), k(0)), body: [...turn, ...next(1)], ...at() } as Stmt, back()); + } + // Nothing linked in since the pass before: the same head, and behind it the same record (or none). + out.push(...walk("c")); + const behind = bin("+", V(HEAD_R), k((1 - D4) >>> 0)); + out.push(dec(GO, k(1), 1), ...when(cmp("==", peek(k(VISIBLE_HEAD)), bin("*", V(HEAD_R), k(4))), when(cmp("==", V(HEAD_R), k(0)), [set(GO, k(0))], + when(cmp("==", V(BEHIND_R), k(0)), when(cmp("==", peek(behind), k(0)), [set(GO, k(0))]), + when(cmp("==", peek(behind), bin("*", V(BEHIND_R), k(4))), [...uid(BEHIND_R), back(), ...when(cmp("==", V(U), V(BEHIND_UID)), [set(GO, k(0))])]))))); + const came: Stmt[] = [ + ...uid(R), + ...when(all([cmp("==", V(F), k(0)), cmp("==", V(R), V(BEHIND_R)), cmp("==", V(U), V(BEHIND_UID))]), [brk()]), + set(F, k(0)), + pointAt(epdOf(R), SEEN), + ...when(cmp("!=", mark(), u1()), [...when(cmp(">=", V(MADE_N), k(EVENT_CAP)), [set(CAP, k(1)), brk()]), markAs(u1()), ...listed(MADE, MADE_N)]), + ...next(1), + ]; + // What is behind the head now is where the next pass stops (not when the list was too long to take in). + const done: Stmt[] = [ + set(HEAD_R, peek(k(VISIBLE_HEAD), POINTER_BITS, 2)), set(BEHIND_R, k(0)), + ...when(cmp(">=", V(HEAD_R), k(1)), [cpAt(HEAD_R, 1), set(BEHIND_R, here13(POINTER_BITS, 2)), ...when(cmp(">=", V(BEHIND_R), k(1)), [...uid(BEHIND_R), set(BEHIND_UID, V(U))]), back()]), + ]; + out.push(...when(cmp("==", V(GO), k(1)), [ + dec(F, k(1), 1), set(R, peek(k(VISIBLE_HEAD), POINTER_BITS, 2)), + { kind: "while", cond: cmp("!=", V(R), k(0)), body: came, ...at() } as Stmt, + back(), + ...when(cmp("==", V(CAP), k(0)), done), + ])); + if (died) { + // The dying list both ways from the record at its address: Remastered links it the other way round from 1.16.1. + out.push(...walk("d")); + const went: Stmt[] = [ + ...uid(R), pointAt(epdOf(R), SEEN), + ...when(cmp("==", mark(), u1()), [...when(cmp(">=", V(GONE_N), k(EVENT_CAP)), [brk()]), markAs(bin("+", V(U), k(257))), ...listed(GONE, GONE_N)]), + ...when(cmp("==", V(FWD), k(1)), next(1), next(0)), + ]; + out.push(...when(cmp(">=", peek(k(DYING_HEAD)), k(1)), [ + set(R, peek(k(DYING_HEAD), POINTER_BITS, 2)), dec(S0, V(R)), dec(FWD, k(1), 1), + { kind: "while", cond: cmp("!=", V(R), k(0)), body: went, ...at() } as Stmt, + set(FWD, k(0)), cpAt(S0, 0), set(R, here13(POINTER_BITS, 2)), + { kind: "while", cond: cmp("!=", V(R), k(0)), body: went, ...at() } as Stmt, + back(), + ])); + } + return out; + }; + /** A pick: the first unit the filter matches, the nearest to a location's centre, or one drawn at random. */ const pick = (p: Extract, pre: Stmt[]): Unit => { if (p.mouse !== undefined) throw new Leave(); @@ -801,7 +984,7 @@ export function unitValues(program: Program): Program { return [...pre, { kind: "assign", target: p.e, value: u.e, at: s.at, label: s.label }, { kind: "assign", target: p.k, value: kv, at: s.at, label: s.label }]; } case "unitLoop": { - if (s.source) throw new Leave(); + if (s.source) return [...pre, ...eventLoop(s)]; const killed = mayKill(s.body); const out = scan(s.filter, pre, (q) => { turns.set(s.decl.id, { q, killed }); @@ -813,6 +996,12 @@ export function unitValues(program: Program): Program { }); return [...pre, ...out]; } + // (A task's call as an inlined call's: a unit among its parameters two numbers, its body rewritten.) + case "start": { + callOf(s.call, pre); + const made = pre.pop() as Extract; + return [...pre, { ...s, call: made.call }]; + } case "unitWrite": write(s, pre); return pre; case "unitDo": verb(s, pre); return pre; case "select": select(s, pre); return pre; @@ -869,6 +1058,11 @@ export function unitValues(program: Program): Program { }); here = program.at; const body = stmts(program.body); + // The map's events pass, and its lists, in the program that lays it out; the lists in every program that reads them. + const sources = events?.sources ?? new Set(); + const passed = events?.pass && sources.size ? { pass: pass(sources) } : {}; + const lists = sources.size ? [MADE, ...(sources.has("died") || sources.has("killed") ? [GONE] : []), ...(sources.has("ordered") ? [TURNED, TURNED_WAS] : []), ...(sources.has("morphed") ? [CHANGED, CHANGED_WAS] : [])] : []; + const arrays = [...(program.arrays ?? []), ...lists.map((id): ArrayDecl => ({ id, name: "(events)", kind: "number", length: EVENT_CAP, shared: true, at: AT0 }))]; if (usesMe) body.unshift(dec(ME_ID, peek(k(CURRENT)))); - return { ...program, body, ...(program.functions ? { functions: rewritten } : {}) }; + return { ...program, body, ...(program.functions ? { functions: rewritten } : {}), ...passed, ...(lists.length ? { arrays } : {}) }; } diff --git a/compiler/ir.ts b/compiler/ir.ts index c99c000..c79b103 100644 --- a/compiler/ir.ts +++ b/compiler/ir.ts @@ -695,6 +695,12 @@ export type Stmt = | { kind: "textBlock"; decl: VarDecl; block: string; bytes: number; text?: string; data?: boolean; at: At } /** A map of our own only: Display Text of block `block` as it is now, to `to` as a print's is. */ | { kind: "showText"; block: string; bytes: number; to: number; at: At } + /** + * A map of our own only (`compiler/backend/units.ts`): the current player pointed at the dword of `table` — a table of + * the events pass's, a dword for each of the game's unit places, 336 bytes apart as they are — that is the unit's whose + * record's EPD `of` is: `of` plus the distance from the unit table to the table, which the layout knows. + */ + | { kind: "pointAt"; of: NumExpr; table: string; 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 }; @@ -710,6 +716,11 @@ export interface Program { functions?: FuncDecl[]; /** Present when the program throws or catches anything (B4): where an error in flight is kept. */ errors?: ProgramErrors; + /** + * A map of our own only (`compiler/backend/units.ts`): the frame's events pass — what came onto the map, died, took up + * an order or turned into another type since the pass before — run before the programs' turns, once for the map. + */ + pass?: Stmt[]; body: Stmt[]; at: At; } diff --git a/compiler/simulateIr.ts b/compiler/simulateIr.ts index fe015b4..241acd7 100644 --- a/compiler/simulateIr.ts +++ b/compiler/simulateIr.ts @@ -1308,7 +1308,7 @@ class ProgramRun { } case "remark": return "next"; // (A map of our own's own statements, which only its backend makes and never hands the simulator.) - case "textBlock": case "showText": throw new Error(`the simulator does not run a ${s.kind}`); + case "textBlock": case "showText": case "pointAt": throw new Error(`the simulator does not run a ${s.kind}`); } } diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 666daac..5f4aa4f 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -3568,8 +3568,8 @@ found what they left out; where a step moved, its old number is given. queries scheduled (ideas 6, 7) still to come. 11. **Tasks, `async` and errors** (was 6; errors new). Part 1 ✓: `try`, `catch`, `finally`, `throw`, `uncaught`, a program's `errors`, the error classes — "Errors on our own backend". Part 2 ✓: `start`, `cancel`, `running`, - `Promise.all`/`race`/`any`, `every`, `after`, a task's error awaited — "Tasks on our own backend". Part 3: the - `on.*` handlers and the pass that finds what they are handed. + `Promise.all`/`race`/`any`, `every`, `after`, a task's error awaited — "Tasks on our own backend". Part 3 ✓: the + `on.*` handlers and the pass that finds what they are handed — "Unit events on our own backend". 12. **Input** (was 7; idea 13). Keys, clicks, the mouse, typed lines, sync channels, the `local.*` reads and the user's player id. The test harness types and presses through chatEvent's and MSQC's addresses (`GameSim`): it moves with them, and the map stops needing what MSQC takes (a location, the Valkyrie's entry, Player 12's units). @@ -3927,7 +3927,7 @@ lists what no script reaches. A step is done when its scripts say so. | 8 Texts as values | 48 of 48 | | 9 Arrays that grow, the heap | 27 of 27 | | 10 Units | 99 of 99 | -| 11 Tasks, `async` and errors | 35 of 50 | +| 11 Tasks, `async` and errors | 50 of 50 | | 12 Input | 0 of 26 | | 13 The map's own triggers | 0 of 13 | @@ -4023,6 +4023,15 @@ 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:** walking a list of the game's is slower than + eudplib's: a record about 3.2 µs a frame against 1.9 — its pointer read a bit at a time (20 bits), then copied into + the places that read it — so a frame with 46 units dying costs 146 µs against 89, and `on.order` (every unit on the + map read each frame) 221 µs against 164 with 80 units. A frame where nothing comes or goes is the same (5 µs). The + way past both: a trigger for each unit place, laid out as a region, that holds the place's uniqueness byte and order + as it last saw them in its own conditions — a place unchanged costs one condition that fails (about 15 ns), and only + a place that changed reads anything — walked from place 0 and then 1,699 down to the lowest place ever handed out, + as a loop over units is. It would hand units on in the table's order, where the lists and the simulator hand them + newest first: the simulator to follow. - 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). @@ -5033,6 +5042,47 @@ Played in Remastered (2026-10-22, recording 20.57.34): all 9 lines word for word each group at the lifted game's frame (2, 14, 22, 29, 40, 43, 47, 75), the frames where 32 tasks start among them; no hitch. +### Unit events on our own backend (2026-10-22) + +Step 11, part 3 (`compiler/backend/units.ts`: the events pass and the loops over what it found; the layout's table of +plain dwords 336 bytes apart and the pass before the run's `@wake`; the emitter's `pointAt`). The ledger's 15 rows of +`on.*` are taken: step 11 is done, 50 of 50. + +- **The map's events pass**, the Python side's `Events`, once for the map before every program's turn (laid out by the + first program with events, for what all of them loop over): the list of the units on the map from its head to the + record that was behind the head at the pass before — the units linked in since — each not seen before marked seen and + listed; the dying list both ways from its head (Remastered links it the other way round from 1.16.1), each unit seen + and not yet handed on listed; with `on.order` or `on.morphed`, every unit's order and type against what the pass + before found. A frame where nothing came on is two or three conditions; the lists hold 512 units each, as the Python + side's. +- **What it keeps of each unit place** is a table of plain dwords 336 bytes apart, as the places are: a unit's dword of + it is its EPD plus the distance from the unit table to the table, which only the layout knows, so the current player + is set to the EPD and the distance added (`pointAt`). The layout lays such a table out before the triggers, which fill + the room between its dwords: the probe's map is 1.05 MB against eudplib's 1.78. +- **A listener** is a task that loops over what the pass found, as the front end writes it; a handler that waits starts + a task for each unit it is handed. A walk points the current player at the record and reads it there; each walk has + its own variables (one shared by all three had places in each, and every pointer read copied into all of them: + idle with `on.order`, 423 µs → 221). +- **Found on the way:** an action whose number is a place is found by its index in its trigger, counted over the + trigger's actions — a write before it in the same trigger moved it: `pointAt` lays out a trigger of its own, as a + store does. A unit's place in a task's call (`start`) was rewritten as an expression, not as statements. + +| Lifted game, Remastered's weights, µs a frame | Ours | eudplib | +| --- | ---: | ---: | +| nothing coming or going, `on.created`/`on.died`/`on.killed` (Binary Burghs, about 80 units) | **4.7** | 5.1 | +| forty units made, handed to `on.created` the next frame | **210** | 248 | +| forty-six units on the dying list | 146 | **89** | +| nothing coming or going, with `on.order` too | 221 | **164** | +| the map (`probes/own-events.ts`) | **1.05 MB** | 1.78 MB | + +The walks are slower than eudplib's, and the plan's *Open* list says how to be faster at all of it. + +Checked: `tests/emitter.test.ts` ("a map of our own, unit events": a unit table and the game's two lists laid out as +the game keeps them, changed between frames — a unit linked in, an order taken up, a type changed, a unit dying in a +fight — the payload walked from its bytes frame after frame, each line at its frame; a handler that waits, a task each); +`probes/own-events.ts` both ways in the lifted game, every line the same at the same frame and "ok" (made, died, the +handler that waits, an order). + ### 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/input/on-chat.ts b/ledger/input/on-chat.ts index ca8b257..865057d 100644 --- a/ledger/input/on-chat.ts +++ b/ledger/input/on-chat.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: an event handled (on.chat) +// ledger: step 12, refused: the players' input (chat) // A function run each time a player types a line that fits a pattern: `on.chat("-give {n}", (p, { n }) => …)`. program(async () => { on.chat("-give {n}", (p, { n }) => { diff --git a/ledger/input/on-key-from.ts b/ledger/input/on-key-from.ts index 7eeab33..6aae70d 100644 --- a/ledger/input/on-key-from.ts +++ b/ledger/input/on-key-from.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: an event handled (on.key) +// ledger: step 12, refused: the players' input (key) // A function run each time one player presses a key: `on.key("Space", (p) => …, { from: P1 })`. program(async () => { let n = 0; diff --git a/ledger/input/on-key.ts b/ledger/input/on-key.ts index 12e1943..90ed392 100644 --- a/ledger/input/on-key.ts +++ b/ledger/input/on-key.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: an event handled (on.key) +// ledger: step 12, refused: the players' input (key) // A function run each time a player presses a key: `on.key("F7", (p) => …)`. program(async () => { on.key("F7", (p) => { diff --git a/ledger/programs/listener-at-the-top.ts b/ledger/programs/listener-at-the-top.ts index fbcf4c9..4eac16a 100644 --- a/ledger/programs/listener-at-the-top.ts +++ b/ledger/programs/listener-at-the-top.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.created) +// ledger: step 11, takes // A listener at the top of a script, beside a program: a program of its own that listens from the start of the game. on.created((u) => { u.energy = 50; diff --git a/ledger/units/on-created-at-the-top.ts b/ledger/units/on-created-at-the-top.ts index 1a1d740..21053f1 100644 --- a/ledger/units/on-created-at-the-top.ts +++ b/ledger/units/on-created-at-the-top.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.created) +// ledger: step 11, takes // A listener written at the top of a script, not in a program: it listens from the start of the game. on.created(units.ZergZergling, (u) => { u.energy = 50; diff --git a/ledger/units/on-created-filter.ts b/ledger/units/on-created-filter.ts index a4c80fa..f61a98f 100644 --- a/ledger/units/on-created-filter.ts +++ b/ledger/units/on-created-filter.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.created) +// ledger: step 11, takes // The units that come onto the map that a filter matches: `on.created({ owner: P2 }, (u) => …)`. program(async () => { on.created({ owner: P2 }, (u) => { diff --git a/ledger/units/on-created-type.ts b/ledger/units/on-created-type.ts index 414cbc3..cba962a 100644 --- a/ledger/units/on-created-type.ts +++ b/ledger/units/on-created-type.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.created) +// ledger: step 11, takes // The units of one type that come onto the map: `on.created(units.ZergZergling, (u) => …)`. program(async () => { on.created(units.ZergZergling, (u) => { diff --git a/ledger/units/on-created.ts b/ledger/units/on-created.ts index f9c6914..49c4aad 100644 --- a/ledger/units/on-created.ts +++ b/ledger/units/on-created.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.created) +// ledger: step 11, takes // Every unit that comes onto the map, handed to a function in the next frame: `on.created((u) => …)`. program(async () => { on.created((u) => { diff --git a/ledger/units/on-died-filter.ts b/ledger/units/on-died-filter.ts index 45f7d29..acadfcc 100644 --- a/ledger/units/on-died-filter.ts +++ b/ledger/units/on-died-filter.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.died) +// ledger: step 11, takes // The units that die that a filter matches, as they died: `on.died({ owner: P8 }, (u) => …)`. program(async () => { on.died({ owner: P8 }, (u) => { diff --git a/ledger/units/on-died.ts b/ledger/units/on-died.ts index ee01743..4932c0f 100644 --- a/ledger/units/on-died.ts +++ b/ledger/units/on-died.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.died) +// ledger: step 11, takes // Every unit that dies or is removed, handed to a function as it was: `on.died((u) => …)`. program(async () => { let n = 0; diff --git a/ledger/units/on-event-async.ts b/ledger/units/on-event-async.ts index 200f59b..312640d 100644 --- a/ledger/units/on-event-async.ts +++ b/ledger/units/on-event-async.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.died) +// ledger: step 11, takes // An event's function that waits, started as a task each time: `on.died(…, async (u) => { await sleep(…); … })`. program(async () => { on.died({ owner: P8 }, async (u) => { diff --git a/ledger/units/on-event-stopped.ts b/ledger/units/on-event-stopped.ts index f2d145b..8dd8b37 100644 --- a/ledger/units/on-event-stopped.ts +++ b/ledger/units/on-event-stopped.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.created) +// ledger: step 11, takes // An event's listening stopped by the task it gives back: `const t = on.created(…); t.cancel()`. program(async () => { const t = on.created((u) => { diff --git a/ledger/units/on-killed-by.ts b/ledger/units/on-killed-by.ts index f1c1e9b..e1a1952 100644 --- a/ledger/units/on-killed-by.ts +++ b/ledger/units/on-killed-by.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.killed) +// ledger: step 11, takes // The units one player kills of another's: `on.killed({ owner: P8, by: P1 }, (u, by) => …)`. program(async () => { on.killed({ owner: P8, by: P1 }, (u, by) => { diff --git a/ledger/units/on-killed.ts b/ledger/units/on-killed.ts index 79d445b..a211ce4 100644 --- a/ledger/units/on-killed.ts +++ b/ledger/units/on-killed.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.killed) +// ledger: step 11, takes // Every unit killed in a fight, with the player whose attack killed it: `on.killed((u, by) => …)`. program(async () => { on.killed((u, by) => { diff --git a/ledger/units/on-morphed-type.ts b/ledger/units/on-morphed-type.ts index e21b9a5..831a21f 100644 --- a/ledger/units/on-morphed-type.ts +++ b/ledger/units/on-morphed-type.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.morphed) +// ledger: step 11, takes // The units that turn into one type: `on.morphed(units.TerranSiegeTankSiegeMode, (u) => …)`. program(async () => { on.morphed(units.TerranSiegeTankSiegeMode, (u) => { diff --git a/ledger/units/on-morphed.ts b/ledger/units/on-morphed.ts index 2edc4fa..d6b05b7 100644 --- a/ledger/units/on-morphed.ts +++ b/ledger/units/on-morphed.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.morphed) +// ledger: step 11, takes // A unit that turns into another type and stays the same unit, with the type it was: `on.morphed((u, was) => …)`. program(async () => { on.morphed((u, was) => { diff --git a/ledger/units/on-order-of-one.ts b/ledger/units/on-order-of-one.ts index 77ff87f..52e0ed0 100644 --- a/ledger/units/on-order-of-one.ts +++ b/ledger/units/on-order-of-one.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.order) +// ledger: step 11, takes // The units of a type that take up one order: `on.order(units.TerranMarine, orders.Move, (u) => …)`. program(async () => { on.order(units.TerranMarine, orders.Move, (u) => { diff --git a/ledger/units/on-order.ts b/ledger/units/on-order.ts index a474f71..55539e6 100644 --- a/ledger/units/on-order.ts +++ b/ledger/units/on-order.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: an event handled (on.order) +// ledger: step 11, takes // Every unit that takes up a new order, with the order and the one it had: `on.order((u, order, was) => …)`. program(async () => { on.order((u, order, was) => { diff --git a/probes/own-events.ts b/probes/own-events.ts new file mode 100644 index 0000000..26a77b3 --- /dev/null +++ b/probes/own-events.ts @@ -0,0 +1,72 @@ +// TrigScript 4, the backend: unit events in a map of our own (scripts/build-own.mts) — step 11 of the way off eudplib, +// part 3. The map's events pass, before the programs' turns each frame: the list of the units on the map from its head +// to where the pass before stopped (the units linked in since), each not seen before marked seen and listed; the dying +// list both ways from its head; with on.order, every unit's order against what the pass before found. A listener is a +// task that loops over what the pass found. +// 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. The frames after "Weighed" +// are to weigh. +// npx tsx scripts/build-own.mts probes/own-events.ts /tmp/own-events.scx +// npx tsx scripts/build-fixture.mts probes/own-events.ts /tmp/own-events-eud.scx +import { program, print, sleep, frames } from "trigscript"; + +program(async () => { + print("Own events: the payload runs."); + let made = 0, marines = 0, theirs = 0, died = 0, killed = 0, moved = 0, late = 0; + on.created((u) => { + made += 1; + if (u.type == units.TerranMarine) marines += 1; + }); + on.created({ owner: P2 }, (u) => { + theirs += 1; + }); + on.died((u) => { + died += 1; + }); + on.killed((u, by) => { + if (by == 0) killed += 1; + }); + on.order(units.TerranMarine, orders.Move, (u) => { + moved += 1; + }); + // A handler that waits: a task each time. + on.died(units.ZergZergling, async (u) => { + await sleep(frames(3)); + late += 1; + }); + await sleep(frames(2)); + + // A: what was on the map before the first pass is not handed on: a listener sleeps a frame before it looks. + const atStart = made; + if (made == 0 && died == 0) print("A: nothing at the start ok"); else print(`A: WRONG ${made} ${died}`); + + // B: units made by the program come on in the next frame. + createUnit(P1, units.TerranMarine, 2, locations.Anywhere); + createUnit(P2, units.ZergZergling, 3, locations.Anywhere); + await sleep(frames(2)); + if (made == 5 && marines == 2 && theirs == 3) print("B: made ok"); else print(`B: made WRONG ${made} ${marines} ${theirs}`); + + // C: units killed by the program die, not in a fight; the handler that waits counts a frame or three later. + killUnit(P2, units.ZergZergling); + await sleep(frames(2)); + const lings = died; + await sleep(frames(4)); + if (lings >= 7 && killed == 0 && late == lings) print(`C: died ok (${lings})`); else print(`C: died WRONG ${lings} ${killed} ${late}`); + + // D: an order taken up. + const m = first({ owner: P1, type: units.TerranMarine }); + m?.order("move", locations.Anywhere); + await sleep(frames(3)); + if (moved == 1) print("D: an order ok"); else print(`D: an order WRONG ${moved}`); + + print("Weighed:"); + // W1: nothing coming or going, a frame of the pass and the listeners alone. + await sleep(frames(4)); + // W2: forty units made in one frame, each handed to on.created the next. + createUnit(P1, units.TerranMarine, 40, locations.Anywhere); + await sleep(frames(4)); + // W3: forty killed in one frame. + killUnit(P1, units.TerranMarine); + await sleep(frames(6)); + if (made == atStart + 5 + 40 && died == lings + 46) print("W: weighed ok"); else print(`W: weighed WRONG ${made - atStart} ${died - lings}`); +}); diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index 5570dcc..c06ead9 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -1071,7 +1071,8 @@ const laidOut = new WeakMap; own: ReturnT * `frames`: walked that many times, as the game walks the triggers once a frame — from `@run` (the map's count of frames * one more) when more than one, the memory kept from one to the next; each text shown noted with its frame. */ -function runOwn(g: Graph, memory?: Map, arrays = new Map(), runs?: { graph: Graph; owner: number }[], frames = 1): Env { +/** `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) => void): 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))) { @@ -1097,10 +1098,11 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map u(DEATHS + 4 * (epd === 13 ? rd(0x6509b0) : epd)); PRINTED.frames = []; for (let frame = 1; frame <= frames; frame++) { + between?.(frame, wr); let pc = frames > 1 ? own.triggers.get("@run")! : own.address; const shownBefore = PRINTED.got.length; for (let steps = 0; !(pc & 0x80000000); steps++) { - if (steps > 200_000_000) throw new Error("the payload does not end"); + 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)}`); let holds = (rd(pc + 2376) & 8) === 0; for (let k = 0; holds && k < 16; k++) { @@ -2116,3 +2118,65 @@ describe("a map of our own, tasks", () => { ].join("\n")); }); }); + +describe("a map of our own, unit events", () => { + /** + * The game's unit table, its list of the units on the map and its dying list laid out as the game keeps them, two + * units to start with; each frame the world may change (`then`), and the payload is walked from its bytes. + */ + const TABLE = 0x59cca8, place = (k: number) => TABLE + 336 * k; + const run = (script: string, frames: number, then: (frame: number, w: (addr: number, value: number) => void) => void): string[] => { + const r = compileScript(ts, { "main.ts": script }, defaultScriptNames(), { lib: defaultLib(), emitter: false }); + expect(r.diagnostics).toEqual([]); + const o = emitOwn(r); + if (typeof o === "string") throw new Error(o); + const memory = new Map(); + const unit = (k: number, type: number, uid: number, order = 3) => { + memory.set(place(k) + 0x08, 40 * 256); + memory.set(place(k) + 0x0c, 0x700000 + k); + memory.set(place(k) + 0x4c, order * 256); + memory.set(place(k) + 0x64, type); + memory.set(place(k) + 0xa4, uid * 256); + }; + unit(1699, 0, 9); + unit(1698, 37, 8); + memory.set(0x628430, place(1698)); + memory.set(place(1698) + 4, place(1699)); + 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()}`); + }; + + it("hands on what came on, died, was killed, took up an order and turned into another type, in the frame it did", () => { + const got = run([ + "program(async () => {", + " on.created((u) => { print(`made ${u.type}`); });", + " on.died((u) => { print(`died ${u.type}`); });", + " on.killed((u, by) => { print(`killed ${u.type} by ${by}`); });", + " on.order((u, order, was) => { print(`order ${u.type} ${order} ${was}`); });", + " on.morphed((u, was) => { print(`morphed ${u.type} from ${was}`); });", + "});", + ].join("\n"), 12, (f, w) => { + // Frame 3: a Marine at place 1697, linked in behind the head. Frame 5: the Marine at 1699 takes up order 6. + // Frame 7: the Zergling turns into type 38. Frame 9: the new Marine dies in a fight with Player 1's: no hit points, + // the order Die, out of the list and onto the dying one. + if (f === 3) { w(place(1697) + 0x08, 40 * 256); w(place(1697) + 0x0c, 0x700001); w(place(1697) + 0x4c, 0x0300); w(place(1697) + 0xa4, 7 * 256); w(place(1697) + 4, place(1699)); w(place(1698) + 4, place(1697)); } + if (f === 5) w(place(1699) + 0x4c, 0x0600); + if (f === 7) w(place(1698) + 0x64, 38); + if (f === 9) { w(place(1697) + 0x08, 0); w(place(1697) + 0x4c, 0x0000); w(place(1697) + 0x90, 0); w(place(1698) + 4, place(1699)); w(place(1697) + 4, 0); w(0x62842c, place(1697)); } + }); + expect(got).toEqual(["3 made 0", "5 order 0 6 3", "7 morphed 38 from 37", "9 died 0", "9 killed 0 by 0"]); + }); + + it("starts a task for each unit a handler that waits is handed", () => { + 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 [[1697, 7], [1696, 6]]) { w(place(kk) + 0x0c, 0x700001); w(place(kk) + 0x4c, 0x0300); w(place(kk) + 0x64, 5); w(place(kk) + 0xa4, uid * 256); } + if (f === 3) { w(place(1696) + 4, place(1697)); w(place(1697) + 4, place(1699)); w(place(1698) + 4, place(1696)); } + }); + expect(got).toEqual(["5 late 5", "5 late 5"]); + }); +}); diff --git a/tests/ledger.test.ts b/tests/ledger.test.ts index 3bb9fd0..ea83939 100644 --- a/tests/ledger.test.ts +++ b/tests/ledger.test.ts @@ -14,6 +14,7 @@ const NOT_FROM_A_SCRIPT: Record = { graph: "the emitter's own statement, made only when a map is built through eudplib with the emitter on", textBlock: "a map of our own's statement, which compiler/backend/texts.ts makes for a made text's block", showText: "a map of our own's statement, which compiler/backend/texts.ts makes to print a made text's block", + pointAt: "a map of our own's statement, which compiler/backend/units.ts makes for the events pass's tables", cast: "`u32()`, `i32()` and `fixed()` settle a value's type; `compiler/numbers.ts` takes every cast out once types are known", "intrinsic floor": "`compiler/numbers.ts` makes it the number itself, or a shift of a fixed one", "intrinsic ceil": "`compiler/numbers.ts` makes it the number itself, or a shift of a fixed one", From 5884778cf4d6a79752fabaf70a7db0db24f25b73 Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 22 Oct 2026 22:00:34 -0600 Subject: [PATCH 2/3] Plan: step 11's events probe played in Remastered Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- docs/v4-plan.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 5f4aa4f..836e39b 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -5083,6 +5083,9 @@ fight — the payload walked from its bytes frame after frame, each line at its `probes/own-events.ts` both ways in the lifted game, every line the same at the same frame and "ok" (made, died, the handler that waits, an order). +Played in Remastered (2026-10-22, recording 21.59.29): all 7 lines word for word and in order, each at the lifted +game's frame (2, 4, 6, 12, 15, 29); the Marines made and killed as the script says; no hitch. + ### 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 From d804368b5d47ba35a0142e8b6199966686c590f2 Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 22 Oct 2026 22:39:53 -0600 Subject: [PATCH 3/3] Own backend: unit events by a watch of every unit place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The events pass no longer walks the game's lists by their pointers: it is a trigger for each unit place, whose conditions compare the place's record with what the trigger saw there last, held in its own conditions' numbers (the uniqueness byte, whether the unit is alive or its order, its type). A place as it was costs that one trigger; one that changed goes through its stub to the handler, which lists what came on, died, took up an order or turned into another type, and writes what it sees back into the trigger. A place whose unit is dead or gone is watched by its uniqueness byte alone: the handler switches the second condition off. The places are walked newest first, from the one the game hands out next. The 336-byte tables and pointAt are gone. Lifted game, Remastered's weights, µs a frame (ours / eudplib): with on.order, nothing happening 7.2 / 164, forty-six dying 9.0 / 224; without it, forty-six dying 8.7 / 89, forty made 188 / 248, forty killed 180 / 392, nothing happening 9.3 / 5.2 with 128 places handed out (the one frame eudplib's build is ahead on: in the plan's Open list). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/emit.ts | 89 +++++++++++++++++---- compiler/backend/graph.ts | 7 +- compiler/backend/heap.ts | 2 +- compiler/backend/layout.ts | 9 +-- compiler/backend/texts.ts | 2 +- compiler/backend/units.ts | 159 ++++++++++++++----------------------- compiler/ir.ts | 9 ++- compiler/simulateIr.ts | 2 +- docs/v4-plan.md | 106 ++++++++++++++----------- probes/own-events.ts | 8 +- tests/emitter.test.ts | 34 ++++---- tests/ledger.test.ts | 2 +- 12 files changed, 226 insertions(+), 203 deletions(-) diff --git a/compiler/backend/emit.ts b/compiler/backend/emit.ts index 7666afd..c13c09f 100644 --- a/compiler/backend/emit.ts +++ b/compiler/backend/emit.ts @@ -409,8 +409,10 @@ const CENTRE_LINE = 0x640b60 + 218 * 12, CENTRE_BYTES = 218, FIRST_FREE_UNIT = 0 export const TRIGGER_LISTS = 0x51a280; /** Where the game keeps the current player: whom Display Text shows to, and what "the current player" names. */ const CURRENT_PLAYER_ADDRESS = 0x6509b0; -/** The game's unit table: its places, how far apart, the first one's EPD (a table of the events pass's is laid out as it). */ -const UNIT_PLACES = 1700, UNIT_STRIDE = 336, UNIT_TABLE_EPD = (0x59cca8 - 0x58a364) / 4; +/** The game's unit table: its places, how far apart, where the first one is (`watch`). */ +const UNIT_PLACES = 1700, UNIT_STRIDE = 336, UNIT_TABLE_ADDRESS = 0x59cca8, UNIT_TABLE_EPD = (UNIT_TABLE_ADDRESS - 0x58a364) / 4; +/** The events pass's variables the watch writes (`units.ts`'s): the record's EPD, the trigger's first condition's number, the boundary. */ +const EVENT_RECORD = "@ev.e", EVENT_FIELDS = "@ev.wf", EVENT_BOUNDARY = "@ev.be"; /** * The 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, @@ -492,7 +494,7 @@ function gameConds(r: ConditionRecord, when: boolean): Cond[] | "true" | "false" } /** The kinds of statement, told apart from the expressions of the same name (`pop`, `call`). */ -export const STATEMENT_KINDS = new Set(["declare", "assignText", "storeText", "releaseText", "textLoop", "assign", "declareArray", "store", "push", "setLength", "assignBool", "assignUnit", "unitLoop", "unitWrite", "unitDo", "tableWrite", "if", "while", "do", "for", "unrolled", "switch", "break", "continue", "return", "sleep", "parallel", "pointAt", "start", "cancel", "action", "syncSend", "select", "centerLocation", "print", "buildText", "block", "try", "throw", "uncaught", "remark", "graph"]); +export const STATEMENT_KINDS = new Set(["declare", "assignText", "storeText", "releaseText", "textLoop", "assign", "declareArray", "store", "push", "setLength", "assignBool", "assignUnit", "unitLoop", "unitWrite", "unitDo", "tableWrite", "if", "while", "do", "for", "unrolled", "switch", "break", "continue", "return", "sleep", "parallel", "start", "cancel", "action", "syncSend", "select", "centerLocation", "print", "buildText", "block", "try", "throw", "uncaught", "remark", "graph", "watch"]); // ── what the emitter takes ── @@ -1119,7 +1121,7 @@ class Scope { return (!!slots && slots.every((slot) => this.taskCall(slot.call))) || this.island(s); } case "cancel": return (this.native && this.lowers(s.task) && (this.num(s.task), true)) || this.island(s); - case "pointAt": return (this.native && this.lowers(s.of) && (this.num(s.of), true)) || this.island(s); + case "watch": return (this.native && this.stmts(s.handler, false)) || this.island(s); case "parallel": return (this.native && s.branches.every((b) => this.stmts(b, false))) || this.island(s); // (Where a `throw` goes is the emitter's, `Emitter.handler`: the program keeps the error in flight.) case "try": return (this.native && !!this.errors && this.stmts(s.body, inLoop) && this.stmts(s.catch, inLoop)) || this.island(s); @@ -1202,7 +1204,7 @@ interface Store { array: ArrayDecl; index: Linear; reads: CellRead[]; value: Lin /** (`at` on a copy or a call: the statement it is for, when its trigger is another's.) */ /** (`label`: every place of `target` set to where `to` is — where a function that calls itself goes back to.) */ /** (`route`: a variable's cell into these dwords, and nothing else of its places.) */ -type Item = { act: Act } | { write: Write } | { fill: { temp: string; target: string; apart?: boolean; top?: number } } | { copy: string; at?: At } | { island: Stmt; writes: Set } | { bump: { target: string; add: number } } | { call: { fn: FixedFn; target: string }; at?: At } | { label: { target: string; to: string } } | { route: { from: string; into: Dword[]; add?: number } }; +type Item = { act: Act } | { write: Write } | { fill: { temp: string; target: string; apart?: boolean; top?: number } } | { copy: string; at?: At } | { island: Stmt; writes: Set } | { bump: { target: string; add: number } } | { call: { fn: FixedFn; target: string }; at?: At } | { label: { target: string; to: string } } | { pointer: { target: string; to: Dword } } | { route: { from: string; into: Dword[]; add?: number } }; /** * `keep`: an empty trigger that stays (a jump whose next pointer a place sets). `guarded`: an `if` in one trigger (its * conditions, its statements' actions), which may be followed by the copies of what it writes — a copy whether or not @@ -1358,6 +1360,8 @@ class Emitter { frames: { result?: { id: string; kind: string }; end: string }[] = []; /** Where an error thrown here goes: the `catch` of each `try` around, in the function being laid out, innermost last. */ handlers: string[] = []; + /** The variables whose cells nothing reads: a label or a pointer set for them is set at their other places only. */ + cellless = new Set(); /** * Inside statements only an error goes through — those that end in a `throw`, and a `catch` (a map of our own): what * they read of a variable is refreshed from its cell where it is read, as a routed variable's is, never a place that @@ -1895,7 +1899,7 @@ class Emitter { case "break": return this.goto(this.loops[this.loops.length - 1].exit); case "continue": return this.goto(this.loops[this.loops.length - 1].cont); case "start": return this.scope.islands.has(s) ? this.left(s) : this.start(s); - case "pointAt": return this.pointAt(s); + case "watch": return this.watch(s); case "cancel": return this.scope.islands.has(s) ? this.left(s) : this.cancel(s); case "parallel": return this.scope.islands.has(s) ? this.left(s) : this.parallel(s); case "try": { @@ -2678,17 +2682,60 @@ class Emitter { } /** - * The current player at a unit's dword of a table of the events pass's (`units.ts`): set to the unit's EPD (a place - * of it), then the distance from the unit table to the table added, as the layout has it. The table a region of - * plain dwords, one a unit place, 336 bytes apart. + * The events pass's watch (`units.ts`): a trigger for each of the game's unit places, whose conditions read the + * place's record where it is against what the trigger saw there last — its own conditions' numbers, 0 for a place + * never handed out. They are walked from the place the game will hand out next (the entry's next pointer, moved down + * as places are first handed out) up to place 1 699 and then to place 0: the places the game has handed out, newest + * first. A place as it was is the one trigger: its conditions hold, and it puts the trigger before it back and goes + * on to the next. One that changed goes to its stub — the record's EPD into `@ev.e`, the trigger's first condition's + * number into `@ev.wf`, where to go on into `RET`, the place handed out after it into `NEXT` — and to the handler, + * laid out once, after which the boundary's check moves the entry down when the place was the one handed out next. */ - pointAt(s: Extract): void { - if (!this.regions.has(s.table)) this.regions.set(s.table, { id: s.table, cells: UNIT_PLACES, stride: UNIT_STRIDE }); - const l = linear(s.of)!; - const set: Act = { dword: { game: CURRENT_PLAYER_ADDRESS }, modifier: "set", value: u(l.c) }; - if (l.terms.length) this.actPlaces.set(set, { value: { id: l.terms[0].id, scale: l.terms[0].k, add: l.c } }); - // (A trigger of its own, its actions first: an action whose number is a place is found by its index there.) - this.push({ conds: [], items: [{ act: set }, { act: { dword: { game: CURRENT_PLAYER_ADDRESS }, modifier: "add", value: { epdOf: s.table, plus: u(-UNIT_TABLE_EPD) } } }] }); + 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"); + // (Variables whose places are next pointers and numbers, their cells read by nothing: what a stub sets is the + // places. Where the handler goes on to, and the place handed out after this one: the numbers of the two actions of + // `move` that point the walk's start and the handler's way on at it.) + const RET = `${this.key}.evRet`, NEXT = `${this.key}.evNext`; + for (const v of [RET, NEXT]) this.own.add(v); + for (const v of [RET, NEXT, EVENT_FIELDS]) this.cellless.add(v); + this.placesOf(RET).push({ dword: nextOf(back), scale: 1, add: 0 }); + this.placesOf(NEXT).push({ dword: { at: move, field: { act: 0, part: "value" } }, scale: 1, add: 0 }, { dword: { at: move, field: { act: 1, part: "value" } }, scale: 1, add: 0 }); + // (After place k in the walk: k + 1, then 0 after 1 699, the end after 0; before it, k − 1, and 1 699 before 0.) + const after = (k: number) => (k === 0 ? exit : k === UNIT_PLACES - 1 ? W(0) : W(k + 1)); + const before = (k: number) => (k === 0 ? UNIT_PLACES - 1 : k - 1); + // (The place handed out after it: 1 699 after 0, then down; none after 1.) + const handedNext = (k: number) => (k === 0 ? UNIT_PLACES - 1 : Math.max(1, k - 1)); + const putBack = (k: number): Item => ({ act: { dword: nextOf(W(k)), modifier: "set", value: { label: S(k) } } }); + // (Kept, though empty: their next pointers are written.) + this.push({ label: entry, conds: [], items: [], next: W(0), keep: true }); + for (let k = 0; k < UNIT_PLACES; k++) { + const record = UNIT_TABLE_ADDRESS + UNIT_STRIDE * k; + this.push({ label: W(k), conds: s.fields.map((f): Cond => ({ dword: { game: record + 4 * f.dword }, compare: f.compare, value: 0, ...(f.mask !== undefined ? { mask: f.mask } : {}) })), items: [putBack(before(k)), { act: { dword: nextOf(W(k)), modifier: "set", value: { label: after(k) } } }], next: S(k) }); + const items: Item[] = [putBack(before(k))]; + this.write(EVENT_RECORD, { c: UNIT_TABLE_EPD + (UNIT_STRIDE / 4) * k, terms: [] }, true, items); + items.push({ pointer: { target: EVENT_FIELDS, to: { at: W(k), field: { cond: 0 } } } }, { label: { target: RET, to: after(k) } }, { label: { target: NEXT, to: W(handedNext(k)) } }); + this.push({ label: S(k), conds: [], items, next: handler }); + } + this.land(handler); + this.stmts(s.handler); + // The place the game hands out next is the boundary (`EVENT_BOUNDARY` its distance from place 0, 0 to begin with): + // when it is handed out, the walk starts at the next one from now on, and goes on from there now — it was not + // walked in this frame. + const record: BoolExpr = { kind: "compare", op: "==", left: { kind: "var", id: EVENT_RECORD }, right: { kind: "binary", op: "+", left: { kind: "var", id: EVENT_BOUNDARY }, right: { kind: "const", value: UNIT_TABLE_EPD }, at: this.at!, label: "" }, at: this.at!, label: "" }; + this.jump(record, false, stays, this.at); + this.push({ label: move, conds: [], items: [{ act: { dword: nextOf(entry), modifier: "set", value: 0 } }, { act: { dword: nextOf(back), modifier: "set", value: 0 } }] }); + const first = this.label("e0"), moved = this.label("em"); + this.jump({ kind: "compare", op: "==", left: { kind: "var", id: EVENT_RECORD }, right: { kind: "const", value: UNIT_TABLE_EPD }, at: this.at!, label: "" }, true, first, this.at); + this.write(EVENT_BOUNDARY, { c: u(-UNIT_TABLE_EPD - UNIT_STRIDE / 4), terms: [{ id: EVENT_RECORD, k: 1 }] }); + this.goto(moved); + this.land(first); + this.write(EVENT_BOUNDARY, { c: (UNIT_STRIDE / 4) * (UNIT_PLACES - 1), terms: [] }); + this.land(moved); + this.land(stays); + this.push({ label: back, conds: [], items: [], next: W(0), keep: true }); + this.push({ label: exit, conds: [], items: [putBack(0)] }); } /** A trigger of a slot's or a branch's that says whether nothing runs in it (done) or something does. */ @@ -4436,11 +4483,18 @@ class Emitter { else if ("fill" in it) pieces.push({ fill: it.fill }); else if ("route" in it) pieces.push({ route: it.route }); else if ("label" in it) { - for (const pl of this.placesOf(it.label.target)) { + for (const pl of this.placesOf(it.label.target).slice(this.cellless.has(it.label.target) ? 1 : 0)) { if (!pl.dword || pl.scale !== 1 || pl.add !== 0) throw new Error(`emitter: a place of ${it.label.target} that is no dword of its own`); acts().push({ dword: pl.dword, modifier: "set", value: { label: to(it.label.to) } }); } } + else if ("pointer" in it) { + // (Every place of a variable set to a dword's EPD: the events pass's, `watch`.) + for (const pl of this.placesOf(it.pointer.target).slice(this.cellless.has(it.pointer.target) ? 1 : 0)) { + if (!pl.dword || pl.scale !== 1 || pl.add !== 0) throw new Error(`emitter: a place of ${it.pointer.target} that is no dword of its own`); + acts().push({ dword: pl.dword, modifier: "set", value: { epd: it.pointer.to } }); + } + } else if ("copy" in it) { // (A variable never below zero and at most its `top`: only the bits it can have.) const top = this.scope.decls.get(it.copy)?.top; @@ -6027,6 +6081,7 @@ class Rewriter { return [...pre, { ...s, call: made.call }]; } case "parallel": return [{ ...s, branches: s.branches.map((b) => this.stmts(b)) }]; + case "watch": return [{ ...s, handler: this.stmts(s.handler) }]; case "switch": { const pre: Stmt[] = []; // (Tested against each case: one variable plus a constant — bits under a mask computed first.) diff --git a/compiler/backend/graph.ts b/compiler/backend/graph.ts index 3f89bea..239075f 100644 --- a/compiler/backend/graph.ts +++ b/compiler/backend/graph.ts @@ -206,12 +206,7 @@ export interface Entry { from: string; into: Dword; add: Num; scale?: number } export interface Graph { entries: Entry[]; triggers: Trigger[]; arrays?: { id: string; stride: number }[]; cells?: Region[]; templates?: { id: string; bytes: number[]; at?: number; data?: boolean }[]; rows?: string[][]; tasks?: string; pass?: string } /** Where the events pass goes when it is done: its run's `@wake`. */ export const PASS_END = "@passed"; -/** - * `stride`: a table of plain dwords this many bytes apart instead of cell triggers — a table of the events pass's, a - * dword for each of the game's unit places, 336 bytes apart as the places are (`epdOf` its first dword's EPD); the - * layout puts other things in between. - */ -export interface Region { id: string; cells: number; values?: number[]; stride?: number } +export interface Region { id: string; cells: number; values?: number[] } /** * A region of cell triggers, as an array's id: `{ element }` is its first cell's number, `{ epdOf }` that number's EPD diff --git a/compiler/backend/heap.ts b/compiler/backend/heap.ts index 5e8fa64..17ddac1 100644 --- a/compiler/backend/heap.ts +++ b/compiler/backend/heap.ts @@ -840,7 +840,7 @@ export function growingArrays(program: Program, cells = HEAP_CELLS): Program { /** The statements whose bodies run again and again. */ const LOOPS = new Set(["while", "do", "for", "unitLoop", "textLoop", "unrolled"]); /** The kinds of statement (a `call` and a `pop` with a label: an expression of either has none). */ -const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "graph", "if", "parallel", "pointAt", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "while"]); +const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "graph", "if", "parallel", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "watch", "while"]); /** * The variables the closures count (`VarDecl.held`: a reference to an environment, a row of the front end's arrays — the diff --git a/compiler/backend/layout.ts b/compiler/backend/layout.ts index d66ece8..cd6e8d4 100644 --- a/compiler/backend/layout.ts +++ b/compiler/backend/layout.ts @@ -328,11 +328,8 @@ export function layout(input: LayoutInput): OwnMap { { label: run, conds: [], acts: [...players.flatMap((p): Act[] => [{ dword: { at: entry(p), field: "next" }, modifier: "set", value: listEnd(p) }, { dword: { at: gate(p), field: "next" }, modifier: "set", value: listEnd(p) }]), { dword: { var: NOW }, modifier: "add", value: 1 }], next: first(0) }, ); for (const t of runs.flatMap((r) => r.graph.triggers)) if (t.copy || t.island || t.call) throw new Error(`${t.label}: a copy, an island or a call has no place in a map of our own`); - // The triggers stacked, a row as one — after the tables of plain dwords `stride` bytes apart, which they fill in - // between. + // The triggers stacked, a row as one. const space = new Space(); - const strided = new Map(); - for (const r of new Map(runs.flatMap((x) => x.graph.cells ?? []).filter((x) => x.stride).map((x) => [x.id, x])).values()) strided.set(r.id, base + 4 * space.stack(Array.from({ length: r.cells }, (_, k) => (k * r.stride!) / 4))); const at = new Map(); const rowOf = new Map(); for (const row of runs.flatMap((r) => r.graph.rows ?? [])) for (const l of row) rowOf.set(l, row); @@ -350,8 +347,7 @@ export function layout(input: LayoutInput): OwnMap { const routes = [...routed]; const graphs = runs.map((r) => r.graph); // (A region every run names — the heap's — is laid out once.) - const allRegions = [...new Map(graphs.flatMap((g) => g.cells ?? []).map((r) => [r.id, r])).values()]; - const regionList = allRegions.filter((r) => !r.stride); + const regionList = [...new Map(graphs.flatMap((g) => g.cells ?? []).map((r) => [r.id, r])).values()]; const cellCount = routes.length + regionList.reduce((n, r) => n + r.cells, 0); const cellBase = cellCount ? base + 4 * space.stack(Array.from({ length: cellCount }, (_, k) => CELL_HOLDS.map((d) => d + CELL_DWORDS * k)).flat(), space.top) : base; routes.forEach((id, k) => at.set(ROUTE_CELL + id, cellBase + 72 * k)); @@ -441,7 +437,6 @@ export function layout(input: LayoutInput): OwnMap { if ("addrOf" in v && regionOf(v.addrOf) !== undefined) return u(regionOf(v.addrOf)! + v.plus); if ("addrOf" in v) throw new Error(`a jump into ${v.addrOf}'s cells, which a map of our own does not lay out`); if (regionOf(v.epdOf) !== undefined) return u(epd(regionOf(v.epdOf)! + 348) + v.plus); - if (strided.has(v.epdOf)) return u(epd(strided.get(v.epdOf)!) + v.plus); const l = lists.get(v.epdOf); if (l === undefined) throw new Error(`no list ${v.epdOf}`); return u(epd(l) + v.plus); diff --git a/compiler/backend/texts.ts b/compiler/backend/texts.ts index ecadddd..dd84646 100644 --- a/compiler/backend/texts.ts +++ b/compiler/backend/texts.ts @@ -1470,7 +1470,7 @@ const TEXT_BYTES = 1023; const DISPLAY_TEXT = 9; // (`call` and `pop` are numbers too: a list of numbers that starts with one is walked as statements, which does to // each what walking it as a number would.) -const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "graph", "if", "parallel", "pointAt", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "while"]); +const STMT_KINDS = new Set(["action", "assign", "assignBool", "assignText", "assignUnit", "block", "break", "buildText", "call", "cancel", "centerLocation", "continue", "declare", "declareArray", "do", "for", "graph", "if", "parallel", "pop", "print", "push", "releaseText", "remark", "return", "select", "setLength", "sleep", "start", "store", "storeText", "switch", "syncSend", "tableWrite", "textLoop", "throw", "try", "uncaught", "unitDo", "unitLoop", "unitWrite", "unrolled", "watch", "while"]); function isStmt(o: unknown): boolean { return !!o && typeof o === "object" && STMT_KINDS.has((o as { kind?: string }).kind ?? ""); } diff --git a/compiler/backend/units.ts b/compiler/backend/units.ts index d87b492..c32fd05 100644 --- a/compiler/backend/units.ts +++ b/compiler/backend/units.ts @@ -84,29 +84,25 @@ const ME_ID = "@u.me"; export const UNIT_END = "@unit.end"; /** - * The frame's events pass (TrigScript 4, step 11, part 3), the map's one, before the programs' turns — the Python side's - * `Events`. Its tables, a dword a unit place 336 bytes apart as the places are (`pointAt`): the unit seen there (its - * uniqueness byte plus 1, and 256 more once its death has been handed on), and its order and type as the pass before - * found them (plus 1). Its lists of this frame's units, each an EPD: what came on, what died, what took up an order or - * turned into another type (with what it was), and how many of each. + * The frame's events pass (TrigScript 4, step 11, part 3), the map's one, before the programs' turns: a watch of every + * unit place (`emit.ts`'s `watch`), whose handler lists this frame's units, each an EPD — what came on, what died, what + * took up an order or turned into another type (with what it was) — and how many of each. */ -const SEEN = "@ev.seen", EV_ORDERS = "@ev.orders", EV_TYPES = "@ev.types"; const MADE = "@ev.made", GONE = "@ev.gone", TURNED = "@ev.turned", TURNED_WAS = "@ev.turnedWas", CHANGED = "@ev.changed", CHANGED_WAS = "@ev.changedWas"; const MADE_N = "@ev.madeN", GONE_N = "@ev.goneN", TURNED_N = "@ev.turnedN", CHANGED_N = "@ev.changedN"; -/** Where the pass before left the list of the units on the map: its head and the record behind it (each its pointer's bits above the lowest two), that one's uniqueness byte. */ -const HEAD_R = "@ev.hr", BEHIND_R = "@ev.br", BEHIND_UID = "@ev.bu"; +/** + * What the watch hands its handler: the record's EPD, and the number of the watch trigger's first condition (its next + * ones five dwords on); the distance from place 0 of the place the game hands out next. + */ +const EV_RECORD = "@ev.e", EV_FIELDS = "@ev.wf", EV_BOUNDARY = "@ev.be"; 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, HEAD_R, BEHIND_R, BEHIND_UID].map((id) => ({ id, name: "(events)", kind: "number", shared: true, 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].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."); -/** The game's lists: of the units on the map (the newest linked in behind its head), and of the dying ones. */ -const VISIBLE_HEAD = epd(0x628430), DYING_HEAD = epd(0x62842c); /** 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), TYPE_LOW = word(0x64, 1); +const HP_DWORD = 0x08 >> 2, LAST_ATTACKER = word(0x90, 1); /** The most units of each kind a pass hands on in a frame, as the Python side's. */ const EVENT_CAP = 512; -/** A unit pointer's bits that can be set: the unit table is below 16 MB. */ -const POINTER_BITS = 0x00fffffc; type EventSource = "created" | "died" | "killed" | "ordered" | "morphed"; /** The sources a piece of the IR loops over the events pass's units of. */ export function eventSources(x: unknown, out = new Set()): Set { @@ -414,9 +410,6 @@ export function unitValues(program: Program, events?: { sources: Set ({ kind: "pointAt", of: u, table, ...at() }); - const mark = () => peek(k(CURRENT_PLAYER)); - const markAs = (value: NumExpr): Stmt => poke(k(CURRENT_PLAYER), value); const back = (): Stmt => poke(k(CURRENT), ME); const store = (array: string, index: NumExpr, value: NumExpr): Stmt => ({ kind: "store", array, index, value, ...at() }); const element = (array: string, index: NumExpr): NumExpr => ({ kind: "element", array, index, ...at() } as NumExpr); @@ -469,91 +462,61 @@ export function unitValues(program: Program, events?: { sources: Set): Stmt[] => { here = AT0; - const died = sources.has("died") || sources.has("killed"); - // A record is its pointer's bits above the lowest two (`R`, 0 for none): its EPD that less `D4`. A walk points the - // current player at the record's dwords and reads them there; the pointer is read once, into one variable. - const D4 = DEATHS / 4; - // (Each walk its own record and uniqueness byte: a variable read a bit at a time is copied into all its places, and - // one shared by the walks had places in each.) - let R = "", RR = "", U = ""; - const walk = (name: string) => { R = `@ev.${name}r`; RR = `@ev.${name}rr`; U = `@ev.${name}u`; return [dec(R, k(0)), dec(U, k(0), 255)]; }; - const F = "@ev.first", GO = "@ev.go", CAP = "@ev.full", S0 = "@ev.s0", FWD = "@ev.fwd", O = "@ev.o", C = "@ev.c"; - const u1 = (): NumExpr => bin("+", V(U), k(1)); - const epdOf = (r: string) => bin("-", V(r), k(D4)); - const cpAt = (r: string, off: number): Stmt => poke(k(CURRENT), bin("+", V(r), k((off - D4) >>> 0))); - const here13 = (mask = 0xffffffff, shift = 0) => peek(k(CURRENT_PLAYER), mask, shift); - /** The record after (`off` 1) or before (0) the walk's in its list. */ - const next = (off: number): Stmt[] => [cpAt(R, off), set(R, here13(POINTER_BITS, 2))]; - const uid = (r: string): Stmt[] => [cpAt(r, UID.dword), set(U, here13(UID.mask, UID.shift))]; - const out: Stmt[] = [dec(CAP, k(0), 1), set(MADE_N, k(0))]; + const died = sources.has("died") || sources.has("killed"), ordered = sources.has("ordered"), morphed = sources.has("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 }); + 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 (died) out.push(set(GONE_N, k(0))); - const watched: [string, string, string, string, Bits][] = []; - if (sources.has("ordered")) { out.push(set(TURNED_N, k(0))); watched.push([EV_ORDERS, TURNED, TURNED_WAS, TURNED_N, ORDER]); } - if (sources.has("morphed")) { out.push(set(CHANGED_N, k(0))); watched.push([EV_TYPES, CHANGED, CHANGED_WAS, CHANGED_N, TYPE_LOW]); } - // (What is done only when something is found reads the record from a copy: a place of the record in it would be one - // more that every record's pointer read writes.) - const listed = (list: string, n: string, extra: Stmt[] = []): Stmt[] => [dec(RR, V(R)), ...when(cmp("<", V(n), k(EVENT_CAP)), [store(list, V(n), epdOf(RR)), ...extra, set(n, bin("+", V(n), k(1)))])]; - if (watched.length) { - // (A unit not seen before only has them kept: the units that came on are taken after this.) - out.push(...walk("w"), dec(O, k(0), 0xffff), dec(C, k(0)), set(R, peek(k(VISIBLE_HEAD), POINTER_BITS, 2))); - const turn: Stmt[] = []; - for (const [table, list, wasList, n, b] of watched) { - turn.push(cpAt(R, b.dword), set(O, here13(b.mask, b.shift)), pointAt(epdOf(R), table), set(C, mark()), ...when(cmp("!=", V(C), bin("+", V(O), k(1))), [ - markAs(bin("+", V(O), k(1))), dec(RR, V(R)), ...uid(RR), pointAt(epdOf(RR), SEEN), - ...when(cmp("==", mark(), u1()), listed(list, n, [store(wasList, V(n), bin("-", V(C), k(1)))])), - ])); - } - out.push({ kind: "while", cond: cmp("!=", V(R), k(0)), body: [...turn, ...next(1)], ...at() } as Stmt, back()); - } - // Nothing linked in since the pass before: the same head, and behind it the same record (or none). - out.push(...walk("c")); - const behind = bin("+", V(HEAD_R), k((1 - D4) >>> 0)); - out.push(dec(GO, k(1), 1), ...when(cmp("==", peek(k(VISIBLE_HEAD)), bin("*", V(HEAD_R), k(4))), when(cmp("==", V(HEAD_R), k(0)), [set(GO, k(0))], - when(cmp("==", V(BEHIND_R), k(0)), when(cmp("==", peek(behind), k(0)), [set(GO, k(0))]), - when(cmp("==", peek(behind), bin("*", V(BEHIND_R), k(4))), [...uid(BEHIND_R), back(), ...when(cmp("==", V(U), V(BEHIND_UID)), [set(GO, k(0))])]))))); - const came: Stmt[] = [ - ...uid(R), - ...when(all([cmp("==", V(F), k(0)), cmp("==", V(R), V(BEHIND_R)), cmp("==", V(U), V(BEHIND_UID))]), [brk()]), - set(F, k(0)), - pointAt(epdOf(R), SEEN), - ...when(cmp("!=", mark(), u1()), [...when(cmp(">=", V(MADE_N), k(EVENT_CAP)), [set(CAP, k(1)), brk()]), markAs(u1()), ...listed(MADE, MADE_N)]), - ...next(1), - ]; - // What is behind the head now is where the next pass stops (not when the list was too long to take in). - const done: Stmt[] = [ - set(HEAD_R, peek(k(VISIBLE_HEAD), POINTER_BITS, 2)), set(BEHIND_R, k(0)), - ...when(cmp(">=", V(HEAD_R), k(1)), [cpAt(HEAD_R, 1), set(BEHIND_R, here13(POINTER_BITS, 2)), ...when(cmp(">=", V(BEHIND_R), k(1)), [...uid(BEHIND_R), set(BEHIND_UID, V(U))]), back()]), - ]; - out.push(...when(cmp("==", V(GO), k(1)), [ - dec(F, k(1), 1), set(R, peek(k(VISIBLE_HEAD), POINTER_BITS, 2)), - { kind: "while", cond: cmp("!=", V(R), k(0)), body: came, ...at() } as Stmt, - back(), - ...when(cmp("==", V(CAP), k(0)), done), - ])); - if (died) { - // The dying list both ways from the record at its address: Remastered links it the other way round from 1.16.1. - out.push(...walk("d")); - const went: Stmt[] = [ - ...uid(R), pointAt(epdOf(R), SEEN), - ...when(cmp("==", mark(), u1()), [...when(cmp(">=", V(GONE_N), k(EVENT_CAP)), [brk()]), markAs(bin("+", V(U), k(257))), ...listed(GONE, GONE_N)]), - ...when(cmp("==", V(FWD), k(1)), next(1), next(0)), - ]; - out.push(...when(cmp(">=", peek(k(DYING_HEAD)), k(1)), [ - set(R, peek(k(DYING_HEAD), POINTER_BITS, 2)), dec(S0, V(R)), dec(FWD, k(1), 1), - { kind: "while", cond: cmp("!=", V(R), k(0)), body: went, ...at() } as Stmt, - set(FWD, k(0)), cpAt(S0, 0), set(R, here13(POINTER_BITS, 2)), - { kind: "while", cond: cmp("!=", V(R), k(0)), body: went, ...at() } as Stmt, - back(), - ])); - } + if (ordered) out.push(set(TURNED_N, k(0))); + if (morphed) out.push(set(CHANGED_N, k(0))); + const h: Stmt[] = []; + // What the watch saw, its conditions' numbers five dwords apart. + h.push(poke(k(CURRENT), V(EV_FIELDS))); + fields.forEach((f, i) => { if (i) h.push(step(5)); h.push(dec(old(f.name), peek(k(CURRENT_PLAYER), f.read))); }); + h.push(back()); + // What the place holds now. + // (Read into a variable only what is compared or written back; whether it is alive asked of the record.) + h.push(dec(now("uid"), peek(of(e, UID.dword), 0xff00))); + if (ordered) h.push(dec(now("order"), peek(of(e, ORDER.dword), 0xff00))); + if (morphed) h.push(dec(now("type"), peek(of(e, TYPE.dword), 0xffff))); + const ALIVE = "@ev.alive"; + h.push(dec(ALIVE, k(0), 1), ...when(all([cmp(">=", peek(of(e, SPRITE)), k(1)), cmp(">=", ordered ? V(now("order")) : peek(of(e, ORDER.dword), 0xff00), k(0x100))]), [set(ALIVE, k(1))])); + const listed = (list: string, n: string, extra: Stmt[] = []): Stmt[] => when(cmp("<", V(n), k(EVENT_CAP)), [store(list, V(n), e), ...extra, set(n, bin("+", V(n), k(1)))]); + 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 (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)); + // What it sees now, into the watch's numbers: whether it is alive, or its order. For a unit not alive, only another + // in its place is news: the second condition's kind (the top byte of its dword after the number) is 0 then, and + // the game reads no further. + const seen: Record = { uid: V(now("uid")), order: ordered ? V(now("order")) : k(0), alive: bin("*", V(ALIVE), k(0x100)), type: morphed ? V(now("type")) : k(0) }; + h.push(poke(k(CURRENT), V(EV_FIELDS))); + fields.forEach((f, i) => { + if (i) h.push(step(i === 2 ? 4 : 5)); + h.push(poke(k(CURRENT_PLAYER), seen[f.name])); + if (i === 1) h.push(step(1), poke(k(CURRENT_PLAYER), bin("*", V(ALIVE), k(0x0f000000)), 0xff000000)); + }); + h.push(back()); + out.push({ kind: "watch", fields: fields.map(({ dword, mask, compare }) => ({ dword, ...(mask !== undefined ? { mask } : {}), compare })), handler: h, ...at() } as Stmt); return out; }; diff --git a/compiler/ir.ts b/compiler/ir.ts index c79b103..ec1f0b5 100644 --- a/compiler/ir.ts +++ b/compiler/ir.ts @@ -696,11 +696,12 @@ export type Stmt = /** A map of our own only: Display Text of block `block` as it is now, to `to` as a print's is. */ | { kind: "showText"; block: string; bytes: number; to: number; at: At } /** - * A map of our own only (`compiler/backend/units.ts`): the current player pointed at the dword of `table` — a table of - * the events pass's, a dword for each of the game's unit places, 336 bytes apart as they are — that is the unit's whose - * record's EPD `of` is: `of` plus the distance from the unit table to the table, which the layout knows. + * A map of our own only (`compiler/backend/units.ts`): the events pass's watch of the game's unit places — a trigger + * for each, whose conditions read `fields` of the unit record where it is (`dword` into it, `mask`) against what the + * trigger saw there last, its own numbers. A place as it was goes on to the next; one that changed runs `handler`, + * with `@ev.e` the record's EPD and the current player at the trigger's first condition's number (`emit.ts`). */ - | { kind: "pointAt"; of: NumExpr; table: string; at: At; label: string } + | { kind: "watch"; fields: { dword: number; mask?: number; compare: "atLeast" | "exactly" }[]; handler: Stmt[]; 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/compiler/simulateIr.ts b/compiler/simulateIr.ts index 241acd7..24eff3d 100644 --- a/compiler/simulateIr.ts +++ b/compiler/simulateIr.ts @@ -1308,7 +1308,7 @@ class ProgramRun { } case "remark": return "next"; // (A map of our own's own statements, which only its backend makes and never hands the simulator.) - case "textBlock": case "showText": case "pointAt": throw new Error(`the simulator does not run a ${s.kind}`); + case "textBlock": case "showText": case "watch": throw new Error(`the simulator does not run a ${s.kind}`); } } diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 836e39b..5d9b27e 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -4023,15 +4023,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:** walking a list of the game's is slower than - eudplib's: a record about 3.2 µs a frame against 1.9 — its pointer read a bit at a time (20 bits), then copied into - the places that read it — so a frame with 46 units dying costs 146 µs against 89, and `on.order` (every unit on the - map read each frame) 221 µs against 164 with 80 units. A frame where nothing comes or goes is the same (5 µs). The - way past both: a trigger for each unit place, laid out as a region, that holds the place's uniqueness byte and order - as it last saw them in its own conditions — a place unchanged costs one condition that fails (about 15 ns), and only - a place that changed reads anything — walked from place 0 and then 1,699 down to the lowest place ever handed out, - as a loop over units is. It would hand units on in the table's order, where the lists and the simulator hand them - newest first: the simulator to follow. +- **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. + 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. - 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). @@ -5044,47 +5045,60 @@ hitch. ### Unit events on our own backend (2026-10-22) -Step 11, part 3 (`compiler/backend/units.ts`: the events pass and the loops over what it found; the layout's table of -plain dwords 336 bytes apart and the pass before the run's `@wake`; the emitter's `pointAt`). The ledger's 15 rows of -`on.*` are taken: step 11 is done, 50 of 50. - -- **The map's events pass**, the Python side's `Events`, once for the map before every program's turn (laid out by the - first program with events, for what all of them loop over): the list of the units on the map from its head to the - record that was behind the head at the pass before — the units linked in since — each not seen before marked seen and - listed; the dying list both ways from its head (Remastered links it the other way round from 1.16.1), each unit seen - and not yet handed on listed; with `on.order` or `on.morphed`, every unit's order and type against what the pass - before found. A frame where nothing came on is two or three conditions; the lists hold 512 units each, as the Python - side's. -- **What it keeps of each unit place** is a table of plain dwords 336 bytes apart, as the places are: a unit's dword of - it is its EPD plus the distance from the unit table to the table, which only the layout knows, so the current player - is set to the EPD and the distance added (`pointAt`). The layout lays such a table out before the triggers, which fill - the room between its dwords: the probe's map is 1.05 MB against eudplib's 1.78. +Step 11, part 3 (`compiler/backend/units.ts`: the events pass's handler and the loops over what it found; the emitter's +`watch`; the pass before the run's `@wake` in the layout). The ledger's 15 rows of `on.*` are taken: step 11 is done, +50 of 50. + +- **The map's events pass**, once for the map before every program's turn (laid out by the first program with events, + for what all of them loop over), is a watch of the game's unit places: a trigger for each, whose conditions read the + place's record where it is against what the trigger saw there last, its own conditions' numbers — the uniqueness + byte, and as the events need them whether the unit is alive or its order, and its type. A place as it was is that + one trigger: its conditions hold, and its two actions put the trigger before it back and go on to the next. One that + changed goes to its stub (the record's EPD, where the trigger's numbers are, where to go on) and to the handler, laid + out once: a unit of another uniqueness byte that is alive came on, the one seen alive and alive no more died, one + that took up another order or turned into another type did; and what it sees now goes back into the trigger's + numbers. +- **A place whose unit is dead or gone is watched by its uniqueness byte alone:** the handler writes 0 into the kind of + the trigger's second condition, and the game reads no further (48 ns a place against 60 for one alive). Every unit + dies through the order Die, its sprite still there, and the game hands a place's first unit a uniqueness byte above + 0, so the sprite is not watched. +- **The places are walked newest first**, as the simulator hands units on: from the place the game will hand out next + (the boundary) up to place 1 699, then place 0. The game hands places out in that order and never-used ones last, so + the boundary only moves down: when the handler sees it handed out, the walk starts at the next one from then on, and + goes on from there in the same frame. - **A listener** is a task that loops over what the pass found, as the front end writes it; a handler that waits starts - a task for each unit it is handed. A walk points the current player at the record and reads it there; each walk has - its own variables (one shared by all three had places in each, and every pointer read copied into all of them: - idle with `on.order`, 423 µs → 221). + a task for each unit it is handed. What was on the map before the first pass is not handed on: a listener sleeps a + frame before it first looks (eudplib's build is the same). +- **Built first, the eudplib build's way:** walking the game's lists by their pointers. A record cost about 3.2 µs a + frame against eudplib's 1.9 (the pointer read a bit at a time and copied into the places that read it): 46 units + dying cost 146 µs a frame against 89, and `on.order` 221 against 164. It was replaced before the merge. - **Found on the way:** an action whose number is a place is found by its index in its trigger, counted over the - trigger's actions — a write before it in the same trigger moved it: `pointAt` lays out a trigger of its own, as a - store does. A unit's place in a task's call (`start`) was rewritten as an expression, not as statements. + trigger's actions — a write before it in the same trigger moved it. A unit's place in a task's call (`start`) was + rewritten as an expression, not as statements. An empty trigger whose next pointer is written must be kept. -| Lifted game, Remastered's weights, µs a frame | Ours | eudplib | +| Lifted game, Remastered's weights, µs a frame (`probes/own-events.ts`; without `on.order` beside it) | Ours | eudplib | | --- | ---: | ---: | -| nothing coming or going, `on.created`/`on.died`/`on.killed` (Binary Burghs, about 80 units) | **4.7** | 5.1 | -| forty units made, handed to `on.created` the next frame | **210** | 248 | -| forty-six units on the dying list | 146 | **89** | -| nothing coming or going, with `on.order` too | 221 | **164** | -| the map (`probes/own-events.ts`) | **1.05 MB** | 1.78 MB | - -The walks are slower than eudplib's, and the plan's *Open* list says how to be faster at all of it. - -Checked: `tests/emitter.test.ts` ("a map of our own, unit events": a unit table and the game's two lists laid out as -the game keeps them, changed between frames — a unit linked in, an order taken up, a type changed, a unit dying in a -fight — the payload walked from its bytes frame after frame, each line at its frame; a handler that waits, a task each); -`probes/own-events.ts` both ways in the lifted game, every line the same at the same frame and "ok" (made, died, the -handler that waits, an order). - -Played in Remastered (2026-10-22, recording 21.59.29): all 7 lines word for word and in order, each at the lifted -game's frame (2, 4, 6, 12, 15, 29); the Marines made and killed as the script says; no hitch. +| nothing coming or going, `on.order` among the events | **7.2** | 164 | +| forty-six units dying, `on.order` among the events | **9.0** | 224 | +| forty units made / killed in a frame, `on.order` among the events | **227 / 228** | 506 / 528 | +| without `on.order`: forty-six units dying | **8.7** | 89 | +| without `on.order`: forty units made / killed in a frame | **188 / 180** | 248 / 392 | +| without `on.order`: nothing coming or going, about 80 places handed out | 6.7 | **5.1** | +| without `on.order`: the same, 128 places handed out | 9.3 | **5.2** | +| the first frame (the listeners started) | **249** | 1,272 | +| the map, `on.order` among the events / without | 1.96 / 1.84 MB | **1.78 / 1.27 MB** | + +eudplib's build without `on.order` looks at its lists' heads alone when nothing comes on and nothing is dying: on such +a frame it is ahead, by 60 ns for each place the game has handed out (Open, below). Every other frame is ours. + +Checked: `tests/emitter.test.ts` ("a map of our own, unit events": a unit table laid out as the game keeps it, changed +between frames — a unit handed out, an order taken up, a type changed, a unit dying in a fight, its place handed to +another — the payload walked from its bytes frame after frame, each line at its frame; a handler that waits, a task +each); `probes/own-events.ts` and the same without `on.order`, both ways in the lifted game, every line the same at +the same frame and "ok". + +Played in Remastered (2026-10-22, recording 21.59.29, the lists' build): all 7 lines word for word and in order, each at +the lifted game's frame (2, 4, 6, 12, 15, 29); the Marines made and killed as the script says; no hitch. ### Triggers stacked (2026-10-20) diff --git a/probes/own-events.ts b/probes/own-events.ts index 26a77b3..b91af90 100644 --- a/probes/own-events.ts +++ b/probes/own-events.ts @@ -1,8 +1,8 @@ // TrigScript 4, the backend: unit events in a map of our own (scripts/build-own.mts) — step 11 of the way off eudplib, -// part 3. The map's events pass, before the programs' turns each frame: the list of the units on the map from its head -// to where the pass before stopped (the units linked in since), each not seen before marked seen and listed; the dying -// list both ways from its head; with on.order, every unit's order against what the pass before found. A listener is a -// task that loops over what the pass found. +// part 3. The map's events pass, before the programs' turns each frame: a trigger for each unit place the game has +// handed out, whose conditions hold what it saw there last (the uniqueness byte, whether the unit is alive or its +// order); a place that changed goes to the handler, which lists what came on, died or took up an order. A listener is +// a task that loops over what the pass found. // 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. The frames after "Weighed" // are to weigh. diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index c06ead9..846dfde 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -2121,8 +2121,8 @@ describe("a map of our own, tasks", () => { describe("a map of our own, unit events", () => { /** - * The game's unit table, its list of the units on the map and its dying list laid out as the game keeps them, two - * units to start with; each frame the world may change (`then`), and the payload is walked from its bytes. + * The game's unit table as the game keeps it, two units to start with in the places it hands out first (0, then + * 1 699); each frame the world may change (`then`), and the payload is walked from its bytes. */ const TABLE = 0x59cca8, place = (k: number) => TABLE + 336 * k; const run = (script: string, frames: number, then: (frame: number, w: (addr: number, value: number) => void) => void): string[] => { @@ -2138,10 +2138,8 @@ describe("a map of our own, unit events", () => { memory.set(place(k) + 0x64, type); memory.set(place(k) + 0xa4, uid * 256); }; - unit(1699, 0, 9); - unit(1698, 37, 8); - memory.set(0x628430, place(1698)); - memory.set(place(1698) + 4, place(1699)); + unit(0, 0, 9); + unit(1699, 37, 8); 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()}`); @@ -2156,16 +2154,19 @@ describe("a map of our own, unit events", () => { " on.order((u, order, was) => { print(`order ${u.type} ${order} ${was}`); });", " on.morphed((u, was) => { print(`morphed ${u.type} from ${was}`); });", "});", - ].join("\n"), 12, (f, w) => { - // Frame 3: a Marine at place 1697, linked in behind the head. Frame 5: the Marine at 1699 takes up order 6. - // Frame 7: the Zergling turns into type 38. Frame 9: the new Marine dies in a fight with Player 1's: no hit points, - // the order Die, out of the list and onto the dying one. - if (f === 3) { w(place(1697) + 0x08, 40 * 256); w(place(1697) + 0x0c, 0x700001); w(place(1697) + 0x4c, 0x0300); w(place(1697) + 0xa4, 7 * 256); w(place(1697) + 4, place(1699)); w(place(1698) + 4, place(1697)); } - if (f === 5) w(place(1699) + 0x4c, 0x0600); - if (f === 7) w(place(1698) + 0x64, 38); - if (f === 9) { w(place(1697) + 0x08, 0); w(place(1697) + 0x4c, 0x0000); w(place(1697) + 0x90, 0); w(place(1698) + 4, place(1699)); w(place(1697) + 4, 0); w(0x62842c, place(1697)); } + ].join("\n"), 14, (f, w) => { + // Frame 3: a Marine at place 1698, the next the game hands out. Frame 5: the Marine at place 0 takes up order 6. + // Frame 7: the Zergling turns into type 38. Frame 9: the new Marine dies in a fight with Player 1's: no hit + // points, the order Die. Frame 11: it is gone, and a Firebat (type 32) is handed its place, the uniqueness byte + // one on. + if (f === 3) { w(place(1698) + 0x08, 40 * 256); w(place(1698) + 0x0c, 0x700001); w(place(1698) + 0x4c, 0x0300); w(place(1698) + 0xa4, 7 * 256); } + if (f === 5) w(place(0) + 0x4c, 0x0600); + if (f === 7) w(place(1699) + 0x64, 38); + if (f === 9) { w(place(1698) + 0x08, 0); w(place(1698) + 0x4c, 0x0000); w(place(1698) + 0x90, 0); } + if (f === 10) w(place(1698) + 0x0c, 0); + if (f === 11) { w(place(1698) + 0x08, 50 * 256); w(place(1698) + 0x0c, 0x700002); w(place(1698) + 0x4c, 0x0300); w(place(1698) + 0x64, 32); w(place(1698) + 0xa4, 8 * 256); } }); - expect(got).toEqual(["3 made 0", "5 order 0 6 3", "7 morphed 38 from 37", "9 died 0", "9 killed 0 by 0"]); + 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", () => { @@ -2174,8 +2175,7 @@ describe("a map of our own, unit events", () => { " 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 [[1697, 7], [1696, 6]]) { w(place(kk) + 0x0c, 0x700001); w(place(kk) + 0x4c, 0x0300); w(place(kk) + 0x64, 5); w(place(kk) + 0xa4, uid * 256); } - if (f === 3) { w(place(1696) + 4, place(1697)); w(place(1697) + 4, place(1699)); w(place(1698) + 4, place(1696)); } + 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); } }); expect(got).toEqual(["5 late 5", "5 late 5"]); }); diff --git a/tests/ledger.test.ts b/tests/ledger.test.ts index ea83939..25f95e1 100644 --- a/tests/ledger.test.ts +++ b/tests/ledger.test.ts @@ -14,7 +14,7 @@ const NOT_FROM_A_SCRIPT: Record = { graph: "the emitter's own statement, made only when a map is built through eudplib with the emitter on", textBlock: "a map of our own's statement, which compiler/backend/texts.ts makes for a made text's block", showText: "a map of our own's statement, which compiler/backend/texts.ts makes to print a made text's block", - pointAt: "a map of our own's statement, which compiler/backend/units.ts makes for the events pass's tables", + watch: "a map of our own's statement, which compiler/backend/units.ts makes for the events pass", cast: "`u32()`, `i32()` and `fixed()` settle a value's type; `compiler/numbers.ts` takes every cast out once types are known", "intrinsic floor": "`compiler/numbers.ts` makes it the number itself, or a shift of a fixed one", "intrinsic ceil": "`compiler/numbers.ts` makes it the number itself, or a shift of a fixed one",