From b2ec26d2bc87f633a6cb1d65f790ba995496c84d Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 22 Oct 2026 20:09:58 -0600 Subject: [PATCH 1/3] =?UTF-8?q?Own=20backend:=20errors=20=E2=80=94=20try,?= =?UTF-8?q?=20catch,=20finally,=20throw,=20uncaught,=20a=20stack=20run=20o?= =?UTF-8?q?ut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 11, part 1 of the way off eudplib. The ledger's rows of errors are taken, 12 of 12 (six added). A throw is a jump to the catch of the nearest try; out of a called function that never calls itself it goes through a trigger of the function's own that each call points at where its error goes, so nothing is looked at on the way back (eudplib looks after every call). A function that calls itself goes out from its end and the call looks at the error once its frame is back. A stack that runs out throws a RangeError. What only an error goes through (statements ending in a throw, a catch; never a loop) reads its values from their cells where it reads them, never as places every write keeps. probes/own-errors.ts, lifted game, Remastered's weights (ours / eudplib): A-B 0.211 / 1.492 ms, E-F 0.611 / 2.412, 100 throws caught 0.876 / 2.846, 300 calls of a function that may throw 0.183 / 0.208. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/emit.ts | 231 ++++++++++++++++++--- compiler/backend/errors.ts | 69 ++++++ compiler/backend/own.ts | 6 +- docs/v4-plan.md | 84 +++++++- ledger/language/error-class.ts | 2 +- ledger/language/error-fields.ts | 18 ++ ledger/language/error-stack.ts | 14 ++ ledger/language/finally-on-return.ts | 22 ++ ledger/language/rethrow.ts | 2 +- ledger/language/stack-overflow-caught.ts | 15 ++ ledger/language/throw-from-function.ts | 2 +- ledger/language/throw-through-recursion.ts | 15 ++ ledger/language/throw-uncaught.ts | 2 +- ledger/language/try-catch.ts | 2 +- ledger/language/try-finally.ts | 2 +- ledger/language/uncaught-in-function.ts | 12 ++ ledger/tasks/task-error-awaited.ts | 2 +- ledger/tasks/task-error-not-awaited.ts | 2 +- probes/own-errors.ts | 216 +++++++++++++++++++ tests/emitter.test.ts | 178 +++++++++++++++- 20 files changed, 853 insertions(+), 43 deletions(-) create mode 100644 compiler/backend/errors.ts create mode 100644 ledger/language/error-fields.ts create mode 100644 ledger/language/error-stack.ts create mode 100644 ledger/language/finally-on-return.ts create mode 100644 ledger/language/stack-overflow-caught.ts create mode 100644 ledger/language/throw-through-recursion.ts create mode 100644 ledger/language/uncaught-in-function.ts create mode 100644 probes/own-errors.ts diff --git a/compiler/backend/emit.ts b/compiler/backend/emit.ts index cd006ff..1565f91 100644 --- a/compiler/backend/emit.ts +++ b/compiler/backend/emit.ts @@ -18,11 +18,12 @@ * falls through one trigger whose conditions failed (13.4 ns); a branch is a trigger that sets its own next pointer to * where it goes, put back by an action at the place it went to. */ -import { STACK_DEPTH, type ArrayDecl, type BoolExpr, type Call, type FuncDecl, type NumExpr, type Program, type ReadSource, type Stmt, type TableCell, type TextPart, type VarDecl, type At } from "../ir"; +import { STACK_DEPTH, type ArrayDecl, type BoolExpr, type Call, type FuncDecl, type NumExpr, type Program, type ProgramErrors, type ReadSource, type Stmt, type TableCell, type TextPart, type VarDecl, type At } from "../ir"; import { PACK_FROM } from "../eud"; 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, STACK, SWITCHES, TRIGGER_BYTES, 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. */ @@ -668,6 +669,8 @@ class Scope { returning: string[] = []; /** Whether a call that may come back into its own function is taken: the program then keeps a stack. */ recursion = false; + /** A map of our own whose program throws or catches: where the error in flight is kept (`ownErrors`). */ + errors?: ProgramErrors; /** * A call in a map of our own: its arguments written into the function's parameters, which are variables like any @@ -680,6 +683,11 @@ class Scope { if (c.saves.arrays.length) return this.no(`a growing array declared in ${c.saves.within}, which calls itself`); for (const id of c.saves.vars) if (!this.ok(id)) return this.no(`the variable ${this.decls.get(id)?.name ?? id} of ${c.saves.within}, which calls itself`); this.recursion = true; + // (A program that keeps errors throws a RangeError where the stack runs out: the function that sets it.) + if (this.errors) { + if (!this.function(OVERFLOW_FN)) return false; + this.written.add(errorLine(this.errors)); + } } for (const p of c.params) { const d = p.decl; @@ -1079,6 +1087,9 @@ class Scope { return true; } case "return": return (this.native && this.returning.length > 0 && this.returned(s)) || 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); + case "throw": return (this.native && !!this.errors) || this.island(s); default: return this.island(s); } } @@ -1111,7 +1122,8 @@ class Scope { // ── the layout ── /** A write of a variable, laid out where the statement was, expanded once every place of the variable is known. */ -interface Write { target: string; value: Linear; at?: At } +/** `cold`: written where only an error goes (`Emitter.cold`): what it reads is refreshed from its cell before it, never a place of it. */ +interface Write { target: string; value: Linear; at?: At; cold?: boolean } /** An action of an expanded write: it writes `into`; a variable it reads plus a constant is in its own number. */ interface WriteAct { into: Place; modifier: "set" | "add"; value: Num; reads?: Read } /** @@ -1162,7 +1174,7 @@ type Item = { act: Act } | { write: Write } | { fill: { temp: string; target: st * conditions, its statements' actions), which may be followed by the copies of what it writes — a copy whether or not * the conditions held, which writes back what the places hold when they did not. */ -interface Proto { label: string; conds: Cond[]; items: Item[]; next?: string; at?: At; depth?: number; keep?: boolean; guarded?: boolean; guardOf?: Stmt } +interface Proto { label: string; conds: Cond[]; items: Item[]; next?: string; at?: At; depth?: number; keep?: boolean; guarded?: boolean; guardOf?: Stmt; cold?: boolean } /** * A place of a variable: a dword that holds it times `scale` plus `add` (wrapping) — known (its cell, a home, a @@ -1286,12 +1298,29 @@ class Emitter { * it starts, and the trigger it ends at, whose next pointer every call sets to where it goes on — and those still * to lay out. `depth`: the most loops around a call of it, which its triggers count as theirs. */ - fns = new Map(); + fns = new Map(); pending: string[] = []; /** The loops around the function being laid out, at its deepest call. */ depthBase = 0; /** The function or inlined call being laid out, innermost last: the variable its `return` writes, where it goes. */ frames: { result?: { id: string; kind: string }; end: string }[] = []; + /** Where an error thrown here goes: the `catch` of each `try` around, in the function being laid out, innermost last. */ + handlers: string[] = []; + /** + * Inside statements only an error goes through — those that end in a `throw`, and a `catch` (a map of our own): what + * they read of a variable is refreshed from its cell where it is read, as a routed variable's is, never a place that + * every write of the variable keeps — a throw's message made from a parameter cost every call of the function an + * action and a copy, for an error that seldom comes. + */ + cold = 0; + /** + * Of each function an error comes out of from a trigger of its own (`thrown`), the actions of the calls that point + * that trigger's next pointer at where the error goes from each: where every call names the same place, the + * trigger is given it when built, and the calls set nothing (`settleThrown`). + */ + thrownSets = new Map(); + thrownProtos = new Map(); + private throwing?: Set; /** * A map of our own whose functions call themselves: a stack each, by function — its id, the variable of the * emitter's own that holds the cells in use (`sp`: what addresses the stack — a store's destination, a cell's @@ -1357,7 +1386,7 @@ class Emitter { } push(p: Omit & { label?: string }): Proto { - const t: Proto = { label: p.label ?? this.label("t"), conds: p.conds, items: p.items, next: p.next, at: p.at ?? this.at, depth: this.depthBase + this.loops.length, ...(p.keep ? { keep: true } : {}), ...(p.guarded ? { guarded: true } : {}), ...(p.guardOf ? { guardOf: p.guardOf } : {}) }; + const t: Proto = { label: p.label ?? this.label("t"), conds: p.conds, items: p.items, next: p.next, at: p.at ?? this.at, depth: this.depthBase + this.loops.length, ...(p.keep ? { keep: true } : {}), ...(p.guarded ? { guarded: true } : {}), ...(p.guardOf ? { guardOf: p.guardOf } : {}), ...(this.cold ? { cold: true } : {}) }; this.protos.push(t); return t; } @@ -1379,7 +1408,7 @@ class Emitter { } write(target: string, value: Linear, copy = true, into: Item[] = this.open().items, copies = into): void { - const w: Write = { target, value, ...(this.at ? { at: this.at } : {}) }; + const w: Write = { target, value, ...(this.at ? { at: this.at } : {}), ...(this.cold ? { cold: true } : {}) }; // (The copy is there for a variable that turns out to need one: the rest leave it out.) into.push({ write: w }); if (copy) copies.push({ copy: target, ...(this.at ? { at: this.at } : {}) }); @@ -1594,16 +1623,25 @@ class Emitter { // ── statements ── stmts(list: Stmt[]): void { - for (const s of list) this.stmt(s); + const cold = this.scope.native && !!this.scope.errors && throwsAtEnd(list); + if (cold) this.cold++; + try { + for (const s of list) this.stmt(s); + } finally { + if (cold) this.cold--; + } } stmt(s: Stmt): void { - const outer = this.at; + const outer = this.at, cold = this.cold; if (s.at) this.at = s.at; + // (A loop may turn many times on an error's way too: it, its test and its body, are not cold.) + if (s.kind === "while" || s.kind === "for" || s.kind === "do") this.cold = 0; try { this.statement(s); } finally { this.at = outer; + this.cold = cold; } } @@ -1800,6 +1838,23 @@ class Emitter { return this.left(s); case "break": return this.goto(this.loops[this.loops.length - 1].exit); case "continue": return this.goto(this.loops[this.loops.length - 1].cont); + case "try": { + // The body, with its `catch` as where an error thrown in it goes; the `catch` after it, reached only that way. + // (Nothing of it is done while the game runs until an error is thrown.) + const caught = this.label("catch"), end = this.label("tend"); + this.handlers.push(caught); + this.stmts(s.body); + this.handlers.pop(); + this.goto(end); + this.land(caught); + this.cold++; + this.stmts(s.catch); + this.cold--; + this.land(end); + return; + } + // (The error in flight is set by the statements before it, the line it was thrown at among them: `ownErrors`.) + case "throw": return this.goto(this.handler()); default: return this.left(s); } } @@ -2107,6 +2162,12 @@ class Emitter { } fn.depth = Math.max(fn.depth, this.depthBase + this.loops.length); const back = this.label("back"); + // An error out of a function that never comes back into itself goes on from a trigger of the function's own, + // whose next pointer the call points at where the error goes from here: nothing is looked at on the way back. + // After a call of one that may come back, and after a call that keeps a frame — which takes it back first — the + // error in flight is looked at. + const throws = this.mayThrow(c.fn), routed = throws && !f.recursive; + if (routed && !fn.thrown) fn.thrown = this.label("fthrow"); // (A function that calls itself also keeps where it goes back to in a variable, which a frame keeps: the frame // of a call that may come back is the function's the call is in.) const ra = f.recursive ? this.returnOf(c.fn, fn.exit) : null; @@ -2114,10 +2175,19 @@ class Emitter { if (c.saves && !within) throw new Error(`emitter: a call of ${f.name} that keeps a frame, outside a function`); const kept = c.saves ? this.keep(c, this.returnOf(this.current!, within!.exit)) : null; if (kept) this.arguments(c); - this.open().items.push(ra ? { label: { target: ra, to: back } } : { act: { dword: nextOf(fn.exit), modifier: "set", value: { label: back } } }); + const items = this.open().items; + items.push(ra ? { label: { target: ra, to: back } } : { act: { dword: nextOf(fn.exit), modifier: "set", value: { label: back } } }); + if (routed) { + const to = kept ? back : this.handler(), item: Item = { act: { dword: nextOf(fn.thrown!), modifier: "set", value: { label: to } } }; + items.push(item); + let sets = this.thrownSets.get(c.fn); + if (!sets) this.thrownSets.set(c.fn, (sets = [])); + sets.push({ items, item, to }); + } this.goto(fn.entry); this.land(back); if (kept) this.bringBack(kept); + if (throws && (!routed || kept)) this.jump(this.inFlight(), true, this.handler(), this.at); if (c.result && f.result) { // What the function gives back goes into its cell alone, and each call reads it from there: as places, a // return would write every call's, and each of those actions would be a place of what the return reads — @@ -2185,11 +2255,19 @@ class Emitter { const room = this.label("room"); this.jump({ kind: "compare", op: "<", left: { kind: "var", id: this.depth }, right: { kind: "const", value: this.stackDepth }, unsigned: true, at: this.at!, label: "" }, true, room, this.at); const line = this.at?.line ?? "?"; - this.open().items.push( - { act: { dword: { game: 0 }, modifier: "set", value: 0, display: `\x06TrigScript: stack overflow in ${c.saves!.within}, line ${line} - ${this.stackDepth} calls deep. The program has stopped.` } }, - { act: { dword: { data: WAKE_TICK }, modifier: "set", value: U32 - 1 } }, - ); - this.goto(END); + if (this.scope.errors) { + // A program that keeps errors throws a RangeError, as JavaScript does: where the call is, before anything of it + // is kept. + this.call({ fn: OVERFLOW_FN, name: "(stack overflow)", params: [], body: [], at: this.at!, label: "" }); + this.write(errorLine(this.scope.errors), { c: this.at?.line ?? 0, terms: [] }); + this.goto(this.handler()); + } else { + this.open().items.push( + { act: { dword: { game: 0 }, modifier: "set", value: 0, display: `\x06TrigScript: stack overflow in ${c.saves!.within}, line ${line} - ${this.stackDepth} calls deep. The program has stopped.` } }, + { act: { dword: { data: WAKE_TICK }, modifier: "set", value: U32 - 1 } }, + ); + this.goto(END); + } this.land(room); // (Each variable through one of the emitter's own, written just before: a store's number is a place no refresh // reaches, and the variable itself may have to be read from its cell where it is used.) @@ -2528,6 +2606,72 @@ class Emitter { this.goto(frame.end); } + /** + * Where an error thrown here goes: the `catch` of the nearest `try` around it, or out of the function being laid out + * — from its trigger for errors (`thrown`), or, for one that may come back into itself, from its end, where the call + * looks at the error in flight. (A program's body is all inside a `try` of the front end's.) + */ + handler(): string { + const h = this.handlers[this.handlers.length - 1]; + if (h !== undefined) return h; + const fn = this.current !== undefined ? this.fns.get(this.current) : undefined; + if (!fn) throw new Error("emitter: an error with nothing to go to"); + return fn.thrown ?? fn.exit; + } + + /** Whether a variable has a cell its reads can be refreshed from where they are (a map of our own: all but a function's arguments). */ + hasCell(id: string): boolean { + const d = this.placesOf(id)[0].dword; + return !!d && "var" in d; + } + + /** Whether an error is in flight: its class's code is not 0. */ + inFlight(): BoolExpr { + return { kind: "compare", op: ">=", left: { kind: "var", id: this.scope.errors!.thrown }, right: { kind: "const", value: 1 }, unsigned: true, at: this.at!, label: "" }; + } + + /** + * Whether an error may come out of a called function: one with a `throw` outside every `try` of its own, or a call + * that may run out of stack, or a call of such a function — worked out for every function the program reaches at + * once, until nothing more comes in. + */ + mayThrow(fn: string): boolean { + if (!this.scope.errors) return false; + if (!this.throwing) { + const out = new Set(); + const escapes = (x: unknown): boolean => { + if (!x || typeof x !== "object") return false; + if (Array.isArray(x)) return x.some(escapes); + const o = x as Record; + if (o.kind === "throw" || o.saves) return true; + // (What the body of a `try` throws, its `catch` takes; what the `catch` throws goes on.) + if (o.kind === "try") return escapes(o.catch); + if (typeof o.fn === "string" && Array.isArray(o.params) && out.has(o.fn)) return true; + return Object.values(o).some((v) => v && typeof v === "object" && escapes(v)); + }; + const ids = [...this.scope.taken.keys()]; + for (let grew = true; grew; ) { + grew = false; + for (const id of ids) if (!out.has(id) && escapes(this.scope.bodyOf!(id))) { out.add(id); grew = true; } + } + this.throwing = out; + } + return this.throwing.has(fn); + } + + /** + * A function whose calls all point its trigger for errors at the same place: that trigger goes there as built, and + * the calls' actions go. + */ + settleThrown(): void { + for (const [id, sets] of this.thrownSets) { + const proto = this.thrownProtos.get(id); + if (!proto || new Set(sets.map((x) => x.to)).size !== 1) continue; + for (const x of sets) x.items.splice(x.items.indexOf(x.item), 1); + proto.next = sets[0].to; + } + } + /** * The functions the calls reached, each laid out once after the program (which never falls into them): its body, * what it gives back off its end (0), and the trigger it ends at — empty, kept, its next pointer the call's. A @@ -2543,13 +2687,22 @@ class Emitter { this.land(fn.entry); const result = f.result ? { id: f.result.decl.id, kind: f.result.kind } : undefined; this.frames.push({ ...(result ? { result } : {}), end: fn.exit }); + // (No `try` of the caller's is around the function's body: an error leaves it first. Nor is it cold for its call.) + const handlers = this.handlers, cold = this.cold; + this.handlers = []; + this.cold = 0; this.stmts(body); + this.handlers = handlers; + this.cold = cold; this.frames.pop(); const last = body.filter((s) => s.kind !== "remark").at(-1); if (result && last?.kind !== "return") this.write(result.id, { c: 0, terms: [] }); this.push({ label: fn.exit, conds: [], items: [], keep: true, at: f.at }); + // (Its trigger for errors, its next pointer the call's, as the end's is.) + if (fn.thrown) this.thrownProtos.set(id, this.push({ label: fn.thrown, conds: [], items: [], keep: true, at: f.at })); this.depthBase = outer; } + this.settleThrown(); } /** @@ -3713,7 +3866,8 @@ class Emitter { const self = w.value.terms.filter((t) => t.id === target); if (self.length && self[0].k !== 1) copied.add(target); for (const t of w.value.terms) { - if (t.id === target) continue; + // (A write only an error goes through reads its values from their cells: no place of them, `cold`.) + if (t.id === target || (w.cold && this.hasCell(t.id))) continue; if (!readers.has(t.id)) readers.set(t.id, new Set()); readers.get(t.id)!.add(target); } @@ -3854,9 +4008,14 @@ class Emitter { graph(entries: string[]): Graph | string { // A comparison's number is a place of what it reads: its trigger keeps its label and the condition its index. + // (In a trigger only an error goes through, refreshed from the variable's cell before it instead: `coldConds`.) + const coldConds = new Map>(); for (const p of this.protos) p.conds.forEach((c, k) => { const r = this.condReads.get(c); - if (r) this.placesOf(r.id).push({ dword: { at: p.label, field: { cond: k } }, scale: r.scale, add: r.add }); + if (r && p.cold && this.hasCell(r.id)) { + const of = coldConds.get(p.label) ?? new Map(); + coldConds.set(p.label, of.set(r.id, [...(of.get(r.id) ?? []), { dword: { at: p.label, field: { cond: k } }, scale: r.scale, add: r.add }])); + } else if (r) this.placesOf(r.id).push({ dword: { at: p.label, field: { cond: k } }, scale: r.scale, add: r.add }); const q = this.condPlayers.get(c); if (q) this.placesOf(q.id).push({ dword: { at: p.label, field: { cond: k, part: "player" } }, scale: q.scale, add: q.add, base: q.base }); }); @@ -3896,10 +4055,16 @@ class Emitter { // Each target's writes expanded once all its places are known — the writes that read it come first in the // order, and each action that reads a variable is a place of it. const expanded = new Map(); + /** The actions of writes only an error goes through that read a variable: refreshed from its cell before them. */ + const coldActs = new Set(); for (const v of order) for (const w of this.writes.get(v) ?? []) { const acts = this.expand(w, copied.has(v)); expanded.set(w, acts); - for (const a of acts) if (a.reads) this.placesOf(a.reads.id).push({ act: a, scale: a.reads.scale, add: a.reads.add, ...(a.reads.base ? { base: a.reads.base } : {}) }); + for (const a of acts) { + if (!a.reads) continue; + if (w.cold && this.hasCell(a.reads.id)) coldActs.add(a); + else this.placesOf(a.reads.id).push({ act: a, scale: a.reads.scale, add: a.reads.add, ...(a.reads.base ? { base: a.reads.base } : {}) }); + } } // Every fall-through said, so that dropping a trigger leaves the one before it going where it went. this.protos.forEach((p, i) => { if (p.next === undefined) p.next = this.protos[i + 1]?.label ?? END; }); @@ -3946,7 +4111,7 @@ class Emitter { } // What a trigger's conditions read of routed variables: the places refreshed before it. const condReads = (p: Proto): Map => { - const out = new Map(); + const out = new Map(coldConds.get(p.label) ?? []); for (const id of routed) for (const pl of this.places.get(id)!.slice(1)) { const d = pl.dword; const here = d && (("at" in d && d.at === p.label && typeof d.field === "object" && "cond" in d.field) || ("data" in d && p.conds.some((c) => "data" in c.dword && c.dword.data === d.data))); @@ -4033,6 +4198,7 @@ class Emitter { for (const piece of pieces) { if (!("acts" in piece) || p.conds.length) { cut.push(piece); continue; } let cur: (Act | WriteAct)[] = [], need = new Map(), dirty = new Set(); + const coldRead = new Set([...piece.acts].filter((a): a is WriteAct => coldActs.has(a as WriteAct)).map((a) => a.reads!.id)); const flush = () => { for (const [id, list] of need) cut.push({ refresh: id, places: list }); if (cur.length) cut.push({ acts: cur }); @@ -4050,7 +4216,7 @@ class Emitter { else groups.push([a]); } for (const group of groups) { - const reads = group.filter((a): a is WriteAct => "into" in a && !!a.reads && routed.has(a.reads.id)); + const reads = group.filter((a): a is WriteAct => "into" in a && !!a.reads && (routed.has(a.reads.id) || coldActs.has(a as WriteAct))); // (A refresh is a trigger before the actions it is for: not before an action whose fields are places, which // keeps the label.) if (reads.some((a) => dirty.has(a.reads!.id)) || (reads.length && cur.some((a) => fixed.has(a as Act)))) flush(); @@ -4058,7 +4224,7 @@ class Emitter { cur.push(...group); for (const a of group) { const owner = "into" in a ? cellOf.get(a.into) : undefined; - if (owner && routed.has(owner)) dirty.add(owner); + if (owner && (routed.has(owner) || coldRead.has(owner))) dirty.add(owner); } } flush(); @@ -4322,8 +4488,8 @@ function joinRuns(triggers: Trigger[], keep: Set): void { export function liveAfterCalls(body: Stmt[]): Map> { const out = new Map>(); - /** What is live where a `break`, a `continue` and a `return` go. */ - type Flow = { brk: Set; cont: Set; ret: Set }; + /** What is live where a `break`, a `continue`, a `return` and an error go. */ + type Flow = { brk: Set; cont: Set; ret: Set; thr: Set }; const union = (...xs: Set[]) => new Set(xs.flatMap((x) => [...x])); const reads = (e: unknown) => (e && typeof e === "object" ? readIn(e as NumExpr) : new Set()); const seq = (list: Stmt[], after: Set, flow: Flow): Set => { @@ -4355,17 +4521,20 @@ export function liveAfterCalls(body: Stmt[]): Map> { case "call": { const c = s.call; // (Recorded on every pass of a loop around it: the last is the one that holds.) - if (c.saves) out.set(c, new Set(after)); + // (What the `catch` it may throw into reads, too: the frame is taken back before the error goes there.) + if (c.saves) out.set(c, union(after, flow.thr)); let live = new Set(after); if (c.result) live.delete(c.result.decl.id); // An inlined call's body where it is: its `return` goes on to what follows the call. - if (c.fn === undefined) live = seq(c.body, live, { brk: live, cont: live, ret: live }); + if (c.fn === undefined) live = seq(c.body, live, { brk: live, cont: live, ret: live, thr: flow.thr }); // (A call that may come back sets parameters that are this function's own, and takes back after it what it // keeps: what was live after it is so before it too.) if (!c.saves) for (const p of c.params) live.delete(p.decl.id); - return union(live, ...c.params.map((p) => reads(p.init))); + return union(live, flow.thr, ...c.params.map((p) => reads(p.init))); } case "return": return union(reads(s.value), flow.ret); + case "throw": return flow.thr; + case "try": return seq(s.body, after, { ...flow, thr: seq(s.catch, after, flow) }); case "if": return union(reads(s.cond), seq(s.then, after, flow), seq(s.else ?? [], after, flow)); case "block": return seq(s.body, after, flow); case "while": return loop(s.cond, s.body, [], after, true, flow); @@ -4391,7 +4560,7 @@ export function liveAfterCalls(body: Stmt[]): Map> { } }; const none = new Set(); - seq(body, none, { brk: none, cont: none, ret: none }); + seq(body, none, { brk: none, cont: none, ret: none, thr: none }); return out; } @@ -4440,6 +4609,12 @@ function writtenIn(list: Stmt[], into = new Set()): Set { return into; } +/** Whether statements end in a `throw`: all they do is on an error's way. */ +function throwsAtEnd(list: Stmt[]): boolean { + const last = list.filter((s) => s.kind !== "remark").at(-1); + return last?.kind === "throw" || (last?.kind === "block" && throwsAtEnd(last.body)); +} + /** Whether a value or a test reads a cell of an array. */ function readsCell(x: unknown): boolean { if (!x || typeof x !== "object") return false; @@ -5470,6 +5645,7 @@ class Rewriter { return [{ ...s, body: this.stmts(s.body), ...(this.own && !pre.length ? { cond } : {}) }]; } case "unrolled": return [{ ...s, iterations: s.iterations.map((it) => this.stmts(it)) }]; + case "try": return [{ ...s, body: this.stmts(s.body), catch: this.stmts(s.catch) }]; case "switch": { const pre: Stmt[] = []; // (Tested against each case: one variable plus a constant — bits under a mask computed first.) @@ -5554,7 +5730,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): 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): 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; @@ -5590,6 +5766,7 @@ export function emitBody(body: Stmt[], decls: Map, key: string, scope.functions = functions; scope.arrays = arrays; scope.bodyOf = bodyOf; + scope.errors = errors; }; const rw = new Rewriter(key, take, true, functions); rw.arrays = arrays; diff --git a/compiler/backend/errors.ts b/compiler/backend/errors.ts new file mode 100644 index 0000000..46bfef3 --- /dev/null +++ b/compiler/backend/errors.ts @@ -0,0 +1,69 @@ +/** + * Errors on a map of our own (TrigScript 4, step 11 of the way off eudplib). The front end has done most of it (B4): + * before a `throw` it sets the error in flight — its class's code into `Program.errors.thrown`, its message into + * `message` — `finally` is copied before every way out, and a program's body is a `try` whose `catch` is `uncaught`. + * What is left is where the walk goes, which is the emitter's (`Emitter.handler`): a `throw` goes to the `catch` of the + * nearest `try` around it, or out of the function it is in, and the call goes on from there. + * + * `ownErrors` rewrites what the emitter would need texts for, before the texts are lowered (`texts.ts`): the line each + * error is thrown at kept in a variable of the program's, `uncaught` as the print the Python side makes of it, and — + * when a function calls itself — the RangeError a stack that runs out throws, a function the emitter calls there. + */ +import { ERROR_CODES, STACK_OVERFLOW_MESSAGE, type At, type FuncDecl, type Program, type ProgramErrors, type Stmt, type VarDecl } from "../ir"; + +/** The variable the line an error was thrown at is kept in: what an uncaught one is said with. */ +export const errorLine = (errors: ProgramErrors): string => `${errors.thrown}@line`; +/** The function that sets the error in flight to the RangeError of a stack that ran out (`Emitter.keep`). */ +export const OVERFLOW_FN = "@error.overflow"; + +export function ownErrors(program: Program): Program { + const errors = program.errors; + if (!errors) return program; + const line = errorLine(errors), at0 = program.at; + const set = (target: string, value: number, at: At): Stmt => ({ kind: "assign", target, value: { kind: "const", value }, at, label: "" }); + // (`throw` and `uncaught` are statements only: wherever a list holds one, it is a list of statements.) + const walk = (list: Stmt[]): Stmt[] => deep(list) as Stmt[]; + const deep = (x: unknown): unknown => { + if (Array.isArray(x)) return x.flatMap((y) => (y && typeof y === "object" && (y.kind === "throw" || y.kind === "uncaught") ? statement(y as Stmt) : [deep(y)])); + if (!x || typeof x !== "object") return x; + const out: Record = {}; + for (const [k, v] of Object.entries(x)) out[k] = v && typeof v === "object" ? deep(v) : v; + return out; + }; + const statement = (s: Stmt): Stmt[] => { + switch (s.kind) { + // Where it was thrown: a caught error thrown again keeps where it came from. + case "throw": return s.rethrow ? [s] : [set(line, s.at.line, s.at), s]; + // Said in red to the player the program runs as, as a fault of the game's is, and given up. + case "uncaught": return [ + { kind: "print", to: CURRENT_PLAYER, position: "chat", at: s.at, label: s.label, parts: [ + { kind: "text", text: "\x06TrigScript: uncaught " }, + { kind: "value", text: { kind: "textOf", array: errors.names, index: { kind: "var", id: errors.thrown }, at: s.at } }, + { kind: "text", text: ": " }, + { kind: "value", text: { kind: "textVar", id: errors.message } }, + { kind: "text", text: ", line " }, + { kind: "number", expr: { kind: "var", id: line } }, + { kind: "text", text: "." }, + ] }, + set(errors.thrown, 0, s.at), + ]; + default: return [s]; + } + }; + const decl: VarDecl = { id: line, name: "(error line)", kind: "number", shared: true, at: at0 }; + const functions: FuncDecl[] = (program.functions ?? []).map((f) => ({ ...f, body: walk(f.body) })); + if (functions.some((f) => f.recursive)) { + functions.push({ id: OVERFLOW_FN, name: "(stack overflow)", params: [], at: at0, body: [ + { kind: "assignText", target: errors.message, value: { kind: "text", text: STACK_OVERFLOW_MESSAGE }, at: at0, label: "" }, + set(errors.thrown, ERROR_CODES.RangeError, at0), + ] }); + } + return { + ...program, + body: [{ kind: "declare", decl, init: { kind: "const", value: 0 }, at: at0, label: "" }, ...walk(program.body)], + ...(functions.length ? { functions } : {}), + }; +} + +/** Player 13 in a print: the current player. */ +const CURRENT_PLAYER = 13; diff --git a/compiler/backend/own.ts b/compiler/backend/own.ts index ddbda82..db37d39 100644 --- a/compiler/backend/own.ts +++ b/compiler/backend/own.ts @@ -16,6 +16,7 @@ import type { Act, Cond, Dword, Graph, Num, Trigger, Value } from "./graph"; import { TextPool, knownTexts } from "./texts"; import { HEAP_GLOBALS, growingArrays, heldVariables, isHeapGlobal, loopScoped } from "./heap"; import { UNIT_GLOBALS, unitValues } from "./units"; +import { ownErrors } from "./errors"; export interface OwnRun { /** The program's index among the script's. */ @@ -59,8 +60,9 @@ export function emitOwn(r: Pick, p const runs: OwnRun[] = []; const values = new Map(); // Every program's texts known when built first: the pool is numbered once they are all in, before any is lowered. + // (What an error needs texts for — an uncaught one said, a stack that ran out — written out before: `errors.ts`.) const pool = new TextPool(); - const known = r.ir.map((p) => knownTexts(p, pool, textBase)); + const known = r.ir.map((p) => knownTexts(ownErrors(p), pool, textBase)); const texts = pool.finish(); // Units as two numbers each (`units.ts`), then arrays that grow on the heap (`heap.ts`), once the texts' numbers are // in: those passes copy what they rewrite. @@ -89,7 +91,7 @@ export function emitOwn(r: Pick, p 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); + const graph = emitBody(program.body, decls, n ? `own${n}` : "own", functions, arrays, undefined, player, players, program.errors); if (typeof graph === "string") return graph; const name = (id: string) => (!n || isHeapGlobal(id) || UNIT_GLOBALS.some((d) => d.id === id) ? id : shared.has(id) ? (k ? `${id}#p${k}` : id) : `${id}#${n}`); const g = n ? renamed(graph, `#${n}`, name) : graph; diff --git a/docs/v4-plan.md b/docs/v4-plan.md index e95ebbb..95c2c19 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -3566,9 +3566,10 @@ found what they left out; where a step moved, its old number is given. 10. **Units** (was 5) ✓ — "Units on our own backend". Every unit value, unit loops, fields, flags, writes and verbs, picks, `select()` and the game command it sends (the events' rows are step 11's, `underMouse`'s step 12's); the queries scheduled (ideas 6, 7) still to come. -11. **Tasks, `async` and errors** (was 6; errors new). `start`, `cancel`, `running`, `Promise.all`/`race`/`any`, - `every`, `after`, `on.*` handlers, on the walk as the scheduler (idea 2). `try`, `catch`, `finally`, `throw`, - `uncaught`, a program's `errors`, the error classes. +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, on the walk as the scheduler (idea 2). Part 3: + the `on.*` handlers and the pass that finds what they are handed. 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). @@ -3898,7 +3899,7 @@ Bad Apple: 5, 6, 7, as the fog library. The bullets: 5 (a death count read), 9. ### The ledger (2026-10-19) -`ledger/` holds a script for each feature of the language and the library — 843 of them, in folders by area — whose +`ledger/` holds a script for each feature of the language and the library — 849 of them, in folders by area — whose first line says which step of the way off eudplib makes it a map of our own's, and whether a map of our own takes it today, in the words of its refusal: @@ -3926,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 | 0 of 44 | +| 11 Tasks, `async` and errors | 12 of 50 | | 12 Input | 0 of 26 | | 13 The map's own triggers | 0 of 13 | @@ -4003,6 +4004,22 @@ the ledger is the list it can be written again from at 4.0. are, and a loop's body is written up to three times (place 0, two places a turn). `give()` leaves the game's counts as they were (both builds: eudplib's `cgive`), so `command()` after it reads the old owner's; the game's own Give Units does not. +- **Errors on a map of our own (step 11, part 1), what is left:** a call in a loop of a function that may throw is + about eudplib's (W1, 0.18 ms to 0.21 for 300): the error costs it one action, but the call itself does not keep step + 1's lead (1.5×) — the argument is copied into each place of the parameter (here three: its cell, the home a signed + comparison reads, the action that adds 1; about 170 ns), and the result is copied out (75 ns). Value ranges carried + through calls would let a parameter never below zero be compared without a home; the result could be read where it + is. A throw is 3.2× eudplib's (W2), and most of what is left is its message, made where it is thrown (7.3 µs of 8.7 + when the message has a number in it): the throw could keep where it was thrown and its values, and the message be + made only where it is read — a `catch` that reads `e.message`, an uncaught error said — about 1.3 µs a throw, 20× + eudplib's, for the many catches that read nothing. In code only an error goes through, a signed comparison still + reads its variable's home (the variable plus 2³¹), a place every write keeps. The map is bigger (1.52 MB to 1.05). +- A variable set from a constant less itself (`j = 1 - j`) is copied a bit at a time — 32 triggers, about 0.5 µs, where + eudplib's is two actions: the game's subtract stops at 0, so the emitter copies a variable read times −1 (found + weighing the errors probe, 2026-10-22). +- A call worked out when the script is built that throws is a compile error even inside a `try` (both builds): + `try { total += check(9); } catch { … }` says "too big: 9 — this expression is computed when the script is built"; + JavaScript would catch it. Found writing `tests/emitter.test.ts` ("a map of our own, errors"). - A map of our own: JSX inside a program was "made while the game runs" even when it held nothing of the program's — taken since step 8's part 3; (`Math.tan` and `Math.hypot`, refused as what they become, are taken since step 4's part 5; `a >> 0`, refused where @@ -4890,6 +4907,63 @@ game's (193 units, 3 Marines, 115 Zerglings), T about 4.2 s after the first grou one given to Player 2 is shot by Player 1's own, two Marines are in the panel after `select()`, the walked Marine ends at the right of the row; no hitch. +### Errors on our own backend (2026-10-22) + +Step 11, part 1 (`compiler/backend/errors.ts`, a pass before the texts'; the emitter's `try`, `throw` and `handler`). +The ledger's rows of errors are taken, 12 of 12 (six added for what they did not reach: an error up through a function +that calls itself, a stack that runs out caught, `finally` on a `return`, a class's own field and `name`, `e.stack`, an +uncaught error thrown in a called function). The two rows of a task's error awaited wait for the tasks (part 2). + +- **What the front end does already** (B4): before a `throw` it puts the error in flight — its class's code into + `Program.errors.thrown`, its message into `message` — copies `finally` before every way out, and makes a program's + body a `try` whose `catch` is `uncaught`. `errors.ts` writes out what the emitter would need texts for, before the + texts are lowered: the line each error is thrown at into a variable of the program's (one thrown again keeps it), + `uncaught` as the print the Python side makes ("TrigScript: uncaught RangeError: …, line N." in red, and the code + back to 0), and for a program with a function that calls itself, a function that sets the RangeError of a stack + that ran out. +- **A throw is a jump**: to the `catch` of the nearest `try` around it. In a called function with no `try` around it, + out of the function: one that never calls itself goes out through a trigger of its own for errors, whose next + pointer each call points at where an error goes from that call, in the trigger that sets where it goes back to — so + nothing is looked at on the way back, where eudplib looks at the error in flight after every call of a function + that may throw. When every call names the same place, that trigger is given it when the map is built, and the calls + set nothing. A function that calls itself goes out from its end, as a `return` does: each call takes its frame back + and then looks at the error in flight (a frame keeps what a `catch` after the call reads, too). A `try` costs + nothing while nothing is thrown. +- **A stack that runs out throws a RangeError** ("Maximum call stack size exceeded", at the line of the call), as + JavaScript, the simulator and the Python side's build do; a map of our own stopped the program there before, and + still does in a program that neither throws nor catches. +- **What only an error goes through is cold**: statements that end in a `throw`, and a `catch`. What they read of a + variable is refreshed from its cell just before it is read, as a routed variable's is, and is never a place of it + that every write keeps. Without it, a message made from a parameter cost every call of the function an action and a + copy (about 75 ns), and each throw wrote the error's code and its message's lengths into every place a `catch` read + them from (about 90 actions a throw in the probe). The payload went from 1.88 MB to 1.21; a throw from 11.4 µs to 8.7. + A loop is never cold, its test and its body: it may turn many times on an error's way too (`while (alive()) { … }` + and a `throw` after it, the whole body of a program, laid out its reads refreshed at every turn before). + +| Lifted game, Remastered's weights (`probes/own-errors.ts`) | Ours | eudplib | +| --- | ---: | ---: | +| A–B: a throw caught, classes told apart, messages made while the game runs | **0.211 ms** | 1.492 ms | +| C–D: thrown again, `finally`, out of called functions | **0.080 ms** | 0.125 ms | +| E–F: up through a function that calls itself; a stack run out at 1,024 calls, caught | **0.611 ms** | 2.412 ms | +| W1: 300 calls of a function that may throw, none thrown | **0.183 ms** | 0.208 ms | +| W2: 100 errors thrown in a called function, each caught (a number in the message) | **0.876 ms** | 2.846 ms | +| W3: an error up through 40 frames, four times | **0.096 ms** | 0.364 ms | +| W4: 200 turns through a `try` with a `finally`, one in two left by `continue` | **0.029 ms** | 0.058 ms | +| the uncaught error said | **0.023 ms** | 0.057 ms | +| the map | 1.52 MB | 1.05 MB | + +W1 is where the call is, not the error: the error costs one action a call. What is left is in the ledger's *Open* list. + +Checked: `tests/emitter.test.ts` ("a map of our own, errors": try and catch, the classes and a field, a rethrow and +`finally` on every way out, out of called functions and a function's own `try`, uncaught, through recursion and its +frames, the stack run out — and every one of the ledger's scripts of errors — the payload walked from its bytes against +the simulator); the 30 scripts of `tests/errors.test.ts` a map of our own takes, the same way (once, not kept as a +test); the probe both ways in the lifted game, every line the same at the same frame and "ok". + +Played in Remastered (2026-10-22, recording 20.07.15): all 10 lines word for word and in order, the uncaught error in +red and nothing after it; the groups 2, 2, 4, 18 and 2 game frames apart, as in the lifted game — the frame a stack runs +out 1,024 calls deep and the frame of 100 throws among them; no hitch. + ### 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/language/error-class.ts b/ledger/language/error-class.ts index 7e58723..1d4b4d7 100644 --- a/ledger/language/error-class.ts +++ b/ledger/language/error-class.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: try +// ledger: step 11, takes // A class of error of the script's own, `class Late extends Error`, told apart in a `catch` with `instanceof`. class Late extends Error {} program(() => { diff --git a/ledger/language/error-fields.ts b/ledger/language/error-fields.ts new file mode 100644 index 0000000..0771b45 --- /dev/null +++ b/ledger/language/error-fields.ts @@ -0,0 +1,18 @@ +// ledger: step 11, takes +// A class of error with a field of its own, its `name`, and a message made while the game runs, read in the `catch`. +class OutOfGold extends Error { + need: number; + constructor(need: number) { + super(`need ${need} more`); + this.need = need; + } +} +program(() => { + let gold = 0; + while (gold < 20) gold += 7; + try { + if (gold < 50) throw new OutOfGold(50 - gold); + } catch (e) { + if (e instanceof OutOfGold) print(`${e.name}: ${e.message} (${e.need})`); + } +}); diff --git a/ledger/language/error-stack.ts b/ledger/language/error-stack.ts new file mode 100644 index 0000000..9434151 --- /dev/null +++ b/ledger/language/error-stack.ts @@ -0,0 +1,14 @@ +// ledger: step 11, takes +// `e.stack`: the error's class, message and the line it was thrown at, made where it is thrown because it is read. +function check(n: number) { + if (n > 1) throw new RangeError(`n is ${n}`); +} +program(() => { + let k = 0; + while (k < 2) k += 1; + try { + check(k); + } catch (e) { + if (e instanceof Error) print(e.stack ?? ""); + } +}); diff --git a/ledger/language/finally-on-return.ts b/ledger/language/finally-on-return.ts new file mode 100644 index 0000000..e1138bb --- /dev/null +++ b/ledger/language/finally-on-return.ts @@ -0,0 +1,22 @@ +// ledger: step 11, takes +// `finally` in a function: it runs on a `return` out of the `try`, and on an error's way out to the caller's `catch`. +program(() => { + let k = 0; + while (k < 2) k += 1; + let cleaned = 0; + function guarded(n: number): number { + try { + if (n > 2) throw new Error("too big"); + return n * 2; + } finally { + cleaned += 1; + } + } + let total = guarded(k - 1); + try { + total += guarded(k + 5); + } catch { + total += 100; + } + print(`${total} ${cleaned}`); +}); diff --git a/ledger/language/rethrow.ts b/ledger/language/rethrow.ts index ce4ee1f..ddce6cd 100644 --- a/ledger/language/rethrow.ts +++ b/ledger/language/rethrow.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: try +// ledger: step 11, takes // An error caught and thrown again, `catch (e) { …; throw e; }`, to a `try` further out. program(() => { let k = 0; diff --git a/ledger/language/stack-overflow-caught.ts b/ledger/language/stack-overflow-caught.ts new file mode 100644 index 0000000..5ee8e70 --- /dev/null +++ b/ledger/language/stack-overflow-caught.ts @@ -0,0 +1,15 @@ +// ledger: step 11, takes +// A function that calls itself past the stack's depth throws a RangeError, as JavaScript's does, which a `catch` takes. +function deep(n: number): number { + if (n > 1000000) return 0; + return deep(n + 1) + 1; +} +program(() => { + let k = 0; + while (k < 2) k += 1; + try { + print(`${deep(k)}`); + } catch (e) { + if (e instanceof RangeError) print(e.message); + } +}); diff --git a/ledger/language/throw-from-function.ts b/ledger/language/throw-from-function.ts index 6ad9ba8..a11835a 100644 --- a/ledger/language/throw-from-function.ts +++ b/ledger/language/throw-from-function.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: try +// ledger: step 11, takes // An error thrown in a called function, caught by the `try` around the call. function check(n: number) { if (n > 2) throw new Error("too big"); diff --git a/ledger/language/throw-through-recursion.ts b/ledger/language/throw-through-recursion.ts new file mode 100644 index 0000000..e075a38 --- /dev/null +++ b/ledger/language/throw-through-recursion.ts @@ -0,0 +1,15 @@ +// ledger: step 11, takes +// An error thrown at the bottom of a function that calls itself, caught around the first call: up through every frame. +function down(n: number): number { + if (n == 0) throw new Error("bottom"); + return down(n - 1) + 1; +} +program(() => { + let k = 0; + while (k < 2) k += 1; + try { + print(`${down(k + 3)}`); + } catch { + print("caught at the top"); + } +}); diff --git a/ledger/language/throw-uncaught.ts b/ledger/language/throw-uncaught.ts index b32bb97..5233102 100644 --- a/ledger/language/throw-uncaught.ts +++ b/ledger/language/throw-uncaught.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: try +// ledger: step 11, takes // A `throw` nothing catches: the game says the error, and the program stops there. program(() => { let k = 0; diff --git a/ledger/language/try-catch.ts b/ledger/language/try-catch.ts index 0473dc6..b133a51 100644 --- a/ledger/language/try-catch.ts +++ b/ledger/language/try-catch.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: try +// ledger: step 11, takes // `try { … } catch { … }`: an error thrown inside the `try` goes to its `catch`. program(() => { let k = 0; diff --git a/ledger/language/try-finally.ts b/ledger/language/try-finally.ts index 560f27a..ee5b4e2 100644 --- a/ledger/language/try-finally.ts +++ b/ledger/language/try-finally.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: try +// ledger: step 11, takes // `finally`: what runs however the `try` is left. program(() => { let k = 0; diff --git a/ledger/language/uncaught-in-function.ts b/ledger/language/uncaught-in-function.ts new file mode 100644 index 0000000..33fff6f --- /dev/null +++ b/ledger/language/uncaught-in-function.ts @@ -0,0 +1,12 @@ +// ledger: step 11, takes +// An error thrown in a called function that nothing catches: said in red with the line of the `throw`, and the program stops. +function explode(k: number) { + throw new RangeError(`k is ${k}`); +} +program(() => { + let k = 0; + while (k < 2) k += 1; + print("before"); + if (k > 1) explode(k); + print("after"); +}); diff --git a/ledger/tasks/task-error-awaited.ts b/ledger/tasks/task-error-awaited.ts index 2981a9d..fea6955 100644 --- a/ledger/tasks/task-error-awaited.ts +++ b/ledger/tasks/task-error-awaited.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: try +// ledger: step 11, refused: a task started // An error a task ends on, thrown again where the task is awaited and caught there: `try { await t; } catch (e) { … }`. async function wave(n: number) { await sleep(frames(2)); diff --git a/ledger/tasks/task-error-not-awaited.ts b/ledger/tasks/task-error-not-awaited.ts index 265ffa7..9f7bfc0 100644 --- a/ledger/tasks/task-error-not-awaited.ts +++ b/ledger/tasks/task-error-not-awaited.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: try +// ledger: step 11, refused: a task started // An error a task ends on that nothing awaits: the task ends and the game says so, and the program goes on. async function worker() { await sleep(frames(1)); diff --git a/probes/own-errors.ts b/probes/own-errors.ts new file mode 100644 index 0000000..d2b6b15 --- /dev/null +++ b/probes/own-errors.ts @@ -0,0 +1,216 @@ +// TrigScript 4, the backend: errors in a map of our own (scripts/build-own.mts) — step 11 of the way off eudplib, part 1. +// A `throw` goes to the `catch` of the nearest `try` around it, out of the functions it is in: from a trigger of the +// function's own that each call points at where its error goes — nothing is looked at on the way back — or, for a +// function that calls itself, from its end, the call looking at the error once its frame is taken back. A stack that +// runs out throws a RangeError; an error nothing catches is said in red, and the program stops there. +// 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. The last line is red — "TrigScript: uncaught RangeError: the end, line 214." — and nothing comes after. +// npx tsx scripts/build-own.mts probes/own-errors.ts /tmp/own-errors.scx +// npx tsx scripts/build-fixture.mts probes/own-errors.ts /tmp/own-errors-eud.scx +import { program, print, sleep, frames } from "trigscript"; + +class OutOfGold extends Error { + need: number; + constructor(need: number) { + super(`need ${need} more`); + this.need = need; + } +} + +/** n + 1, or a RangeError past 2. */ +function check(n: number): number { + if (n > 2) throw new RangeError(`too big: ${n}`); + return n + 1; +} +/** check's, doubled: an error goes on through it. */ +function twice(n: number): number { + return check(n) * 2; +} +/** check's, or -1: its own try keeps its errors. */ +function safe(n: number): number { + try { + return check(n); + } catch { + return -1; + } +} +function buy(gold: number, price: number): number { + if (gold < price) throw new OutOfGold(price - gold); + return gold - price; +} +/** Down to 0, then an error, up through every frame. */ +function down(n: number): number { + if (n == 0) throw new Error("bottom"); + return down(n - 1) + 1; +} +/** Each level keeps n and half around its call; the catch reads them once its frame is back. */ +function halves(n: number): number { + const half = n >> 1; + try { + if (n < 3) throw new Error("small"); + return halves(half) + n; + } catch { + return half * 100 + n; + } +} +function forever(n: number): number { + if (n > 1000000) return 0; + return forever(n + 1) + 1; +} + +program(() => { + print("Own errors: the payload runs."); + let k = 0; + while (k < 2) k += 1; + + // A: a throw in the try goes to its catch; a try with no throw runs its body only. + let a = 0; + try { + if (k > 1) throw new Error("too many"); + a = 5; + } catch (e) { + if (e instanceof Error && e.message == "too many") a = 7; + } + let quiet = 0; + try { + quiet = 1; + } catch { + quiet = 2; + } + if (a == 7 && quiet == 1) print("A: try and catch ok"); else print(`A: try and catch WRONG ${a} ${quiet}`); + + // B: classes told apart, a class's field read, a message made while the game runs. + let said = ""; + for (let i = 0; i < 4; i++) { + try { + if (i == 0) buy(k * 10, 35); + if (i == 1) throw new RangeError(`i is ${i}, k ${k}`); + if (i == 2) throw new TypeError("type"); + said += "|through"; + } catch (e) { + if (e instanceof OutOfGold) said += `|${e.need} ${e.message}`; + else if (e instanceof RangeError) said += `|range ${e.message}`; + else if (e instanceof Error) said += `|${e.name}`; + } + } + if (said == "|15 need 15 more|range i is 1, k 2|TypeError|through") print("B: classes ok"); else print(`B: classes WRONG ${said}`); + sleep(frames(2)); + + // C: a caught error thrown again to the try further out; finally on each way out. + let cleaned = 0, outer = ""; + try { + try { + if (k > 1) throw new Error("inner"); + } catch (e) { + cleaned += 100; + throw e; + } finally { + cleaned += 1; + } + } catch (e) { + if (e instanceof Error) outer = e.message; + } + for (let i = 0; i < 4; i++) { + try { + if (i == 1) continue; + if (i == 2) break; + } finally { + cleaned += 10; + } + } + if (outer == "inner" && cleaned == 131) print("C: rethrow and finally ok"); else print(`C: rethrow and finally WRONG ${outer} ${cleaned}`); + + // D: out of called functions to the try around the call; a function's own try. + let total = 0, n = 0, stopped = ""; + try { + while (n < 5) { + total += twice(n); + n += 1; + } + } catch (e) { + if (e instanceof Error) stopped = e.message; + } + try { + total += check(k + 7); + } catch { + total += 1000; + } + if (total == 1012 && n == 3 && stopped == "too big: 3" && safe(1) == 2 && safe(k + 5) == -1) print("D: out of functions ok"); + else print(`D: out of functions WRONG ${total} ${n} ${stopped}`); + sleep(frames(2)); + + // E: up through a function that calls itself, its frames taken back on the way. + let bottom = ""; + try { + print(`E: WRONG, came back with ${down(k + 3)}`); + } catch (e) { + if (e instanceof Error) bottom = e.message; + } + const h = halves(k * 20); + if (bottom == "bottom" && h == 177) print("E: through recursion ok"); else print(`E: through recursion WRONG ${bottom} ${h}`); + + // F: a stack that runs out throws a RangeError. + let ran = ""; + try { + ran = `${forever(k)}`; + } catch (e) { + if (e instanceof RangeError) ran = e.message; + } + if (ran == "Maximum call stack size exceeded") print("F: stack overflow caught ok"); else print(`F: stack overflow WRONG ${ran}`); + sleep(frames(4)); + + print("Weighed:"); + sleep(frames(2)); + // W1: 300 calls of a function that may throw and does not. + let w = 0, i = 0, j = 0; + while (i < 300) { + w += check(j); + j += 1; + if (j > 1) j = 0; + i += 1; + } + sleep(frames(4)); + // W2: 100 errors thrown in a called function, each caught in the loop. + let caught = 0; + i = 0; + while (i < 100) { + try { + w += check(i + k); + } catch { + caught += 1; + } + i += 1; + } + sleep(frames(4)); + // W3: an error up through 40 frames, four times. + i = 0; + while (i < 4) { + try { + w += down(38 + k); + } catch { + caught += 1; + } + i += 1; + } + sleep(frames(4)); + // W4: 200 turns through a try with a finally, one in two left by continue. + let f = 0; + i = 0; + while (i < 200) { + i += 1; + try { + if ((i & 1) == 0) continue; + w += 1; + } finally { + f += 1; + } + } + sleep(frames(4)); + if (caught == 103 && f == 200 && w == 450 + 3 + 100) print("W: weighed ok"); else print(`W: weighed WRONG ${caught} ${f} ${w}`); + sleep(frames(2)); + + // Z: an error nothing catches, said in red where it was thrown; the program stops there. + if (k > 1) throw new RangeError("the end"); + print("Z: WRONG, the program went on"); +}); diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index c3771aa..994b54c 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -4,7 +4,7 @@ // conditions compare memory with their own numbers, actions write memory, the walk follows next pointers), from the // same random values, every variable compared after. The game probe probes/game-emitter.ts does the same in the // lifted game, for fewer loops. -import { writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import type { ArrayDecl, BoolExpr, Call, FuncDecl, NumExpr, Stmt, TextPart, VarDecl } from "../compiler/ir"; import { emitBody, emitLoop, emitterCounts } from "../compiler/backend/emit"; @@ -1766,3 +1766,179 @@ describe("a map of our own, units", () => { ].join("\n"), UNITS, HOME); }); }); + +describe("a map of our own, errors", () => { + /** What a script prints as the simulator runs it, and as its payload does, walked from its bytes (one pass). */ + const both = (script: string): { want: string[]; got: string[] } => { + const r = compileScript(ts, { "main.ts": script }, defaultScriptNames(), { lib: defaultLib(), emitter: false }); + expect(r.diagnostics).toEqual([]); + const hex = (text: string) => shown([...new TextEncoder().encode(text)]); + const want = simulatePrograms(r.ir, 1, { strings: r.strings }).events.filter((e) => e.text !== undefined && e.action.type === 9).map((e) => hex(e.text!)); + const o = emitOwn(r); + if (typeof o === "string") throw new Error(o); + PRINTED.got = []; + runOwn(o.runs[0].graph, undefined, o.values); + return { want, got: PRINTED.got }; + }; + const same = (script: string, lines?: string[]) => { + const r = both(script); + expect(r.want.length).toBeGreaterThan(0); + expect(r.got).toEqual(r.want); + if (lines) expect(r.want).toEqual(lines.map((l) => shown([...new TextEncoder().encode(l)]))); + }; + /** A program whose `k` is 2, not known when built. */ + const program = (body: string, before = "") => `${before}\nprogram(() => {\n let k = 0;\n while (k < 2) k += 1;\n${body}\n});\n`; + + it("goes to the catch of the try around a throw, and on after it", () => { + same(program([ + " try {", + " if (k > 1) throw new Error(\"too many\");", + " print(\"fine\");", + " } catch (e) {", + " if (e instanceof Error) print(`caught ${e.message}`);", + " }", + " try { print(\"no throw\"); } catch { print(\"never\"); }", + " print(\"after\");", + ].join("\n")), ["caught too many", "no throw", "after"]); + }); + + it("tells classes of error apart, the script's own with fields among them, and makes a message while the game runs", () => { + same(program([ + " for (let i = 0; i < 4; i++) {", + " try {", + " if (i == 0) throw new Late(k * 5);", + " if (i == 1) throw new RangeError(`i is ${i}, k ${k}`);", + " if (i == 2) throw new TypeError(\"type\");", + " print(`${i} went through`);", + " } catch (e) {", + " if (e instanceof Late) print(`late by ${e.by}: ${e.message}`);", + " else if (e instanceof RangeError) print(`range: ${e.message}`);", + " else if (e instanceof Error) print(`other: ${e.message}`);", + " }", + " }", + ].join("\n"), "class Late extends Error {\n by: number;\n constructor(by: number) {\n super(\"late\");\n this.by = by;\n }\n}"), ["late by 10: late", "range: i is 1, k 2", "other: type", "3 went through"]); + }); + + it("throws a caught error again, to the try further out, and finally runs however the try is left", () => { + same(program([ + " let cleaned = 0;", + " try {", + " try {", + " if (k > 1) throw new Error(\"inner\");", + " } catch (e) {", + " print(\"seen inside\");", + " throw e;", + " } finally {", + " cleaned += 1;", + " }", + " } catch (e) {", + " if (e instanceof Error) print(`caught outside: ${e.message}, cleaned ${cleaned}`);", + " }", + " for (let i = 0; i < 4; i++) {", + " try {", + " if (i == 1) continue;", + " if (i == 2) break;", + " } finally {", + " cleaned += 10;", + " }", + " }", + " print(`${cleaned}`);", + ].join("\n")), ["seen inside", "caught outside: inner, cleaned 1", "31"]); + }); + + it("comes out of called functions to the try around the call, and a function's own try keeps its errors", () => { + same(program([ + " let total = 0, n = 0;", + " try {", + " while (n < 5) {", + " total += outer(n);", + " n += 1;", + " }", + " } catch (e) {", + " if (e instanceof Error) print(`stopped at ${n}: ${e.message}`);", + " }", + " try { total += check(k + 7); } catch { total += 1000; }", + " print(`${total} ${safe(1)} ${safe(7)}`);", + ].join("\n"), [ + "function check(n: number) {", + " if (n > 2) throw new Error(`too big: ${n}`);", + " return n + 1;", + "}", + "function outer(n: number) {", + " return check(n) * 2;", + "}", + "function safe(n: number) {", + " try {", + " return check(n);", + " } catch {", + " return -1;", + " }", + "}", + ].join("\n")), ["stopped at 3: too big: 3", "1012 2 -1"]); + }); + + it("says an error nothing catches in red, where it was thrown, and stops the program there", () => { + same(program([ + " print(\"before\");", + " if (k > 1) explode(k);", + " print(\"after\");", + ].join("\n"), "function explode(k: number) {\n throw new RangeError(`k is ${k}`);\n}"), ["before", "\x06TrigScript: uncaught RangeError: k is 2, line 2."]); + }); + + it("comes up through a function that calls itself, its frames taken back on the way", () => { + same(program([ + " try {", + " print(`${down(k + 3)}`);", + " } catch (e) {", + " if (e instanceof Error) print(`caught ${e.message}`);", + " }", + " print(`${halves(k * 20)}`);", + ].join("\n"), [ + "function down(n: number): number {", + " if (n == 0) throw new Error(\"bottom\");", + " return down(n - 1) + 1;", + "}", + // (Each level keeps `n` and `half` around the call; the catch reads them after the frame is taken back.) + "function halves(n: number): number {", + " const half = n >> 1;", + " try {", + " if (n < 3) throw new Error(\"small\");", + " return halves(half) + n;", + " } catch {", + " return half * 100 + n;", + " }", + "}", + ].join("\n")), ["caught bottom", `${40 + 20 + 10 + 5 + 102}`]); + }); + + it("prints what the simulator prints for each of the ledger's scripts of errors", () => { + for (const name of ["try-catch", "try-finally", "rethrow", "error-class", "error-fields", "error-stack", "throw-from-function", "throw-through-recursion", "finally-on-return", "throw-uncaught", "uncaught-in-function", "stack-overflow-caught"]) { + const r = both(readFileSync(`ledger/language/${name}.ts`, "utf8")); + expect([name, r.want.length > 0]).toEqual([name, true]); + expect([name, r.got]).toEqual([name, r.want]); + } + }); + + it("lays a loop out the same on an error's way, a throw after it", () => { + // (Only what runs once before a throw is cold, its reads refreshed from their cells: a loop may turn many times.) + const lines = (end: string) => { + const r = compileScript(ts, { "main.ts": `program(() => {\n let i = 0, s = 0;\n while (i < 100) {\n s += i * 3;\n i += 1;\n }\n ${end}\n});\n` }, defaultScriptNames(), { lib: defaultLib(), emitter: false }); + const o = emitOwn(r); + if (typeof o === "string") throw new Error(o); + return o.runs[0].graph.triggers.filter((t) => t.at && t.at.line >= 3 && t.at.line <= 5).map((t) => [t.conds.length, t.acts.length, !!t.copy]); + }; + expect(lines(`throw new Error("done");`)).toEqual(lines(`print("done");`)); + // (And one whose message reads the sum: its read is refreshed where it is made, no place the loop keeps.) + expect(lines(`throw new Error(\`\${s}\`);`)).toEqual(lines(`print("done");`)); + }); + + it("throws a RangeError where a function that calls itself runs out of stack", () => { + same(program([ + " try {", + " print(`${forever(k)}`);", + " } catch (e) {", + " if (e instanceof Error) print(`${e instanceof RangeError} ${e.message}`);", + " }", + ].join("\n"), "function forever(n: number): number {\n if (n > 1000000) return 0;\n return forever(n + 1) + 1;\n}"), ["true Maximum call stack size exceeded"]); + }); +}); From 50744166f5fb6c3a67e7f757f282c42fbdd703e7 Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 22 Oct 2026 20:54:19 -0600 Subject: [PATCH 2/3] =?UTF-8?q?Own=20backend:=20tasks,=20Promise.all=20and?= =?UTF-8?q?=20race=20=E2=80=94=20coroutines=20on=20the=20walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 11, part 2 of the way off eudplib: 23 more of the ledger's rows (35 of step 11's 50; the on.* events are part 3). The program, each task's slot and each branch of a Promise.all or race is a coroutine: a trigger that wakes it once the map's own count of frames reaches the frame its sleep wrote, passed by as done while nothing runs in it. A sleep is one copy of that count now, where it read the game's tick a bit at a time. The tasks have their turn after the program's, in the simulator's order. A start that runs once has one slot; one that can run while its tasks do has its max of slots, each with its own copy of the task's variables. The front end's catch around every task's body is laid out once, a function each task calls, and an uncaught error is said through one. Also: a loop that sleeps tests its whole condition every turn (a task may change it while the loop sleeps); an array of texts longer than 64 cells takes its blocks from the pool. probes/own-tasks.ts, lifted game, Remastered's weights, µs a frame (ours / eudplib): sixteen tasks asleep 0.7 / 40.2, sixteen awake 3.9 / 90.4, sixteen started 10.0 / 141.5, Promise.all of four 1.0 / 41.0; the map 1.37 / 2.05 MB. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/emit.ts | 450 +++++++++++++++++++-- compiler/backend/errors.ts | 108 ++++- compiler/backend/graph.ts | 23 +- compiler/backend/layout.ts | 30 +- compiler/backend/own.ts | 12 +- compiler/backend/texts.ts | 8 +- docs/v4-plan.md | 73 +++- ledger/programs/owner-all-players-tasks.ts | 2 +- ledger/sounds/music-loop.ts | 2 +- ledger/tasks/cutscene-play.ts | 2 +- ledger/tasks/generator-as-a-task.ts | 2 +- ledger/tasks/promise-all-values.ts | 2 +- ledger/tasks/promise-all.ts | 2 +- ledger/tasks/promise-any.ts | 2 +- ledger/tasks/promise-race.ts | 2 +- ledger/tasks/start-arrow.ts | 2 +- ledger/tasks/start-call.ts | 2 +- ledger/tasks/start-max.ts | 2 +- ledger/tasks/start-void.ts | 2 +- ledger/tasks/task-awaited.ts | 2 +- ledger/tasks/task-cancel.ts | 2 +- ledger/tasks/task-error-awaited.ts | 2 +- ledger/tasks/task-error-not-awaited.ts | 2 +- ledger/tasks/task-outlives-program.ts | 2 +- ledger/tasks/task-running.ts | 2 +- ledger/tasks/task-starts-a-task.ts | 2 +- ledger/tasks/timers-after.ts | 2 +- ledger/tasks/timers-every-cancelled.ts | 2 +- ledger/tasks/timers-every-times.ts | 2 +- ledger/tasks/timers-every.ts | 2 +- ledger/texts/ui-dialog.tsx | 2 +- ledger/texts/ui-lines.tsx | 2 +- ledger/texts/ui-menu.tsx | 2 +- probes/own-tasks.ts | 148 +++++++ tests/emitter.test.ts | 182 ++++++++- 35 files changed, 985 insertions(+), 101 deletions(-) create mode 100644 probes/own-tasks.ts diff --git a/compiler/backend/emit.ts b/compiler/backend/emit.ts index 1565f91..a701302 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, STACK, SWITCHES, TRIGGER_BYTES, 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, 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; @@ -167,7 +167,6 @@ function side(e: NumExpr): Side | null { function arrayWhy(a: ArrayDecl): string | undefined { if (a.texts) return "an array of texts"; if (a.dynamic || a.through) return "an array that grows"; - if (a.task !== undefined) return "an array of a task's"; if (a.kind !== "number" && a.kind !== "boolean") return `an array of ${a.kind}s`; return undefined; } @@ -583,7 +582,8 @@ class Scope { const d = this.decls.get(id); // (A boolean is a cell holding 1 or 0, as a number is.) // (A `u8` / `u16` in a map of our own: every store into it stopped at its top first, `Rewriter.top`.) - return !!d && (d.kind === "number" || d.kind === "boolean") && (!d.bits || this.native) && d.task === undefined && !d.held; + // (A task's variable in a map of our own: each slot's own, `planTasks`.) + return !!d && (d.kind === "number" || d.kind === "boolean") && (!d.bits || this.native) && (d.task === undefined || this.native) && !d.held; } stmts(list: Stmt[], inLoop: boolean): boolean { @@ -671,6 +671,9 @@ class Scope { recursion = false; /** A map of our own whose program throws or catches: where the error in flight is kept (`ownErrors`). */ errors?: ProgramErrors; + /** A map of our own: each `start`'s slots (`planTasks`), and the starts that run once — one slot, never busy. */ + slots = new Map(); + once = new Set(); /** * A call in a map of our own: its arguments written into the function's parameters, which are variables like any @@ -719,6 +722,8 @@ class Scope { if (state !== undefined) return state || this.no(`a call of ${f?.name ?? id}`); const body = this.bodyOf?.(id); if (!f || !body) return this.no(`a call of ${id}`); + // (A called function is laid out once, outside every coroutine: one that waits could not say whose turn it gives back.) + if (this.slots.size && holdsKind(body, "sleep")) return this.no(`a call of ${f.name}, which waits, in a program with tasks`); this.taken.set(id, "walking"); let ok = true; if (f.result) { @@ -733,6 +738,25 @@ class Scope { return ok; } + /** A task's call (a map of our own): its parameters, the slot's own variables, which the start writes; its body. */ + taskCall(c: Call): boolean { + for (const p of c.params) { + const d = p.decl; + if (!this.ok(d.id)) return this.no(`the parameter ${d.name}, a ${d.kind}`); + if (d.kind === "boolean" ? !this.test(p.init as BoolExpr) : !this.lowers(p.init as NumExpr)) return this.no(`the value given to ${d.name}`); + if (d.kind !== "boolean") this.num(p.init as NumExpr); + this.declared.add(d.id); + this.written.add(d.id); + } + // (What a task gives back nothing reads: its `return` only ends it.) + this.returning.push("void"); + try { + return this.stmts(c.body, false); + } finally { + this.returning.pop(); + } + } + /** * A part of a printed text a map of our own takes: a text, a whole number (signed or a `u32`) the emitter lowers, a * byte from one (a colour code chosen as the map is played). Not yet a fixed number, a player's name or colour, a @@ -1087,6 +1111,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.) + case "start": { + const slots = this.native && !s.listen ? 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 "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); case "throw": return (this.native && !!this.errors) || this.island(s); @@ -1168,13 +1199,30 @@ 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[] } }; +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 } }; /** * `keep`: an empty trigger that stays (a jump whose next pointer a place sets). `guarded`: an `if` in one trigger (its * conditions, its statements' actions), which may be followed by the copies of what it writes — a copy whether or not * the conditions held, which writes back what the places hold when they did not. */ -interface Proto { label: string; conds: Cond[]; items: Item[]; next?: string; at?: At; depth?: number; keep?: boolean; guarded?: boolean; guardOf?: Stmt; cold?: boolean } +/** `off`: a trigger laid out done (`Trigger.off`): a task's slot or a branch's, switched on by what starts it. */ +interface Proto { label: string; conds: Cond[]; items: Item[]; next?: string; at?: At; depth?: number; keep?: boolean; guarded?: boolean; guardOf?: Stmt; cold?: boolean; off?: boolean } + +/** + * A coroutine (TrigScript 4, step 11): where its wake keeps the frame it sleeps until and where it goes on from — the + * fields a sleep writes — the action a resume puts its wake back with, where its turn goes when it sleeps, and what its + * end lays out. The program's wake is its run's `@wake` (`layout.ts`); a task's slot and a branch of a `Promise.all` or + * `race` have a trigger of their own, "the count of frames has reached W: go to R", passed by while done. + */ +interface Co { tick: Dword; at: Dword; reset: Act; yieldTo: string; end: () => void } + +/** + * One of a `start`'s slots, as `emitBody` plans them: the task's call as the slot runs it (variables of its own but in + * the first slot), its trigger in the tasks' turn, where its body starts, its generation and the number it is known by + * while it runs (`slot | task << 8 | generation << 16`; 0 when nothing is in it). + */ +interface TaskSlot { call: Call; check: string; entry: string; gen: string; handle: string } +type StartStmt = Extract; /** * A place of a variable: a dword that holds it times `scale` plus `add` (wrapping) — known (its cell, a home, a @@ -1231,7 +1279,8 @@ function inRegion(arrays: Map, id: string): boolean { * top first (`Rewriter.top`). */ function nativeArray(a: ArrayDecl): boolean { - return (a.kind === "number" || a.kind === "boolean") && !a.dynamic && !a.texts && !a.through && a.task === undefined; + // (A task's: in place, its start having one slot — `planTasks`.) + return (a.kind === "number" || a.kind === "boolean") && !a.dynamic && !a.texts && !a.through; } /** The fixed-point functions the emitter makes as its own triggers rather than calling the Python side's. */ @@ -1313,6 +1362,17 @@ class Emitter { * action and a copy, for an error that seldom comes. */ cold = 0; + /** The coroutine being laid out: the program's (`emitBody`), a task's slot (`tasks()`), a branch (`parallel()`). */ + co!: Co; + /** The starts laid out, by task number, and those whose slots' bodies are still to lay out; inside a task's body, how deep. */ + pools = new Map(); + pendingTasks: number[] = []; + inTask = 0; + /** Whether a task starts one (`again`): the tasks' turn goes round again, for it to have its first turn in this frame too. */ + again = false; + /** Where the program's turn goes when it sleeps or ends: the tasks' turn (or the run's end, with no task). */ + get tasksLabel(): string { return `${this.key}:tasks`; } + get againVar(): string { return `${this.key}.again`; } /** * Of each function an error comes out of from a trigger of its own (`thrown`), the actions of the calls that point * that trigger's next pointer at where the error goes from each: where every call names the same place, the @@ -1386,7 +1446,7 @@ class Emitter { } push(p: Omit & { label?: string }): Proto { - const t: Proto = { label: p.label ?? this.label("t"), conds: p.conds, items: p.items, next: p.next, at: p.at ?? this.at, depth: this.depthBase + this.loops.length, ...(p.keep ? { keep: true } : {}), ...(p.guarded ? { guarded: true } : {}), ...(p.guardOf ? { guardOf: p.guardOf } : {}), ...(this.cold ? { cold: true } : {}) }; + const t: Proto = { label: p.label ?? this.label("t"), conds: p.conds, items: p.items, next: p.next, at: p.at ?? this.at, depth: this.depthBase + this.loops.length, ...(p.keep ? { keep: true } : {}), ...(p.guarded ? { guarded: true } : {}), ...(p.guardOf ? { guardOf: p.guardOf } : {}), ...(this.cold ? { cold: true } : {}), ...(p.off ? { off: true } : {}) }; this.protos.push(t); return t; } @@ -1761,7 +1821,9 @@ class Emitter { // What of the test nothing in the loop changes is tested once, on the way in (a u32 counter against a signed // bound is the bound's sign and the counter's place: the sign does not change). // (Everything the loop may write: a boolean set from a test, what a call in it writes, all the way down.) - const { once, every } = s.cond ? split(s.cond, writes({ kind: "block", body: [...s.body, ...(s.kind === "for" ? s.update : [])], at: s.at }, this.scope.functions)) : { once: null, every: null }; + // (Not in a loop that sleeps: while it sleeps, a task or a branch may write anything it reads.) + const sleeps = holdsKind(s.body, "sleep") || (s.kind === "for" && holdsKind(s.update, "sleep")); + const { once, every } = !s.cond ? { once: null, every: null } : sleeps ? { once: null, every: s.cond } : split(s.cond, writes({ kind: "block", body: [...s.body, ...(s.kind === "for" ? s.update : [])], at: s.at }, this.scope.functions)); if (once) this.jump(once, false, exit, s.at); this.land(head); if (every) this.jump(every, false, exit, s.at); @@ -1822,22 +1884,16 @@ class Emitter { } case "sleep": { if (!this.scope.native) return this.left(s); - // A map of our own (layout.ts): the tick the program wakes at (the game's tick now, a bit at a time, plus the - // frames) and where it goes on, written into the trigger that heads the owner's list every pass; then this - // pass ends. Where it goes on puts that trigger back to ending the pass. - const frames = s.cycles ?? Math.max(1, Math.round(((s.ms ?? 0) * FRAMES_PER_SECOND) / 1000)); - const resume = this.label("wake"); - this.open().items.push({ act: { dword: { data: WAKE_TICK }, modifier: "set", value: frames } }, { act: { dword: { data: WAKE_AT }, modifier: "set", value: { label: resume } } }); - for (let bit = 0; bit < 32; bit++) this.push({ conds: [{ dword: { game: GAME_TICK }, compare: "atLeast", value: 1, mask: 2 ** bit }], items: [{ act: { dword: { data: WAKE_TICK }, modifier: "add", value: 2 ** bit } }] }); - this.goto(END); - this.push({ label: resume, conds: [], items: [{ act: { dword: { data: WAKE_NEXT }, modifier: "set", value: { label: END } } }] }); - return; + return this.sleepIn(this.co, s.cycles ?? Math.max(1, Math.round(((s.ms ?? 0) * FRAMES_PER_SECOND) / 1000))); } case "print": if (this.scope.native && !this.scope.islands.has(s)) return this.print(s); return this.left(s); case "break": return this.goto(this.loops[this.loops.length - 1].exit); case "continue": return this.goto(this.loops[this.loops.length - 1].cont); + case "start": return this.scope.islands.has(s) ? this.left(s) : this.start(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": { // The body, with its `catch` as where an error thrown in it goes; the `catch` after it, reached only that way. // (Nothing of it is done while the game runs until an error is thrown.) @@ -2262,11 +2318,8 @@ class Emitter { this.write(errorLine(this.scope.errors), { c: this.at?.line ?? 0, terms: [] }); this.goto(this.handler()); } else { - this.open().items.push( - { act: { dword: { game: 0 }, modifier: "set", value: 0, display: `\x06TrigScript: stack overflow in ${c.saves!.within}, line ${line} - ${this.stackDepth} calls deep. The program has stopped.` } }, - { act: { dword: { data: WAKE_TICK }, modifier: "set", value: U32 - 1 } }, - ); - this.goto(END); + this.open().items.push({ act: { dword: { game: 0 }, modifier: "set", value: 0, display: `\x06TrigScript: stack overflow in ${c.saves!.within}, line ${line} - ${this.stackDepth} calls deep. The program has stopped.` } }); + this.co.end(); } this.land(room); // (Each variable through one of the emitter's own, written just before: a store's number is a place no refresh @@ -2606,6 +2659,205 @@ class Emitter { this.goto(frame.end); } + // ── coroutines and tasks (a map of our own: TrigScript 4, step 11) ── + + /** + * A sleep of a coroutine: the frame it wakes at — the map's count of frames now plus these, one copy — and where it + * goes on, written into its wake; then its turn is given back. Where it goes on puts its wake back as it was, and goes + * on to `then` when there is one. + */ + sleepIn(co: Co, frames: number, then?: string): void { + const resume = this.label("wake"); + this.open().items.push({ route: { from: NOW, into: [co.tick], add: frames } }, { act: { dword: co.at, modifier: "set", value: { label: resume } } }); + this.goto(co.yieldTo); + this.push({ label: resume, conds: [], items: [{ act: co.reset }], ...(then ? { next: then } : {}) }); + } + + /** A trigger of a slot's or a branch's that says whether nothing runs in it (done) or something does. */ + private flags(check: string, on: boolean): Item { + return { act: { dword: { at: check, field: "flags" }, modifier: "set", value: on ? TRIGGER_ON : TRIGGER_OFF } }; + } + + /** + * A task into a free slot of its start: the arguments as they are now into the slot's parameters, its generation one + * more, the number it is known by, and its trigger in the tasks' turn switched on, due now, at the body's start. With + * every slot running nothing starts: the game says so once, and the number is 0, as the Python side's build does. A + * start that runs once has one slot, which is free. + */ + start(s: StartStmt): void { + const slots = this.scope.slots.get(s)!; + if (!this.pools.has(s.task)) { + this.pools.set(s.task, { start: s, slots }); + this.pendingTasks.push(s.task); + } + if (this.inTask) this.again = true; + if (this.scope.once.has(s)) { + this.arguments(slots[0].call); + return this.startSlot(s, slots[0], 0); + } + // (The arguments into variables of the emitter's own first: the slot is chosen after.) + const temps = slots[0].call.params.map((p) => { + const t = `${this.key}.ta${this.n++}`; + this.own.add(t); + if (p.decl.kind === "boolean") this.setBool(t, p.init as BoolExpr); + else this.write(t, linear(p.init as NumExpr)!); + return t; + }); + const done = this.label("started"), into = slots.map(() => this.label("slot")); + slots.forEach((slot, k) => this.branch([{ dword: { at: slot.check, field: "flags" }, compare: "atLeast", value: 8, mask: 8 }], into[k], this.at)); + const said = `${this.key}.said${s.task}`, quiet = this.label("quiet"); + this.own.add(said); + this.jump({ kind: "compare", op: ">=", left: { kind: "var", id: said }, right: { kind: "const", value: 1 }, unsigned: true, at: this.at!, label: "" }, true, quiet, this.at); + this.open().items.push({ act: { dword: { game: 0 }, modifier: "set", value: 0, display: `\x06TrigScript: ${s.call.name ?? "a task"} did not start, line ${this.at?.line ?? "?"} - all ${slots.length} of this start's tasks are running.` } }); + this.write(said, { c: 1, terms: [] }); + this.land(quiet); + if (s.handle) this.write(s.handle, { c: 0, terms: [] }); + this.goto(done); + slots.forEach((slot, k) => { + this.land(into[k]); + slot.call.params.forEach((p, j) => (p.decl.kind === "boolean" ? this.setBool(p.decl.id, { kind: "var", id: temps[j] }) : this.write(p.decl.id, { c: 0, terms: [{ id: temps[j], k: 1 }] }))); + this.startSlot(s, slot, k); + this.goto(done); + }); + this.land(done); + } + + private startSlot(s: StartStmt, slot: TaskSlot, k: number): void { + // Its generation one more, from 1 again past 65 535; the number it is known by from it. + const past = this.label("gen"); + this.jump({ kind: "compare", op: "<", left: { kind: "var", id: slot.gen }, right: { kind: "const", value: 0xffff }, unsigned: true, at: this.at!, label: "" }, true, past, this.at); + this.write(slot.gen, { c: 0, terms: [] }); + this.land(past); + this.write(slot.gen, { c: 1, terms: [{ id: slot.gen, k: 1 }] }); + this.write(slot.handle, { c: k + 256 * s.task, terms: [{ id: slot.gen, k: 65536 }] }); + this.open().items.push( + { act: { dword: { at: slot.check, field: { cond: 0 } }, modifier: "set", value: 0 } }, + { act: { dword: { at: slot.check, field: { act: 0, part: "value" } }, modifier: "set", value: { label: slot.entry } } }, + this.flags(slot.check, true), + ); + if (this.inTask) this.write(this.againVar, { c: 1, terms: [] }); + if (s.handle) this.write(s.handle, { c: 0, terms: [{ id: slot.handle, k: 1 }] }); + } + + /** `t.cancel()`: the slot whose task the number names, while that task is in it, switched off — it is not resumed again. */ + cancel(s: Extract): void { + const h = s.task; + for (const slot of [...this.scope.slots.values()].flat()) { + const past = this.label("nc"); + this.jump({ kind: "and", items: [{ kind: "compare", op: ">=", left: h, right: { kind: "const", value: 1 }, unsigned: true, at: this.at!, label: "" }, { kind: "compare", op: "==", left: h, right: { kind: "var", id: slot.handle }, at: this.at!, label: "" }] }, false, past, this.at); + this.open().items.push(this.flags(slot.check, false)); + this.write(slot.handle, { c: 0, terms: [] }); + this.land(past); + } + } + + /** + * `Promise.all` and `Promise.race`: each branch a coroutine of its own, with a trigger that wakes it, all due as the + * statement is reached, each run in order until it sleeps; then, until all have ended (or one has, a race), the + * coroutine around sleeps a frame at a time and runs again those still running, in order — as the simulator does. A + * race goes on from the end of the first branch to end; the others are switched off where they are. + */ + parallel(s: Extract): void { + const outer = this.co, race = s.mode === "race"; + const checks = s.branches.map(() => this.label("br")), nexts = checks.map((c) => `${c}:after`), entries = checks.map(() => this.label("bre")); + const dispatch = this.label("disp"), test = this.label("ptest"), after = this.label("pafter"); + const items = this.open().items; + checks.forEach((c, j) => items.push( + { act: { dword: { at: c, field: { cond: 0 } }, modifier: "set", value: 0 } }, + { act: { dword: { at: c, field: { act: 0, part: "value" } }, modifier: "set", value: { label: entries[j] } } }, + this.flags(c, true), + )); + this.land(dispatch); + checks.forEach((c, j) => { + this.push({ label: c, conds: [{ dword: { var: NOW }, compare: "atLeast", value: 0 }], items: [{ act: { dword: nextOf(c), modifier: "set", value: { label: entries[j] } } }], next: nexts[j], off: true }); + this.push({ label: nexts[j], conds: [], items: [], next: j + 1 < checks.length ? checks[j + 1] : test }); + }); + this.land(test); + if (!race) { + // (All ended: on at once.) + const wait = this.label("pwait"); + for (const c of checks) this.branch([{ dword: { at: c, field: "flags" }, compare: "atMost", value: 0, mask: 8 }], wait, this.at); + this.goto(after); + this.land(wait); + } + this.sleepIn(outer, 1, dispatch); + s.branches.forEach((b, j) => { + const reset: Act = { dword: nextOf(checks[j]), modifier: "set", value: { label: nexts[j] } }; + this.push({ label: entries[j], conds: [], items: [{ act: reset }] }); + this.co = { tick: { at: checks[j], field: { cond: 0 } }, at: { at: checks[j], field: { act: 0, part: "value" } }, reset, yieldTo: nexts[j], end: outer.end }; + this.stmts(b); + this.co = outer; + this.open().items.push(...(race ? checks : [checks[j]]).map((c) => this.flags(c, false))); + this.goto(race ? after : nexts[j]); + }); + this.land(after); + } + + /** + * Each start's slots' bodies, laid out once each after the program, as a called function's are: the task's call, its + * coroutine the slot's — its wake the slot's trigger in the tasks' turn — and at its end the slot switched off and the + * number it was known by 0. + */ + tasks(): void { + for (let task = this.pendingTasks.shift(); task !== undefined; task = this.pendingTasks.shift()) { + for (const slot of this.pools.get(task)!.slots) { + const after = `${slot.check}:after`; + const reset: Act = { dword: nextOf(slot.check), modifier: "set", value: { label: after } }; + const end = () => { + this.open().items.push(this.flags(slot.check, false)); + this.write(slot.handle, { c: 0, terms: [] }); + this.goto(after); + }; + const outer = { co: this.co, handlers: this.handlers, cold: this.cold, loops: this.loops, frames: this.frames, current: this.current, depthBase: this.depthBase }; + this.co = { tick: { at: slot.check, field: { cond: 0 } }, at: { at: slot.check, field: { act: 0, part: "value" } }, reset, yieldTo: after, end }; + this.handlers = []; + this.cold = 0; + this.loops = []; + this.frames = []; + this.current = undefined; + this.depthBase = 0; + this.inTask++; + this.push({ label: slot.entry, conds: [], items: [{ act: reset }] }); + const done = this.label("tend"); + this.frames.push({ end: done }); + this.stmts(slot.call.body); + this.frames.pop(); + this.land(done); + end(); + this.inTask--; + Object.assign(this, outer); + } + } + } + + /** + * The tasks' turn, after the program's: each start's slots, in the order of the starts' numbers and then of their + * slots — a slot that is free passed by as done, one asleep a condition that fails, one awake sent into its body — and, + * when a task can start one, round again while one was started, for it to have its first turn in this frame too. + * Returns where it starts. + */ + turnOfTasks(): string | undefined { + const slots = [...this.pools.values()].sort((a, b) => a.start.task - b.start.task).flatMap((p) => p.slots); + const round = this.label("again"); + this.push({ label: this.tasksLabel, conds: [], items: [], next: slots.length ? slots[0].check : END }); + slots.forEach((slot, i) => { + this.push({ label: slot.check, conds: [{ dword: { var: NOW }, compare: "atLeast", value: 0 }], items: [{ act: { dword: nextOf(slot.check), modifier: "set", value: { label: slot.entry } } }], next: `${slot.check}:after`, off: true }); + this.push({ label: `${slot.check}:after`, conds: [], items: [], next: i + 1 < slots.length ? slots[i + 1].check : this.again ? round : END }); + }); + if (this.again) { + // (A jump is put back where it lands: not at the graph's end, which is no trigger of its own.) + const more = this.label("more"); + this.land(round); + this.own.add(this.againVar); + this.jump({ kind: "compare", op: ">=", left: { kind: "var", id: this.againVar }, right: { kind: "const", value: 1 }, unsigned: true, at: this.at!, label: "" }, true, more, this.at); + this.goto(END); + this.land(more); + this.write(this.againVar, { c: 0, terms: [] }); + this.goto(this.tasksLabel); + } + return slots[0]?.check; + } + /** * Where an error thrown here goes: the `catch` of the nearest `try` around it, or out of the function being laid out * — from its trigger for errors (`thrown`), or, for one that may come back into itself, from its end, where the call @@ -2702,7 +2954,6 @@ class Emitter { if (fn.thrown) this.thrownProtos.set(id, this.push({ label: fn.thrown, conds: [], items: [], keep: true, at: f.at })); this.depthBase = outer; } - this.settleThrown(); } /** @@ -4142,7 +4393,7 @@ class Emitter { const fixed = new Set(); let n = 0; for (const p of kept) { - type Piece = { acts: (Act | WriteAct)[] } | { fill: { temp: string; target: string; apart?: boolean; top?: number } } | { copy: string; at?: At; island?: boolean; top?: number } | { island: Stmt } | { call: { fn: FixedFn; target: string }; at?: At } | { refresh: string; places: (Place | WriteAct)[] } | { route: { from: string; into: Dword[] } }; + type Piece = { acts: (Act | WriteAct)[] } | { fill: { temp: string; target: string; apart?: boolean; top?: number } } | { copy: string; at?: At; island?: boolean; top?: number } | { island: Stmt } | { call: { fn: FixedFn; target: string }; at?: At } | { refresh: string; places: (Place | WriteAct)[] } | { route: { from: string; into: Dword[]; add?: number } }; const pieces: Piece[] = [{ acts: [] }]; const acts = () => { const last = pieces[pieces.length - 1]; @@ -4243,7 +4494,7 @@ class Emitter { continue; } if ("route" in piece) { - const t: Trigger = { label: label(), conds: [], acts: [], copy: { from: piece.route.from, into: piece.route.into.map((dword) => ({ dword, add: 0 })) }, ...(p.at ? { at: p.at } : {}) }; + const t: Trigger = { label: label(), conds: [], acts: [], copy: { from: piece.route.from, into: piece.route.into.map((dword) => ({ dword, add: piece.route.add ?? 0 })) }, ...(p.at ? { at: p.at } : {}) }; routes.add(t); made.push(t); continue; @@ -4273,7 +4524,7 @@ class Emitter { for (let i = 0; i === 0 || i < piece.acts.length; i += MOST_ACTS) { if (i === 0 && piece.acts.length === 0 && made.length) break; // (A trigger with conditions keeps its own label, entered through its refreshes when it has any.) - const t: Trigger = { label: p.conds.length && i === 0 ? p.label : label(), conds: p.conds.length && i === 0 ? p.conds.map((c) => ({ ...c })) : [], acts: [], ...(p.at ? { at: p.at } : {}) }; + const t: Trigger = { label: p.conds.length && i === 0 ? p.label : label(), conds: p.conds.length && i === 0 ? p.conds.map((c) => ({ ...c })) : [], acts: [], ...(p.at ? { at: p.at } : {}), ...(p.off && p.conds.length && i === 0 ? { off: true } : {}) }; piece.acts.slice(i, i + MOST_ACTS).forEach((a, k) => { if ("into" in a) { at.set(a, { at: t.label, field: { act: k, part: "value" } }); @@ -4609,6 +4860,109 @@ function writtenIn(list: Stmt[], into = new Set()): Set { return into; } +/** Whether a piece of the IR holds a statement or an expression of this kind. */ +function holdsKind(x: unknown, kind: string): boolean { + if (!x || typeof x !== "object") return false; + if (Array.isArray(x)) return x.some((y) => holdsKind(y, kind)); + const o = x as Record; + return o.kind === kind || Object.entries(o).some(([k, v]) => k !== "original" && v && typeof v === "object" && holdsKind(v, kind)); +} + +/** + * The tasks of a map of our own (TrigScript 4, step 11): each `start`'s slots — one when the start can run only once + * (in no loop, nowhere twice, in no task of more than one slot), else as many as its `max`, each slot after the first a + * copy of the task's call whose variables of the task's are its own (as the simulator swaps them) — and the program + * with `running` and `taskHandle` worked out against the slots: a number is a task's while one slot's number is it. + * Or why not. The new variables' declarations go into `known`. + */ +function planTasks(root: Stmt, key: string, known: Map, arrays: Map): { root: Stmt; slots: Map; once: Set } | string { + // Which starts run more than once, and which start each is inside. + const seen = new Map(), many = new Set(), parent = new Map(); + const visit = (x: unknown, looped: boolean, task: Stmt | null): void => { + if (!x || typeof x !== "object") return; + if (Array.isArray(x)) { x.forEach((y) => visit(y, looped, task)); return; } + const o = x as Record; + if (o.kind === "start" && typeof o.task === "number" && o.call) { + const st = o as unknown as StartStmt; + seen.set(st, (seen.get(st) ?? 0) + 1); + if (looped || seen.get(st)! > 1) many.add(st); + parent.set(st, task); + visit(st.call.params, looped, task); + visit(st.call.body, false, st); + return; + } + const loop = o.kind === "while" || o.kind === "for" || o.kind === "do" || o.kind === "unitLoop" || o.kind === "textLoop"; + for (const [k, v] of Object.entries(o)) if (k !== "original" && v && typeof v === "object") visit(v, looped || loop, task); + }; + visit(root, false, null); + for (const [st, p] of parent) if (p && many.has(p)) return `a task started inside a task of more than one slot (line ${st.at.line})`; + // (A slot's arrays would be its own, as its variables are: not yet.) + for (const st of many) { + const a = [...arrays.values()].find((x) => x.task === (st as StartStmt).task); + if (a) return `an array of a task that runs in more than one slot at a time (${a.name}, line ${st.at.line})`; + } + // The slots: their numbers' variables, and for each slot after the first, the task's variables renamed. + // (Declared as the program starts: what is worked out from them — a slot's number from its generation — is kept in + // places, which a write sets.) + const plans = new Map(), declares: Stmt[] = []; + for (const st of parent.keys()) { + const s = st as StartStmt; + const n = many.has(st) ? s.max : 1; + const handles = Array.from({ length: n }, (_, k) => `@slot${s.task}.${k}.id`); + plans.set(s.task, { n, handles }); + for (let k = 0; k < n; k++) for (const id of [`@slot${s.task}.${k}.gen`, handles[k]]) { + const decl: VarDecl = { id, name: "(task)", kind: "number", shared: false, at: s.at }; + known.set(id, decl); + declares.push({ kind: "declare", decl, init: { kind: "const", value: 0 }, at: s.at, label: "" }); + } + } + const every = [...plans.values()].flatMap((p) => p.handles); + const slots = new Map(), once = new Set(); + // (Copied where it is rewritten: the program's IR is the simulator's too, and each run's.) + const remake = (x: unknown, handle: NumExpr, rename: Map): unknown => { + if (Array.isArray(x)) return x.map((y) => remake(y, handle, rename)); + if (!x || typeof x !== "object") return typeof x === "string" && rename.has(x) ? rename.get(x) : x; + const o = x as Record; + if (o.kind === "taskHandle") return handle; + if (o.kind === "running") { + const h = remake(o.task, handle, rename) as NumExpr, at = (o.at ?? { file: "", line: 0, column: 0 }) as At; + if (!every.length) return { kind: "const", value: false }; + return { kind: "and", items: [{ kind: "compare", op: ">=", left: h, right: { kind: "const", value: 1 }, unsigned: true, at, label: "" }, { kind: "or", items: every.map((id) => ({ kind: "compare", op: "==", left: h, right: { kind: "var", id }, at, label: "" })) }] }; + } + if (o.kind === "start" && typeof o.task === "number" && o.call) { + const s = o as unknown as StartStmt, plan = plans.get(s.task)!; + const calls = plan.handles.map((h, k) => { + // (Slot k's own variables: the task's, `id~k`.) + const own = new Map(rename); + if (k) for (const d of declsIn(s.call)) if (d.task === s.task) own.set(d.id, `${d.id}~${k}`); + const call = remake(s.call, { kind: "var", id: h }, own) as Call; + for (const d of declsIn(call)) if (!known.has(d.id)) known.set(d.id, d); + return call; + }); + const made = { ...s, call: calls[0] } as StartStmt; + slots.set(made, calls.map((call, k) => ({ call, check: `${key}:task${s.task}.${k}`, entry: `${key}:task${s.task}.${k}:body`, gen: `@slot${s.task}.${k}.gen`, handle: plan.handles[k] }))); + if (!many.has(s)) once.add(made); + return made; + } + const out: Record = {}; + for (const [k, v] of Object.entries(o)) out[k] = remake(v, handle, rename); + return out; + }; + const made = remake(root, { kind: "const", value: 0 }, new Map()) as Stmt; + return { root: declares.length ? { kind: "block", body: [...declares, made], at: made.at } : made, slots, once }; +} + +/** Every declaration in a piece of the IR: a `declare`'s, a loop's, a call's parameters and result. */ +function declsIn(x: unknown, out: VarDecl[] = []): VarDecl[] { + if (Array.isArray(x)) { x.forEach((y) => declsIn(y, out)); return out; } + if (!x || typeof x !== "object") return out; + const o = x as Record; + const d = o.decl as VarDecl | undefined; + if (d && typeof d === "object" && typeof d.id === "string" && typeof d.kind === "string") out.push(d); + for (const [k, v] of Object.entries(o)) if (k !== "decl" && v && typeof v === "object") declsIn(v, out); + return out; +} + /** Whether statements end in a `throw`: all they do is on an error's way. */ function throwsAtEnd(list: Stmt[]): boolean { const last = list.filter((s) => s.kind !== "remark").at(-1); @@ -5646,6 +6000,15 @@ class Rewriter { } case "unrolled": return [{ ...s, iterations: s.iterations.map((it) => this.stmts(it)) }]; case "try": return [{ ...s, body: this.stmts(s.body), catch: this.stmts(s.catch) }]; + // (A task's arguments as a call's, worked out where it is started; its body rewritten as an inlined call's.) + case "start": { + if (!this.own) return [s]; + const pre: Stmt[] = []; + this.call(s.call, pre, where); + const made = pre.pop() as Extract; + return [...pre, { ...s, call: made.call }]; + } + case "parallel": return [{ ...s, branches: s.branches.map((b) => this.stmts(b)) }]; case "switch": { const pre: Stmt[] = []; // (Tested against each case: one variable plus a constant — bits under a mask computed first.) @@ -5774,9 +6137,14 @@ export function emitBody(body: Stmt[], decls: Map, key: string, rw.players = players; rw.bits = tops; const block = rw.stmt(plainFns(inlined({ kind: "block", body, at: body[0]?.at } as Stmt, functions)) as Stmt); - const s: Stmt = block.length === 1 ? block[0] : ({ kind: "block", body: block, at: body[0]?.at } as Stmt); + // The tasks' slots, each start's planned (and the variables of a slot after the first declared). + const planned = planTasks(block.length === 1 ? block[0] : ({ kind: "block", body: block, at: body[0]?.at } as Stmt), key, known, arrays); + if (typeof planned === "string") return planned; + const s = planned.root; const scope = new Scope(known); rules(scope); + scope.slots = planned.slots; + scope.once = planned.once; // (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 || "?"; @@ -5799,11 +6167,28 @@ export function emitBody(body: Stmt[], decls: Map, key: string, // Stacks: each `sp` and `depth` set at the start, which sets every place of each `sp` — the fields of its stack it // addresses. (Which stacks there are is known once the functions are laid out: the writes go into this trigger then.) const start = scope.recursion ? e.push({ conds: [], items: [] }) : null; + // The program a coroutine whose wake is its run's `@wake`; when it sleeps or ends, the tasks have their turn. + e.co = { + tick: { data: WAKE_TICK }, + at: { data: WAKE_AT }, + reset: { dword: { data: WAKE_NEXT }, modifier: "set", value: { label: e.tasksLabel } }, + yieldTo: e.tasksLabel, + // At its end the program wakes no more. + end: () => { + e.open().items.push({ act: { dword: { data: WAKE_TICK }, modifier: "set", value: U32 - 1 } }); + e.goto(e.tasksLabel); + }, + }; e.stmt(s); - // At its end the program wakes no more. - e.open().items.push({ act: { dword: { data: WAKE_TICK }, modifier: "set", value: U32 - 1 } }); - e.goto(END); - e.functions(); + e.co.end(); + // The functions the program calls and the tasks it starts, each of which may call and start more. + while (true) { + e.functions(); + e.tasks(); + if (!e.pending.length && !e.pendingTasks.length) break; + } + const tasks = e.turnOfTasks(); + e.settleThrown(); e.emitDigits(); if (start) { const writes: Write[] = [...[...e.stacks.values()].map((st) => st.sp), ...(e.stacks.size ? [e.depth] : [])].map((target) => ({ target, value: { c: 0, terms: [] } })); @@ -5818,6 +6203,7 @@ export function emitBody(body: Stmt[], decls: Map, key: string, g.cells = [...(g.cells ?? []), ...[...e.stacks.values()].map((st) => ({ id: st.id, cells: st.frame * e.stackDepth }))]; } if (typeof g !== "string" && e.templates.length) g.templates = e.templates; + if (typeof g !== "string" && tasks) g.tasks = tasks; if (typeof g !== "string" && 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/errors.ts b/compiler/backend/errors.ts index 46bfef3..e47bb55 100644 --- a/compiler/backend/errors.ts +++ b/compiler/backend/errors.ts @@ -15,6 +15,14 @@ import { ERROR_CODES, STACK_OVERFLOW_MESSAGE, type At, type FuncDecl, type Progr export const errorLine = (errors: ProgramErrors): string => `${errors.thrown}@line`; /** The function that sets the error in flight to the RangeError of a stack that ran out (`Emitter.keep`). */ export const OVERFLOW_FN = "@error.overflow"; +/** The function that says an error nothing caught, where more than one place may: each a call of it. */ +const UNCAUGHT_FN = "@error.uncaught"; +/** + * The functions a task's error goes through: the front end's `catch` around a task's body — the error noted in the + * task's slot for an `await` of it, or said — which reads the task's number (`taskHandle`) and nothing of the task's + * own, laid out once for every task whose `catch` is the same, a call each (a start of sixteen slots had sixteen). + */ +const TASK_FAILED_FN = "@error.task"; export function ownErrors(program: Program): Program { const errors = program.errors; @@ -26,32 +34,64 @@ export function ownErrors(program: Program): Program { const deep = (x: unknown): unknown => { if (Array.isArray(x)) return x.flatMap((y) => (y && typeof y === "object" && (y.kind === "throw" || y.kind === "uncaught") ? statement(y as Stmt) : [deep(y)])); if (!x || typeof x !== "object") return x; + const o = (x as { kind?: string }).kind === "start" ? outline(x as Extract) : (x as object); const out: Record = {}; - for (const [k, v] of Object.entries(x)) out[k] = v && typeof v === "object" ? deep(v) : v; + for (const [k, v] of Object.entries(o)) out[k] = v && typeof v === "object" ? deep(v) : v; return out; }; - const statement = (s: Stmt): Stmt[] => { - switch (s.kind) { - // Where it was thrown: a caught error thrown again keeps where it came from. - case "throw": return s.rethrow ? [s] : [set(line, s.at.line, s.at), s]; - // Said in red to the player the program runs as, as a fault of the game's is, and given up. - case "uncaught": return [ - { kind: "print", to: CURRENT_PLAYER, position: "chat", at: s.at, label: s.label, parts: [ + // (Said where it is when only one place says it; else once, in a function each place calls — a task's body has one + // in every slot.) + let sites = 0; + const count = (x: unknown): void => { + if (Array.isArray(x)) x.forEach(count); + else if (x && typeof x === "object") { + if ((x as { kind?: string }).kind === "uncaught") sites++; + Object.values(x).forEach(count); + } + }; + count([program.body, program.functions ?? []]); + // (A task's `catch` first: it says an uncaught error too.) + const failed: { shape: string; f: FuncDecl }[] = []; + const outline = (st: Extract): Extract => { + const top = st.call.body.length === 1 ? st.call.body[0] : undefined; + if (top?.kind !== "try" || !taskCatch(top.catch, st.task)) return st; + const shape = canonical(top.catch); + let f = failed.find((x) => x.shape === shape)?.f; + if (!f) { + const h: VarDecl = { id: `${TASK_FAILED_FN}${failed.length}.h`, name: "(task)", kind: "number", shared: false, at: st.at }; + const body = replaced(top.catch, (o) => (o.kind === "taskHandle" ? { kind: "var", id: h.id } : undefined)) as Stmt[]; + f = { id: `${TASK_FAILED_FN}${failed.length}`, name: "(a task's error)", params: [h], at: st.at, body }; + failed.push({ shape, f }); + } + const call: Stmt = { kind: "call", call: { fn: f.id, name: f.name, params: [{ decl: f.params[0], init: { kind: "taskHandle", at: top.at }, label: "" }], body: [], at: top.at, label: "" }, at: top.at, label: "" }; + return { ...st, call: { ...st.call, body: [{ ...top, catch: [call] }] } }; + }; + const said = (at: At, label: string): Stmt[] => [ + { kind: "print", to: CURRENT_PLAYER, position: "chat", at, label, parts: [ { kind: "text", text: "\x06TrigScript: uncaught " }, - { kind: "value", text: { kind: "textOf", array: errors.names, index: { kind: "var", id: errors.thrown }, at: s.at } }, + { kind: "value", text: { kind: "textOf", array: errors.names, index: { kind: "var", id: errors.thrown }, at } }, { kind: "text", text: ": " }, { kind: "value", text: { kind: "textVar", id: errors.message } }, { kind: "text", text: ", line " }, { kind: "number", expr: { kind: "var", id: line } }, { kind: "text", text: "." }, ] }, - set(errors.thrown, 0, s.at), + set(errors.thrown, 0, at), ]; + const statement = (s: Stmt): Stmt[] => { + switch (s.kind) { + // Where it was thrown: a caught error thrown again keeps where it came from. + case "throw": return s.rethrow ? [s] : [set(line, s.at.line, s.at), s]; + // Said in red to the player the program runs as, as a fault of the game's is, and given up. + case "uncaught": return sites > 1 ? [{ kind: "call", call: { fn: UNCAUGHT_FN, name: "(uncaught)", params: [], body: [], at: s.at, label: s.label }, at: s.at, label: s.label }] : said(s.at, s.label); default: return [s]; } }; const decl: VarDecl = { id: line, name: "(error line)", kind: "number", shared: true, at: at0 }; const functions: FuncDecl[] = (program.functions ?? []).map((f) => ({ ...f, body: walk(f.body) })); + if (sites > 1) functions.push({ id: UNCAUGHT_FN, name: "(uncaught)", params: [], at: at0, body: said(at0, "") }); + const body = walk(program.body); + for (const { f } of failed) functions.push({ ...f, body: walk(f.body) }); if (functions.some((f) => f.recursive)) { functions.push({ id: OVERFLOW_FN, name: "(stack overflow)", params: [], at: at0, body: [ { kind: "assignText", target: errors.message, value: { kind: "text", text: STACK_OVERFLOW_MESSAGE }, at: at0, label: "" }, @@ -60,10 +100,56 @@ export function ownErrors(program: Program): Program { } return { ...program, - body: [{ kind: "declare", decl, init: { kind: "const", value: 0 }, at: at0, label: "" }, ...walk(program.body)], + body: [{ kind: "declare", decl, init: { kind: "const", value: 0 }, at: at0, label: "" }, ...body], ...(functions.length ? { functions } : {}), }; } /** Player 13 in a print: the current player. */ const CURRENT_PLAYER = 13; + +/** + * Whether a `catch` is the front end's around a task's body: it starts by taking the task's number, reads it nowhere + * else, and reads no variable of the task's — what lets it be laid out once for every slot and every task. + */ +function taskCatch(list: Stmt[], task: number): boolean { + const first = list[0]; + if (first?.kind !== "declare" || (first.init as { kind?: string }).kind !== "taskHandle") return false; + let handles = 0, own = false; + const look = (x: unknown): void => { + if (Array.isArray(x)) { x.forEach(look); return; } + if (!x || typeof x !== "object") return; + const o = x as Record; + if (o.kind === "taskHandle") handles++; + if (o.decl && typeof o.decl === "object" && (o.decl as VarDecl).task === task) own = true; + if (o.kind === "start" || o.kind === "sleep" || o.kind === "parallel" || o.kind === "return" || o.kind === "break" || o.kind === "continue") own = true; + Object.values(o).forEach(look); + }; + look(list); + // (What it reads of the task's would be declared in the task: a variable it reads and does not declare is the program's.) + return handles === 1 && !own; +} + +/** A piece of the IR as text, the variables it declares numbered in the order they come, `at` and labels left out. */ +function canonical(x: unknown): string { + const names = new Map(); + const look = (y: unknown): void => { + if (Array.isArray(y)) { y.forEach(look); return; } + if (!y || typeof y !== "object") return; + const o = y as Record; + if (o.decl && typeof o.decl === "object" && !names.has((o.decl as VarDecl).id)) names.set((o.decl as VarDecl).id, `#${names.size}`); + Object.values(o).forEach(look); + }; + look(x); + return JSON.stringify(x, (k, v) => (k === "at" || k === "label" || k === "range" ? undefined : typeof v === "string" && names.has(v) ? names.get(v) : v)); +} + +/** A copy of a piece of the IR with each object `f` gives something for replaced by it. */ +function replaced(x: unknown, f: (o: Record) => unknown): unknown { + if (Array.isArray(x)) return x.map((y) => replaced(y, f)); + if (!x || typeof x !== "object") return x; + const o = x as Record; + const r = f(o); + if (r !== undefined) return r; + return Object.fromEntries(Object.entries(o).map(([k, v]) => [k, replaced(v, f)])); +} diff --git a/compiler/backend/graph.ts b/compiler/backend/graph.ts index a7650c5..3ee285a 100644 --- a/compiler/backend/graph.ts +++ b/compiler/backend/graph.ts @@ -67,9 +67,12 @@ export interface FixedFn { fn: "fmul_vars" | "fdiv_vars" | "fmul_by" | "fdiv_by" /** * `current`: the dword whose top byte is the trigger's current action (where a trigger held by a Wait or a * Transmission goes on from); an action's `flags` dword has its flags in the low byte (bit 0: a Wait or a Transmission - * started, which the game steps over the next time it is reached). + * started, which the game steps over the next time it is reached). `flags`: the trigger's own (4, preserved; 8 more, + * done: the walk passes it by before its conditions, at about 40% of what an empty trigger costs it). */ -export type Field = { cond: number; part?: "value" | "player" | "mask" } | { act: number; part: ActPart } | "next" | "current"; +export type Field = { cond: number; part?: "value" | "player" | "mask" } | { act: number; part: ActPart } | "next" | "current" | "flags"; +/** A trigger's flags: preserved, and preserved and done (passed by: `Trigger.off`). */ +export const TRIGGER_ON = 4, TRIGGER_OFF = 12; export type ActPart = "value" | "dest" | "modifier" | "mask" | "text" | "wav" | "time" | "flags"; export type Compare = "atLeast" | "atMost" | "exactly"; @@ -129,7 +132,8 @@ export const SWITCHES = 0x58dc40; * A trigger: its conditions all hold, its actions run, in order. Then the walk goes to `next` — a label of the graph's, * or `END` for what follows the graph — or, unset, to the trigger after it in the list. */ -export interface Trigger { label: string; conds: Cond[]; acts: Act[]; next?: string; at?: At; copy?: Copy; island?: Stmt; call?: Call } +/** `off`: laid out done (`TRIGGER_OFF`), passed by until an action of the graph's switches it on: a task's slot with nothing in it. */ +export interface Trigger { label: string; conds: Cond[]; acts: Act[]; next?: string; at?: At; copy?: Copy; island?: Stmt; call?: Call; off?: boolean } /** * A call of a fixed-point function, in place of a trigger as a copy is: its parameters' cells were written by the @@ -159,6 +163,13 @@ export interface Copy { from: string; into: { dword: Dword; add: Num; scale?: nu /** Where the walk goes when it leaves the graph: the statement after it. */ export const END = "@end"; +/** + * A map of our own's count of frames (TrigScript 4, step 11): a variable of the map's, one more each pass before the + * runs (`layout.ts`). A coroutine's wake — the program's, a task's, a branch of a `Promise.all`'s — asks whether it has + * reached the frame its sleep wrote: one copy of it plus the frames, where the game's own tick was read a bit at a time. + */ +export const NOW = "@now"; + /** * A copy into the graph on the way in: the variable's value times `scale` (1 when unset; wrapping) plus `add`, written * into the dword. The variables a graph reads that something else writes come in this way, once each time the walk @@ -184,7 +195,11 @@ export interface Entry { from: string; into: Dword; add: Num; scale?: number } * order, `TRIGGER_BYTES` apart. Every other trigger the layout puts where it fits (`layout.ts`). */ /** (A template with `at` is the game's own memory there — the line a print at the centre of the screen is written into — and its bytes are written by the program, not laid out.) */ -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`: 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[] } /** diff --git a/compiler/backend/layout.ts b/compiler/backend/layout.ts index 0331b1e..2cd8269 100644 --- a/compiler/backend/layout.ts +++ b/compiler/backend/layout.ts @@ -30,8 +30,8 @@ * 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, TRIGGER_BYTES, type Act, type Cond, type Dword, type Graph, type Value } from "./graph"; -import { GAME_TICK, TRIGGER_LISTS, WAKE_AT, WAKE_NEXT, WAKE_TICK } from "./emit"; +import { END, NOW, 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"; export const STRINGS = 0x191943c8; @@ -274,10 +274,11 @@ export function layout(input: LayoutInput): OwnMap { const routed = new Set(runs.flatMap((r) => [...r.routed])); const pad = -input.strings & 3; const base = STRINGS + input.strings + pad; - // The payload's triggers. Each run's `@wake` first: when the game's tick has reached the one the run sleeps until - // and its player is in the game (their list not empty), it sends the walk where the run goes on (its `@init`, the - // first time) with the current player theirs; otherwise on to the next run. A sleep writes `@wake`'s tick and where - // to go on; the run's end, a tick never reached. `@init`: `@wake` back to going on to the next run once woken. + // The payload's triggers. Each run's `@wake` first: when the map's count of frames (`NOW`) has reached the one the run + // sleeps until and its player is in the game (their list not empty), it sends the walk where the run goes on (its + // `@init`, the first time) with the current player theirs; otherwise on to the run's tasks, or the next run. A sleep + // writes `@wake`'s frame and where to go on; the run's end, a frame never reached. `@init`: `@wake` back to going on + // past the program once woken. const wake = (i: number) => (i ? `@wake#${i}` : "@wake"), init = (i: number) => (i ? `@init#${i}` : "@init"); const stop = "@stop", run = "@run", entry = (p: number) => `@entry${p}`, gate = (p: number) => `@gate${p}`; const after = (i: number) => (i + 1 < runs.length ? wake(i + 1) : stop); @@ -286,16 +287,16 @@ export function layout(input: LayoutInput): OwnMap { // (Where each run's graph ends: the next run.) const ends = new Map(runs.map((_, i) => [i ? `${END}#${i}` : END, after(i)])); const endOf = (l: string) => ends.get(l) ?? l; - type T = { label: string; conds: Cond[]; acts: Act[]; next?: string | number; prev?: number }; + type T = { label: string; conds: Cond[]; acts: Act[]; next?: string | number; prev?: number; off?: boolean }; const triggers: T[] = runs.flatMap(({ graph, owner }, i) => [ { label: wake(i), - conds: [{ dword: { game: GAME_TICK }, compare: "atLeast", value: 0 }, { dword: { game: list(owner) + 8 }, compare: "atMost", value: 0x7fffffff }], + conds: [{ dword: { var: NOW }, compare: "atLeast", value: 0 }, { dword: { game: list(owner) + 8 }, compare: "atMost", value: 0x7fffffff }], acts: [{ dword: { at: wake(i), field: "next" }, modifier: "set", value: { label: init(i) } }, { dword: { game: CURRENT_PLAYER }, modifier: "set", value: owner }], - next: after(i), + next: graph.tasks ?? after(i), } satisfies T, - { label: init(i), conds: [], acts: [{ dword: { at: wake(i), field: "next" }, modifier: "set", value: { label: after(i) } }], next: graph.triggers[0]?.label ?? after(i) } satisfies T, - ...graph.triggers.map((t, k) => ({ label: t.label, conds: t.conds, acts: t.acts, next: t.next !== undefined ? endOf(t.next) : graph.triggers[k + 1]?.label ?? after(i) })), + { label: init(i), conds: [], acts: [{ dword: { at: wake(i), field: "next" }, modifier: "set", value: { label: graph.tasks ?? after(i) } }], next: graph.triggers[0]?.label ?? after(i) } satisfies T, + ...graph.triggers.map((t, k) => ({ label: t.label, conds: t.conds, acts: t.acts, next: t.next !== undefined ? endOf(t.next) : graph.triggers[k + 1]?.label ?? after(i), ...(t.off ? { off: true } : {}) })), ]); triggers.push( // (Every pass ends here: the game's wait before the next pass set to none, as eudplib's eudTurbo does, so the @@ -321,8 +322,8 @@ export function layout(input: LayoutInput): OwnMap { acts: [{ dword: { at: gate(p), field: "next" }, modifier: "set", value: { label: run } }], next: listEnd(p), })), - // Every entry and gate back to ending at its list's end, 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) }]), next: wake(0) }, + // 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) }, ); 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. @@ -385,6 +386,7 @@ export function layout(input: LayoutInput): OwnMap { const f = d.field; if (f === "next") return t + 4; if (f === "current") return t + 2404; + if (f === "flags") return t + 2376; if ("cond" in f) return t + 8 + 20 * f.cond + (f.part === "player" ? 4 : f.part === "mask" ? 0 : 8); return t + 328 + 32 * f.act + { value: 20, dest: 16, modifier: 24, mask: 0, text: 4, wav: 8, time: 12, flags: 28 }[f.part]; }; @@ -521,7 +523,7 @@ export function layout(input: LayoutInput): OwnMap { dv.setUint8(pad + o + 28 - base, 20); if (a.mask !== undefined) dv.setUint16(pad + o + 30 - base, MASKED, true); }); - w32(n + 2376, 4); // preserved + w32(n + 2376, t.off ? TRIGGER_OFF : TRIGGER_ON); // preserved (and done, one passed by) }); for (const l of listData) l.values.forEach((v, i) => w32(l.at + 4 * l.stride * i, v)); for (const t of graphs.flatMap((g) => g.templates ?? [])) if (!input.templateAt?.has(t.id) && t.at === undefined) t.bytes.forEach((b, i) => dv.setUint8(pad + templates.get(t.id)! + i - base, b)); diff --git a/compiler/backend/own.ts b/compiler/backend/own.ts index db37d39..17fd69c 100644 --- a/compiler/backend/own.ts +++ b/compiler/backend/own.ts @@ -12,7 +12,7 @@ import type { CompileResult } from "../compiler"; import type { ArrayDecl, FuncDecl, Program, VarDecl } from "../ir"; import { ANY_PLAYERS, emitBody, groupSlots, type MapPlayers } from "./emit"; -import type { Act, Cond, Dword, Graph, Num, Trigger, Value } from "./graph"; +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"; @@ -80,8 +80,8 @@ export function emitOwn(r: Pick, p } }; collect(program); - // (The heap's own variables, which no program declares: one cell each for the map.) - for (const d of [...HEAP_GLOBALS, ...UNIT_GLOBALS]) decls.set(d.id, d); + // (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); 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. @@ -93,7 +93,7 @@ export function emitOwn(r: Pick, p // (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); if (typeof graph === "string") return graph; - const name = (id: string) => (!n || isHeapGlobal(id) || 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 || 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); @@ -142,5 +142,9 @@ function renamed(g: Graph, suffix: string, name: (id: string) => string): Graph ...(g.cells ? { cells: g.cells.map((c) => ({ ...c, id: name(c.id) })) } : {}), ...(g.templates ? { templates: g.templates.map((t) => ({ ...t, id: t.id + suffix })) } : {}), ...(g.rows ? { rows: g.rows.map((r) => r.map(label)) } : {}), + ...(g.tasks ? { tasks: label(g.tasks) } : {}), }; } + +/** The map's count of frames (`NOW`): a variable of every run's, one more each pass. */ +const NOW_DECL: VarDecl = { id: NOW, name: "(frames)", kind: "number", shared: true, routed: true, global: true, at: { file: "", line: 0, column: 0 } }; diff --git a/compiler/backend/texts.ts b/compiler/backend/texts.ts index d960489..9797531 100644 --- a/compiler/backend/texts.ts +++ b/compiler/backend/texts.ts @@ -900,7 +900,8 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog * of them the first time a text is stored there — its EPD then in the first array, 0 before — and gives it back * when the cell's text is let go of (popped, cut off, declared again). */ - interface TextArray { addr: string; block: string; chars: string; cells: Made[]; grows?: boolean } + /** `length`: of one that does not grow but is too long for a block a cell (`MOST_TEXT_CELLS`), which takes its blocks as one that grows does. */ + interface TextArray { addr: string; block: string; chars: string; cells: Made[]; grows?: boolean; length?: number } const textArrays = new Map(); const findTextArrays = (x: unknown): void => { if (Array.isArray(x)) { x.forEach(findTextArrays); return; } @@ -912,6 +913,9 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog textArrays.set(o.addr!, { addr: o.addr!, block: o.block!, chars: o.chars!, cells: Array.from({ length: a.length }, (_, i) => ({ e: `${o.addr}.${i}@e`, n: `${o.addr}.${i}@n`, c: `${o.addr}.${i}@c`, block: `${o.addr}.${i}@block` })) }); } else if (a && a.dynamic && !a.slice && !a.through && a.task === undefined) textArrays.set(o.addr!, { addr: o.addr!, block: o.block!, chars: o.chars!, cells: [], grows: true }); + // (Longer: its cells' blocks from the pool, the first time a text is stored in each — the messages of the errors + // a program's tasks end on, a cell a slot, which few of them ever hold.) + else if (a && !a.dynamic && !a.slice && !a.through) textArrays.set(o.addr!, { addr: o.addr!, block: o.block!, chars: o.chars!, cells: [], grows: true, length: a.length }); } for (const v of Object.values(o)) if (v && typeof v === "object") findTextArrays(v); }; @@ -1239,7 +1243,7 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog const reads = readsArray(s.value, a.addr); const made = reads ? [...blockOf(SCRATCH), ...w.pre, ...begin(V(SCRATCH.e)), ...w.body, ...finish(SCRATCH)] : w.pre; const fill = reads ? copyBlock(SCRATCH, into) : [...begin(V(into.e)), ...w.body, ...finish(into)]; - return [dec(at, num(s.index)), ...made, when(cmp("<", V(at), { kind: "length", array: a.addr, at: here } as NumExpr, true), [ + return [dec(at, num(s.index)), ...made, when(cmp("<", V(at), a.length !== undefined ? k(a.length) : ({ kind: "length", array: a.addr, at: here } as NumExpr), true), [ dec(into.e, element(a.addr, V(at))), dec(into.n, k(0)), dec(into.c, k(0)), when(cmp("==", V(into.e), k(0)), [...takeBlock(into.e), store(a.addr, V(at), V(into.e))]), when(cmp("!=", V(into.e), k(0)), fill), diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 95c2c19..45f5ba7 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -3567,9 +3567,9 @@ found what they left out; where a step moved, its old number is given. picks, `select()` and the game command it sends (the events' rows are step 11's, `underMouse`'s step 12's); the queries scheduled (ideas 6, 7) still to come. 11. **Tasks, `async` and errors** (was 6; errors new). Part 1 ✓: `try`, `catch`, `finally`, `throw`, `uncaught`, a - program's `errors`, the error classes — "Errors on our own backend". Part 2: `start`, `cancel`, `running`, - `Promise.all`/`race`/`any`, `every`, `after`, a task's error awaited, on the walk as the scheduler (idea 2). Part 3: - the `on.*` handlers and the pass that finds what they are handed. + 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. 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 | 12 of 50 | +| 11 Tasks, `async` and errors | 35 of 50 | | 12 Input | 0 of 26 | | 13 The map's own triggers | 0 of 13 | @@ -4014,6 +4014,15 @@ the ledger is the list it can be written again from at 4.0. made only where it is read — a `catch` that reads `e.message`, an uncaught error said — about 1.3 µs a throw, 20× eudplib's, for the many catches that read nothing. In code only an error goes through, a signed comparison still reads its variable's home (the variable plus 2³¹), a place every write keeps. The map is bigger (1.52 MB to 1.05). +- **Tasks on a map of our own (step 11, part 2), what is left:** a start that can run while its tasks still run lays + the task's body out once a slot (`max`, 16 unless said): the fastest at its turn, and small since a task's `catch` + is laid out once (a 16-slot start's body is 9 triggers in the probe), but a large body grows the map sixteen times; + one body with the slot's variables swapped in and out at each turn, as the Python side does, would be the way for a + large one. Such a task's arrays, and a start inside it, are refused for now (they would be a slot's own). A called + function that waits is refused in a program with tasks (it is laid out once, outside every coroutine). A task asleep + is a condition that fails each frame (13 ns), one with nothing in it a trigger passed by as done: a timer wheel + (idea 2) would make a sleeper cost nothing until its frame. "did not start" is said in red by the game, as the Python + side's build says it, where the simulator notes a fault (both builds). - 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). @@ -4964,6 +4973,62 @@ Played in Remastered (2026-10-22, recording 20.07.15): all 10 lines word for wor red and nothing after it; the groups 2, 2, 4, 18 and 2 game frames apart, as in the lifted game — the frame a stack runs out 1,024 calls deep and the frame of 100 throws among them; no hitch. +### Tasks on our own backend (2026-10-22) + +Step 11, part 2 (`compiler/backend/emit.ts`: `planTasks`, the coroutines, `start`, `cancel`, `parallel`, `tasks()`, the +tasks' turn). The ledger's rows of tasks and of `Promise.all`, `race` and `any` are taken, 23 of them — 35 of step 11's +50; the 15 left are the `on.*` events, part 3. + +- **A coroutine** is the program, a task's slot or a branch of a `Promise.all` or `race`: a trigger that wakes it — + "the map's count of frames has reached W: go to R" — whose W and R its sleep writes. The program's is its run's + `@wake`; a slot's and a branch's are triggers of their own, passed by as done while nothing runs in them (the walk + skips a trigger whose done flag is set before its conditions, at about 40% of an empty trigger's cost). +- **The map's count of frames** (`@now`) is one more each pass (`@run`). A sleep copies it, plus its frames, into its + wake: one copy, where the program's sleep read the game's tick a bit at a time (32 triggers, about 0.5 µs; now about + 70 ns, for every program). +- **The tasks' turn** follows the program's turn, and the program's wake when it does not hold: each start's slots in + the order of the starts' numbers, as the simulator orders them; when a task can start one, round again while one + was started, for it to have its first turn in this frame too. A task outlives its program. +- **A start** that can run only once — in no loop, nowhere twice, in no task of more than one slot — has one slot, + always free when it runs: its arguments go straight into the task's parameters. One that can run while its tasks do + has its `max` of slots, each with its own copy of the task's body and variables (the variables the simulator swaps): + it finds the first free slot, says once in red that none was when all run, and the task's number is + `slot | task << 8 | generation << 16`, as the Python side's. `running` is a number one slot's number is; `cancel` + switches that slot off where it waits. +- **`Promise.all` and `race`**: each branch a coroutine with a trigger of its own, all due as the statement is + reached and run in order; then the coroutine around sleeps a frame at a time and runs those still running, until + all have ended — or one has, a race, which goes on from that branch's end with the others switched off. +- **A task's error**: the front end's `catch` around every task's body (the error noted in the slot for an `await` of + it, or said) reads the task's number and nothing of the task's, and is laid out once, a function each task calls + with its number; an uncaught error is said through one function too. A probe with two starts of sixteen slots was + 19.8 MB before, 1.37 MB after (eudplib's 2.05). An array of texts longer than 64 cells — the errors' messages, a + cell a slot — takes a cell's block from the pool the first time it holds a text, as an array that grows does. +- **Found on the way:** a loop's test was split into what nothing in the loop writes, tested once on the way in — but + while the loop sleeps a task may write it (`await t` is `while (t.running) sleep(1)`): a loop that sleeps tests its + whole condition each turn. A slot's number is worked out from its generation into places, which only a write sets: + the slot's variables are declared as the program starts. + +| Lifted game, Remastered's weights (`probes/own-tasks.ts`), µs a frame | Ours | eudplib | +| --- | ---: | ---: | +| W1: sixteen tasks asleep, none waking | **0.7** | 40.2 | +| W2: sixteen tasks awake every frame, a little each | **3.9** | 90.4 | +| sixteen tasks started in one frame | **10.0** | 141.5 | +| W3: the program asleep a frame at a time (the sixteen asleep beside it) | **0.9** | 40.4 | +| W4: four branches of a `Promise.all`, each asleep a frame at a time | **1.0** | 41.0 | +| A: a task awaited, `running`, a task cancelled | **1.0** | 7.8 | +| F: a task's error caught where it is awaited | **20.7** | 55.2 | +| the first frame | **23.1** | 1,084.1 | +| the map | **1.37 MB** | 2.05 MB | + +A task's turn costs about 0.2 µs (W2, less the frame around it) against eudplib's 3.1: its variables are its slot's +own, no swapping; a slot asleep 13 ns a frame, a free one less. What is left is in the ledger's *Open* list. + +Checked: `tests/emitter.test.ts` ("a map of our own, tasks": every row of `ledger/tasks/`, `music.loop`, `ui.lines`, a +program for every player with tasks, and cancelling, slots and their numbers, a task started from a task earlier and +later in the turn, branches inside a task and a race inside a branch, errors in tasks — the payload walked from its +bytes frame after frame against the simulator, every text at its frame); the probe both ways in the lifted game, every +line the same at the same frame and "ok". + ### 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/programs/owner-all-players-tasks.ts b/ledger/programs/owner-all-players-tasks.ts index 48c0bba..f70801f 100644 --- a/ledger/programs/owner-all-players-tasks.ts +++ b/ledger/programs/owner-all-players-tasks.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // Each player's own tasks in a program for every player: `start(…)` with `{ owner: AllPlayers }`. async function hello(n: number) { await sleep(frames(n)); diff --git a/ledger/sounds/music-loop.ts b/ledger/sounds/music-loop.ts index 2317992..3c252ea 100644 --- a/ledger/sounds/music-loop.ts +++ b/ledger/sounds/music-loop.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A song played again and again beside the program: `start(music.loop(song))`. import { music } from "trigscript/music"; const lead = music.synth({ release: 0.05 }); diff --git a/ledger/tasks/cutscene-play.ts b/ledger/tasks/cutscene-play.ts index 571353d..0df4135 100644 --- a/ledger/tasks/cutscene-play.ts +++ b/ledger/tasks/cutscene-play.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: a task started +// ledger: step 12, refused: the players' input (key) // trigscript/cutscene: a scene started as a task, which the player may skip with Escape: `await play(P1, start(scene()))`. import { play } from "trigscript/cutscene"; async function scene() { diff --git a/ledger/tasks/generator-as-a-task.ts b/ledger/tasks/generator-as-a-task.ts index 0d6d250..bbdb2a7 100644 --- a/ledger/tasks/generator-as-a-task.ts +++ b/ledger/tasks/generator-as-a-task.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A generator walked inside a task started beside the program. function* steps() { yield 1; diff --git a/ledger/tasks/promise-all-values.ts b/ledger/tasks/promise-all-values.ts index 0d3993c..bb5d1c1 100644 --- a/ledger/tasks/promise-all-values.ts +++ b/ledger/tasks/promise-all-values.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: Promise.all +// ledger: step 11, takes // Each side's value from a `Promise.all`: `const [a, b] = await Promise.all([…])`. async function slow(n: number): Promise { await sleep(frames(n)); diff --git a/ledger/tasks/promise-all.ts b/ledger/tasks/promise-all.ts index 1975fb4..f4fd4f2 100644 --- a/ledger/tasks/promise-all.ts +++ b/ledger/tasks/promise-all.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: Promise.all +// ledger: step 11, takes // Two waits side by side, the program going on when both have ended: `await Promise.all([…])`. program(async () => { await Promise.all([ diff --git a/ledger/tasks/promise-any.ts b/ledger/tasks/promise-any.ts index 1c629ce..7bbfcd4 100644 --- a/ledger/tasks/promise-any.ts +++ b/ledger/tasks/promise-any.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: Promise.race +// ledger: step 11, takes // `Promise.any`, the same as a race (nothing in a program fails): `await Promise.any([…])`. program(async () => { let n = 0; diff --git a/ledger/tasks/promise-race.ts b/ledger/tasks/promise-race.ts index a698755..3e450e9 100644 --- a/ledger/tasks/promise-race.ts +++ b/ledger/tasks/promise-race.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: Promise.race +// ledger: step 11, takes // Two waits side by side, the program going on when the first ends: `await Promise.race([…])`. async function wave(n: number): Promise { await sleep(frames(n)); diff --git a/ledger/tasks/start-arrow.ts b/ledger/tasks/start-arrow.ts index 186f56f..4d8e3b4 100644 --- a/ledger/tasks/start-arrow.ts +++ b/ledger/tasks/start-arrow.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A task written where it is started: `start(async () => { … })`. program(async () => { start(async () => { diff --git a/ledger/tasks/start-call.ts b/ledger/tasks/start-call.ts index f8aeaa5..9cfd961 100644 --- a/ledger/tasks/start-call.ts +++ b/ledger/tasks/start-call.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A task started beside the program, the program going on at once: `start(tick(3))`. async function tick(n: number) { let i = 0; diff --git a/ledger/tasks/start-max.ts b/ledger/tasks/start-max.ts index f5b3053..2387e1e 100644 --- a/ledger/tasks/start-max.ts +++ b/ledger/tasks/start-max.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A start that lets so many of its tasks run at once: `start(wave(n), { max: 4 })`. async function wave(n: number) { await sleep(frames(n)); diff --git a/ledger/tasks/start-void.ts b/ledger/tasks/start-void.ts index 18cb006..e1a9373 100644 --- a/ledger/tasks/start-void.ts +++ b/ledger/tasks/start-void.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A task started by a call nothing waits for: `void later()`. async function later() { await sleep(frames(2)); diff --git a/ledger/tasks/task-awaited.ts b/ledger/tasks/task-awaited.ts index 1561a9e..5562757 100644 --- a/ledger/tasks/task-awaited.ts +++ b/ledger/tasks/task-awaited.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A task waited for: `await t`, the program going on in the frame the task ends. async function short() { await sleep(frames(3)); diff --git a/ledger/tasks/task-cancel.ts b/ledger/tasks/task-cancel.ts index 4299404..c784379 100644 --- a/ledger/tasks/task-cancel.ts +++ b/ledger/tasks/task-cancel.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A task stopped where it is: `t.cancel()`. async function forever() { while (true) await sleep(frames(1)); diff --git a/ledger/tasks/task-error-awaited.ts b/ledger/tasks/task-error-awaited.ts index fea6955..0bbc5ec 100644 --- a/ledger/tasks/task-error-awaited.ts +++ b/ledger/tasks/task-error-awaited.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // An error a task ends on, thrown again where the task is awaited and caught there: `try { await t; } catch (e) { … }`. async function wave(n: number) { await sleep(frames(2)); diff --git a/ledger/tasks/task-error-not-awaited.ts b/ledger/tasks/task-error-not-awaited.ts index 9f7bfc0..33f7baf 100644 --- a/ledger/tasks/task-error-not-awaited.ts +++ b/ledger/tasks/task-error-not-awaited.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // An error a task ends on that nothing awaits: the task ends and the game says so, and the program goes on. async function worker() { await sleep(frames(1)); diff --git a/ledger/tasks/task-outlives-program.ts b/ledger/tasks/task-outlives-program.ts index f56f90b..f7bc6f9 100644 --- a/ledger/tasks/task-outlives-program.ts +++ b/ledger/tasks/task-outlives-program.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A task that runs on after the program has ended. program(async () => { start(async () => { diff --git a/ledger/tasks/task-running.ts b/ledger/tasks/task-running.ts index 18731e4..e2b96fb 100644 --- a/ledger/tasks/task-running.ts +++ b/ledger/tasks/task-running.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // Whether a task still runs: `t.running`, true until it ends or is cancelled. async function short() { await sleep(frames(3)); diff --git a/ledger/tasks/task-starts-a-task.ts b/ledger/tasks/task-starts-a-task.ts index de97475..9d7307d 100644 --- a/ledger/tasks/task-starts-a-task.ts +++ b/ledger/tasks/task-starts-a-task.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A task that starts a task of its own. async function inner(n: number) { await sleep(frames(n)); diff --git a/ledger/tasks/timers-after.ts b/ledger/tasks/timers-after.ts index 71ebfb2..5b76bfe 100644 --- a/ledger/tasks/timers-after.ts +++ b/ledger/tasks/timers-after.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // trigscript/timers: `after(delay, fn)`, a function run once, so many frames from now, in a task beside the program. import { after, SECOND } from "trigscript/timers"; program(async () => { diff --git a/ledger/tasks/timers-every-cancelled.ts b/ledger/tasks/timers-every-cancelled.ts index f94cabc..e55d5b6 100644 --- a/ledger/tasks/timers-every-cancelled.ts +++ b/ledger/tasks/timers-every-cancelled.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // trigscript/timers: an `every` stopped by the task it gives back: `const t = every(…); t.cancel()`. import { every, SECOND } from "trigscript/timers"; program(async () => { diff --git a/ledger/tasks/timers-every-times.ts b/ledger/tasks/timers-every-times.ts index ad6aaf7..4fe28fb 100644 --- a/ledger/tasks/timers-every-times.ts +++ b/ledger/tasks/timers-every-times.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // trigscript/timers: `every(period, fn, times)`, a function run so many times, so many frames apart. import { every } from "trigscript/timers"; program(async () => { diff --git a/ledger/tasks/timers-every.ts b/ledger/tasks/timers-every.ts index 8e8d063..c31466a 100644 --- a/ledger/tasks/timers-every.ts +++ b/ledger/tasks/timers-every.ts @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // trigscript/timers: `every(period, fn)`, a function run every so many frames in a task beside the program, handed how many times it has run. import { every, SECOND } from "trigscript/timers"; program(async () => { diff --git a/ledger/texts/ui-dialog.tsx b/ledger/texts/ui-dialog.tsx index fd5919a..2bd28ac 100644 --- a/ledger/texts/ui-dialog.tsx +++ b/ledger/texts/ui-dialog.tsx @@ -1,4 +1,4 @@ -// ledger: step 12, refused: a task started +// ledger: step 12, refused: the players' input (key) // Someone speaking to a player, the words typed in, closed with Space, from `trigscript/ui`: // `await say(P1, talk, "Raynor", "…")` and ``. import { DialogBox, say } from "trigscript/ui"; diff --git a/ledger/texts/ui-lines.tsx b/ledger/texts/ui-lines.tsx index 7078b7c..c149644 100644 --- a/ledger/texts/ui-lines.tsx +++ b/ledger/texts/ui-lines.tsx @@ -1,4 +1,4 @@ -// ledger: step 11, refused: a task started +// ledger: step 11, takes // A text kept in a player's text lines and shown again as the program's variables change: `ui.lines(P1, () => …)`. program(async () => { let score = 0; diff --git a/ledger/texts/ui-menu.tsx b/ledger/texts/ui-menu.tsx index c31c2fc..c3bb5c3 100644 --- a/ledger/texts/ui-menu.tsx +++ b/ledger/texts/ui-menu.tsx @@ -1,4 +1,4 @@ -// ledger: step 12, refused: a task started +// ledger: step 12, refused: the players' input (key) // A menu a player chooses from with W, S and Space, shown in the text lines, from `trigscript/ui`: // `await choose(P1, ITEMS.length, shop)` and ``. import { MenuList, choose } from "trigscript/ui"; diff --git a/probes/own-tasks.ts b/probes/own-tasks.ts new file mode 100644 index 0000000..93ded22 --- /dev/null +++ b/probes/own-tasks.ts @@ -0,0 +1,148 @@ +// TrigScript 4, the backend: tasks and Promise.all / race in a map of our own (scripts/build-own.mts) — step 11 of the +// way off eudplib, part 2. The program, each task's slot and each branch of a Promise.all or race is a coroutine: a +// trigger that wakes it once the map's count of frames reaches the frame its sleep wrote. The tasks have their turn +// after the program's, a start's slots in order; a slot with nothing in it is passed by as done. +// 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. One line says in red that a task did not start, as a start whose tasks all run says it. +// npx tsx scripts/build-own.mts probes/own-tasks.ts /tmp/own-tasks.scx +// npx tsx scripts/build-fixture.mts probes/own-tasks.ts /tmp/own-tasks-eud.scx +import { program, print, sleep, frames } from "trigscript"; +import { every, after } from "trigscript/timers"; + +program(async () => { + let waves = 0, turns = 0, children = 0, work = 0; + /** Ends after n frames, giving back twice n. */ + async function slow(n: number): Promise { + await sleep(frames(n)); + return n * 2; + } + /** Says when it ends, n frames on. */ + async function wave(n: number) { + await sleep(frames(n)); + waves += n; + } + /** Counts its turns until cancelled. */ + async function forever() { + while (true) { + turns += 1; + await sleep(frames(1)); + } + } + /** Starts a task of its own a frame after it starts. */ + async function parent() { + await sleep(frames(1)); + start(child(2)); + } + async function child(n: number) { + await sleep(frames(n)); + children += 1; + } + /** Ends on an error after n frames. */ + async function fails(n: number) { + await sleep(frames(n)); + if (n > 0) throw new RangeError(`failed after ${n}`); + } + /** Weighed: asleep for a long while. */ + async function sleeper() { + await sleep(frames(400)); + } + /** Weighed: awake every frame, a little each turn. */ + async function busy(n: number) { + let i = 0; + while (i < n) { + work += 1; + i += 1; + await sleep(frames(1)); + } + } + + print("Own tasks: the payload runs."); + let k = 0; + while (k < 2) k += 1; + + // A: a task awaited, `running` true until it ends, a task cancelled where it waits. + const t = start(slow(3)); + let seen = 0; + while (t.running) { + seen += 1; + await sleep(frames(1)); + } + const f = start(forever()); + await sleep(frames(5)); + const was = f.running; + f.cancel(); + const counted = turns; + await sleep(frames(3)); + if (seen == 4 && was && !f.running && turns == counted && counted == 5) print("A: awaited, running, cancelled ok"); + else print(`A: awaited, running, cancelled WRONG ${seen} ${was} ${counted} ${turns}`); + + // B: a start's slots — three at once of a start that lets two run: the third does not start (said in red). + let i = 1; + let started = 0; + while (i <= 3) { + const w = start(wave(i + k), { max: 2 }); + if (w.running) started += 1; + i += 1; + } + await sleep(frames(6)); + const again = start(wave(1), { max: 2 }); + await again; + if (started == 2 && waves == 3 + 4 + 1) print("B: slots ok"); else print(`B: slots WRONG ${started} ${waves}`); + + // C: Promise.all and Promise.race, their values. + const [a, b] = await Promise.all([slow(2), slow(k + 2)]); + const r = await Promise.race([slow(5), slow(k + 1)]); + if (a == 4 && b == 8 && r == 6) print("C: all and race ok"); else print(`C: all and race WRONG ${a} ${b} ${r}`); + + // D: a task that starts one; E: every and after. + start(parent()); + let ticks = 0, fired = 0; + const e = every(2, (n) => { + ticks = n; + }); + after(5, () => { + fired += 1; + }); + await sleep(frames(9)); + e.cancel(); + await sleep(frames(2)); + if (children == 1 && ticks == 4 && fired == 1) print("D-E: a task's task, every and after ok"); else print(`D-E: WRONG ${children} ${ticks} ${fired}`); + + // F: an error a task ends on, caught where it is awaited. + let caught = ""; + const g = start(fails(k)); + try { + await g; + } catch (err) { + if (err instanceof RangeError) caught = err.message; + } + if (caught == "failed after 2") print("F: a task's error caught ok"); else print(`F: a task's error WRONG ${caught}`); + await sleep(frames(4)); + + print("Weighed:"); + // W1: sixteen tasks asleep, a frame where none wakes. + let s = 0; + while (s < 16) { + start(sleeper(), { max: 16 }); + s += 1; + } + await sleep(frames(6)); + // W2: sixteen tasks awake every frame, each a little each turn. + s = 0; + while (s < 16) { + start(busy(6), { max: 16 }); + s += 1; + } + await sleep(frames(8)); + // W3: the program alone, asleep a frame at a time. + let n = 0; + while (n < 6) { + n += 1; + await sleep(frames(1)); + } + // W4: four branches of a Promise.all, each asleep a frame at a time. + await Promise.all([slow(4), slow(4), slow(4), slow(4)]); + await sleep(frames(4)); + if (work == 16 * 6 && n == 6) print("W: weighed ok"); else print(`W: weighed WRONG ${work} ${n}`); +}); diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index 994b54c..5570dcc 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -147,7 +147,8 @@ const CALLS = { whole: false }; * 0 byte as nothing) less 0x0D — which StarCraft draws as nothing, and which a map of our own fills the room around a * number with — in hex. */ -const PRINTED: { want: string[]; got: string[] } = { want: [], got: [] }; +/** `frames`: the frame each of `got` was shown in, by a walk of several (`runOwn`'s `frames`). */ +const PRINTED: { want: string[]; got: string[]; frames: number[] } = { want: [], got: [], frames: [] }; const shown = (bytes: number[]) => bytes.filter((b) => b !== 0x0d).map((b) => b.toString(16).padStart(2, "0")).join(""); /** * The statements the evaluator may still run for the program in hand (a hundred thousand), its calls' bodies included: a program whose @@ -299,7 +300,7 @@ function key(d: Dword): string { if ("template" in d) return `template:${d.template}:${d.word}`; if ("cell" in d) return `epd:${TABLE_EPD(d.cell) + 18 * d.index}`; const f = d.field; - return f === "next" || f === "current" ? `${d.at}|${f}` : "cond" in f ? `${d.at}|cond:${f.cond}${f.part === "player" ? ":player" : f.part === "mask" ? ":mask" : ""}` : `${d.at}|act:${f.act}:${f.part}`; + return f === "next" || f === "current" || f === "flags" ? `${d.at}|${f}` : "cond" in f ? `${d.at}|cond:${f.cond}${f.part === "player" ? ":player" : f.part === "mask" ? ":mask" : ""}` : `${d.at}|act:${f.act}:${f.part}`; } function exec(g: Graph, env: Env): Env { @@ -1066,7 +1067,11 @@ describe("the emitter, a map of our own's texts", () => { const laidOut = new WeakMap; own: ReturnType; texts: string[]; templates: string[] }>(); /** `memory`: dwords of the game's as the map starts, by address (a player's name, their colour); `arrays`: the lists the graph reads. */ /** `runs`: instead of `g` alone, several runs laid out one after another, as a map of our own runs them each frame. */ -function runOwn(g: Graph, memory?: Map, arrays = new Map(), runs?: { graph: Graph; owner: number }[]): Env { +/** + * `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 { // (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))) { @@ -1090,7 +1095,10 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map dv.getUint8(a - base); // (A player field of 13 is the current player: the dword whose EPD the game's current player is.) const at = (epd: number) => u(DEATHS + 4 * (epd === 13 ? rd(0x6509b0) : epd)); - let pc = own.address; + PRINTED.frames = []; + for (let frame = 1; frame <= frames; frame++) { + 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 (!inside(pc)) throw new Error(`the walk left the payload: 0x${pc.toString(16)}`); @@ -1125,6 +1133,8 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map>> 0 : (rd(addr) & ~bit) >>> 0); continue; } + // (Play WAV: noted as a sound played, which sound aside.) + if (kind === 8) { PRINTED.got.push("wav"); continue; } if (kind !== 45) throw new Error(`action kind ${kind}`); const dest = at(rd(o + 16)), value = rd(o + 20), modifier = byte(o + 27), old = rd(dest); if (dv.getUint16(o + 30 - base, true) === 0x4353) { @@ -1138,6 +1148,8 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map [id, rd(a)])); for (const [id, n] of WRITABLE) { const r = own.regions.get(id); @@ -1942,3 +1954,165 @@ describe("a map of our own, errors", () => { ].join("\n"), "function forever(n: number): number {\n if (n > 1000000) return 0;\n return forever(n + 1) + 1;\n}"), ["true Maximum call stack size exceeded"]); }); }); + +describe("a map of our own, tasks", () => { + /** + * What a script prints over so many frames, each text with its frame — and each sound played, as "wav": as the + * simulator runs it, and from its payload's bytes (every run of it, one a player of a program for each). + */ + const both = (script: string, frames: number, file = "main.ts"): { want: string[]; got: string[] } => { + const r = compileScript(ts, { [file]: script }, defaultScriptNames(), { lib: defaultLib(), emitter: false }); + expect(r.diagnostics).toEqual([]); + const hex = (text: string) => shown([...new TextEncoder().encode(text)]); + const events = simulatePrograms(r.ir, frames, { strings: r.strings }).events.filter((e) => (e.text !== undefined && e.action.type === 9) || e.action.type === 8); + const first = events[0]?.frame ?? 0; + const want = events.map((e) => `${e.frame - first} ${e.action.type === 8 ? "wav" : hex(e.text!)}`); + // (Player 1 alone in the game, as the simulator runs a program for each player: for the first.) + const o = emitOwn(r, { slots: [0], forces: [0, 0, 0, 0, 0, 0, 0, 0] }); + if (typeof o === "string") throw new Error(o); + PRINTED.got = []; + runOwn(o.runs[0].graph, undefined, o.values, o.runs.length > 1 ? o.runs.map((x) => ({ graph: x.graph, owner: x.player })) : undefined, frames); + const start = PRINTED.frames[0] ?? 0; + return { want, got: PRINTED.got.map((t, i) => `${PRINTED.frames[i] - start} ${t}`) }; + }; + for (const path of ["sounds/music-loop.ts", "texts/ui-lines.tsx", "programs/owner-all-players-tasks.ts"]) it(`runs ledger/${path} as the simulator does, frame for frame`, () => { + const r = both(readFileSync(`ledger/${path}`, "utf8"), 200, path.endsWith(".tsx") ? "main.tsx" : "main.ts"); + expect(r.want.length).toBeGreaterThan(0); + expect(r.got).toEqual(r.want); + }); + const ROWS = ["start-call", "start-arrow", "start-void", "start-max", "task-awaited", "task-running", "task-cancel", "task-outlives-program", "task-starts-a-task", "generator-as-a-task", "timers-after", "timers-every", "timers-every-times", "timers-every-cancelled", "promise-all", "promise-all-values", "promise-race", "promise-any", "task-error-awaited", "task-error-not-awaited"]; + for (const name of ROWS) it(`runs ledger/tasks/${name} as the simulator does, frame for frame`, () => { + const r = both(readFileSync(`ledger/tasks/${name}.ts`, "utf8"), 200); + // (A task cancelled says nothing: the tests below do.) + if (name !== "task-cancel") expect(r.want.length).toBeGreaterThan(0); + expect(r.got).toEqual(r.want); + }); + const same = (script: string, frames = 120) => { + const r = both(script, frames); + expect(r.want.length).toBeGreaterThan(0); + expect(r.got).toEqual(r.want); + }; + + it("cancels a task where it waits, and says it no longer runs", () => { + same([ + "async function ticker(name: number) {", + " let i = 0;", + " while (true) {", + " print(`${name}: ${i}`);", + " i += 1;", + " await sleep(frames(2));", + " }", + "}", + "program(async () => {", + + " const a = start(ticker(1));", + " const b = start(ticker(2));", + " await sleep(frames(5));", + " a.cancel();", + " print(`${a.running} ${b.running}`);", + " await sleep(frames(4));", + " b.cancel();", + " a.cancel();", + " print(`${a.running} ${b.running}`);", + "});", + ].join("\n")); + }); + + it("gives a start's tasks its slots, says once when all run, and reuses a slot with a new number", () => { + // (The game says in red that a task did not start, as the Python side's build does; the simulator notes a fault.) + const r = both([ + "async function wave(n: number) {", + " await sleep(frames(n));", + " print(`wave ${n}`);", + "}", + "program(async () => {", + " let n = 1;", + " while (n <= 4) {", + " const t = start(wave(n * 2), { max: 2 });", + " print(`${n}: ${t.running}`);", + " n += 1;", + " }", + " await sleep(frames(3));", + " n = 1;", + " while (n <= 2) {", + " const t = start(wave(n), { max: 2 });", + " print(`again ${n}: ${t.running}`);", + " n += 1;", + " }", + " await sleep(frames(10));", + "});", + ].join("\n"), 120); + const said = shown([...new TextEncoder().encode("\x06TrigScript: wave did not start, line 8 - all 2 of this start's tasks are running.")]); + expect(r.got.filter((t) => t.endsWith(said))).toEqual([`0 ${said}`]); + expect(r.got.filter((t) => !t.endsWith(said))).toEqual(r.want); + }); + + it("starts a task from a task later in the turn, and earlier, in the frame it is started", () => { + same([ + "async function late(n: number) {", + " print(`late ${n} starts`);", + " await sleep(frames(1));", + " print(`late ${n} ends`);", + "}", + "async function early() {", + " await sleep(frames(2));", + " print(\"early starts late\");", + " start(late(1));", + " await sleep(frames(2));", + "}", + "program(async () => {", + " start(late(0));", + " start(early());", + " print(\"program\");", + "});", + ].join("\n")); + }); + + it("waits for branches inside a task, and a race inside a branch", () => { + same([ + "async function pause(n: number): Promise {", + " await sleep(frames(n));", + " return n;", + "}", + "async function pair(): Promise {", + " const [a, b] = await Promise.all([pause(2), pause(5)]);", + " return a + b;", + "}", + "async function task() {", + " const [x, y] = await Promise.all([pair(), Promise.race([pause(3), pause(7)])]);", + " print(`task ${x} ${y}`);", + "}", + "program(async () => {", + " start(task());", + " let i = 0;", + " while (i < 8) { print(`frame ${i}`); i += 1; await sleep(frames(1)); }", + "});", + ].join("\n")); + }); + + it("throws in a task to its own catch, and to the program waiting on it", () => { + same([ + "async function risky(n: number) {", + " await sleep(frames(n));", + " try {", + " if (n > 1) throw new RangeError(`n is ${n}`);", + " } catch (e) {", + " if (e instanceof RangeError) print(`caught in the task: ${e.message}`);", + " }", + " if (n > 2) throw new Error(`gave up at ${n}`);", + " print(`task ${n} ends`);", + "}", + "program(async () => {", + " const a = start(risky(1));", + " const b = start(risky(3));", + " await a;", + " print(\"a done\");", + " try {", + " await b;", + " } catch (e) {", + " if (e instanceof Error) print(`the program caught: ${e.message}`);", + " }", + "});", + ].join("\n")); + }); +}); From 8b99fecfefa59ee0153a19752dc70286e2267597 Mon Sep 17 00:00:00 2001 From: Jeany Date: Thu, 22 Oct 2026 20:59:07 -0600 Subject: [PATCH 3/3] Plan: step 11's tasks probe played in Remastered Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- docs/v4-plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 45f5ba7..666daac 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -5029,6 +5029,10 @@ later in the turn, branches inside a task and a race inside a branch, errors in bytes frame after frame against the simulator, every text at its frame); the probe both ways in the lifted game, every line the same at the same frame and "ok". +Played in Remastered (2026-10-22, recording 20.57.34): all 9 lines word for word and in order, "did not start" in red; +each group at the lifted game's frame (2, 14, 22, 29, 40, 43, 47, 75), the frames where 32 tasks start among them; no +hitch. + ### 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