From b486662a0f4fd343a927dc85c5a25821b379a5ea Mon Sep 17 00:00:00 2001 From: Jeany Date: Fri, 23 Oct 2026 19:11:23 -0600 Subject: [PATCH 1/5] The gate before the Python goes: random whole programs, every probe both ways TrigScript 4, step 16. Whole programs made at random (tests/randomScript.ts), rendered as TrigScript and as JavaScript from one tree, run as JavaScript, in the simulator and as the built map walked from its bytes (tests/random-scripts.test.ts, 60 seeds in CI), and with scripts/random-scripts.mts in the lifted game on both builds, the game's thirteen-line ring modelled; --reduce cuts a failing seed to the smallest script. scripts/gate-sweep.mts builds every probe both ways and plays both. Found and fixed in the front end: a game condition in ?:, ! or && taken as true when the script was built (the evaluator and the hoister); 7 / 2 folded to 3.5; constant sums, products, shifts, negation and Math.abs not wrapping at 32 bits; a store's index worked out after a value that calls or pops. Found and fixed in the map of our own: a do loop whose test needs values first (IR do.before), a cell at a masked index compared with a constant, calls among a print's parts run through the one cursor (texts read ahead), a game read against a negative constant asked unsigned, a text ternary with one text losing its call, tasks dying with the program's body, sync() inside a branch, a called text function's result (Snake), a two-term index (Tetris), and the texts' do-loop work dropped by the rewriter. tests/gate.test.ts holds each as its smallest script. The simulator's comments and docs/ir.md name the backend as their model. The plan's section has the numbers, the sweep and the Open list; the play pack is in Maps/TrigScript/gate/. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/emit.ts | 40 ++- compiler/backend/input.ts | 4 +- compiler/backend/texts.ts | 121 ++++++-- compiler/hoist.ts | 79 +++++ compiler/ir.ts | 3 +- compiler/simulateIr.ts | 17 +- compiler/structured.ts | 59 ++-- docs/ir.md | 14 +- docs/v4-plan.md | 162 +++++++++- scripts/gate-sweep.mts | 119 ++++++++ scripts/random-scripts.mts | 169 +++++++++++ tests/gate.test.ts | 135 +++++++++ tests/own-build.test.ts | 46 +-- tests/ownScenario.ts | 46 +++ tests/random-scripts.test.ts | 31 ++ tests/randomRun.ts | 66 ++++ tests/randomScript.ts | 567 +++++++++++++++++++++++++++++++++++ 17 files changed, 1565 insertions(+), 113 deletions(-) create mode 100644 scripts/gate-sweep.mts create mode 100644 scripts/random-scripts.mts create mode 100644 tests/gate.test.ts create mode 100644 tests/ownScenario.ts create mode 100644 tests/random-scripts.test.ts create mode 100644 tests/randomRun.ts create mode 100644 tests/randomScript.ts diff --git a/compiler/backend/emit.ts b/compiler/backend/emit.ts index b95fb87..8f0152f 100644 --- a/compiler/backend/emit.ts +++ b/compiler/backend/emit.ts @@ -1067,7 +1067,7 @@ class Scope { case "unrolled": return s.iterations.every((it) => this.stmts(it, true)); case "if": return this.test(s.cond) ? this.stmts(s.then, inLoop) && this.stmts(s.else ?? [], inLoop) : this.island(s); case "while": return (!s.cond || this.test(s.cond)) ? this.stmts(s.body, true) : this.island(s); - case "do": return this.test(s.cond) ? this.stmts(s.body, true) : this.island(s); + case "do": return this.stmts(s.body, true) && this.stmts(s.before ?? [], true) && (this.test(s.cond) || this.island(s)); case "for": { if (s.cond && !this.test(s.cond)) return this.island(s); this.counters.push(writtenIn(s.update)); @@ -1862,6 +1862,8 @@ class Emitter { this.stmts(s.body); this.loops.pop(); this.land(cont); + // (What the test needs computed first, at every turn's end — a `continue` lands here too.) + if (s.before) this.stmts(s.before); this.jump(s.cond, false, exit, s.at); this.goto(head); this.land(exit); @@ -5514,10 +5516,24 @@ class Rewriter { const read = (k === 0 ? c.left : c.right) as Extract, other = k === 0 ? c.right : c.left; const l = linear(other); if (!l || l.terms.length > 1 || !gameRead(read.read)) return null; + const base = read.read.record; const range: readonly [number, number] | undefined = l.terms.length ? (other as NumExpr & { range?: readonly [number, number] }).range : [l.c, l.c]; - if (!range || range[0] < 0) return null; const flip = { "<": ">", ">": "<", "<=": ">=", ">=": "<=", "==": "==", "!=": "!=" } as const; const op = k === 0 ? c.op : flip[c.op]; + // A constant below zero, compared signed (`i32(deaths(…)) >= -10`): the numbers it holds for, from the lowest to the + // highest, as bits — those below zero are above 2 147 483 647 as bits — one or two runs of the condition's own + // "at least" and "at most" (as `cellCompare` reads a cell). + if (!l.terms.length && (l.c | 0) < 0 && c.unsigned !== true && op !== "==" && op !== "!=") { + const epd = read.read.epd ? { epd: read.read.epd } : {}; + const raw = (comparison: number, amount: number): BoolExpr => ({ kind: "cond", record: { ...base, comparison, amount: u(amount) }, ...epd }); + const all = (items: BoolExpr[]): BoolExpr => (items.length === 0 ? { kind: "const", value: true } : items.length === 1 ? items[0] : { kind: "and", items }); + const between = (lo: number, hi: number): BoolExpr => all([...(u(lo) > 0 ? [raw(0, lo)] : []), ...(u(hi) < U32 - 1 ? [raw(1, hi)] : [])]); + const K = l.c | 0, MIN_S = -2147483648, MAX_S = 2147483647; + const [lo, hi] = op === "<" ? [MIN_S, K - 1] : op === "<=" ? [MIN_S, K] : op === ">" ? [K + 1, MAX_S] : [K, MAX_S]; + if (lo > hi) return { kind: "const", value: false }; + return lo >= 0 || hi < 0 ? between(lo, hi) : { kind: "or", items: [between(lo, -1), between(0, hi)] }; + } + if (!range || range[0] < 0) return null; const how: Record = { ">=": [0, 0], ">": [0, 1], "<=": [1, 0], "<": [1, -1], "==": [10, 0], "!=": [10, 0] }; const [comparison, add] = how[op]; if (range[0] + add < 0) return op === "<" && range[1] < 1 ? { kind: "const", value: false } : null; @@ -5927,7 +5943,10 @@ class Rewriter { if (!el || !inRegion(this.arrays, el.array)) return null; const a = this.arrays.get(el.array)!; const index = this.flat(el.index, pre, where), r = (index as NumExpr & { range?: readonly [number, number] }).range; - const at = this.side(index, pre, where), cell: NumExpr = { ...el, index: at }; + // (A cell is read at one variable plus a constant: an index under a mask — `xs[i & 3]`, which `side` would keep as a + // masked side — and one of more terms — `board[y * W + x]` — are computed first.) + const shape = side(index); + const at = shape && "mask" in shape && shape.mask !== undefined ? this.sum(index, pre, where) : this.side(index, pre, where), cell: NumExpr = { ...el, index: at }; const raw = (o: CompareOp, v: number): BoolExpr => ({ kind: "compare", op: o, left: cell, right: { kind: "const", value: u(v) }, unsigned: true, ...where }); const all = (items: BoolExpr[]): BoolExpr => (items.length === 0 ? { kind: "const", value: true } : items.length === 1 ? items[0] : { kind: "and", items }); // The bits between two numbers, ends included, read as they are. @@ -6167,11 +6186,14 @@ class Rewriter { return [s.kind === "for" ? { ...s, cond: undefined, body: [...pre, leave, ...body], update } : { ...s, cond: undefined, body: [...pre, leave, ...body] }]; } case "do": { - // (A map of our own: the test as rewritten when nothing comes before it — something that did would have to run - // at a `continue` too.) + // (A map of our own: the test as rewritten; what it needs computed first runs before it at every turn's end, + // `before`, where a `continue` lands too — the emitter lays it out at the loop's continue label.) const pre: Stmt[] = []; const cond = this.own ? this.cond(s.cond, pre, where) : s.cond; - return [{ ...s, body: this.stmts(s.body), ...(this.own && !pre.length ? { cond } : {}) }]; + if (!this.own) return [{ ...s, body: this.stmts(s.body) }]; + // (What the texts pass put before the test stays, rewritten as statements, and the test's own needs follow it.) + const before = [...this.stmts(s.before ?? []), ...pre]; + return [{ ...s, body: this.stmts(s.body), cond, ...(before.length ? { before } : {}) }]; } 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) }]; @@ -6360,9 +6382,11 @@ export function emitBody(body: Stmt[], decls: Map, key: string, 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. + // At its end the program wakes no more — unless it started tasks, which run on after it (the simulator's rule, + // and the Python side's): its wake then comes every frame and goes straight to the tasks' turn. end: () => { - e.open().items.push({ act: { dword: { data: WAKE_TICK }, modifier: "set", value: U32 - 1 } }); + if (scope.slots.size) e.open().items.push({ act: { dword: { data: WAKE_TICK }, modifier: "set", value: 0 } }, { act: { dword: { data: WAKE_AT }, modifier: "set", value: { label: e.tasksLabel } } }); + else e.open().items.push({ act: { dword: { data: WAKE_TICK }, modifier: "set", value: U32 - 1 } }); e.goto(e.tasksLabel); }, }; diff --git a/compiler/backend/input.ts b/compiler/backend/input.ts index eb92a1d..a01bdf1 100644 --- a/compiler/backend/input.ts +++ b/compiler/backend/input.ts @@ -35,6 +35,7 @@ * line goes as a *chat* message: the pattern's number, then its values. On arrival the pattern's flag is up for a * frame and its values are in cells. */ +import { STATEMENT_KINDS } from "./emit"; import type { ArrayDecl, At, BoolExpr, NumExpr, Program, Stmt, VarDecl } from "../ir"; import type { ActionRecord, TriggerRecord } from "../../vendor/triggers"; import { MAX_CHAT_BYTES, MAX_CHAT_NUMBER, MOUSE_BUTTONS, inputsOf, parseChatPattern, virtualKey, type ChatPattern, type InputSource, type MouseButton } from "../input"; @@ -315,7 +316,8 @@ export function inputValues(program: Program, plan: OwnInputPlan, passes: boolea }; const fix = (x: unknown): unknown => { - if (Array.isArray(x)) return x.map(fix); + // (A list of statements inside a statement — an if's, a loop's, a try's — as statements: a sync() in it is lowered too.) + if (Array.isArray(x)) return x.length && x[0] && typeof x[0] === "object" && STATEMENT_KINDS.has((x[0] as { kind?: string }).kind ?? "") ? stmts(x as Stmt[]) : x.map(fix); if (!x || typeof x !== "object") return x; const o = x as Record & { kind?: string }; if (o.kind === "input") return read(o.input as InputSource); diff --git a/compiler/backend/texts.ts b/compiler/backend/texts.ts index dd84646..7f95b59 100644 --- a/compiler/backend/texts.ts +++ b/compiler/backend/texts.ts @@ -767,17 +767,6 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog /** What a text needs done before it is begun (`pre`), and what writes it at the cursor (`body`). */ interface Writing { pre: Stmt[]; body: Stmt[] } - /** Whether statements only work out numbers: no call (the run time's functions move the cursor) and no text. */ - const makesNothing = (list: Stmt[]): boolean => { - const no = (x: unknown): boolean => { - if (Array.isArray(x)) return x.every(no); - if (!x || typeof x !== "object") return true; - const o = x as { kind?: string }; - if (o.kind === "call" || o.kind === "textBlock" || o.kind === "assignText" || o.kind === "print" || isTextExpr(o as TextExpr)) return false; - return Object.values(o).every((v) => !v || typeof v !== "object" || no(v)); - }; - return no(list); - }; /** A fresh made text with a block of its own, for a text made only to be read. */ const temp = (): Made => { const id = fresh("made"); @@ -801,7 +790,8 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog } const c = choice(t); if (c) { - if (c.texts.length === 1) { const body = writeConst(c.texts[0]); return body ? { pre: [], body } : null; } + // (One text it can be: written as it is — its test still asked when a call in it does something, `f() ? t : t`.) + if (c.texts.length === 1) { const body = writeConst(c.texts[0]); return body ? { pre: holdsCall(c.expr) ? [dec(fresh("pick"), c.expr)] : [], body } : null; } // One of the texts it can be, by a test of its number. const pick = fresh("pick"); const each = c.texts.map((text) => writeConst(text)); @@ -817,8 +807,15 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog // makes no text, so the cursor is left as it is.) const a = write(t.whenTrue), b = write(t.whenFalse); if (!a || !b) return null; - if (!a.pre.length && !b.pre.length) return { pre: [], body: [when(bool(t.cond), a.body, b.body)] }; - return makesNothing(a.pre) && makesNothing(b.pre) ? { pre: [], body: [when(bool(t.cond), [...a.pre, ...a.body], [...b.pre, ...b.body])] } : null; + if (!a.pre.length && !b.pre.length && !holdsCall(t.cond)) return { pre: [], body: [when(bool(t.cond), a.body, b.body)] }; + // (The test is asked once, before the text is begun, its answer kept in a number of its own — it may call a + // function, which may make a text of its own at the cursor — and what the side it picks needs done first is done + // then, under it, as JavaScript does only the side taken. The sides' bodies write under the same answer.) + const c = fresh("cond"); + const test = cmp("!=", V(c), k(0)); + const pre: Stmt[] = [dec(c, { kind: "ternary", cond: bool(t.cond), whenTrue: k(1), whenFalse: k(0), at: here, label: "" })]; + if (a.pre.length || b.pre.length) pre.push(when(test, a.pre, b.pre)); + return { pre, body: [when(test, a.body, b.body)] }; } case "template": return writeParts(t.parts); case "textSlice": { @@ -850,11 +847,52 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog default: return null; } }; - const writeParts = (parts: TextPart[]): Writing | null => { - const out: Writing = { pre: [], body: [] }; - for (const p of parts) { + /** Whether an expression calls a function of the script's: one that may make a text of its own, or change what a part reads. */ + const holdsCall = (x: unknown): boolean => { + if (Array.isArray(x)) return x.some(holdsCall); + if (!x || typeof x !== "object") return false; + const o = x as { kind?: string }; + return o.kind === "call" || o.kind === "textCall" || Object.values(o).some((v) => v && typeof v === "object" && holdsCall(v)); + }; + /** + * A call among a text's parts runs before the text is begun (it may make a text of its own at the cursor), so every + * value up to the last call is worked out first, in the parts' order, as JavaScript reads them: a number into a + * variable of its own, a text of the map's into a number of its own (its texts the variable's), a made text copied + * into a block of its own (`made`) — what the call may change is read before it runs. The parts come back rewritten. + */ + const ahead = (parts: TextPart[]): { pre: Stmt[]; parts: TextPart[]; made: Map } => { + const pre: Stmt[] = [], made = new Map(); + const lastCall = parts.reduce((last, p, i) => (((p.kind === "number" || p.kind === "char") && holdsCall(p.expr)) || (p.kind === "value" && holdsCall(p.text)) ? i : last), -1); + const out = parts.map((p, i): TextPart => { + if (lastCall < 0 || i > lastCall) return p; + if ((p.kind === "number" || p.kind === "char") && !(p.kind === "number" && p.fixed)) { + const t = fresh("part"); + pre.push(dec(t, num(p.expr))); + return { ...p, expr: V(t) }; + } + if (p.kind !== "value" || (i === lastCall && !holdsCall(p.text))) return p; + // (Texts known when built, chosen among: the choice's number kept, its texts the new variable's.) + const c = holdsCall(p.text) ? null : choice(p.text); + if (c) { + const t = fresh("pick"); + pre.push(dec(t, c.expr)); + idVars.set(t, new Set(c.texts)); + return { ...p, text: { kind: "textVar", id: t } }; + } + const m = temp(); + const body = make(p.text, m); + if (body) { pre.push(...blockOf(m), ...body); made.set(i, m); madeVars.set(m.block, m); } + return p; + }); + return { pre, parts: out, made }; + }; + const writeParts = (given: TextPart[]): Writing | null => { + const first = ahead(given); + const out: Writing = { pre: first.pre, body: [] }, parts = first.parts; + for (const [i, p] of parts.entries()) { let part: Writing | null; - if (p.kind === "text") { const body = writeConst(p.text); part = body ? { pre: [], body } : null; } + if (first.made.has(i)) part = { pre: [], body: writeMade(first.made.get(i)!) }; + else if (p.kind === "text") { const body = writeConst(p.text); part = body ? { pre: [], body } : null; } else if (p.kind === "number") part = p.fixed ? null : { pre: [], body: [set(X, num(p.expr)), number(!!p.unsigned)] }; else if (p.kind === "value") part = write(p.text); else if (p.kind === "char") part = { pre: [], body: [set(B, bin("+", num(p.expr), k(0))), when(cmp("!=", V(B), k(0)), [put(), add(CC, 1)])] }; @@ -1005,7 +1043,10 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog if (!ch) return null; pre.push({ kind: "declare", decl: { ...p.decl, kind: "number", text: undefined }, init: ch.expr, at: here, label: p.label }); } - const result = c.result?.kind === "text" ? madeVars.get(c.result.decl.id) : undefined; + // (A called function makes its text into its own result's block — one for the function, whichever call — so a call + // of it reads that block; an inlined call has a result of its own, which its body's returns make.) + const fnResult = c.fn ? (program.functions ?? []).find((f) => f.id === c.fn)?.result : undefined; + const result = c.result?.kind === "text" ? madeVars.get(fnResult?.kind === "text" ? fnResult.decl.id : c.result.decl.id) : undefined; if (c.result?.kind === "text" && !result) return null; if (result) pre.push(...blockOf(result)); results.push({ into: result, failed: false }); @@ -1155,10 +1196,19 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog if (v) return v; } if (o.kind === "textCall") { - const r = call((o as Extract).call); + const c = (o as Extract).call; + const r = call(c); if (!r?.result) return o; pre.push(...r.pre); - return { kind: "textVar", id: [...madeVars].find(([, m]) => m === r.result)![0] }; + const id = [...madeVars].find(([, m]) => m === r.result)![0]; + // (A called function's result block is the function's, one for every call: what this call gave is copied into a + // block of its own before another call of it — `${f(a)} and ${f(b)}` — writes the block again.) + if (c.fn) { + const m = temp(); + const body = make({ kind: "textVar", id }, m); + if (body) { pre.push(...blockOf(m), ...body); madeVars.set(m.block, m); return { kind: "textVar", id: m.block }; } + } + return { kind: "textVar", id }; } // (A call of a number or a condition with texts among its parameters: a statement first, its result read after.) const c = (o as { call?: Call }).call; @@ -1184,7 +1234,23 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog if (!pre.length) return stmt(s); return [{ ...s, cond: { kind: "const", value: true }, body: [...pre, when({ kind: "not", expr: bool(cond) }, [{ kind: "break", at: here, label: "" } as Stmt]), ...stmts(s.body)] }]; } - if (s.kind === "do" || s.kind === "for") return stmt(s); + // (A `do`'s test is asked at every turn's end: what it needs made first goes before it there, `before`, where a + // `continue` lands too. A `for`'s, at the top of each turn, as a `while`'s.) + if (s.kind === "do") { + if ("at" in s && s.at) here = s.at; + const pre: Stmt[] = []; + const cond = hoist(s.cond, pre) as BoolExpr; + if (!pre.length) return stmt(s); + return [{ ...s, cond: bool(cond), before: [...(s.before ?? []), ...pre], body: stmts(s.body) }]; + } + if (s.kind === "for") { + if (!s.cond) return stmt(s); + if ("at" in s && s.at) here = s.at; + const pre: Stmt[] = []; + const cond = hoist(s.cond, pre) as BoolExpr; + if (!pre.length) return stmt(s); + return [{ ...s, cond: undefined, body: [...pre, when({ kind: "not", expr: bool(cond) }, [{ kind: "break", at: here, label: "" } as Stmt]), ...stmts(s.body)], update: stmts(s.update) }]; + } const pre: Stmt[] = []; const t = { ...s, ...Object.fromEntries(Object.entries(s).filter(([key]) => key !== "then" && key !== "else" && key !== "body").map(([key, v]) => [key, v && typeof v === "object" ? hoist(v, pre) : v])) } as Stmt; return [...pre, ...stmt(t)]; @@ -1324,7 +1390,12 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog * current player). Otherwise printed once for each text each can be, under a test of which it is — each choice * worked out once, into a variable of its own. */ - const print = (s: Extract): Stmt[] => { + const print = (given: Extract): Stmt[] => { + // (A call among the parts: what comes before it read first, `ahead`, as JavaScript reads it. A made text among the + // parts goes through `make`, which does the same once more and finds nothing left to do.) + const first = ahead(given.parts); + if (first.pre.length) return [...first.pre, ...print({ ...given, parts: first.parts.map((p, i) => (first.made.has(i) ? { kind: "value", text: { kind: "textVar", id: first.made.get(i)!.block } } : p)) })]; + const s = given; const parts = s.parts.map((p) => (p.kind === "value" ? choice(p.text) : null)); if (s.parts.some((p, i) => (p.kind === "value" && !parts[i]) || p.kind === "name" || p.kind === "color") && s.position === "chat") { // A made text in it, or a player's name or colour. Alone: its block shown as it is. Else the printed text made @@ -1336,8 +1407,10 @@ export function knownTexts(program: Program, pool: TextPool, base: number): Prog return [...blockOf(SCRATCH), ...body, { kind: "showText", block: SCRATCH.block, bytes: BLOCK, to: s.to, at: s.at }]; } if (!parts.some((c) => c && c.texts.length > 1)) { + // (A choice that comes to one text — `f() ? t : t` — still asks its test: a call in it runs, for what it does.) + const asked = parts.flatMap((c) => (c && holdsCall(c.expr) ? [dec(fresh("pick"), c.expr)] : [])); const plain = s.parts.map((p, i) => (parts[i] ? ({ kind: "text", text: parts[i]!.texts[0] } as TextPart) : p)); - return [walk({ ...s, parts: plain }) as Stmt]; + return [...asked, walk({ ...s, parts: plain }) as Stmt]; } if (s.parts.some((p, i) => p.kind === "value" && !parts[i])) return [walk(s) as Stmt]; const choices = parts.filter((c): c is Choice => !!c && c.texts.length > 1); diff --git a/compiler/hoist.ts b/compiler/hoist.ts index 7530757..b326902 100644 --- a/compiler/hoist.ts +++ b/compiler/hoist.ts @@ -745,6 +745,9 @@ function planWithin(ts: typeof TS, checker: TS.TypeChecker, plan: ProgramPlan, a } if (ts.isPrefixUnaryExpression(n) && (n.operator === ts.SyntaxKind.ExclamationToken || n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)) { ok = false; return; } if (ts.isPostfixUnaryExpression(n)) { ok = false; return; } + // (A `?:` picks its side by what its test is in the game — a condition of the game is a record here, true to + // JavaScript: the sides are hoisted on their own, and the compiler tests the condition where it stands.) + if (ts.isConditionalExpression(n)) { ok = false; return; } ts.forEachChild(n, check); }; check(e); @@ -955,6 +958,47 @@ function planWithin(ts: typeof TS, checker: TS.TypeChecker, plan: ProgramPlan, a return plan; } +/** The `__kind` brand of a type (`"fixed"`, `"unit"`, …), when it has one. */ +export function brandOfType(ts: typeof TS, checker: TS.TypeChecker, type: TS.Type): string | undefined { + void ts; + for (const t of type.isIntersection() ? type.types : [type]) { + const p = t.getProperty("__kind"); + if (!p) continue; + const pt = checker.getTypeOfSymbol(p); + const name = (pt.isUnion() ? pt.types : [pt]).find((x): x is TS.StringLiteralType => x.isStringLiteral()); + if (name) return name.value; + } + return undefined; +} + +/** Whether a quotient written in a program divides whole numbers: neither side is typed `fixed` (`7 / 2` is 3; `7 / fixed(2)` is 3.5). */ +export function wholeQuotient(ts: typeof TS, checker: TS.TypeChecker, e: TS.BinaryExpression): boolean { + return wholeArithmetic(ts, checker, e) !== "fraction"; +} + +/** + * How a sum, difference, product or quotient written in a program computes when its sides are known as the script is + * built: as the game would — in 32 bits, signed ("i32") or from 0 up ("u32"), by what the sides are typed — or as a + * fraction when either side is typed `fixed`. (A side whose value is a fraction computes as one whatever this says.) + */ +export function wholeArithmetic(ts: typeof TS, checker: TS.TypeChecker, e: TS.BinaryExpression): "i32" | "u32" | "fraction" { + const brands = [brandOfType(ts, checker, checker.getTypeAtLocation(e.left)), brandOfType(ts, checker, checker.getTypeAtLocation(e.right))]; + return brands.includes("fixed") ? "fraction" : brands.includes("u32") ? "u32" : "i32"; +} + +/** The arithmetic of whole numbers at build time, as JavaScript source: `(a, b) => …` taking the operator's sides. */ +function wholeHelper(ts: typeof TS, f: TS.NodeFactory, op: "+" | "-" | "*" | "/", bits: "i32" | "u32"): TS.ArrowFunction { + const a = f.createIdentifier("a"), b = f.createIdentifier("b"); + const isWhole = (x: TS.Identifier) => f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Number"), "isInteger"), undefined, [x]); + let test: TS.Expression = f.createLogicalAnd(isWhole(a), isWhole(b)); + if (op === "/") test = f.createLogicalAnd(test, f.createStrictInequality(b, f.createNumericLiteral(0))); + const raw: TS.Expression = op === "+" ? f.createAdd(a, b) : op === "-" ? f.createSubtract(a, b) : op === "*" ? f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Math"), "imul"), undefined, [a, b]) : f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Math"), "trunc"), undefined, [f.createDivide(a, b)]); + const wrapped = op === "/" ? raw : bits === "u32" ? f.createUnsignedRightShift(f.createParenthesizedExpression(raw), f.createNumericLiteral(0)) : f.createBitwiseOr(f.createParenthesizedExpression(raw), f.createNumericLiteral(0)); + const plain: TS.Expression = op === "+" ? f.createAdd(a, b) : op === "-" ? f.createSubtract(a, b) : op === "*" ? f.createMultiply(a, b) : f.createDivide(a, b); + return f.createArrowFunction(undefined, undefined, [f.createParameterDeclaration(undefined, undefined, "a"), f.createParameterDeclaration(undefined, undefined, "b")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createConditionalExpression(test, undefined, wrapped, undefined, plain)); +} + + /** What a descriptor's `hoisted()` returns: a thunk per hoisted expression, and a memoised thunk per build-time constant. */ export interface HoistedThunks { h: (() => unknown)[]; c: (() => unknown)[] } @@ -983,8 +1027,43 @@ export function hoistedFunction(ts: typeof TS, checker: TS.TypeChecker, plan: Pr const call = f.createCallExpression(f.createElementAccessExpression(c, f.createNumericLiteral(i)), undefined, []); return ts.isBindingElement(decl) && ts.isIdentifier(decl.name) ? f.createPropertyAccessExpression(call, decl.name.text) : call; }; + const SHIFT_OPS: Partial>" | ">>>">> = { [ts.SyntaxKind.LessThanLessThanToken]: "<<", [ts.SyntaxKind.GreaterThanGreaterThanToken]: ">>", [ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken]: ">>>" }; + const WHOLE_OPS: Partial> = { [ts.SyntaxKind.PlusToken]: "+", [ts.SyntaxKind.MinusToken]: "-", [ts.SyntaxKind.AsteriskToken]: "*", [ts.SyntaxKind.SlashToken]: "/" }; const rewrite = (node: TS.Node): TS.Node => { if (ts.isTypeNode(node)) return node; + // Whole numbers compute in a program as they do in the game: `7 / 2` is 3 and `i + 2147483647` wraps (`7 / fixed(2)` + // is 3.5, a u32 wraps from 0 up). A sum, difference, product or quotient whose sides are not typed `fixed` goes + // through a helper that computes it so when both sides are whole, and as JavaScript does otherwise. + // (Math.abs of a whole number wraps as the game's does: abs(−2 147 483 648) is −2 147 483 648.) + if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === "Math" && node.expression.name.text === "abs" && node.arguments.length === 1 && brandOfType(ts, checker, checker.getTypeAtLocation(node.arguments[0])) !== "fixed") { + const v = f.createIdentifier("v"); + const abs = f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Math"), "abs"), undefined, [v]); + const helper = f.createArrowFunction(undefined, undefined, [f.createParameterDeclaration(undefined, undefined, "v")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createConditionalExpression(f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Number"), "isInteger"), undefined, [v]), undefined, f.createBitwiseOr(abs, f.createNumericLiteral(0)), undefined, abs)); + return f.createCallExpression(f.createParenthesizedExpression(helper), undefined, [ts.visitNode(node.arguments[0], rewrite) as TS.Expression]); + } + // (A shift by 32 or more, or by a number below zero, leaves nothing but the sign, as the game's does.) + // (The negative of a whole number wraps as the game's does; a literal's negative is the literal's own.) + if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken && !ts.isNumericLiteral(node.operand) && brandOfType(ts, checker, checker.getTypeAtLocation(node.operand)) !== "fixed") { + const v = f.createIdentifier("v"); + const helper = f.createArrowFunction(undefined, undefined, [f.createParameterDeclaration(undefined, undefined, "v")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createConditionalExpression(f.createCallExpression(f.createPropertyAccessExpression(f.createIdentifier("Number"), "isInteger"), undefined, [v]), undefined, f.createBitwiseOr(f.createParenthesizedExpression(f.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, v)), f.createNumericLiteral(0)), undefined, f.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, v))); + return f.createCallExpression(f.createParenthesizedExpression(helper), undefined, [ts.visitNode(node.operand, rewrite) as TS.Expression]); + } + if (ts.isBinaryExpression(node) && SHIFT_OPS[node.operatorToken.kind]) { + const op = SHIFT_OPS[node.operatorToken.kind]!; + const a = f.createIdentifier("a"), b = f.createIdentifier("b"); + const far = f.createGreaterThanEquals(f.createUnsignedRightShift(b, f.createNumericLiteral(0)), f.createNumericLiteral(32)); + const none: TS.Expression = op === ">>" ? f.createConditionalExpression(f.createLessThan(a, f.createNumericLiteral(0)), undefined, f.createPrefixUnaryExpression(ts.SyntaxKind.MinusToken, f.createNumericLiteral(1)), undefined, f.createNumericLiteral(0)) : f.createNumericLiteral(0); + const shifted = op === "<<" ? f.createLeftShift(a, b) : op === ">>" ? f.createRightShift(a, b) : f.createUnsignedRightShift(a, b); + const helper = f.createArrowFunction(undefined, undefined, [f.createParameterDeclaration(undefined, undefined, "a"), f.createParameterDeclaration(undefined, undefined, "b")], undefined, f.createToken(ts.SyntaxKind.EqualsGreaterThanToken), f.createConditionalExpression(far, undefined, none, undefined, shifted)); + return f.createCallExpression(f.createParenthesizedExpression(helper), undefined, [ts.visitNode(node.left, rewrite) as TS.Expression, ts.visitNode(node.right, rewrite) as TS.Expression]); + } + if (ts.isBinaryExpression(node) && WHOLE_OPS[node.operatorToken.kind]) { + const bits = wholeArithmetic(ts, checker, node); + if (bits !== "fraction") { + const helper = wholeHelper(ts, f, WHOLE_OPS[node.operatorToken.kind]!, bits); + return f.createCallExpression(f.createParenthesizedExpression(helper), undefined, [ts.visitNode(node.left, rewrite) as TS.Expression, ts.visitNode(node.right, rewrite) as TS.Expression]); + } + } if (ts.isIdentifier(node) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.name === node) && !(ts.isPropertyAssignment(node.parent) && node.parent.name === node)) return constant(node) ?? node; if (ts.isShorthandPropertyAssignment(node)) { const value = constant(node.name); diff --git a/compiler/ir.ts b/compiler/ir.ts index 39e4e92..22c62b0 100644 --- a/compiler/ir.ts +++ b/compiler/ir.ts @@ -588,7 +588,8 @@ export type Stmt = | { kind: "if"; cond: BoolExpr; then: Stmt[]; else?: Stmt[]; at: At; label: string } /** `cond` absent means `while (true)`. */ | { kind: "while"; cond?: BoolExpr; body: Stmt[]; at: At; label: string } - | { kind: "do"; body: Stmt[]; cond: BoolExpr; at: At; label: string; condLabel: string } + /** `before`: statements run before the test each turn, a `continue` included — made by the emitter's rewriter for a map of our own (a test that needs a value computed first); the front end writes none. */ + | { kind: "do"; body: Stmt[]; cond: BoolExpr; at: At; label: string; condLabel: string; before?: Stmt[] } /** `for` over a variable: `init` ran already (it is emitted before), this is the loop with its update. */ | { kind: "for"; cond?: BoolExpr; update: Stmt[]; body: Stmt[]; at: At; label: string; /** The loop is `name.sort(…)`: for the hint on its line; a lowering takes no notice. */ sorts?: string } /** A loop unrolled when the script was built: the body once per value, in order. */ diff --git a/compiler/simulateIr.ts b/compiler/simulateIr.ts index 24eff3d..e8e2ca1 100644 --- a/compiler/simulateIr.ts +++ b/compiler/simulateIr.ts @@ -1,7 +1,10 @@ /** * The program interpreter: runs `program()` bodies from the IR, one player, frame by * frame. It is what Simulate shows and what the tests prove programs with; the model is - * the Python lowering's (`python/trigscript.py`), which is what the game runs. + * the backend's (`compiler/backend/`, the map of our own), which is what the game runs — and was + * the Python lowering's (`python/trigscript.py`), which laid the same programs out through eudplib + * until TrigScript 4's step 17; the gate before it (step 16, `tests/random-scripts.test.ts`) holds + * the two to the same lines. * * A program is a coroutine the game resumes every frame. Its body runs until it sleeps or * ends: loops run to completion within the frame, only `sleep` gives the frame back @@ -56,7 +59,7 @@ export const FRAMES_PER_SECOND = 24; /** What Elapsed Time and the countdown count: a game second is sixteen frames (the slice 2 probe: 21 after 336 frames), not the twenty-four of a second at Fastest. */ export const FRAMES_PER_GAME_SECOND = 16; const U32 = 0x1_0000_0000; -/** Frames with no message before a held key counts as let go; python/trigscript.py has the same number (Input.HELD_GRACE). */ +/** Frames with no message before a held key counts as let go; the backend's input has the same number (`compiler/backend/input.ts`). */ const HELD_GRACE = 2; /** The code of the game's texts nearest each player's colour, by slot, when the map does not change them: red, blue, teal, purple, orange, brown, white, yellow. */ @@ -463,7 +466,7 @@ class ProgramRun { } /** - * Room for `cells` in a growing array, as the game finds it (`python/trigscript.py`): a block twice the size from the + * Room for `cells` in a growing array, as the game finds it (`compiler/backend/heap.ts`): a block twice the size from the * heap, the old one given back. False, and said once, when the heap has none left. */ private grow(a: { decl: ArrayDecl; cells: Value[]; room: number }, cells: number, at: At): boolean { @@ -564,7 +567,7 @@ class ProgramRun { /* ── expressions ── */ /** - * A number, the way the game computes it (`python/trigscript.py`): 32 bits, given here as the + * A number, the way the game computes it (`compiler/backend/emit.ts`): 32 bits, given here as the * signed reading of them (`| 0`). + − × and the bitwise operators are the same bits whichever * way they are read; what reads them one way or the other — ÷, %, a shift right, min and max, a * comparison — says which in the IR. A divisor of 0 gives 0, as `(a / 0) | 0` does. @@ -640,7 +643,7 @@ class ProgramRun { /* ── texts ── */ /** - * A text that was just made, as the game keeps it (`python/trigscript.py`): past `TEXT_BYTES` it is cut, and its + * A text that was just made, as the game keeps it (`compiler/backend/texts.ts`): past `TEXT_BYTES` it is cut, and its * bytes go into a block of the heap. When the heap has none, the text is empty — and both are said. */ private made(s: string, at: At, cameTo = bytesOf(s)): TextValue { @@ -899,7 +902,7 @@ class ProgramRun { /** * A call that may come back into the function it is in: what that function holds goes on the stack, as the game - * keeps it (`python/trigscript.py`) — its variables, and the handle of each array declared in it, which is "no block" + * keeps it (`compiler/backend/emit.ts`, `Emitter.keep`) — its variables, and the handle of each array declared in it, which is "no block" * for the length of the call. Past the depth the map allows the program stops, and says where. */ private keep(c: Call): Frame { @@ -1301,7 +1304,7 @@ class ProgramRun { const message = this.textVars.get(errors.message)?.s ?? ""; const at = this.thrownAt ?? s.at; this.sim.faults.push({ cycle: this.sim.cycle, frame: this.sim.frame, program: this.index, at, message: `Uncaught ${name}${message ? `: ${message}` : ""}` }); - // The game says it in red to the player it ran as, as python/trigscript.py does. + // The game says it in red to the player it ran as (`compiler/backend/errors.ts`). this.sim.print(this, `\x06TrigScript: uncaught ${name}: ${message}, line ${at.line}.`, 13, s.at); this.store(errors.thrown, 0); return "next"; diff --git a/compiler/structured.ts b/compiler/structured.ts index 1abb166..57f4571 100644 --- a/compiler/structured.ts +++ b/compiler/structured.ts @@ -64,6 +64,7 @@ import { clockCall, declarationOf, isOnCall, isScriptClass, isScriptFunction, ke import { ORDER_NAMES } from "./orders"; import { DEAF_KEYS, KEY_NAMES, keyName, parseChatPattern, type ChatPattern, type InputSource } from "./input"; import { scriptParams } from "./api"; +import { brandOfType, wholeArithmetic, wholeQuotient } from "./hoist"; import { hasTextMark, isAction, isBuilder, isChat, isCondition, isDuration, isGameFunction, isGameValue, isInput, isMouse, isPrint, isRead, isReader, isTable, isTrigger, isUnitPick, isUnitQuery, playerColor, READ_ARITY, ScriptError, textParts, type ConditionValue, type GameFunctionValue, type InputValue, type ReadValue, type ScriptString, type TableValue, type UnitPickValue, isSelected, isSelectionCount, SELECTION_ADDRESS } from "./runtime"; import { cellMax } from "./tables"; import { markViewWrites, type View } from "./views"; @@ -802,6 +803,9 @@ export class Structured { } if (depth > 32) return undefined; const sub = (x: TS.Expression) => this.evaluate(x, depth + 1); + // (A value of the game's — a read, a table, a pick, an input — and a condition of the game, which is a record here + // and true or false only when the map is played: nothing is worked out of either.) + const inGame = (v: unknown): boolean => isGameValue(v) || isCondition(v); if (ts.isIdentifier(e)) { const b = this.bindingOf(e); if (!b && e.text === "undefined") return { value: undefined }; @@ -851,7 +855,7 @@ export class Structured { if (ts.isSpreadElement(a)) { const v = sub(a.expression); if (!v || !Array.isArray(v.value)) return undefined; args.push(...(v.value as unknown[])); continue; } const v = sub(a); // A read among the arguments makes the call the program's: an action with that amount, a function inlined. - if (!v || isGameValue(v.value)) return undefined; + if (!v || inGame(v.value)) return undefined; args.push(v.value); } // A text's method over name() or color() — on one, or given one — is the game's too. @@ -859,7 +863,9 @@ export class Structured { // stats(x): a table's index is a plain number when the script runs; its type says which table. if (this.isLibraryCall(e, "stats") && e.arguments.length === 1) args.push(this.brandOf(e.arguments[0])); try { - return { value: (callee.value as (...a: unknown[]) => unknown).apply(self, args) }; + const value = (callee.value as (...a: unknown[]) => unknown).apply(self, args); + // (Math.abs of a whole number is the game's: 32 bits, so abs(−2 147 483 648) is −2 147 483 648, as `| 0` has it.) + return { value: callee.value === Math.abs && typeof value === "number" && Number.isInteger(value) && this.brandOf(e.arguments[0]) !== "fixed" ? value | 0 : value }; } catch (err) { throw new LowerError(err instanceof Error ? err.message : String(err)); } @@ -868,7 +874,7 @@ export class Structured { let out = e.head.text; for (const span of e.templateSpans) { const v = sub(span.expression); - if (!v || isGameValue(v.value)) return undefined; + if (!v || inGame(v.value)) return undefined; out += String(v.value) + span.literal.text; } return { value: out }; @@ -877,8 +883,8 @@ export class Structured { if (ts.isObjectLiteralExpression(e)) { const out: Record = {}; for (const p of e.properties) { - if (ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) { const v = sub(p.initializer); if (!v || isGameValue(v.value)) return undefined; out[p.name.text] = v.value; continue; } - if (ts.isShorthandPropertyAssignment(p)) { const v = sub(p.name); if (!v || isGameValue(v.value)) return undefined; out[p.name.text] = v.value; continue; } + if (ts.isPropertyAssignment(p) && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) { const v = sub(p.initializer); if (!v || inGame(v.value)) return undefined; out[p.name.text] = v.value; continue; } + if (ts.isShorthandPropertyAssignment(p)) { const v = sub(p.name); if (!v || inGame(v.value)) return undefined; out[p.name.text] = v.value; continue; } return undefined; } return { value: out }; @@ -897,9 +903,10 @@ export class Structured { if (ts.isPrefixUnaryExpression(e)) { const v = sub(e.operand); // A read has no value yet: arithmetic over it is the program's, not the script's. - if (!v || isGameValue(v.value)) return undefined; + if (!v || inGame(v.value)) return undefined; switch (e.operator) { - case ts.SyntaxKind.MinusToken: return { value: -(v.value as number) }; + // (The negative of a whole number wraps as the game's does: −(−2 147 483 648) is −2 147 483 648 — a literal past the top is a u32 by the type.) + case ts.SyntaxKind.MinusToken: return { value: typeof v.value === "number" && Number.isInteger(v.value) && !ts.isNumericLiteral(this.unwrap(e.operand)) && brandOfType(ts, this.c.checker, this.c.checker.getTypeAtLocation(e.operand)) !== "fixed" ? -(v.value) | 0 : -(v.value as number) }; case ts.SyntaxKind.PlusToken: return { value: +(v.value as number) }; case ts.SyntaxKind.TildeToken: return { value: ~(v.value as number) }; default: return undefined; @@ -929,7 +936,7 @@ export class Structured { // `options.width ?? 3`, `a || b`, `a && b` of values known when the script is built: as JavaScript decides them, // the right side only looked at when the left does not decide. const l = sub(e.left); - if (!l || isGameValue(l.value)) return undefined; + if (!l || inGame(l.value)) return undefined; const k = e.operatorToken.kind; // Only of plain values: a condition or an action of the library is an object, and its truth is the game's. const v = l.value; @@ -938,12 +945,12 @@ export class Structured { const decided = k === ts.SyntaxKind.QuestionQuestionToken ? v !== null && v !== undefined : k === ts.SyntaxKind.BarBarToken ? !!v : !v; if (decided) return l; const r = sub(e.right); - return r && !isGameValue(r.value) ? r : undefined; + return r && !inGame(r.value) ? r : undefined; } if (ts.isBinaryExpression(e)) { const l = sub(e.left); const r = sub(e.right); - if (!l || !r || isGameValue(l.value) || isGameValue(r.value)) return undefined; + if (!l || !r || inGame(l.value) || inGame(r.value)) return undefined; // A text with name() or color() in it is put together with `+` here, and compared in the game (`readsMark` in hoist.ts). const markIn = (v: unknown) => typeof v === "string" && hasTextMark(v); if ((markIn(l.value) || markIn(r.value)) && e.operatorToken.kind !== ts.SyntaxKind.PlusToken) return undefined; @@ -956,16 +963,24 @@ export class Structured { } const a = l.value as number; const b = r.value as number; + // (Whole numbers add, subtract and multiply as the game does, in 32 bits — wrapping at the ends, or from 0 up for a + // u32 — here as there: `i + 2147483647` for i = 1 is −2 147 483 648. A fraction on either side computes as one.) + const bits = wholeArithmetic(ts, this.c.checker, e); + const whole = typeof a === "number" && typeof b === "number" && Number.isInteger(a) && Number.isInteger(b) && bits !== "fraction"; + const wrap = (v: number) => (bits === "u32" ? v >>> 0 : v | 0); switch (e.operatorToken.kind) { - case ts.SyntaxKind.PlusToken: return { value: (a as unknown as string) + (b as unknown as string) }; - case ts.SyntaxKind.MinusToken: return { value: a - b }; - case ts.SyntaxKind.AsteriskToken: return { value: a * b }; - case ts.SyntaxKind.SlashToken: return { value: a / b }; + case ts.SyntaxKind.PlusToken: return { value: whole ? wrap(a + b) : (a as unknown as string) + (b as unknown as string) }; + case ts.SyntaxKind.MinusToken: return { value: whole ? wrap(a - b) : a - b }; + case ts.SyntaxKind.AsteriskToken: return { value: whole ? wrap(Math.imul(a, b)) : a * b }; + // (Whole numbers divide as whole numbers, towards zero, here as in the game: `7 / 2` is 3, `7 / fixed(2)` 3.5. A + // fraction on either side divides as a fraction; a divisor of 0 is left to the check that names it.) + case ts.SyntaxKind.SlashToken: return { value: wholeQuotient(ts, this.c.checker, e) && Number.isInteger(a) && Number.isInteger(b) && b !== 0 ? Math.trunc(a / b) : a / b }; case ts.SyntaxKind.PercentToken: return { value: a % b }; case ts.SyntaxKind.AsteriskAsteriskToken: return { value: a ** b }; - case ts.SyntaxKind.LessThanLessThanToken: return { value: a << b }; - case ts.SyntaxKind.GreaterThanGreaterThanToken: return { value: a >> b }; - case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken: return { value: a >>> b }; + // (A shift by 32 or more, or by a number below zero, leaves nothing but the sign, as the game's does — JavaScript would shift by the remainder.) + case ts.SyntaxKind.LessThanLessThanToken: return { value: b >>> 0 >= 32 ? 0 : a << b }; + case ts.SyntaxKind.GreaterThanGreaterThanToken: return { value: b >>> 0 >= 32 ? (a < 0 ? -1 : 0) : a >> b }; + case ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken: return { value: b >>> 0 >= 32 ? 0 : a >>> b }; case ts.SyntaxKind.AmpersandToken: return { value: a & b }; case ts.SyntaxKind.BarToken: return { value: a | b }; case ts.SyntaxKind.CaretToken: return { value: a ^ b }; @@ -978,7 +993,7 @@ export class Structured { } if (ts.isConditionalExpression(e)) { const c = sub(e.condition); - if (!c || isGameValue(c.value)) return undefined; + if (!c || inGame(c.value)) return undefined; return c.value ? sub(e.whenTrue) : sub(e.whenFalse); } return undefined; @@ -4840,9 +4855,11 @@ export class Structured { const { ts } = this; const { a } = el; if (a.values) { this.c.error(e.left, `${a.name} was computed when the script was built and is only read in a program; declare it with let inside the program to write to it.`); return; } - // (`xs[n++] = n * 10`: the index before the value, as JavaScript has it — an index with a call in it is worked out first.) - const calls = (x: unknown): boolean => !!x && typeof x === "object" && (Array.isArray(x) ? x.some(calls) : (x as { kind?: string }).kind === "call" || Object.values(x).some(calls)); - const index = calls(el.index) ? this.temp(el.index, e.left) : el.index; + // (`xs[n++] = n * 10`: the index before the value, as JavaScript has it — an index with a call or a pop in it is worked out first.) + const calls = (x: unknown): boolean => !!x && typeof x === "object" && (Array.isArray(x) ? x.some(calls) : (x as { kind?: string }).kind === "call" || (x as { kind?: string }).kind === "pop" || Object.values(x).some(calls)); + // (And one read by the index when the value calls: the call may change what the index reads.) + const callsIn = (n: TS.Node): boolean => ts.isCallExpression(n) || ts.isNewExpression(n) || !!ts.forEachChild(n, (c) => callsIn(c) || undefined); + const index = calls(el.index) || (el.index.kind !== "const" && callsIn(e.right)) ? this.temp(el.index, e.left) : el.index; const emit = (value: NumExpr | BoolExpr) => this.emit({ kind: "store", array: a.id, index, value, at: this.at(e), label: this.label(e) }, e); if (a.kind === "boolean") { if (op !== ts.SyntaxKind.EqualsToken) { this.c.error(e, "Booleans take = only."); return; } diff --git a/docs/ir.md b/docs/ir.md index 96f42e6..196e67b 100644 --- a/docs/ir.md +++ b/docs/ir.md @@ -1,9 +1,11 @@ # The TrigScript IR What a `program(() => { … })` body means, written down as data. The compiler's front end -(`compiler/structured.ts`) turns the TypeScript into this, `python/trigscript.py` lowers -it to eudplib when the map is saved, and `compiler/simulateIr.ts` interprets it for -Simulate and the tests. **Version 24** (23 had no `held` on a variable, and no function kept as a value used a variable of the block it was written in; 22 had nothing only one computer knows: no `syncSend`, no `select`, no `selected` unit and no `sync` input; 21 had no errors: no `try`, `throw` or `uncaught`, no `errors` on a program, and a stack that ran out ended the program; 20 had no functions as values: no `fn` on a `const`, and every variable a function used was declared before the function was first called; 19 had no fixed numbers: no `fixed` on a variable, an array, a `const` or a printed number, and no `fmul`, `fdiv`, `sqrt` or `atan2`; 18 had only two kinds of event: a `unitLoop`'s `source` was `created` or `died`, with no `was` and no `event`; 17 had no events: no `source` on a `unitLoop` and no `listen` on a `start`; 16 had no `buildText`, no `built` and no `char` part: a text was made whole or not at all; 15 had no tasks: no `start`, `cancel`, `running`, and no `task` on a declaration; 14 had no `parallel`: nothing ran beside a program's body; 13 had no text in the cells of an array: no `textAt`, `storeText`, `releaseText`; 12 had no texts: a text was the parts of a `print` and nothing else; 11 had no `slice` and no `through`: every array was cells of its own; 10 had no recursion: no `recursive`, no `saves`, and the heap's top set aside for a stack nothing used; 9 had no functions that are called: every `call` carried a body of its own; 8 had no `unitAt` / `unitPart`, so no array could hold a unit; 7 had no arrays that grow; 6 had no arrays; 5 had unsigned numbers only, the two-sided reading of `+` and `−`, no `>>>` and no `unsigned` anywhere; 4 had no input, no `centerLocation`, and one `variable` on an action where 5 has a list; 3 had no units and no tables; 2 had no reads, no `random(n)`, no bitwise operators +(`compiler/structured.ts`) turns the TypeScript into this, the backend (`compiler/backend/`, +the map of our own) lays it out into the map when it is saved, and `compiler/simulateIr.ts` +interprets it for Simulate and the tests. (Until TrigScript 4's step 17, `python/trigscript.py` +lowered it to eudplib instead; what it decided is the backend's now, and the gate before it — +step 16, random whole programs run every way — holds the two to the same lines.) **Version 24** (23 had no `held` on a variable, and no function kept as a value used a variable of the block it was written in; 22 had nothing only one computer knows: no `syncSend`, no `select`, no `selected` unit and no `sync` input; 21 had no errors: no `try`, `throw` or `uncaught`, no `errors` on a program, and a stack that ran out ended the program; 20 had no functions as values: no `fn` on a `const`, and every variable a function used was declared before the function was first called; 19 had no fixed numbers: no `fixed` on a variable, an array, a `const` or a printed number, and no `fmul`, `fdiv`, `sqrt` or `atan2`; 18 had only two kinds of event: a `unitLoop`'s `source` was `created` or `died`, with no `was` and no `event`; 17 had no events: no `source` on a `unitLoop` and no `listen` on a `start`; 16 had no `buildText`, no `built` and no `char` part: a text was made whole or not at all; 15 had no tasks: no `start`, `cancel`, `running`, and no `task` on a declaration; 14 had no `parallel`: nothing ran beside a program's body; 13 had no text in the cells of an array: no `textAt`, `storeText`, `releaseText`; 12 had no texts: a text was the parts of a `print` and nothing else; 11 had no `slice` and no `through`: every array was cells of its own; 10 had no recursion: no `recursive`, no `saves`, and the heap's top set aside for a stack nothing used; 9 had no functions that are called: every `call` carried a body of its own; 8 had no `unitAt` / `unitPart`, so no array could hold a unit; 7 had no arrays that grow; 6 had no arrays; 5 had unsigned numbers only, the two-sided reading of `+` and `−`, no `>>>` and no `unsigned` anywhere; 4 had no input, no `centerLocation`, and one `variable` on an action where 5 has a list; 3 had no units and no tables; 2 had no reads, no `random(n)`, no bitwise operators and no `print`; 1 had the map's string indices in the records and a `cyclesPerSecond` on the program, for the death-counter backend 3.0 removed). The types are in `compiler/ir.ts`; this is the reference for anyone reading the lowering or writing @@ -113,8 +115,8 @@ size class — a cell each, or a row of twelve each in a per-player program. a block given back waits in its size's list for whoever wants that size next (its first cell links the next), and new ground is taken from the bottom of the heap up. Nothing splits or joins blocks. When there is no block to take, nothing is pushed and the game says so once, in red; the interpreter - records a fault. `compiler/simulateIr.ts` counts blocks exactly as `python/trigscript.py` hands them - out, so both run out at the same push — a probe expects the same number from each. + records a fault. `compiler/simulateIr.ts` counts blocks exactly as the backend (`compiler/backend/heap.ts`) + hands them out, so both run out at the same push — a probe expects the same number from each. - `declareArray` of a growing array first gives back the block the handle holds. So an array declared in a loop, or in a function called again, holds one block at a time, and what the heap can lose is bounded by the number of declarations; there is no other freeing and no collector. @@ -805,7 +807,7 @@ interpreter agree on every case: - A `switch` compares bits: a case of −1 is 0xFFFFFFFF. - **Ranges** (version 25). Any number may carry `range: [lo, hi]`: `compiler/ranges.ts` found its signed reading never below `lo` nor above `hi`. A backend may use it to take only the bits a - value can have — the Python lowering does for a product by a constant, a quotient and a + value can have — the backend does for a product by a constant, a quotient and a remainder by a constant or by a variable, a shift, and a `min` / `max` / `abs` one side of which always wins — and may ignore it. The same pass reads a comparison whose sides are never below zero as `unsigned`, and turns one whose ranges settle it (wherever it is met, diff --git a/docs/v4-plan.md b/docs/v4-plan.md index f292edf..e9d4404 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -3588,7 +3588,7 @@ found what they left out; where a step moved, its old number is given. reads — A1 to A5, Find an EUD Error, cost by line, the desync detector and `sim.program()` name lines with them; `build-fixture` moved onto the own build (the `trigger()` records, Home/Away/Pixel, PEOPLE, the settings); the allowlist's evidence traced again; the lowering bench's baseline anew; a built map tested end to end in CI. -16. **The gate before the Python goes** (new). With it goes the second build every step is held against. Before: +16. **The gate before the Python goes** ✓ ("The gate before the Python goes (2026-10-23)" below). With it goes the second build every step is held against. Before: random whole programs (idea 17) built our way and run in the lifted game against the simulator; every probe and showpiece built our way and played again in Remastered; the simulator's model (`docs/ir.md`: the Python lowering's today) moved to the own backend. @@ -5550,6 +5550,166 @@ Still open from the list: the step 10 to 15 leftovers (sizes and speeds), `j = 1 texts' 1,023-byte count, `shared(0)` set again by each run, `let total = shared(0)`, and P9 to P12 owners confirmed in the game. +### The gate before the Python goes (2026-10-23) + +Step 16, on branch `gate`. The idea 17 of Part D built: whole programs made at random, run every way there is, and +what they found, fixed. Then every probe built both ways and played in the lifted game, the last time the two builds +are held against each other; and the simulator's model named the backend's. + +**Random whole programs** (`tests/randomScript.ts`). A script is drawn as a small tree — the program's variables, its +functions, statements and expressions of the language's core: numbers with every operator, booleans, texts known when +built and made in the game, arrays fixed and growing, a record, `if`, `for`, `while`, `do`, `switch`, `break` and +`continue`, calls and a function that calls itself, `try`, `throw` and `finally`, the deaths table and the switches, +`sleep` — and rendered twice from the one tree: as TrigScript, and as JavaScript whose own arithmetic (32 bits, +`Math.imul`, a quotient cut towards zero, a shift by 32 or more leaving nothing) says what the lines must be. Every +loop carries a guard, every index stays inside its array, a `throw` stands inside a `try`, and a program prints a line +at the end of each of its frames with its seed in it. Three programs to a script, so one compile holds three. + +- **Four runs, the lines compared frame by frame.** JavaScript (`runJs`: each program a generator, `sleep` a yield, + the programs resumed in order on the frame their sleep ends); the simulator; the map of our own built on the tests' + synthetic scenario and walked from its bytes (`tests/ownWalk.ts`); and, with the game, the lifted game on the own + build and on the eudplib build. `tests/random-scripts.test.ts` runs the first three over 60 seeds in CI + (`RANDOM_SCRIPTS=n` for more, `RANDOM_SEED=n` for one, its source printed), about a second a seed; + `scripts/random-scripts.mts` runs any range, adds the game (`--game`, `--eudplib`, a few programs to a map since the + lifted game is 1.16.1 and its string table names 64 KB), and cuts a failing seed down to the smallest script that + still fails the same way (`--reduce=SEED`: whole programs first, then a statement at a time out of every body, a + compound statement replaced by its body, functions and declarations nothing names). +- **What the generator had to learn** about writing TypeScript that means what it says, each a false failure before it + was a rule: `a < b … > (` reads as a generic call to TypeScript's parser whatever `a` is, so no `<` is ever written + (the sides change places); two constants compared have "no overlap", and two constants in arithmetic are worked out + when the script is built, so a side is always a variable; `x == 20` narrows `x` to 20 and a later `x != -77` has no + overlap, so a constant compared for equality is `z + 20` with `z` a number the compiler cannot see (the deaths of a + unit nothing sets); a text variable against a literal narrows it too, so texts are compared variable to variable or + as a template of the variable; a divisor the compiler can see is 0 is a compile error, so divisors are `z` or made + odd; `deaths()` is a u32; a `finally` cannot `break` out of its try; a function that calls itself only with less; + `−(−2 147 483 648)` is a u32 literal. None of these is a bug of the language. + +**Found and fixed, in the front end** — what the compiler worked out when the script was built, against what the +game computes (each the smallest script in `tests/gate.test.ts`): +- A condition of the game in a `?:`, a loop's bound or a text ternary was taken as *true*: `switchIs(…)` is a record + when the script is built, an object, true to JavaScript. The evaluator (`structured.ts`) now treats a condition as a + value of the game's — nothing is worked out over it — and the hoister (`hoist.ts`) never hoists a `?:` whole, so its + sides are hoisted on their own and the condition is tested where it stands. (`if (cond)` was right all along.) +- `7 / 2` was 3.5 where the plan (B6) says 3: a quotient of two whole numbers known when built was JavaScript's. + `wholeQuotient` / `wholeArithmetic` (`hoist.ts`, by the `fixed` and `u32` brands of the sides' types) now decide, in + the evaluator and in every hoisted thunk (a helper emitted by the TypeScript factory around `+ − * /`, the shifts, + unary minus and `Math.abs`): whole numbers add, subtract and multiply in 32 bits — `i + 2147483647` for i = 1 is + −2 147 483 648, `65536 * 65536` is 0 — divide towards zero, shift by 32 or more to nothing, and `−x` and `Math.abs(x)` + wrap; a `u32` from 0 up; a `fixed`, or a side with a fraction, as a fraction. Before, `n = Math.abs(i + 2147483647)` + in an unrolled loop was a compile error ("not 2147483648") and `xs[i & 3] = Math.min(7, i << 1000000)` stored i. +- `xs[ys.pop() & 1] = ys.length ^ 255`: the index is worked out before the value, as JavaScript has it, when it holds a + pop or the value calls (the call may change what the index reads); before, only an index with a call in it was. + +**Found and fixed, in the map of our own:** +- A `do … while` whose test needs a value computed first — `(n1 + n2) >= (n2 | n0)`, a call, two made texts compared — + was refused ("a do it does not lower"): the rewriter left such a test alone, since what it needs would have to run at + a `continue` too. A `do` now carries `before` (IR): statements run before its test at every turn's end, where a + `continue` lands; the emitter lays them out at the loop's continue label, and the texts pass puts a `do`'s and a + `for`'s text work there (a `for`'s at the top of each turn, as a `while`'s). +- A cell at a masked index compared with a constant — `if (xs[n & 3] <= 26)`, in a `?:`, as a boolean argument — was + refused ("a if it does not lower", "the value given to b"): `cellCompare` left the index under its mask, and a + condition reads a cell at a variable plus a constant only. The index is a sum now. Nearly every index a random + program writes is masked, which is why this was 49 of 100 seeds. +- A call among a print's parts clobbered the text: `print(\`s ${f(5)} ${t}\`)` with an `f` that makes a text showed + the text alone. The parts were written at the program's one cursor from the first, and the call — hoisted later, by + the emitter's rewriter — ran its own text through the same cursor. The texts pass now reads every part up to the last + call ahead of the text (`ahead`): a number into a variable of its own, a text of the map's into a number of its own + (its texts the variable's), a made text into a block of its own — in the parts' order, as JavaScript reads them, so + `${t} ${f()} ${t}` with an `f` that changes `t` prints the old and the new. A text ternary's test is asked once, + before the text is begun, and what the side it picks needs done first is done under it. + +- A number of the game's compared with a constant below zero — `if (i32(deaths(P1, u)) >= -10)` — was asked of the + game as the condition's own "at least 4 294 967 286": false. `asked` (the rewriter) now reads such a constant as the + signed number it is and asks the numbers it holds for as bits, one or two runs of "at least" and "at most", as + `cellCompare` reads a cell. (A linear sum's constant arrives from 0 up; its sign is in its top bit.) +- A text ternary whose sides came to one text — `f() ? t : t` — lost its test, and the call in it: a choice of one text + was written as that text. Its test is asked now when it does something. +- A program's tasks died with its body: at its end the run's wake was set to never. The simulator and the Python side + let a task run on ("a task that runs on after the program ends", `probes/game-tasks.ts`); the own build's wake now + comes every frame and goes straight to the tasks' turn when the program started any — 4 of 4 in the lifted game. +- `sync()` inside an `if` or a loop was refused: the input pass lowered a `syncSend` only where it stood at the top of + a list of statements, and left one inside a statement's body to the emitter, which does not take it. The pass walks + every list of statements now. `probes/local-select.tsx` and `local-ui.tsx` build our way (to play: the pack below). + +- **Tetris and Snake did not build our way.** Nobody had asked: the showpieces were played on their eudplib builds + and the ledger holds the language a row at a time. Tetris's `if (cy >= 0 && board[cy * W + cx] !== 0) return false` + was refused by the masked-index fix above — `cellCompare` sent every index through `sum`, and a condition reads a + cell at one variable plus a constant, which `cy * W + cx` is not: an index is computed first now when it is masked or + of more than one term, else read where it is. Snake (and `probes/game-ui.tsx`) call the ui library's `thousands()` + twice in one line: a called function makes its text into *its own* result's block, one for the function, while each + call site had a result of its own that nothing wrote (an inlined call's body writes the call's) — "a assign it does + not lower" on the function's own statements. A call reads the function's block now, and the statement-level hoist + copies what a call gave into a block of its own before a later call writes it again, so `${f(a)} and ${f(b)}` is + right. Both showpieces build our way; the pack below plays them. + +**What the lifted game taught about reading it** (scm-sim's `readText`): the game's text is a ring of thirteen +lines read once a frame, so of a frame that printed more only the last thirteen are seen, and a slot whose text is as +it was when last read, and that the pointer did not pass this frame, is not seen again; a template printed with values +carries `\r` where its padding was (Remastered draws nothing for it, as the walker drops it), and a dot in a pattern +does not match `\r`. `random-scripts.mts --game` runs the simulator's lines through the same ring before comparing, +and `gate-sweep.mts` compares the two builds' lines, both read the same way. And 1.16.1 names the first 64 KB of +strings: a few programs to a map. + +**Left as it is, in the Open list:** a function that calls itself with a `finally` in it is refused ("the variable +(kept (error message)) of f, which calls itself"): the error kept across the `finally` is a text, which the stack of a +map of our own does not keep; a function whose every value is known when the script is built runs as JavaScript, with +JavaScript's arithmetic (`(f8 result) is a number … not 2147483648` where the game would wrap); the lifted game takes +about six programs to a map. + +**The numbers.** The first run of 60 seeds failed 58; after the generator's rules, 93 of 100; after the `do` and the +masked-index fixes and the condition's, 21; after the print's, 14 — then 13, every one the regression of the print fix, +and 0. Seeds 1–100 all agree, 3,826 lines; 201–300 all agree, 4,511 lines; 301–400 (a sample never fixed against): all agree, 5,585 lines (two of its seeds found the last two bugs, the do loop's dropped text work and the shift count's parse). In the lifted game, both +builds: 30 seeds (90 programs, three to a map), 573 lines of each build read through the game's ring, every one among the simulator's lines in its order, and the own build's lines the same as eudplib's, line for line. + +**Every probe, both builds** (`scripts/gate-sweep.mts`): each script under `probes/` built our way and through eudplib +by the command its header gives, each played for 300 frames by the lifted game with nobody at it, the lines compared +frame by frame; a `game-*.ts` probe runs its tests in the game on the own build instead. The table (the scratchpad's `sweep2.md` +and `sweep3.md` of 2026-10-23, 119 scripts) comes to: + +| What | Scripts | +| --- | --- | +| Built both ways, played, the same lines | 78 — every `own-*` probe, the korobeiniki four, sound-demo, music-demo (its sound made first), local-reads/select/ui, classes, map, numbers, arrays, functions, recursion, reads, units, v3, inside, callbacks, spike, strings-spike, remastered-dump, tile-survey, ui-spike, ui-spike-2, frame-cost, the fog and measurement probes that print nothing | +| Built both ways, the same lines as far as the shorter run goes | local-ui, own-input (eudplib's run a line or two further in the frames) | +| Built both ways, lines differ by timing, ours earlier | cutscene ("B: 4 got there" at frame 204 against 221), own-step5 (a transmission's wait), own-triggers (B at frame 3 against 6: eudplib runs a trigger it should not) | +| Built both ways, lines differ because the game is random | tetris, snake (the board drawn from random pieces: played by hand, the pack) | +| Built both ways, eudplib's lines wrong | own-fields ("C: a unit count WRONG" from eudplib, ok from ours), key-held (eudplib prints garbage where ours prints "taps 0 let go 0") | +| Refused by the own build | leftovers (a function that calls itself and gives back a text), strings (a unit's name written from a made text), game-closures and game-references (the length of the texts' address array of records kept in an array that grows) | +| Not built here | bad-apple, halftone, sound-probe (their video, image and base map are not on this disk) | +| `game-*.ts` tests in the game on the own build, all passed | arrays-cost 7, async 3, clamps 2, cursors 4, emitter 10, errors 7, fdiv 7, fixed 6, function-values, generators 3, location-stale 2, math 4, program 3, ranges 5, short-circuit 1, small-gaps, stdlib-core 5, tasks 4 (after the fix above), thresholds 2, ui 1, unit-order 3, values 3, unit-pixels 1 | +| `game-*.ts` tests failing in the game on the own build | events, events-2, events-input, key-held, key-tap, local, local-players, players, tests, raycaster, cutscene, lowering — the harness's events and input on a map of our own (the Open list), to be looked at one by one in step 17; text-arrays past the lifted game's 64 KB of strings | + +**The simulator's model.** `compiler/simulateIr.ts` and `docs/ir.md` named `python/trigscript.py` as the model the +interpreter follows — the heap's blocks, a made text's bytes, the stack's frames, a held key's grace, a number's 32 +bits, an uncaught error said in red. Each names the backend's file now (`heap.ts`, `texts.ts`, `emit.ts`, `input.ts`, +`errors.ts`); the Python is named once, as what laid the same programs out until step 17. The README's account of the +build still says a program the own build does not take goes through eudplib, which is true until step 17 deletes it. + +*Open:* +- Step 16's leftovers (2026-10-23). *A function that calls itself with a `finally`* in it, refused (a text on the + stack); the generator leaves the shape out until then. *A function that calls itself and gives back a text* — the + same stack — refused: `probes/leftovers.ts`'s `path()` ("a function that gives back a text (path)") builds through + eudplib until it is taken, and step 17 must take it or change the probe. *The test harness on maps of our own:* + `probes/events.ts` counts "Display Text Message —" action events, which `sim.events` has only from an eudplib build + (0 of 3, 0 of 2 in the game, the lines themselves shown), and `probes/game-tests.ts`'s typed line and key (`sim.type`, + `sim.press`) reach the Python side's chatEvent and MSQC, not the own build's carriers — both A2 leftovers for step 17 + or the Magenta slice, played probes standing in until then. `probes/game-text-arrays.ts` runs its strings past the + lifted game's 64 KB. *The hoister's narrowing:* `let b = false; if (b) { f(b || x) }` is `never` to TypeScript (the + variable narrowed to false, then nothing), and the error says "must be known when the script is built": right, badly + worded. *The lifted game's eudplib build* prints the same lines as ours, so `--eudplib` is kept only until step 17. + *Texts in records kept in an array that grows:* `probes/game-closures.ts` and `game-references.ts` are refused ("a + assign it does not lower": the length of the texts' address array, `(kept) addr`, `(objects) name.addr`) — a B9 shape + the texts pass does not make; through eudplib until taken. *The `game-*.ts` probes that fail in the lifted game on + the own build* (the table above) are to be gone through one by one at the start of step 17: most drive the players' + input, which the harness sends through the Python side's plugins. *A text made inside a function that calls itself:* `t = \`${t}${f(d - 2)}\`` in `f` reads `t` after the inner call + in the simulator and in the map (the same in both), before it in JavaScript ("zzz000" against "zzz0000"); a template + with a call at the top of the program reads its parts in order. The generator leaves texts out of a function that + calls itself until this is taken. *Build-time functions* run as JavaScript: a function whose + arguments and reads are all known when the script is built is called then, and its body's arithmetic does not wrap + (`i + 2147483647` inside it is 2 147 483 648, then an error naming the result). *The lifted game's strings:* 1.16.1 + names the first 64 KB, so `--game` puts three seeds (nine programs) to a map and says so when a map is past it. + *`EUDPLIB=1`* is now unused by anything but `gate-sweep.mts` and `random-scripts.mts --eudplib`: step 17 removes it + with the Python. + ## Part C — the game, pushed Each is a library over Part B, offered only after its probe. "Known" means a played probe diff --git a/scripts/gate-sweep.mts b/scripts/gate-sweep.mts new file mode 100644 index 0000000..b76efc4 --- /dev/null +++ b/scripts/gate-sweep.mts @@ -0,0 +1,119 @@ +/** + * Every probe and showpiece built our way and played in the lifted game, beside its eudplib build (TrigScript 4, step + * 16: the gate before the Python goes — the last time the two builds are held against each other). For each script + * under probes/: the build command its header gives (the base map among its arguments), the own build and the eudplib + * build, each played with nobody at it for N frames by scm-sim's lifted game (scripts/exact-eud.ts), the lines of text + * compared frame by frame; a game-*.ts probe runs its tests in the game instead (scripts/test-game.mts). The result + * is a table, Markdown, for the plan. + * + * npx tsx scripts/gate-sweep.mts [--frames=300] [--only=name,name] [--no-eudplib] [--out=file.md] + * + * Needs a scm-sim checkout beside this repository (or SCM_SIM_DIR) with its lifted game generated, and for the eudplib + * builds a plugin-eudplib checkout (or EUDPLIB_DIR). A probe whose base map or library is not on disk is said so. + */ +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; + +const root = resolve(import.meta.dirname, ".."); +const args = process.argv.slice(2); +const flag = (n: string) => { const f = args.find((a) => a === `--${n}` || a.startsWith(`--${n}=`)); return f === undefined ? undefined : f.includes("=") ? f.slice(f.indexOf("=") + 1) : ""; }; +const frames = Number(flag("frames") ?? 300); +const only = flag("only")?.split(",").filter(Boolean); +const withEudplib = flag("no-eudplib") === undefined; +const sim = resolve(process.env.SCM_SIM_DIR ?? join(root, "..", "scm-sim")); +if (!existsSync(join(sim, "scripts", "exact-eud.ts"))) { console.error(`no scm-sim checkout with scripts/exact-eud.ts at ${sim} (set SCM_SIM_DIR)`); process.exit(2); } +const eudplibDir = resolve(process.env.EUDPLIB_DIR ?? join(root, "..", "plugin-eudplib")); +const haveEudplib = withEudplib && existsSync(join(eudplibDir, "scripts", "build-map.mts")); + +interface Row { probe: string; own: string; eudplib: string; lines: string; note: string } +const rows: Row[] = []; +const dir = mkdtempSync(join(tmpdir(), "trigscript-gate-")); + +/** The lines the lifted game printed, as `frame:text` from the first line's frame, and how the run ended. */ +function play(map: string): { lines: string[]; ended: string; error?: string } { + const run = spawnSync("node", ["--experimental-strip-types", join(sim, "scripts", "exact-eud.ts"), map, `--frames=${frames}`], { cwd: sim, encoding: "utf8", maxBuffer: 256 << 20 }); + if (run.status !== 0) { + const limit = /past what STR can name/.test(run.stderr); + return { lines: [], ended: "", error: limit ? "strings past 64 KB (1.16.1)" : (run.stderr.split("\n").find((l) => /Error/.test(l)) ?? "the lifted game failed").slice(0, 100) }; + } + const said = run.stdout.split("\n").map((l) => /^\s*(\d+) ([^\n]*)$/.exec(l)).filter((m): m is RegExpExecArray => !!m).map((m) => ({ frame: Number(m[1]), text: m[2].replace(/\r/g, "") })); + const first = said[0]?.frame ?? 0; + const ended = /ended: (.*)$/m.exec(run.stdout)?.[1] ?? ""; + return { lines: said.map((s) => `${s.frame - first}:${s.text}`), ended }; +} + +const probes = readdirSync(join(root, "probes")).filter((f) => /\.tsx?$/.test(f) && (!only || only.includes(f.replace(/\.tsx?$/, "")))).sort(); +for (const file of probes) { + const name = file.replace(/\.tsx?$/, ""); + const source = readFileSync(join(root, "probes", file), "utf8"); + const head = source.split("\n").slice(0, 40).join("\n"); + const t0 = Date.now(); + // A game-*.ts probe: its tests in the game, on the own build. + if (/^game-/.test(name) || /test-game\.mts probes/.test(head)) { + const cmd = /npx tsx scripts\/test-game\.mts probes\/\S+( \S+\.scx)?/.exec(head); + const base = cmd?.[1]?.trim(); + if (base && !existsSync(join(root, base))) { rows.push({ probe: name, own: "—", eudplib: "—", lines: "—", note: `needs ${base}` }); continue; } + if (/debug-game\.mts/.test(head)) { rows.push({ probe: name, own: "—", eudplib: "—", lines: "—", note: "debug-game: run by hand" }); continue; } + const r = spawnSync("npx", ["tsx", join(root, "scripts", "test-game.mts"), join(root, "probes", file), ...(base ? [join(root, base)] : [])], { cwd: root, encoding: "utf8", maxBuffer: 256 << 20, env: { ...process.env, SCM_SIM_DIR: sim } }); + const out = r.stdout + r.stderr; + const game = /in the game[^\n]*?(\d+) of (\d+)|(\d+) of (\d+) in the game/.exec(out); + const passed = out.match(/(\d+) passed/g)?.join(", ") ?? ""; + rows.push({ probe: name, own: r.status === 0 ? "ok" : "FAILED", eudplib: "—", lines: game ? `${game[1] ?? game[3]} of ${game[2] ?? game[4]} in the game` : passed || out.split("\n").filter(Boolean).slice(-1)[0]?.slice(0, 80) || "", note: `${((Date.now() - t0) / 1000).toFixed(0)} s` }); + console.log(`${name}: ${rows.at(-1)!.own} ${rows.at(-1)!.lines}`); + continue; + } + // A played probe: build both ways and play both. + const cmd = /build-fixture(?:\.mts)? probes\/\S+ \S+(?: (\S+\.scx))?/.exec(head); + const base = cmd?.[1]; + const needs = /probes\/lib\/[\w-]+\.mts/.exec(head)?.[0]; + if (base && !existsSync(join(root, base))) { rows.push({ probe: name, own: "—", eudplib: "—", lines: "—", note: `needs ${base}${needs ? ` (${needs})` : ""}` }); console.log(`${name}: needs ${base}`); continue; } + // (A header's own environment — EXTRA_PLUGIN=…, a Python plugin of eudplib's for a measurement — is not passed: it is + // the eudplib build's alone, and the gate holds the script, not the plugin.) + const build = (way: "own" | "eudplib"): { map: string; error?: string } => { + const map = join(dir, `${name}-${way}.scx`); + const b = spawnSync("npx", ["tsx", join(root, "scripts", "build-fixture.mts"), join(root, "probes", file), map, ...(base ? [join(root, base)] : [])], { cwd: root, encoding: "utf8", maxBuffer: 256 << 20, env: { ...process.env, ...(way === "eudplib" ? { EUDPLIB: "1" } : {}), MAP_NAME: `${name} ${way}` } }); + if (b.status !== 0) { + const text = b.stdout + b.stderr; + const refused = /refus\w+[: ]+(.*)/i.exec(text)?.[1] ?? text.split("\n").filter((l) => /error|Error|refus/i.test(l)).slice(-1)[0] ?? text.split("\n").filter(Boolean).slice(-1)[0]; + return { map, error: (refused ?? "build failed").slice(0, 110) }; + } + return { map }; + }; + const own = build("own"); + const row: Row = { probe: name, own: own.error ? `REFUSED: ${own.error}` : "built", eudplib: haveEudplib ? "" : "—", lines: "", note: "" }; + let ownPlay: ReturnType | undefined; + if (!own.error) { + ownPlay = play(own.map); + row.own = ownPlay.error ? `built; ${ownPlay.error}` : `${ownPlay.lines.length} lines${ownPlay.ended && !/frame limit/.test(ownPlay.ended) ? `; ${ownPlay.ended}` : ""}`; + } + if (haveEudplib) { + const e = build("eudplib"); + if (e.error) row.eudplib = `build failed: ${e.error}`; + else { + const p = play(e.map); + row.eudplib = p.error ? `built; ${p.error}` : `${p.lines.length} lines${p.ended && !/frame limit/.test(p.ended) ? `; ${p.ended}` : ""}`; + if (ownPlay && !ownPlay.error && !p.error) { + const same = ownPlay.lines.join("\n") === p.lines.join("\n"); + const prefix = (a: string[], b: string[]) => a.length < b.length && a.every((l, k) => l === b[k]); + if (same) row.lines = "the same"; + else if (prefix(p.lines, ownPlay.lines)) row.lines = `the same as far as eudplib's go (${ownPlay.lines.length - p.lines.length} more of ours in the frames)`; + else if (prefix(ownPlay.lines, p.lines)) row.lines = `the same as far as ours go (${p.lines.length - ownPlay.lines.length} more of eudplib's in the frames)`; + else { + const i = ownPlay.lines.findIndex((l, k) => l !== p.lines[k]); + row.lines = `differ at ${i}: own ${JSON.stringify(ownPlay.lines[i] ?? "")} / eudplib ${JSON.stringify(p.lines[i] ?? "")}`.slice(0, 160); + } + } + } + } + row.note = `${((Date.now() - t0) / 1000).toFixed(0)} s`; + rows.push(row); + console.log(`${name}: own ${row.own} | eudplib ${row.eudplib} | ${row.lines}`); +} +rmSync(dir, { recursive: true, force: true }); + +const table = ["| Probe | Own build, played | eudplib build, played | Lines | Note |", "| --- | --- | --- | --- | --- |", ...rows.map((r) => `| ${r.probe} | ${r.own} | ${r.eudplib} | ${r.lines} | ${r.note} |`)].join("\n"); +const out = flag("out"); +if (out) writeFileSync(out, table + "\n"); +console.log("\n" + table); diff --git a/scripts/random-scripts.mts b/scripts/random-scripts.mts new file mode 100644 index 0000000..df01403 --- /dev/null +++ b/scripts/random-scripts.mts @@ -0,0 +1,169 @@ +/** + * Whole programs made at random, run every way there is (TrigScript 4, step 16: the gate before the Python goes). + * Without the game, each seed is run as JavaScript, in the simulator and as the built map of our own walked from + * its bytes (`tests/randomRun.ts`, what CI runs). With `--game`, the seeds are built our way, several to a map, and + * played in the lifted game, their lines held against the simulator's frame by frame; with `--eudplib` too, the + * same scripts are built through eudplib and played, the last use of the second build. + * + * npx tsx scripts/random-scripts.mts [--seeds=1..200] [--programs=3] [--frames=12] [--game [--eudplib] [--batch=8]] + * npx tsx scripts/random-scripts.mts --show=SEED the script as TrigScript + * npx tsx scripts/random-scripts.mts --reduce=SEED the smallest script that still fails the same way + * + * The game needs a scm-sim checkout beside this repository (or SCM_SIM_DIR) with its lifted game generated; eudplib a + * plugin-eudplib checkout (or EUDPLIB_DIR). The maps are built on fixtures/maps/(2)Binary Burghs.scx, as the probes are. + */ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { randomScript, reduce, renderScript, type Generated } from "../tests/randomScript"; +import { checkRandom, compileRandom, diff, simLines } from "../tests/randomRun"; + +const root = resolve(import.meta.dirname, ".."); +const args = process.argv.slice(2); +const flag = (n: string) => { const f = args.find((a) => a === `--${n}` || a.startsWith(`--${n}=`)); return f === undefined ? undefined : f.includes("=") ? f.slice(f.indexOf("=") + 1) : ""; }; +const programs = Number(flag("programs") ?? 3), frames = Number(flag("frames") ?? 12); +const make = (seed: number) => randomScript(seed, programs, frames); + +if (flag("show") !== undefined) { + process.stdout.write(renderScript(make(Number(flag("show"))))); + process.exit(0); +} + +if (flag("reduce") !== undefined) { + const seed = Number(flag("reduce")); + const g = make(seed); + const first = checkRandom(g); + if (!first.problem) { console.log(`seed ${seed} passes`); process.exit(0); } + console.log(`seed ${seed} fails (${first.kind}): ${first.problem}\n\ncutting down…`); + let tries = 0; + // (The same failure: its kind, and for a compile error or a refusal the message's words — not another error the + // cutting down brought in, a counter cut from the loop that read it, say.) + const words = (o: { kind?: string; problem?: string }) => (o.kind === "compile" || o.kind === "refused" ? (o.problem ?? "").replace(/main\.ts:\d+:\d+ |line \d+|\d+/g, "").split("\n")[0] : ""); + const small = reduce(g, (t) => { tries++; const o = checkRandom(t); return o.kind === first.kind && words(o) === words(first); }); + const last = checkRandom(small); + console.log(`\n${tries} tries; the smallest (${last.kind}): ${last.problem}\n`); + process.stdout.write(renderScript(small)); + process.exit(1); +} + +const [from, to] = (flag("seeds") ?? "1..50").split("..").map(Number); +const seeds = Array.from({ length: (to ?? from) - from + 1 }, (_, i) => from + i); + +// ── without the game ── +const failed: number[] = []; +let lines = 0; +const kinds: Record = {}; +const t0 = Date.now(); +for (const seed of seeds) { + const o = checkRandom(make(seed)); + lines += o.lines; + if (o.problem) { failed.push(seed); kinds[o.kind!] = (kinds[o.kind!] ?? 0) + 1; console.log(`seed ${seed} (${o.kind}): ${o.problem}`); } +} +console.log(`${seeds.length} seeds × ${programs} programs, ${lines} lines compared as JavaScript, in the simulator and from the map's bytes: ${failed.length ? `${failed.length} failed (${JSON.stringify(kinds)}: seeds ${failed.join(", ")})` : "all agree"}; ${((Date.now() - t0) / 1000).toFixed(0)} s`); + +if (flag("game") === undefined) process.exit(failed.length ? 1 : 0); + +// ── in the lifted game ── +const sim = resolve(process.env.SCM_SIM_DIR ?? join(root, "..", "scm-sim")); +if (!existsSync(join(sim, "scripts", "exact-eud.ts"))) { console.error(`no scm-sim checkout with scripts/exact-eud.ts at ${sim} (set SCM_SIM_DIR)`); process.exit(2); } +// (The lifted game is 1.16.1, whose string table names only its first 64 KB: a few programs to a map.) +const batch = Number(flag("batch") ?? 3); +const eudplib = flag("eudplib") !== undefined; +const dir = mkdtempSync(join(tmpdir(), "trigscript-random-")); +const gameFailed: string[] = []; +let gameLines = 0, eudLines = 0; + +/** + * What the lifted game reports of the simulator's lines: it reads the game's ring of thirteen text lines once a frame + * (scm-sim's `readText`), so of a frame that printed more only the last thirteen are seen, and a slot whose text is the + * same as when it was last read, and that the ring's pointer did not pass this frame, is not seen again. Modelled here + * on the simulator's lines, frame by frame, so that the two can be compared line for line. + */ +function throughTheRing(lines: string[]): string[] { + const RING = 13; + const slots = Array.from({ length: RING }, () => ""), reported = Array.from({ length: RING }, () => ""); + let at = 0, ringAt = 0; + const out: string[] = []; + const byFrame = new Map(); + for (const l of lines) { const i = l.indexOf(":"); const f = Number(l.slice(0, i)); byFrame.set(f, [...(byFrame.get(f) ?? []), l.slice(i + 1)]); } + const last = Math.max(-1, ...byFrame.keys()); + for (let f = 0; f <= last; f++) { + // (A slot holds 218 bytes with its end: a longer line is cut there.) + for (const text of byFrame.get(f) ?? []) { slots[at] = new TextDecoder().decode(new TextEncoder().encode(text).subarray(0, 217)).replace(/\uFFFD$/, ""); at = (at + 1) % RING; } + const oldest = at, moved = (oldest - ringAt + RING) % RING; + ringAt = oldest; + for (let k = 0; k < RING; k++) { + const i = (oldest + k) % RING, text = slots[i]; + if (text === reported[i] && k < RING - moved) continue; + reported[i] = text; + if (text) out.push(`${f}:${text}`); + } + } + return out; +} + +/** Whether `got` is `want` with lines left out: each line of `got` found in `want` after the one before. The first that is not, else null. */ +function inOrder(want: string[], got: string[]): string | null { + let k = 0, last = ""; + for (const line of got) { + // (The game wraps a long line where a word ends: the first part starts the simulator's line, the rest is in it.) + const i = want.findIndex((w, j) => j >= k && (w === line || w.startsWith(line.trimEnd()))); + if (i < 0) { + if (last.includes(line.slice(line.indexOf(":") + 1).trim())) continue; + return `${JSON.stringify(line)} is not among the simulator's lines from ${JSON.stringify(want[k] ?? "")} on (${got.length} lines in the game, ${want.length} in the simulator)`; + } + last = want[i]; + k = i + 1; + } + return null; +} + +/** The lifted game's lines as `cycle:text`: a program first runs on the game's frame 2, the simulator's cycle 0. */ +function played(map: string): string[] | string { + const run = spawnSync("node", ["--experimental-strip-types", join(sim, "scripts", "exact-eud.ts"), map, `--frames=${frames + 8}`], { cwd: sim, encoding: "utf8", maxBuffer: 256 << 20 }); + if (run.status !== 0) { + const limit = /past what STR can name/.test(run.stderr); + return limit ? "the map's strings run past the 64 KB the lifted game (1.16.1) can name: fewer programs to a map (--batch)" : `the lifted game failed: ${run.stderr.split("\n").filter((l) => /Error|error/.test(l)).slice(0, 3).join(" | ") || run.stderr.split("\n").slice(-3).join(" | ")}`; + } + const said = run.stdout.split("\n").map((l) => /^\s*(\d+) ([^\n]*)$/.exec(l)).filter((m): m is RegExpExecArray => !!m).map((m) => ({ frame: Number(m[1]), text: m[2].replace(/\r/g, "") })); + if (!said.length) return "the lifted game printed nothing"; + const offset = 2; + return said.filter((s) => s.frame - offset < frames).map((s) => `${s.frame - offset}:${s.text}`); +} + +try { + for (let i = 0; i < seeds.length; i += batch) { + const group = seeds.slice(i, i + batch).filter((s) => !failed.includes(s)); + if (!group.length) continue; + // One script of the group's programs: each program's lines carry its seed. + const g: Generated = { seed: group[0], frames, programs: group.flatMap((s) => make(s).programs) }; + const r = compileRandom(g); + if (r.diagnostics.length) { gameFailed.push(`seeds ${group.join(",")}: ${r.diagnostics[0].message}`); continue; } + const want = throughTheRing(simLines(r, frames).lines); + const seen: Partial> = {}; + const src = join(dir, `random-${group[0]}.ts`); + writeFileSync(src, renderScript(g)); + for (const way of eudplib ? ["own", "eudplib"] : ["own"]) { + const map = join(dir, `random-${group[0]}-${way}.scx`); + const b = spawnSync("npx", ["tsx", join(root, "scripts", "build-fixture.mts"), src, map], { cwd: root, encoding: "utf8", maxBuffer: 256 << 20, env: { ...process.env, ...(way === "eudplib" ? { EUDPLIB: "1" } : {}), MAP_NAME: `random ${group[0]}` } }); + if (b.status !== 0) { gameFailed.push(`seeds ${group.join(",")} (${way} build): ${(b.stdout + b.stderr).split("\n").filter(Boolean).slice(-3).join(" | ")}`); continue; } + const got = played(map); + if (typeof got === "string") { gameFailed.push(`seeds ${group.join(",")} (${way}): ${got}`); continue; } + if (way === "own") gameLines += got.length; else eudLines += got.length; + // The game's lines are a part of the simulator's: every one of them among the simulator's lines of its frame, in + // the simulator's order (the game's ring loses lines of a crowded frame, `throughTheRing` models most of it but + // not the game's own writes to the ring); and the two builds' lines are the same, line for line. + const missing = inOrder(want, got); + seen[way] = got; + if (missing) gameFailed.push(`seeds ${group.join(",")} (${way} in the lifted game, against the simulator): ${missing}`); + else console.log(`seeds ${group[0]}–${group.at(-1)} ${way}: ${got.length} lines, every one the simulator's, in its order (${want.length} in the simulator)`); + if (way === "eudplib" && seen.own && seen.own.join("\n") !== got.join("\n")) gameFailed.push(`seeds ${group.join(",")}: the own build and eudplib's print differently in the lifted game: ${diff(seen.own, got)}`); + } + } +} finally { + if (gameFailed.length) console.log(`(the maps are kept in ${dir})`); else rmSync(dir, { recursive: true, force: true }); +} +for (const f of gameFailed) console.log(f); +console.log(`in the lifted game: ${gameLines} lines of maps of our own${eudplib ? ` and ${eudLines} of eudplib's` : ""} against the simulator: ${gameFailed.length ? `${gameFailed.length} groups failed` : "all agree"}`); +process.exit(failed.length + gameFailed.length ? 1 : 0); diff --git a/tests/gate.test.ts b/tests/gate.test.ts new file mode 100644 index 0000000..354e82d --- /dev/null +++ b/tests/gate.test.ts @@ -0,0 +1,135 @@ +/** + * What the random whole programs found (TrigScript 4, step 16: the gate before the Python goes), each held here as + * the smallest script that showed it: what the front end worked out wrongly when a script was built, and what a map of + * our own refused or got wrong. Each body is run in the simulator and, where it is a map of our own's, as the built + * map walked from its bytes (`ownWalk.ts`); the lines are what JavaScript prints for the same code. + */ +import ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { compileScript, type CompileResult } from "../compiler/compiler"; +import { defaultScriptNames } from "../compiler/names"; +import { simulatePrograms } from "../compiler/simulateIr"; +import { buildOwnMap, isRefused } from "../compiler/backend/build"; +import { emitOwn } from "../compiler/backend/own"; +import { defaultLib } from "../bundle/lib.mjs"; +import { OWN } from "./randomRun"; +import { scenario } from "./ownScenario"; +import { walkBuiltMap } from "./ownWalk"; + +const LIB = defaultLib(); +const NAMES = defaultScriptNames(); +const CHK = scenario(); +const compile = (body: string): CompileResult => compileScript(ts, { "main.ts": `program(${/\bawait\b/.test(body) ? "async " : ""}() => {\n${body}\n});` }, NAMES, { lib: LIB, emitter: false, own: OWN }); +const messages = (body: string) => compile(body).diagnostics.map((d) => d.message); +/** The simulator's lines for a body that compiles. */ +function sim(body: string, frames = 2): string[] { + const r = compile(body); + expect(r.diagnostics.map((d) => d.message)).toEqual([]); + const s = simulatePrograms(r.ir, frames, { strings: r.strings }); + expect(s.faults).toEqual([]); + return s.events.filter((e) => e.text !== undefined).map((e) => e.text!); +} +/** The built map's lines, walked from its bytes; a refusal is the failure. */ +function walked(body: string, frames = 2): string[] { + const r = compile(body); + expect(r.diagnostics.map((d) => d.message)).toEqual([]); + const out = buildOwnMap({ chk: CHK, compiled: r }); + if (isRefused(out)) throw new Error(out.refused); + return walkBuiltMap(out.chk, out.stats.players, out.stats.entries, frames).printed.map((p) => p.text); +} +/** Both agree with what JavaScript prints. */ +function same(body: string, lines: string[], frames = 2): void { + expect(sim(body, frames)).toEqual(lines); + expect(walked(body, frames)).toEqual(lines); +} +const refusal = (body: string): string | null => { const r = compile(body); expect(r.diagnostics).toEqual([]); const o = emitOwn(r); return typeof o === "string" ? o : null; }; + +describe("worked out when the script is built", () => { + it("a condition of the game in a ?:, a loop's bound or a text ternary is tested in the game, not taken as true", () => { + // (switchIs() is a record when the script is built: an object, true to JavaScript. Switch 3 is clear.) + same(`let n0 = 1000000; print(\`\${switchIs(switches.Switch3, "set") ? 24 : n0}\`);`, ["1000000"]); + same(`let n = 0; n = (switchIs(switches.Switch3, "set") ? -77 : -2147483648) & 7; print(\`\${n}\`);`, ["0"]); + same(`const p = { x: -2147483648, y: -77 }; for (let i = 0; i < ((switchIs(switches.Switch3, "set") ? p.y : p.x) & 7); i++) print(\`a \${i}\`); print("end");`, ["end"]); + same(`print(switchIs(switches.Switch3, "set") ? "set" : "clear"); print(\`\${!switchIs(switches.Switch3, "set") ? 1 : 2}\`);`, ["clear", "1"]); + }); + it("whole numbers divide as whole numbers: 7 / 2 is 3, 7 / fixed(2) is 3.5, known or not", () => { + same(`let a = 0; let b = 7; const p = { y: 7 }; a = (-10) / 7; print(\`\${a} \${7 / 2} \${(-10) / b} \${7 / fixed(2)} \${(-10) / (p.y | 1)} \${3.5 / 2} \${(7 / 2) * 4}\`);`, ["-1 3 -1 3.5 -1 1.75 12"]); + same(`const HALF = 7 / 2; const Q = 7 / fixed(2); let x = 1.5; print(\`\${HALF} \${Q} \${x / 2}\`);`, ["3 3.5 0.75"]); + }); + it("whole numbers add, subtract and multiply in 32 bits, and shift as the game shifts", () => { + same(`print(\`\${5 + 2147483647} \${2147483647 + 1} \${(-2147483648) - 1} \${65536 * 65536} \${u32(4000000000) + u32(1)}\`);`, ["-2147483644 -2147483648 2147483647 0 4000000001"]); + // (An unrolled loop's counter is a constant: the sum wraps as it would in the game, and Math.abs of the least number is itself.) + same(`let n = 0; for (let i = 0; i < 2; i++) n = Math.abs(i + 2147483647); print(\`\${n} \${Math.abs(-2147483648)}\`);`, ["-2147483648 -2147483648"]); + same(`let n = 0; const xs = [0, 0, 0, 0]; for (let i = 0; i < 3; i++) xs[i & 3] = Math.min(7, i << 1000000); n = 65536 >> (-7); print(\`\${xs[2]} \${n} \${(-8) >> 40} \${1 >>> 32}\`);`, ["0 0 -1 0"]); + const p = { x: -2147483648, y: -2 }; + same(`const p = { x: ${p.x}, y: ${p.y} }; print(\`\${p.x + p.x} \${p.x * p.y}\`);`, [`${(p.x + p.x) | 0} ${Math.imul(p.x, p.y)}`]); + }); + it("an index with a pop in it is worked out before the value stored, as JavaScript has it", () => { + same(`const xs = [255, 0]; const ys: number[] = [256, 2]; xs[(ys.pop() ?? 0) & 1] = ys.length ^ 255; print(\`\${xs[0]} \${xs[1]} \${ys.length}\`);`, ["254 0 1"]); + }); +}); + +describe("a map of our own", () => { + it("takes a do loop whose test needs a value computed first, a call among them", () => { + same(`let n0 = -77; let n1 = 65536; let n2 = 0; let g = 0; do { g += 1; if (g > 12) break; } while ((n1 + n2) >= (n2 | n0)); print(\`\${g}\`);`, ["13"]); + same(`let n0 = -77; let n1 = 65536; let n2 = 0; let g = 0; do { g += 1; n2 += 1; } while (g < 12 && (n1 * 3) >= (n2 | n0)); print(\`\${g} \${n2}\`);`, ["12 12"]); + same(`let g = 0; let n = 3; function f(): number { return n - g; } do { g += 1; if (g > 12) break; } while (f() > 0); print(\`\${g}\`);`, ["3"]); + same(`let g = 0; let t: string = "Mf"; function f(): boolean { return t !== t; } do { g += 1; if (g > 12) break; } while (f()); print(\`\${g}\`);`, ["1"]); + // (A continue lands on what the test needs: the test still sees the turn's values.) + same(`let n = 0; let g = 0; do { g += 1; if (g > 12) break; if ((g & 1) != 0) continue; n += 1; } while ((n * 3) < (g | 1)); print(\`\${g} \${n}\`);`, ["2 1"]); + }); + it("compares two made texts in a do loop's test, and in a for loop's bound", () => { + same(`let t5: string = "x"; let t6: string = "k9"; let g = 0; t5 = \`\${t5}1\`; t6 = \`\${t6}2\`; do { g += 1; if (g > 12) break; } while (t5 === t6); print(\`\${g}\`);`, ["1"]); + same(`let t5: string = "x"; let t6: string = "k9"; let n1 = 3; let g = 0; t5 = \`\${t5}1\`; t6 = \`\${t6}2\`; do { g += 1; if (g > 12) break; } while (!((t5 === t6) || ((-1) == n1))); print(\`\${g}\`);`, ["13"]); + same(`let t4: string = "k9"; let z = 3; let g = 0; t4 = \`\${t4}\${z}\`; for (let i = 0; i < (((\`\${t4}\` !== t4) ? z : 2) & 7); i++) g += 1; print(\`\${g}\`);`, ["2"]); + // (Inside a function, with a push in the body and a cell read in the test: the texts' work before the test and the test's own both stay.) + same(`let n0 = 255; let t5: string = "ab"; let t6: string = "k9"; const ys: number[] = [2, 65536]; let g = 0; function f(): number { do { g += 1; if (g > 12) break; ys.push(2); } while (("k9".length == ((\`\${t5}\` !== t6) ? n0 : ys[255 % ys.length]))); return ys.length; } t5 = \`\${t5}1\`; print(\`\${f()} \${g}\`);`, ["3 1"]); + }); + it("compares a cell at a masked index with a constant where the test stands: an if, a ?:, a boolean argument", () => { + same(`let n0 = 1; let n = 0; const xs = [256, 65535, 1000000, 255]; if (xs[n0 & 3] <= 26) n = 1; else n = 2; print(\`\${n}\`);`, ["2"]); + same(`let n0 = 3; let n = 0; const xs = [256, 65535, 1000000, 255]; n = (26 >= xs[n0 & 3]) ? 1 : 2; print(\`\${n}\`);`, ["2"]); + same(`let n0 = 1; const xs = [0, 7, 7, 255]; function f(a: number, b: boolean): number { return b ? a : -a; } n0 = f(3, xs[n0 & 3] > 19); print(\`\${n0}\`);`, ["-3"]); + expect(refusal(`let n0 = 1; let n = 0; const xs = [256, 65535, 1000000, 255]; while (n0 < 4) { if (xs[n0 & 3] <= 26) n = 1; else n = 2; n0 += 1; } print(\`\${n}\`);`)).toBeNull(); + }); + it("prints a text beside a call that changes it, each read where JavaScript reads it", () => { + same(`let t: string = "zzz"; function f(a: number): number { t = \`\${t}1\`; return a; } print(\`s \${f(5)} \${t}\`);`, ["s 5 zzz1"]); + same(`let t: string = "zzz"; function f(a: number): number { t = \`\${t}1\`; return a; } print(\`s \${t} \${f(5)} \${t}\`);`, ["s zzz 5 zzz1"]); + same(`let t: string = "zzz"; function f(a: number): boolean { t = \`\${t}1\`; return a > 3; } print(\`s \${f(5) ? t : "no"} 0\`);`, ["s zzz1 0"]); + same(`let t: string = "zzz"; function f(): void { t = "k9"; } function g(): number { f(); return 1; } print(\`s \${t} \${g()} \${t}\`);`, ["s zzz 1 k9"]); + // (A text chosen among made ones before the call, and among known ones.) + same(`let n1 = 2147483647; let b = false; let t: string = "zzz"; function f(a: number): number { t = (40 > t.length) ? \`\${t}\${n1}\` : "Marine"; return -10; } print(\`s \${b ? t : t} \${f(2)} \${t}\`);`, ["s zzz -10 zzz2147483647"]); + same(`let b = false; let t: string = "zzz"; function f(a: number): number { t = "k9"; return -10; } print(\`s \${b ? "ab" : t} \${f(2)} \${b ? "ab" : t}\`);`, ["s zzz -10 k9"]); + }); + it("compares a number of the game's with a constant below zero as the signed number it is", () => { + for (const [op, want] of [[">=", "1"], [">", "1"], ["<=", "0"], ["<", "0"], ["==", "0"], ["!=", "1"]] as const) { + same(`let g = 0; if (i32(deaths(P1, units.ZergZergling)) ${op} (-10)) g = 1; print(\`\${g}\`);`, [want]); + } + same(`let g = 0; if ((-10) <= i32(deaths(P1, units.ZergZergling))) g = 1; if (i32(deaths(P1, units.ZergZergling)) >= 0) g += 2; print(\`\${g}\`);`, ["3"]); + }); + it("asks a text ternary's test for what it does, even when both sides are the same text", () => { + same(`let t: string = "k9"; function f(): boolean { print("in"); return true; } print(\`s \${f() ? t : t}\`);`, ["in", "s k9"]); + same(`let t: string = "k9"; function f(): boolean { try { } catch { } finally { print("in"); } return true; } print(\`s \${f() ? t : t}\`);`, ["in", "s k9"]); + }); + it("runs a program's tasks on after its body ends", () => { + same(`void (async () => { await sleep(frames(3)); print("after"); })(); print("end");`, ["end", "after"], 6); + same(`async function add() { await sleep(frames(2)); print("added"); } const w = start(add()); await w; print("awaited"); void (async () => { await sleep(frames(4)); print("after the end"); })();`, ["added", "awaited", "after the end"], 10); + }); + it("takes sync() inside an if and a loop, where the probes have it", () => { + expect(refusal(`while (true) { if (keyPressed(P1, "W")) { const [x, y] = await sync(P1, local.camera.x + local.mouse.x, local.camera.y + local.mouse.y); print(\`\${x} \${y}\`); } await sleep(frames(1)); }`)).toBeNull(); + }); + it("calls a function that gives back a text twice in one text, each call's own", () => { + const T = `function thousands(n: number): string { return n >= 1000 ? \`\${n / 1000}k\` : \`\${n}\`; }`; + same(`${T} let n = 1500; let m = 3; print(\`\${thousands(n)} and \${thousands(m)}\`);`, ["1k and 3"]); + same(`${T} let n = 1500; let m = 3; let a: string = ""; let b: string = ""; a = thousands(n); b = thousands(m); print(\`\${a} \${b}\`);`, ["1k 3"]); + same(`${T} let n = 1500; let t: string = ""; t = thousands(n); t = \`\${t}+\${thousands(n + 1)}+\${thousands(7)}\`; print(t);`, ["1k+1k+7"]); + }); + it("still refuses a function that calls itself with a finally in it, and says why", () => { + expect(refusal(`let n = 3; function f(d: number): number { if (d <= 0) return 1; try { n += 1; } finally { n += 2; } return f(d - 1) * 2; } print(\`\${f(3)} \${n}\`);`)).toMatch(/which calls itself/); + }); + it("a shift by a constant out of range as a value", () => { + same(`let n0 = 65536; let g = 0; g = n0 >> (-7); let h = n0 << 40; print(\`\${g} \${h}\`);`, ["0 0"]); + }); + it("a comparison of two constants is not written: TypeScript says it has no overlap", () => { + expect(messages(`let n = 0; if (20 == -77) n = 1; print(\`\${n}\`);`)).toEqual([expect.stringMatching(/no overlap/)]); + }); +}); diff --git a/tests/own-build.test.ts b/tests/own-build.test.ts index 267a4a1..1172aba 100644 --- a/tests/own-build.test.ts +++ b/tests/own-build.test.ts @@ -9,8 +9,9 @@ import { compileScript } from "../compiler/compiler"; import { defaultScriptNames } from "../compiler/names"; import { buildOwnMap, isRefused, type OwnBuild } from "../compiler/backend/build"; import { Sections, freeCuwpSlot, intern, readFacts, readStrings, readTriggers, writeChunks, writeStrings } from "../compiler/backend/chk"; +import { scenario, u16 } from "./ownScenario"; import { STRINGS } from "../compiler/backend/layout"; -import { ActionType, PlayerGroup, decodeTriggers, emptyAction, emptyTrigger, encodeTriggers, type TriggerRecord } from "../vendor/triggers"; +import { ActionType, PlayerGroup, decodeTriggers, emptyAction, emptyTrigger, type TriggerRecord } from "../vendor/triggers"; import { programVariables, readTriggerMap, readVariableMap } from "../compiler/gameVariables"; import { simulatePrograms } from "../compiler/simulateIr"; import { walkBuiltMap } from "./ownWalk"; @@ -24,49 +25,6 @@ const compile = (source: string, file = "main.ts") => { return r; }; -const u16 = (...v: number[]) => new Uint8Array(v.flatMap((x) => [x & 0xff, (x >> 8) & 0xff])); - -/** - * A scenario of the few sections the build reads: 64 × 64, P1 human and P2 computer in forces of their own, a string - * table (STR unless `extended`), a trigger list, a placed unit with a flag at odds with its figure, a null tile, - * a UNIx row at the defaults with a setting left in. - */ -function scenario(opts: { strings?: string[]; triggers?: TriggerRecord[]; extended?: boolean; version?: number } = {}): Uint8Array { - const strings = { entries: (opts.strings ?? ["Probe", "P1 says hi"]).map((s) => new TextEncoder().encode(s)) }; - const str = opts.extended ? writeStrings(strings).data : (() => { - const count = strings.entries.length; - const header = 2 * (1 + count); - const size = header + strings.entries.reduce((n, e) => n + e.length + 1, 0); - const data = new Uint8Array(size); - const view = new DataView(data.buffer); - view.setUint16(0, count, true); - let at = header; - strings.entries.forEach((e, i) => { view.setUint16(2 * (1 + i), at, true); data.set(e, at); at += e.length + 1; }); - return data; - })(); - const ownr = new Uint8Array(12); ownr[0] = 6; ownr[1] = 5; - const forc = new Uint8Array(20); forc[1] = 1; - const mtxm = new Uint8Array(64 * 64 * 2).fill(1); mtxm[2 * 5] = 0; mtxm[2 * 5 + 1] = 0; - // One Marine of P1: hit points 100 % with the flag set (at odds), energy flag clear with a figure left in. - const unit = new Uint8Array(36); const uv = new DataView(unit.buffer); - uv.setUint16(8, 0, true); uv.setUint16(14, 2, true); unit[16] = 0; unit[17] = 100; unit[19] = 50; - // UNIx: row 7 at the defaults (byte 1) with its hit points left in. - const unix = new Uint8Array(228 + 228 * (4 + 2 + 1 + 2 + 2 + 2 + 2)); unix[7] = 1; unix[228 + 4 * 7] = 40; - return writeChunks([ - { name: "VER ", data: u16(opts.version ?? 59) }, - { name: "DIM ", data: u16(64, 64) }, - { name: "OWNR", data: ownr }, - { name: "FORC", data: forc }, - { name: opts.extended ? "STRx" : "STR ", data: str }, - { name: "MTXM", data: mtxm }, - { name: "UNIT", data: unit }, - { name: "UNIx", data: unix }, - { name: "UPRP", data: new Uint8Array(64 * 20) }, - { name: "UPUS", data: new Uint8Array(64) }, - { name: "TRIG", data: encodeTriggers(opts.triggers ?? []) }, - ]); -} - const PROGRAM = `program(() => { let n = 0; while (true) { n++; displayText("tick"); sleep(seconds(1)); } });`; const WITH_VALUES = `program(() => { let n = 0; while (true) { n++; displayText(\`n is \${n}\`); sleep(seconds(1)); } });`; diff --git a/tests/ownScenario.ts b/tests/ownScenario.ts new file mode 100644 index 0000000..4c73efa --- /dev/null +++ b/tests/ownScenario.ts @@ -0,0 +1,46 @@ +/** + * A scenario of the few sections the own build reads, for the tests: 64 × 64, P1 human and P2 computer in forces of + * their own, a string table (STR unless `extended`), a trigger list, a placed unit with a flag at odds with its + * figure, a null tile, a UNIx row at the defaults with a setting left in. Shared by `own-build.test.ts` and the random + * scripts (`random-scripts.test.ts`). + */ +import { writeChunks, writeStrings } from "../compiler/backend/chk"; +import { encodeTriggers, type TriggerRecord } from "../vendor/triggers"; + +export const u16 = (...v: number[]) => new Uint8Array(v.flatMap((x) => [x & 0xff, (x >> 8) & 0xff])); + +export function scenario(opts: { strings?: string[]; triggers?: TriggerRecord[]; extended?: boolean; version?: number } = {}): Uint8Array { + const strings = { entries: (opts.strings ?? ["Probe", "P1 says hi"]).map((s) => new TextEncoder().encode(s)) }; + const str = opts.extended ? writeStrings(strings).data : (() => { + const count = strings.entries.length; + const header = 2 * (1 + count); + const size = header + strings.entries.reduce((n, e) => n + e.length + 1, 0); + const data = new Uint8Array(size); + const view = new DataView(data.buffer); + view.setUint16(0, count, true); + let at = header; + strings.entries.forEach((e, i) => { view.setUint16(2 * (1 + i), at, true); data.set(e, at); at += e.length + 1; }); + return data; + })(); + const ownr = new Uint8Array(12); ownr[0] = 6; ownr[1] = 5; + const forc = new Uint8Array(20); forc[1] = 1; + const mtxm = new Uint8Array(64 * 64 * 2).fill(1); mtxm[2 * 5] = 0; mtxm[2 * 5 + 1] = 0; + // One Marine of P1: hit points 100 % with the flag set (at odds), energy flag clear with a figure left in. + const unit = new Uint8Array(36); const uv = new DataView(unit.buffer); + uv.setUint16(8, 0, true); uv.setUint16(14, 2, true); unit[16] = 0; unit[17] = 100; unit[19] = 50; + // UNIx: row 7 at the defaults (byte 1) with its hit points left in. + const unix = new Uint8Array(228 + 228 * (4 + 2 + 1 + 2 + 2 + 2 + 2)); unix[7] = 1; unix[228 + 4 * 7] = 40; + return writeChunks([ + { name: "VER ", data: u16(opts.version ?? 59) }, + { name: "DIM ", data: u16(64, 64) }, + { name: "OWNR", data: ownr }, + { name: "FORC", data: forc }, + { name: opts.extended ? "STRx" : "STR ", data: str }, + { name: "MTXM", data: mtxm }, + { name: "UNIT", data: unit }, + { name: "UNIx", data: unix }, + { name: "UPRP", data: new Uint8Array(64 * 20) }, + { name: "UPUS", data: new Uint8Array(64) }, + { name: "TRIG", data: encodeTriggers(opts.triggers ?? []) }, + ]); +} diff --git a/tests/random-scripts.test.ts b/tests/random-scripts.test.ts new file mode 100644 index 0000000..d54633d --- /dev/null +++ b/tests/random-scripts.test.ts @@ -0,0 +1,31 @@ +/** + * Whole programs made at random (TrigScript 4, step 16: the gate before the Python goes), each run three ways — as + * JavaScript, in the simulator, and as the map of our own walked from its bytes — and every line compared, frame by + * frame. RANDOM_SCRIPTS=n runs n seeds (60 by default; about a second each, three programs a seed); RANDOM_SEED=n one seed, + * its source printed. `scripts/random-scripts.mts` runs the same seeds in the lifted game, and cuts a failure down. + */ +import { describe, expect, it } from "vitest"; +import { randomScript, renderScript } from "./randomScript"; +import { checkRandom } from "./randomRun"; + +const N = Number(process.env.RANDOM_SCRIPTS ?? 60); +const ONE = process.env.RANDOM_SEED ? Number(process.env.RANDOM_SEED) : undefined; + +describe("random scripts", () => { + it("print the same lines in JavaScript, in the simulator and from the built map's bytes", () => { + const bad: string[] = []; + let lines = 0, kinds = { compile: 0, fault: 0, js: 0, refused: 0, walk: 0 } as Record; + const seeds = ONE !== undefined ? [ONE] : Array.from({ length: N }, (_, i) => i + 1); + for (const seed of seeds) { + const g = randomScript(seed); + if (ONE !== undefined) console.log(renderScript(g)); + const o = checkRandom(g); + lines += o.lines; + if (o.problem) { kinds[o.kind!]++; bad.push(`seed ${seed} (${o.kind}): ${o.problem}`); } + } + if (process.env.RANDOM_REPORT) console.log(`${seeds.length} seeds, ${lines} lines compared`, kinds); + expect(bad.join("\n\n")).toBe(""); + // (A seed that prints nothing would compare nothing: the programs print a line a frame.) + expect(lines).toBeGreaterThan(seeds.length * 3); + }, 900_000); +}); diff --git a/tests/randomRun.ts b/tests/randomRun.ts new file mode 100644 index 0000000..dca91a6 --- /dev/null +++ b/tests/randomRun.ts @@ -0,0 +1,66 @@ +/** + * A random script (`randomScript.ts`) put through the three runs a machine without the game can make — JavaScript, + * the simulator, and the built map of our own walked as the game walks it (`ownWalk.ts`) — and the first thing that + * disagrees, said as a line. `tests/random-scripts.test.ts` runs it over the seeds; `scripts/random-scripts.mts` adds + * the lifted game, both builds. + */ +import ts from "typescript"; +import { compileScript, type CompileResult } from "../compiler/compiler"; +import { defaultScriptNames } from "../compiler/names"; +import { buildOwnMap, isRefused } from "../compiler/backend/build"; +import { simulatePrograms } from "../compiler/simulateIr"; +import { defaultLib } from "../bundle/lib.mjs"; +import { renderScript, runJs, type Generated } from "./randomScript"; +import { scenario } from "./ownScenario"; +import { walkBuiltMap } from "./ownWalk"; + +let lib: ReturnType | undefined; +const NAMES = defaultScriptNames(); +const CHK = scenario(); + +export const OWN = { players: { slots: [0, 1], forces: [0, 1, 0, 0, 0, 0, 0, 0] }, humans: [0], width: 64 * 32, height: 64 * 32 }; + +export function compileRandom(g: Generated): CompileResult { + lib ??= defaultLib(); + return compileScript(ts, { "main.ts": renderScript(g) }, NAMES, { lib, emitter: false, own: OWN }); +} + +export interface Outcome { + /** What went wrong, or nothing. */ + problem?: string; + /** Which run disagreed: "compile", "fault", "js", "refused", "walk". */ + kind?: string; + lines: number; +} + +/** The first `n` lines that differ, side by side. */ +export function diff(want: string[], got: string[], n = 3): string { + const out: string[] = []; + for (let i = 0; i < Math.max(want.length, got.length) && out.length < n; i++) if (want[i] !== got[i]) out.push(` at ${i}: expected ${JSON.stringify(want[i])}, got ${JSON.stringify(got[i])}`); + return `${want.length} lines against ${got.length}\n${out.join("\n")}`; +} + +/** The simulator's lines as `cycle:text`. */ +export function simLines(r: CompileResult, frames: number) { + const sim = simulatePrograms(r.ir, frames, { strings: r.strings }); + return { sim, lines: sim.events.filter((e) => e.text !== undefined).map((e) => `${e.cycle}:${e.text}`) }; +} + +export function checkRandom(g: Generated, through: { js?: boolean; walk?: boolean } = { js: true, walk: true }): Outcome { + const r = compileRandom(g); + if (r.diagnostics.length) return { kind: "compile", problem: r.diagnostics.map((d) => `${d.file}:${d.line}:${d.column} ${d.message}`).join("\n"), lines: 0 }; + const { sim, lines } = simLines(r, g.frames); + if (sim.faults.length) return { kind: "fault", problem: sim.faults.map((f) => `${f.at.line}:${f.at.column} ${f.message}`).join("\n"), lines: lines.length }; + if (through.js !== false) { + const js = runJs(g); + if (js.join("\n") !== lines.join("\n")) return { kind: "js", problem: `the simulator against JavaScript: ${diff(js, lines)}`, lines: lines.length }; + } + if (through.walk !== false) { + const out = buildOwnMap({ chk: CHK, compiled: r }); + if (isRefused(out)) return { kind: "refused", problem: out.refused, lines: lines.length }; + const walked = walkBuiltMap(out.chk, out.stats.players, out.stats.entries, g.frames); + const got = walked.printed.map((p) => `${p.frame - 1}:${p.text}`); + if (got.join("\n") !== lines.join("\n")) return { kind: "walk", problem: `the map walked against the simulator: ${diff(lines, got)}`, lines: lines.length }; + } + return { lines: lines.length }; +} diff --git a/tests/randomScript.ts b/tests/randomScript.ts new file mode 100644 index 0000000..7799f77 --- /dev/null +++ b/tests/randomScript.ts @@ -0,0 +1,567 @@ +/** + * Whole programs made at random (TrigScript 4, step 16 of the way off eudplib: the gate before the Python goes; the + * plan's idea 17, "as Csmith does C"). A script is drawn as a small tree — the program's variables, its functions, + * statements and expressions of the language's core: numbers with every operator, booleans, texts known and made, + * arrays fixed and growing, a record, `if`, `for`, `while`, `do`, `switch`, `break` and `continue`, calls and a + * function that calls itself, `try`, `throw` and `finally`, the deaths table and switches, `sleep` — and rendered + * twice from the one tree: as TrigScript, for the compiler, the simulator, the build and the game; and as JavaScript, + * whose own arithmetic (32 bits, `Math.imul`, a quotient cut towards zero) says what the lines must be. Every loop + * carries a guard, every index stays inside its array, and a `throw` stands inside a `try`, so the program always + * ends and never faults: what the four runs print, frame by frame, is what is compared. + * + * `reduce` cuts a program that fails down to the smallest that still does, a statement at a time. + */ + +export interface Rng { next(): number; int(n: number): number; pick(xs: readonly T[]): T; chance(p: number): boolean } + +/** mulberry32: the same seed, the same program, on every machine. */ +export function rng(seed: number): Rng { + let a = seed >>> 0; + const next = () => { + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + return { next, int: (n) => Math.floor(next() * n), pick: (xs) => xs[Math.floor(next() * xs.length)], chance: (p) => next() < p }; +} + +// ── the tree ── + +export type NExpr = + | { k: "const"; v: number } + | { k: "var"; name: string } + | { k: "bin"; op: "+" | "-" | "*" | "/" | "%" | "&" | "|" | "^" | "<<" | ">>"; l: NExpr; r: NExpr } + | { k: "neg"; e: NExpr } + | { k: "tern"; c: BExpr; t: NExpr; f: NExpr } + | { k: "el"; arr: string; idx: NExpr; mask: number } + | { k: "gel"; arr: string; idx: NExpr } + | { k: "len"; arr: string } + | { k: "tlen"; text: TExpr } + | { k: "call"; fn: string; args: Arg[] } + | { k: "math"; fn: "abs" | "min" | "max"; args: NExpr[] } + | { k: "pop"; arr: string } + | { k: "deaths"; unit: string } + | { k: "field"; rec: string; field: string }; +export type BExpr = + | { k: "bconst"; v: boolean } + | { k: "bvar"; name: string } + | { k: "cmp"; op: "<" | "<=" | ">" | ">=" | "==" | "!="; l: NExpr; r: NExpr } + | { k: "and"; items: BExpr[] } + | { k: "or"; items: BExpr[] } + | { k: "not"; e: BExpr } + | { k: "tcmp"; op: "===" | "!=="; l: TExpr; r: TExpr } + | { k: "bcall"; fn: string; args: Arg[] } + | { k: "switchIs"; n: number }; +export type TExpr = + | { k: "tlit"; s: string } + | { k: "tvar"; name: string } + | { k: "tmpl"; parts: (string | NExpr | TExpr)[] } + | { k: "ttern"; c: BExpr; t: TExpr; f: TExpr }; +export type Arg = NExpr | BExpr; + +export type Stmt = + | { k: "assign"; name: string; op: "=" | "+=" | "-="; e: NExpr } + | { k: "bassign"; name: string; e: BExpr } + | { k: "tassign"; name: string; e: TExpr } + | { k: "fassign"; rec: string; field: string; op: "=" | "+="; e: NExpr } + | { k: "if"; c: BExpr; then: Stmt[]; else?: Stmt[] } + | { k: "for"; i: string; bound: NExpr; body: Stmt[] } + | { k: "while"; c: BExpr; guard: string; body: Stmt[] } + | { k: "do"; body: Stmt[]; c: BExpr; guard: string } + | { k: "switch"; e: NExpr; cases: { v: number; body: Stmt[] }[]; def: Stmt[] } + | { k: "break" } + | { k: "continue" } + | { k: "store"; arr: string; idx: NExpr; mask: number; e: NExpr } + | { k: "gstore"; arr: string; idx: NExpr; e: NExpr } + | { k: "push"; arr: string; e: NExpr } + | { k: "callstmt"; fn: string; args: Arg[] } + | { k: "try"; body: Stmt[]; catch: Stmt[]; finally?: Stmt[] } + | { k: "throw"; what: string } + | { k: "print"; parts: (string | NExpr | TExpr)[] } + | { k: "sleep"; n: number } + | { k: "setDeaths"; unit: string; mod: "add" | "set" | "subtract"; e: NExpr } + | { k: "setSwitch"; n: number; to: "set" | "clear" | "toggle" } + | { k: "return"; e?: Arg }; + +export interface Param { name: string; kind: "number" | "boolean" } +export interface Func { name: string; params: Param[]; locals: string[]; returns: "number" | "boolean" | "void"; body: Stmt[]; recursive: boolean } +export interface Decl { name: string; kind: "number" | "boolean" | "text" | "fixed" | "growing" | "record" | "read"; init: number | boolean | string | number[] } +export interface ProgramTree { decls: Decl[]; functions: Func[]; body: Stmt[] } +export interface Generated { seed: number; programs: ProgramTree[]; frames: number } + +const UNITS = ["TerranMarine", "ZergZergling", "ProtossZealot"]; +const WORDS = ["ab", "Marine", "x", "zzz", "Mf", "k9"]; +const EDGES = [0, 1, 2, 3, 7, -1, -2, 255, 256, 1023, 65535, 65536, 2147483647, -2147483648, 1000000, -77]; +const THROWS = ["Error", "RangeError", "TypeError"]; + +interface Scope { + nums: string[]; bools: string[]; texts: string[]; fixed: { name: string; mask: number }[]; growing: string[]; records: string[]; + /** The functions a body may call: those made before it. */ + calls: Func[]; + /** Numbers read but never assigned: the counters of `for` loops. */ + ro: string[]; + /** Inside a loop (break/continue allowed), inside a try (throw allowed), at the program's top (sleep allowed). */ + loop: boolean; tried: boolean; top: boolean; + /** Inside a `finally`: no `return` leaves it. */ + finally?: boolean; + fn?: Func; +} + +/** One program of the seed: `label` tells its lines apart from the other programs' of a batch. */ +export function randomProgram(r: Rng, label: string): ProgramTree { + let names = 0; + const fresh = (p: string) => `${p}${names++}`; + const decls: Decl[] = []; + const scope: Scope = { nums: [], bools: [], texts: [], fixed: [], growing: [], records: [], calls: [], ro: [], loop: false, tried: false, top: true }; + const guard = () => { const g = fresh("g"); decls.push({ name: g, kind: "number", init: 0 }); return g; }; + let prints = 0; + + // (z: a number the compiler cannot see — the deaths of a unit nothing of the script sets — for the divisors, 0 in the game.) + decls.push({ name: "z", kind: "read", init: 0 }); + scope.ro.push("z"); + for (let i = 0, n = 2 + r.int(4); i < n; i++) { const d = fresh("n"); decls.push({ name: d, kind: "number", init: r.pick(EDGES) }); scope.nums.push(d); } + for (let i = 0, n = r.int(3); i < n; i++) { const d = fresh("b"); decls.push({ name: d, kind: "boolean", init: r.chance(0.5) }); scope.bools.push(d); } + for (let i = 0, n = r.int(3); i < n; i++) { const d = fresh("t"); decls.push({ name: d, kind: "text", init: r.pick(WORDS) }); scope.texts.push(d); } + for (let i = 0, n = r.int(3); i < n; i++) { const d = fresh("xs"); const len = r.pick([2, 4, 8]); decls.push({ name: d, kind: "fixed", init: Array.from({ length: len }, () => r.pick(EDGES)) }); scope.fixed.push({ name: d, mask: len - 1 }); } + for (let i = 0, n = r.int(3); i < n; i++) { const d = fresh("ys"); decls.push({ name: d, kind: "growing", init: r.chance(0.5) ? [] : [r.pick(EDGES), r.pick(EDGES)] }); scope.growing.push(d); } + for (let i = 0, n = r.int(2); i < n; i++) { const d = fresh("p"); decls.push({ name: d, kind: "record", init: [r.pick(EDGES), r.pick(EDGES)] }); scope.records.push(d); } + + const num = (s: Scope, depth: number): NExpr => { + const leaf = depth <= 0 || r.chance(0.3); + const leaves: (() => NExpr)[] = [() => ({ k: "const", v: r.chance(0.5) ? r.pick(EDGES) : r.int(40) - 10 })]; + const locals = s.fn ? [...s.fn.params.filter((p) => p.kind === "number").map((p) => p.name), ...s.fn.locals] : []; + const readable = [...s.nums, ...s.ro, ...locals]; + if (readable.length) leaves.push(() => ({ k: "var", name: r.pick(readable) }), () => ({ k: "var", name: r.pick(readable) })); + if (s.fixed.length) leaves.push(() => { const a = r.pick(s.fixed); return { k: "el", arr: a.name, idx: num(s, depth - 1), mask: a.mask }; }); + if (s.growing.length) leaves.push(() => ({ k: "len", arr: r.pick(s.growing) }), () => ({ k: "gel", arr: r.pick(s.growing), idx: num(pure(s), depth - 1) })); + if (s.records.length) leaves.push(() => ({ k: "field", rec: r.pick(s.records), field: r.pick(["x", "y"]) })); + if (s.texts.length && r.chance(0.3)) leaves.push(() => ({ k: "tlen", text: text(s, 0) })); + if (r.chance(0.1)) leaves.push(() => ({ k: "deaths", unit: r.pick(UNITS) })); + if (leaf) return r.pick(leaves)(); + const inner: (() => NExpr)[] = [ + () => { + const op = r.pick(["+", "+", "-", "*", "/", "%", "&", "|", "^", "<<", ">>"] as const); + let l = num(s, depth - 1), right = num(s, depth - 1); + // (A divisor the compiler can see is 0 — a constant, a variable never assigned, a cell of an array as declared — is + // a compile error, as the language has it: those are made odd. One worked out in the game may still be 0.) + if (op === "/" || op === "%") right = r.chance(0.3) ? { k: "var", name: "z" } : { k: "bin", op: "|", l: right, r: { k: "const", v: 1 } }; + if (constLike(l) && constLike(right)) l = unconst(s, l); + return { k: "bin", op, l, r: right }; + }, + () => { const e = num(s, depth - 1); return constLike(e) ? { k: "const", v: Math.min(2147483647, Math.max(-2147483647, -constValue(e))) } : { k: "neg", e }; }, + () => ({ k: "tern", c: bool(s, depth - 1, true), t: num(s, depth - 1), f: num(s, depth - 1) }), + () => { const fn = r.pick(["abs", "min", "max"] as const); const a = num(s, depth - 1); return { k: "math", fn, args: fn === "abs" ? [constLike(a) ? unconst(s, a) : a] : [a, num(s, depth - 1)] }; }, + ]; + const numFns = s.calls.filter((f) => f.returns === "number"); + if (numFns.length) inner.push(() => call(s, r.pick(numFns), depth - 1) as NExpr); + if (s.growing.length && r.chance(0.3)) inner.push(() => ({ k: "pop", arr: r.pick(s.growing) })); + return r.pick(inner)(); + }; + const call = (s: Scope, f: Func, depth: number): NExpr | BExpr => { + const args: Arg[] = f.params.map((p) => (p.kind === "number" ? (f.recursive ? (s.fn === f ? { k: "bin", op: "-", l: { k: "var", name: p.name }, r: { k: "const", v: 1 + r.int(2) } } : { k: "const", v: r.int(5) }) : num(s, depth)) : bool(s, depth))); + return f.returns === "boolean" ? { k: "bcall", fn: f.name, args } : { k: "call", fn: f.name, args }; + }; + /** The scope with nothing in it that changes an array that grows: no pop, no call (an index, and a value stored beside one). */ + const pure = (s: Scope): Scope => ({ ...s, growing: [], calls: [] }); + /** A constant, or the negative of one: TypeScript gives it a literal type, and the compiler works it out when the script is built. */ + const constLike = (e: NExpr): boolean => e.k === "const" || (e.k === "neg" && constLike(e.e)); + const constValue = (e: NExpr): number => (e.k === "const" ? e.v : e.k === "neg" ? -constValue(e.e) : 0); + /** A side that is not a constant where one is wanted: a variable of the scope, else the constant itself. */ + const unconst = (s: Scope, e: NExpr): NExpr => { + const names = [...s.nums, ...s.ro, ...(s.fn ? [...s.fn.locals, ...s.fn.params.filter((p) => p.kind === "number").map((p) => p.name)] : [])]; + return names.length ? { k: "bin", op: "+", l: { k: "var", name: r.pick(names) }, r: e } : e; + }; + // (A comparison of two constants is a TypeScript error — "no overlap" — and an operation on two is worked out when the + // script is built, by JavaScript's arithmetic: neither is made, so that what is compared is what the game computes.) + const cmp = (s: Scope, depth: number): BExpr => { + let l = num(s, depth); + let right = num(s, depth); + const op = r.pick(["<", "<=", ">", ">=", "==", "!="] as const); + if (constLike(l) && constLike(right)) l = unconst(s, l); + // (`x == 20` narrows x to 20 for TypeScript, and a later `x != -77` then has "no overlap": a constant compared for + // equality is a constant plus z, which TypeScript cannot see through.) + if (op === "==" || op === "!=") { + if (constLike(right)) right = { k: "bin", op: "+", l: { k: "var", name: "z" }, r: right }; + if (constLike(l)) l = { k: "bin", op: "+", l: { k: "var", name: "z" }, r: l }; + } + return { k: "cmp", op, l, r: right }; + }; + /** `cond`: where a condition of the game may stand (an if, a loop, a ternary) — a `Condition` is not a boolean value. */ + const bool = (s: Scope, depth: number, cond = false): BExpr => { + const leaf = depth <= 0 || r.chance(0.3); + const locals = s.fn ? s.fn.params.filter((p) => p.kind === "boolean").map((p) => p.name) : []; + const leaves: (() => BExpr)[] = [() => cmp(s, depth - 1)]; + if (s.bools.length + locals.length) leaves.push(() => ({ k: "bvar", name: r.pick([...s.bools, ...locals]) })); + if (r.chance(0.1)) leaves.push(() => ({ k: "bconst", v: r.chance(0.5) })); + if (cond && r.chance(0.1)) leaves.push(() => ({ k: "switchIs", n: 1 + r.int(3) })); + if (s.texts.length && r.chance(0.3)) leaves.push(() => { + // (A variable against a literal narrows the variable's type to it, and a second literal then has "no overlap": a text + // of the variable's made new — a template of it — is a string to TypeScript; a variable is compared with a variable.) + const name = r.pick(s.texts); + return r.chance(0.5) ? { k: "tcmp", op: r.pick(["===", "!=="]), l: { k: "tvar", name }, r: { k: "tvar", name: r.pick(s.texts) } } : { k: "tcmp", op: r.pick(["===", "!=="]), l: { k: "tmpl", parts: [{ k: "tvar", name }] }, r: text(s, 0) }; + }); + if (leaf) return r.pick(leaves)(); + const inner: (() => BExpr)[] = [ + () => ({ k: "and", items: [bool(s, depth - 1, cond), bool(s, depth - 1, cond)] }), + () => ({ k: "or", items: [bool(s, depth - 1, cond), bool(s, depth - 1, cond)] }), + () => ({ k: "not", e: bool(s, depth - 1, cond) }), + () => cmp(s, depth - 1), + ]; + const boolFns = s.calls.filter((f) => f.returns === "boolean"); + if (boolFns.length) inner.push(() => call(s, r.pick(boolFns), depth - 1) as BExpr); + return r.pick(inner)(); + }; + const text = (s: Scope, depth: number): TExpr => { + const leaves: (() => TExpr)[] = [() => ({ k: "tlit", s: r.pick(WORDS) })]; + if (s.texts.length) leaves.push(() => ({ k: "tvar", name: r.pick(s.texts) }), () => ({ k: "tvar", name: r.pick(s.texts) })); + if (depth <= 0 || r.chance(0.4)) return r.pick(leaves)(); + const inner: (() => TExpr)[] = [ + () => ({ k: "tmpl", parts: [r.pick(WORDS), num(s, 1), ...(r.chance(0.5) ? [text(s, depth - 1)] : [])] }), + () => ({ k: "ttern", c: bool(s, 1, true), t: text(s, depth - 1), f: text(s, depth - 1) }), + ]; + return r.pick(inner)(); + }; + const printed = (s: Scope): Stmt => { + const parts: (string | NExpr | TExpr)[] = [`${label}.${prints++}`]; + for (let i = 0, n = 1 + r.int(3); i < n; i++) { + parts.push(" "); + parts.push(r.chance(0.7) || !s.texts.length ? num(s, 1) : text(s, 1)); + } + return { k: "print", parts }; + }; + + const stmt = (s: Scope, depth: number, budget: { n: number }): Stmt => { + budget.n--; + const locals = s.fn ? s.fn.locals : []; + const choices: (() => Stmt)[] = []; + const targets = [...s.nums, ...locals]; + if (targets.length) choices.push( + () => ({ k: "assign", name: r.pick(targets), op: r.pick(["=", "=", "+=", "-="]), e: num(s, 2) }), + () => ({ k: "assign", name: r.pick(targets), op: "=", e: num(s, 3) }), + ); + if (s.bools.length) choices.push(() => ({ k: "bassign", name: r.pick(s.bools), e: bool(s, 2) })); + if (s.texts.length) choices.push(() => { + const name = r.pick(s.texts); + // (A made text grows by a few characters a turn: kept under forty, so no text nears what a made one holds.) + const e = r.chance(0.5) ? text(s, 2) : { k: "tmpl", parts: [{ k: "tvar", name }, num(s, 1)] } as TExpr; + return { k: "tassign", name, e: e.k === "tmpl" ? { k: "ttern", c: { k: "cmp", op: "<", l: { k: "tlen", text: { k: "tvar", name } }, r: { k: "const", v: 40 } }, t: e, f: { k: "tlit", s: r.pick(WORDS) } } : e }; + }); + if (s.records.length) choices.push(() => ({ k: "fassign", rec: r.pick(s.records), field: r.pick(["x", "y"]), op: r.pick(["=", "+="]), e: num(s, 2) })); + if (s.fixed.length) choices.push(() => { const a = r.pick(s.fixed); return { k: "store", arr: a.name, idx: num(s, 1), mask: a.mask, e: num(s, 2) }; }); + if (s.growing.length) choices.push(() => ({ k: "push", arr: r.pick(s.growing), e: num(s, 2) }), () => ({ k: "gstore", arr: r.pick(s.growing), idx: num(pure(s), 1), e: num(pure(s), 2) })); + const voidFns = s.calls.filter((f) => f.returns === "void"); + if (voidFns.length) choices.push(() => { const f = r.pick(voidFns); return { k: "callstmt", fn: f.name, args: (call(s, f, 1) as { args: Arg[] }).args }; }); + if (s.calls.length) choices.push(() => { const f = r.pick(s.calls); return { k: "callstmt", fn: f.name, args: (call(s, f, 1) as { args: Arg[] }).args }; }); + choices.push(() => printed(s)); + if (r.chance(0.15)) choices.push(() => ({ k: "setDeaths", unit: r.pick(UNITS), mod: r.pick(["add", "set", "subtract"]), e: { k: "bin", op: "&", l: num(s, 1), r: { k: "const", v: 255 } } })); + if (r.chance(0.1)) choices.push(() => ({ k: "setSwitch", n: 1 + r.int(3), to: r.pick(["set", "clear", "toggle"]) })); + if (s.loop && r.chance(0.3)) choices.push(() => ({ k: "break" }), () => ({ k: "continue" })); + if (s.tried && r.chance(0.3)) choices.push(() => ({ k: "throw", what: r.pick(THROWS) })); + if (s.top && r.chance(0.25)) choices.push(() => ({ k: "sleep", n: 1 + r.int(2) })); + if (s.fn && s.fn.returns !== "void" && !s.finally && r.chance(0.15)) choices.push(() => ({ k: "return", e: s.fn!.returns === "number" ? num(s, 2) : bool(s, 2) })); + if (depth > 0 && budget.n > 0) { + const inner = (over: Partial) => block({ ...s, ...over }, depth - 1, budget, 1 + r.int(3)); + choices.push( + () => ({ k: "if", c: bool(s, 2, true), then: inner({}), ...(r.chance(0.5) ? { else: inner({}) } : {}) }), + () => { const i = fresh("i"); const b = inner({ loop: true, ro: [...s.ro, i] }); return { k: "for", i, bound: r.chance(0.5) ? { k: "const", v: 1 + r.int(6) } : { k: "bin", op: "&", l: num(s, 1), r: { k: "const", v: 7 } }, body: b }; }, + () => ({ k: "while", c: bool(s, 2, true), guard: guard(), body: inner({ loop: true }) }), + () => ({ k: "do", body: inner({ loop: true }), c: bool(s, 2, true), guard: guard() }), + () => ({ k: "switch", e: { k: "bin", op: "&", l: num(s, 1), r: { k: "const", v: 3 } }, cases: [0, 1, 2].filter(() => r.chance(0.7)).map((v) => ({ v, body: inner({ loop: false }) })), def: inner({ loop: false }) }), + // (No `finally` where a function that calls itself may be called inside the try: the error kept across the + // finally is a text, which a map of our own does not keep on its stack — the plan's Open list, step 16.) + () => ({ k: "try", body: inner({ tried: true }), catch: inner({}), ...(r.chance(0.4) && !anyRecursive ? { finally: inner({ tried: false, loop: false, finally: true }) } : {}) }), + ); + } + return r.pick(choices)(); + }; + const block = (s: Scope, depth: number, budget: { n: number }, n: number): Stmt[] => { + const out: Stmt[] = []; + for (let i = 0; i < n && budget.n > 0; i++) { + const st = stmt(s, depth, budget); + out.push(st); + // (Nothing follows a break, a continue, a throw or a return in its block.) + if (st.k === "break" || st.k === "continue" || st.k === "throw" || st.k === "return") break; + } + return out; + }; + + // The functions: each calls only those before it; one in three that gives a number calls itself, bounded by its first + // parameter. (Which call themselves is decided first: a `finally` anywhere beside one is a text on the stack — see `try`.) + const functions: Func[] = []; + const shapes = Array.from({ length: r.int(4) }, () => { const returns = r.pick(["number", "number", "boolean", "void"] as const); return { returns, recursive: returns === "number" && r.chance(0.35) }; }); + const anyRecursive = shapes.some((f) => f.recursive); + for (const { returns, recursive } of shapes) { + const name = fresh("f"); + const params: Param[] = recursive ? [{ name: fresh("d"), kind: "number" }] : Array.from({ length: r.int(3) }, () => ({ name: fresh("a"), kind: r.chance(0.75) ? "number" : "boolean" })); + const locals = Array.from({ length: r.int(3) }, () => fresh("l")); + const f: Func = { name, params, locals, returns, body: [], recursive }; + // (No text made inside a function that calls itself: `t = \`${t}${f(d - 2)}\`` reads t after the inner call in the + // simulator and the map, before it in JavaScript — the plan's Open list, step 16.) + const inner: Scope = { ...scope, texts: recursive ? [] : scope.texts, calls: recursive ? [...functions, f] : [...functions], loop: false, tried: false, top: false, fn: f }; + const budget = { n: 4 + r.int(6) }; + if (recursive) { + const d = params[0].name; + f.body = [ + { k: "if", c: { k: "cmp", op: "<=", l: { k: "var", name: d }, r: { k: "const", v: 0 } }, then: [{ k: "return", e: num({ ...inner, calls: functions }, 1) }] }, + ...block(inner, 1, budget, 1 + r.int(2)).filter((s) => s.k !== "return"), + { k: "return", e: { k: "bin", op: r.pick(["+", "-", "*", "^"]), l: { k: "call", fn: name, args: [{ k: "bin", op: "-", l: { k: "var", name: d }, r: { k: "const", v: 1 } }] }, r: num({ ...inner, calls: functions }, 1) } }, + ]; + } else { + f.body = block(inner, 2, budget, 2 + r.int(3)); + if (f.body.at(-1)?.k === "return") f.body.pop(); + if (returns !== "void") f.body.push({ k: "return", e: returns === "number" ? num(inner, 2) : bool(inner, 2) }); + } + functions.push(f); + } + scope.calls = functions; + + // The body: a few frames' worth, a line printed at the end of each, a sleep between. + const body: Stmt[] = []; + const frames = 3 + r.int(4); + const budget = { n: 10 + r.int(20) }; + for (let f = 0; f < frames; f++) { + body.push(...block(scope, 2, budget, 1 + r.int(4))); + body.push(printed(scope)); + if (f < frames - 1) body.push({ k: "sleep", n: 1 + r.int(2) }); + } + return { decls, functions, body }; +} + +/** `programs` programs of one seed, in one script (one compile, one map, their lines told apart by `p`). */ +export function randomScript(seed: number, programs = 3, frames = 12): Generated { + const r = rng(seed * 7919 + 17); + return { seed, programs: Array.from({ length: programs }, (_, i) => randomProgram(r, `s${seed}p${i}`)), frames }; +} + +// ── rendered as TrigScript and as JavaScript ── + +type Mode = "ts" | "js"; + +const UNIT_INDEX: Record = { TerranMarine: 0, ZergZergling: 37, ProtossZealot: 65 }; + +function rn(e: NExpr, m: Mode): string { + switch (e.k) { + case "const": return e.v < 0 ? `(${e.v})` : String(e.v); + case "var": return e.name; + case "neg": return m === "ts" ? `(-${rn(e.e, m)})` : `((-${rn(e.e, m)}) | 0)`; + case "tern": return `(${rb(e.c, m)} ? ${rn(e.t, m)} : ${rn(e.f, m)})`; + case "el": return `${e.arr}[${rn(e.idx, m)} & ${e.mask}]`; + // (A read of an array that grows, inside its length; 0 when it is empty.) + case "gel": return m === "ts" ? `(${e.arr}.length > 0 ? ${e.arr}[(${rn(e.idx, m)} & 1023) % ${e.arr}.length] : 0)` : `(${e.arr}.length > 0 ? ${e.arr}[__mod(${rn(e.idx, m)} & 1023, ${e.arr}.length)] : 0)`; + case "len": return `${e.arr}.length`; + case "tlen": return `${rt(e.text, m)}.length`; + case "call": return `${e.fn}(${e.args.map((a) => ra(a, m)).join(", ")})`; + case "math": return m === "ts" ? `Math.${e.fn}(${e.args.map((a) => rn(a, m)).join(", ")})` : `(Math.${e.fn}(${e.args.map((a) => rn(a, m)).join(", ")}) | 0)`; + case "pop": return `(${e.arr}.pop() ?? 0)`; + case "deaths": return m === "ts" ? `i32(deaths(P1, units.${e.unit}))` : `__deaths(${UNIT_INDEX[e.unit]})`; + case "field": return `${e.rec}.${e.field}`; + case "bin": { + const l = rn(e.l, m), r = rn(e.r, m); + // (`a << (b > (c))` is a generic call to TypeScript's parser too: the count written as a sum.) + if (m === "ts") return `(${l} ${e.op} ${e.op === "<<" && r.startsWith("(") ? `(0 + ${r})` : r})`; + switch (e.op) { + case "+": case "-": return `((${l} ${e.op} ${r}) | 0)`; + case "*": return `Math.imul(${l}, ${r})`; + case "/": return `__div(${l}, ${r})`; + case "%": return `__mod(${l}, ${r})`; + case "<<": return `__shl(${l}, ${r})`; + case ">>": return `__shr(${l}, ${r})`; + default: return `(${l} ${e.op} ${r})`; + } + } + } +} +function rb(e: BExpr, m: Mode): string { + switch (e.k) { + case "bconst": return String(e.v); + case "bvar": return e.name; + case "cmp": { + const l = rn(e.l, m), r = rn(e.r, m); + // (`a < b … > (` reads as a generic call to TypeScript's parser, whatever `a` is: `<` is never written — the sides + // change places and the comparison turns around.) + return e.op === "<" ? `(${r} > ${l})` : `(${l} ${e.op} ${r})`; + } + case "and": return `(${e.items.map((x) => rb(x, m)).join(" && ")})`; + case "or": return `(${e.items.map((x) => rb(x, m)).join(" || ")})`; + case "not": return `!${rb(e.e, m)}`; + case "tcmp": return `(${rt(e.l, m)} ${e.op} ${rt(e.r, m)})`; + case "bcall": return `${e.fn}(${e.args.map((a) => ra(a, m)).join(", ")})`; + case "switchIs": return m === "ts" ? `switchIs(switches.Switch${e.n}, "set")` : `__switches[${e.n}]`; + } +} +function rt(e: TExpr, m: Mode): string { + switch (e.k) { + case "tlit": return JSON.stringify(e.s); + case "tvar": return e.name; + case "tmpl": return "`" + e.parts.map((p) => (typeof p === "string" ? p : "${" + ("k" in p && isText(p) ? rt(p as TExpr, m) : rn(p as NExpr, m)) + "}")).join("") + "`"; + case "ttern": return `(${rb(e.c, m)} ? ${rt(e.t, m)} : ${rt(e.f, m)})`; + } +} +const isText = (e: NExpr | TExpr | BExpr): e is TExpr => e.k === "tlit" || e.k === "tvar" || e.k === "tmpl" || e.k === "ttern"; +const isBool = (e: Arg): e is BExpr => e.k === "bconst" || e.k === "bvar" || e.k === "cmp" || e.k === "and" || e.k === "or" || e.k === "not" || e.k === "tcmp" || e.k === "bcall" || e.k === "switchIs"; +const ra = (a: Arg, m: Mode) => (isBool(a) ? rb(a, m) : rn(a, m)); + +function rs(s: Stmt, m: Mode, ind: string): string { + const blk = (b: Stmt[]) => `{\n${b.map((x) => rs(x, m, ind + " ")).join("")}${ind}}`; + switch (s.k) { + case "assign": return `${ind}${s.name} ${m === "ts" || s.op === "=" ? s.op : "="} ${m === "ts" || s.op === "=" ? rn(s.e, m) : `((${s.name} ${s.op[0]} ${rn(s.e, m)}) | 0)`};\n`; + case "bassign": return `${ind}${s.name} = ${rb(s.e, m)};\n`; + case "tassign": return `${ind}${s.name} = ${rt(s.e, m)};\n`; + case "fassign": return `${ind}${s.rec}.${s.field} ${m === "ts" || s.op === "=" ? s.op : "="} ${m === "ts" || s.op === "=" ? rn(s.e, m) : `((${s.rec}.${s.field} + ${rn(s.e, m)}) | 0)`};\n`; + case "if": return `${ind}if (${rb(s.c, m)}) ${blk(s.then)}${s.else ? ` else ${blk(s.else)}` : ""}\n`; + // (The bound first: `i < (` would read as a generic call to TypeScript's parser.) + case "for": return `${ind}for (let ${s.i} = 0; ${rn(s.bound, m)} > ${s.i}; ${s.i}++) ${blk(s.body)}\n`; + case "while": return `${ind}while (${rb(s.c, m)}) {\n${ind} ${s.guard} += 1;\n${ind} if (${s.guard} > 12) break;\n${s.body.map((x) => rs(x, m, ind + " ")).join("")}${ind}}\n`; + case "do": return `${ind}do {\n${ind} ${s.guard} += 1;\n${ind} if (${s.guard} > 12) break;\n${s.body.map((x) => rs(x, m, ind + " ")).join("")}${ind}} while (${rb(s.c, m)});\n`; + case "switch": { + const ends = (b: Stmt[]) => b.at(-1)?.k === "throw" || b.at(-1)?.k === "return"; + return `${ind}switch (${rn(s.e, m)}) {\n${s.cases.map((c) => `${ind} case ${c.v}: ${ends(c.body) ? blk(c.body) : blk(c.body).replace(/}$/, " break;\n" + ind + " }")}\n`).join("")}${ind} default: ${blk(s.def)}\n${ind}}\n`; + } + case "break": return `${ind}break;\n`; + case "continue": return `${ind}continue;\n`; + case "store": return `${ind}${s.arr}[${rn(s.idx, m)} & ${s.mask}] = ${rn(s.e, m)};\n`; + case "gstore": return m === "ts" ? `${ind}if (${s.arr}.length > 0) ${s.arr}[(${rn(s.idx, m)} & 1023) % ${s.arr}.length] = ${rn(s.e, m)};\n` : `${ind}if (${s.arr}.length > 0) ${s.arr}[__mod(${rn(s.idx, m)} & 1023, ${s.arr}.length)] = ${rn(s.e, m)};\n`; + case "push": return `${ind}${s.arr}.push(${rn(s.e, m)});\n`; + case "callstmt": return `${ind}${s.fn}(${s.args.map((a) => ra(a, m)).join(", ")});\n`; + case "try": return `${ind}try ${blk(s.body)} catch ${blk(s.catch)}${s.finally ? ` finally ${blk(s.finally)}` : ""}\n`; + case "throw": return `${ind}throw new ${s.what}("thrown");\n`; + case "print": return `${ind}${m === "ts" ? "print" : "__print"}(${rt({ k: "tmpl", parts: s.parts }, m)});\n`; + case "sleep": return m === "ts" ? `${ind}sleep(frames(${s.n}));\n` : `${ind}yield ${s.n};\n`; + case "setDeaths": return m === "ts" ? `${ind}setDeaths(P1, units.${s.unit}, ${JSON.stringify(s.mod)}, ${rn(s.e, m)});\n` : `${ind}__setDeaths(${UNIT_INDEX[s.unit]}, ${JSON.stringify(s.mod)}, ${rn(s.e, m)});\n`; + case "setSwitch": return m === "ts" ? `${ind}setSwitch(switches.Switch${s.n}, ${JSON.stringify(s.to)});\n` : `${ind}__switches[${s.n}] = ${s.to === "set" ? "true" : s.to === "clear" ? "false" : `!__switches[${s.n}]`};\n`; + case "return": return `${ind}return${s.e ? " " + ra(s.e, m) : ""};\n`; + } +} + +function renderDecl(d: Decl, m: Mode): string { + switch (d.kind) { + case "number": return `let ${d.name} = ${d.init as number < 0 ? `(${d.init})` : d.init};\n`; + case "read": return m === "ts" ? `const ${d.name} = i32(deaths(P1, units.ProtossArchon));\n` : `const ${d.name} = 0;\n`; + // (Not `let b = false`: TypeScript narrows the variable to that value, and `if (b)` then holds `never`. A test of z, 0 in the game.) + case "boolean": return `let ${d.name} = (z ${d.init ? "==" : "!="} 0);\n`; + case "text": return `let ${d.name}${m === "ts" ? ": string" : ""} = ${JSON.stringify(d.init)};\n`; + case "fixed": return `const ${d.name} = [${(d.init as number[]).join(", ")}];\n`; + case "growing": return `const ${d.name}${m === "ts" ? ": number[]" : ""} = [${(d.init as number[]).join(", ")}];\n`; + case "record": return `const ${d.name} = { x: ${(d.init as number[])[0]}, y: ${(d.init as number[])[1]} };\n`; + } +} +function renderFunc(f: Func, m: Mode): string { + const params = f.params.map((p) => (m === "ts" ? `${p.name}: ${p.kind}` : p.name)).join(", "); + const ret = m === "ts" ? `: ${f.returns}` : ""; + return ` function ${f.name}(${params})${ret} {\n${f.locals.map((l) => ` let ${l} = 0;\n`).join("")}${f.body.map((s) => rs(s, m, " ")).join("")} }\n`; +} +export function renderProgram(p: ProgramTree, m: Mode): string { + const head = m === "ts" ? "program(() => {\n" : "function* () {\n"; + return `${head}${p.decls.map((d) => " " + renderDecl(d, m)).join("")}${p.functions.map((f) => renderFunc(f, m)).join("")}${p.body.map((s) => rs(s, m, " ")).join("")}${m === "ts" ? "});\n" : "}"}`; +} +/** The script as TrigScript: `programs` programs, each with a program of its own. */ +export function renderScript(g: Generated): string { + return g.programs.map((p) => renderProgram(p, "ts")).join("\n"); +} + +// ── run as JavaScript ── + +const PRELUDE = ` +const __div = (a, b) => (b === 0 ? 0 : (a / b) | 0); +const __mod = (a, b) => (b === 0 ? 0 : (a % b) | 0); +const __shl = (a, b) => { b >>>= 0; return b >= 32 ? 0 : (a << b) | 0; }; +const __shr = (a, b) => { b >>>= 0; return b >= 32 ? (a < 0 ? -1 : 0) : a >> b; }; +const __deathsTable = new Map(); +const __deaths = (u) => (__deathsTable.get(u) ?? 0) | 0; +const __setDeaths = (u, mod, v) => { const was = __deathsTable.get(u) ?? 0; v = v >>> 0; __deathsTable.set(u, (mod === "set" ? v : mod === "add" ? (was + v) >>> 0 : Math.max(0, was - v)) >>> 0); }; +const __switches = [false, false, false, false]; +`; + +/** + * What JavaScript prints for the script, frame by frame: each program a generator, resumed in the order of the + * programs on the frame its sleep ends; a line is `cycle:text`, as the simulator's events are read. + */ +export function runJs(g: Generated): string[] { + const out: string[] = []; + let cycle = 0; + const print = (t: string) => out.push(`${cycle}:${t}`); + // (One function holds every program, so the deaths table and the switches are the game's: one for all of them.) + const all = new Function("__print", `${PRELUDE}\nreturn [${g.programs.map((p) => renderProgram(p, "js")).join(",\n")}];`)(print) as (() => Generator)[]; + const runs = all.map((mk) => ({ gen: mk(), wake: 0, done: false })); + for (cycle = 0; cycle < g.frames; cycle++) { + for (const run of runs) { + if (run.done || run.wake > cycle) continue; + const r = run.gen.next(); + if (r.done) run.done = true; + else run.wake = cycle + (r.value as number); + } + } + return out; +} + +// ── cut down ── + +/** Every list of statements in the tree, with its owner, so one statement at a time can be taken out. */ +function* bodies(p: ProgramTree): Generator { + const walk = function* (b: Stmt[]): Generator { + yield b; + for (const s of b) { + if (s.k === "if") { yield* walk(s.then); if (s.else) yield* walk(s.else); } + else if (s.k === "for" || s.k === "while" || s.k === "do") yield* walk(s.body); + else if (s.k === "switch") { for (const c of s.cases) yield* walk(c.body); yield* walk(s.def); } + else if (s.k === "try") { yield* walk(s.body); yield* walk(s.catch); if (s.finally) yield* walk(s.finally); } + } + }; + yield* walk(p.body); + for (const f of p.functions) yield* walk(f.body); +} + +/** + * The smallest script that still fails `fails`: whole programs first, then a statement at a time out of every body, + * then an `if`/`try`/loop replaced by its own body, until nothing more can go. A `return` that ends a function's body + * and the loop guards stay: without them the program would not be one. + */ +export function reduce(g: Generated, fails: (g: Generated) => boolean): Generated { + let cur: Generated = structuredClone(g); + // Whole programs. + for (let i = cur.programs.length - 1; i >= 0 && cur.programs.length > 1; i--) { + const t = { ...cur, programs: cur.programs.filter((_, k) => k !== i) }; + if (fails(t)) cur = t; + } + let changed = true; + while (changed) { + changed = false; + for (const p of cur.programs) { + for (const b of [...bodies(p)]) { + for (let i = b.length - 1; i >= 0; i--) { + const s = b[i]; + const last = i === b.length - 1 && s.k === "return"; + if (last) continue; + const removed = b.splice(i, 1); + if (fails(cur)) { changed = true; continue; } + b.splice(i, 0, ...removed); + // A compound statement replaced by its first body. + const inner = s.k === "if" ? s.then : s.k === "for" || s.k === "while" || s.k === "do" ? s.body : s.k === "try" ? s.body : s.k === "switch" ? s.def : null; + if (inner && inner.every((x) => x.k !== "break" && x.k !== "continue" && x.k !== "throw") && !(s.k === "for" && JSON.stringify(inner).includes(`"${s.i}"`))) { + b.splice(i, 1, ...inner); + if (fails(cur)) { changed = true; continue; } + b.splice(i, inner.length, s); + } + } + } + // Functions nothing calls any more. + for (let i = p.functions.length - 1; i >= 0; i--) { + const f = p.functions[i]; + if (JSON.stringify([p.body, p.functions.filter((x) => x !== f)]).includes(`"fn":"${f.name}"`)) continue; + p.functions.splice(i, 1); + if (fails(cur)) { changed = true; continue; } + p.functions.splice(i, 0, f); + } + // Declarations nothing names. + for (let i = p.decls.length - 1; i >= 0; i--) { + const d = p.decls[i]; + if (JSON.stringify([p.body, p.functions]).includes(`"${d.name}"`)) continue; + p.decls.splice(i, 1); + if (fails(cur)) { changed = true; continue; } + p.decls.splice(i, 0, d); + } + } + } + return cur; +} From 7eb9f31f04641c3ac6c42b9ad3f18790221fec00 Mon Sep 17 00:00:00 2001 From: Jeany Date: Sat, 24 Oct 2026 17:28:40 -0600 Subject: [PATCH 2/5] The carriers' in-transit flag at byte 14 of the unit-properties record, not 16 Since step 14 the build wrote the state flag two bytes late, so every map built our way made its input carriers in the open and no key, click, sync or typed line reached a program. The first play of Tetris found it; the step 12 probe answers every key again, and game-key-tap and game-events-input pass in the game on the own build. The test checks byte 14 set and byte 16 clear. The plan says what was found. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/chk.ts | 2 +- docs/v4-plan.md | 27 +++++++++++++++++++++++---- tests/own-build.test.ts | 4 +++- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/compiler/backend/chk.ts b/compiler/backend/chk.ts index 6f15677..b7312f8 100644 --- a/compiler/backend/chk.ts +++ b/compiler/backend/chk.ts @@ -244,7 +244,7 @@ export function markInTransit(s: Sections, slot: number): void { const view = new DataView(uprp.buffer); const at = (slot - 1) * CUWP_STRIDE; view.setUint16(at, view.getUint16(at, true) | IN_TRANSIT, true); - view.setUint16(at + 16, view.getUint16(at + 16, true) | IN_TRANSIT, true); + view.setUint16(at + 14, view.getUint16(at + 14, true) | IN_TRANSIT, true); s.set("UPRP", uprp); const upus = s.get("UPUS"); if (upus && upus.length >= slot) { const next = upus.slice(); next[slot - 1] = 1; s.set("UPUS", next); } diff --git a/docs/v4-plan.md b/docs/v4-plan.md index e9d4404..c65c608 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -5684,6 +5684,24 @@ bits, an uncaught error said in red. Each names the backend's file now (`heap.ts `errors.ts`); the Python is named once, as what laid the same programs out until step 17. The README's account of the build still says a program the own build does not take goes through eudplib, which is true until step 17 deletes it. +**Played, and what the first play found (2026-10-24).** The pack's Tetris, played in Remastered: every piece fell +where it appeared, no key moved or turned it, and the game ended on a stack in one column. Written into the lifted +game's key table the same way (the step 12 runner), the own build of `probes/own-input.ts` printed nothing after its +first line either — and the same probe built at the step 13 merge answered every key. The build in the editor (step 14) +took the unit-properties record over from `build-own.mts`, and `markInTransit` in `compiler/backend/chk.ts` wrote the +carriers' "in transit" state flag at byte 16 of the twenty-byte record, where the game reads it at byte 14 (the +valid-flags half was right). Every carrier since then was made in the open, an ordinary unit, and no point sent to one +came back: nothing of the players' input reached a program on any map built our way since step 14 — the step 12 and +13 plays in Remastered were of `build-own.mts` maps, step 14's played probe pressed no key, the suite's test of the +record asserted byte 16 too, and nothing in CI presses a key in the game. Byte 14 now, the test with it (and byte 16 +clear). The step 12 probe prints every line again (sync, presses, held, clicks, the mouse, typed lines); Tetris drops +on Space and slides on A and D in the lifted game; and `game-key-tap` and `game-events-input` pass their tests in the +game on the own build — so the harness does reach the own build's carriers, and the row of failing `game-*.ts` tests +in the table above is largely this one fault (`game-key-held` reads 11 frames held where the simulator says 12: the +late release the send pass adds for Remastered's dropped frame, counted differently by the two; for step 17). The ten +pack maps that read input were rebuilt (own-input, input, events, cutscene, tetris, key-held, local-select, snake, +raycaster, local-ui); the other four are as they were. + *Open:* - Step 16's leftovers (2026-10-23). *A function that calls itself with a `finally`* in it, refused (a text on the stack); the generator leaves the shape out until then. *A function that calls itself and gives back a text* — the @@ -5691,16 +5709,17 @@ build still says a program the own build does not take goes through eudplib, whi eudplib until it is taken, and step 17 must take it or change the probe. *The test harness on maps of our own:* `probes/events.ts` counts "Display Text Message —" action events, which `sim.events` has only from an eudplib build (0 of 3, 0 of 2 in the game, the lines themselves shown), and `probes/game-tests.ts`'s typed line and key (`sim.type`, - `sim.press`) reach the Python side's chatEvent and MSQC, not the own build's carriers — both A2 leftovers for step 17 - or the Magenta slice, played probes standing in until then. `probes/game-text-arrays.ts` runs its strings past the + `sim.press`) were thought to reach only the Python side's chatEvent and MSQC — they reach the own build's carriers too, + once the record was right (2026-10-24, above); the action-event counts stay an A2 leftover for step 17 or the + Magenta slice. `probes/game-text-arrays.ts` runs its strings past the lifted game's 64 KB. *The hoister's narrowing:* `let b = false; if (b) { f(b || x) }` is `never` to TypeScript (the variable narrowed to false, then nothing), and the error says "must be known when the script is built": right, badly worded. *The lifted game's eudplib build* prints the same lines as ours, so `--eudplib` is kept only until step 17. *Texts in records kept in an array that grows:* `probes/game-closures.ts` and `game-references.ts` are refused ("a assign it does not lower": the length of the texts' address array, `(kept) addr`, `(objects) name.addr`) — a B9 shape the texts pass does not make; through eudplib until taken. *The `game-*.ts` probes that fail in the lifted game on - the own build* (the table above) are to be gone through one by one at the start of step 17: most drive the players' - input, which the harness sends through the Python side's plugins. *A text made inside a function that calls itself:* `t = \`${t}${f(d - 2)}\`` in `f` reads `t` after the inner call + the own build* (the table above) are to be gone through one by one at the start of step 17: most drove the players' + input, and the unit-properties record was the fault (2026-10-24); what is left after it is to be seen. *A text made inside a function that calls itself:* `t = \`${t}${f(d - 2)}\`` in `f` reads `t` after the inner call in the simulator and in the map (the same in both), before it in JavaScript ("zzz000" against "zzz0000"); a template with a call at the top of the program reads its parts in order. The generator leaves texts out of a function that calls itself until this is taken. *Build-time functions* run as JavaScript: a function whose diff --git a/tests/own-build.test.ts b/tests/own-build.test.ts index 1172aba..ac4757e 100644 --- a/tests/own-build.test.ts +++ b/tests/own-build.test.ts @@ -113,7 +113,9 @@ describe("the build in the editor", () => { const uprp = s.get("UPRP")!; const view = new DataView(uprp.buffer, uprp.byteOffset, uprp.byteLength); expect(view.getUint16(20, true) & 4).toBe(4); - expect(view.getUint16(20 + 16, true) & 4).toBe(4); + expect(view.getUint16(20 + 14, true) & 4).toBe(4); + // (The state flags are byte 14 of the record. Written at byte 16 from step 14 on, every carrier was made in the open and no key reached a program; the first play of Tetris found it.) + expect(view.getUint16(20 + 16, true)).toBe(0); expect(s.get("UPUS")![1]).toBe(1); expect(out.lines.join("\n")).toMatch(/input: 1 keys, 0 sync slots; carriers of type 58/); expect(out.lines.at(-1)).toMatch(/2 triggers of the map's own \(4 copies, 1 hyper triggers left out\)/); From fd74beb6a92b6630349d1705a2ff88d66980ff7f Mon Sep 17 00:00:00 2001 From: Jeany Date: Sat, 24 Oct 2026 17:56:30 -0600 Subject: [PATCH 3/5] Every input id is the map's across runs: a second program hears the players too A run after the first had every @in. array renamed as its own, so only the first program that read keys, clicks or typed lines ever heard them. The prefix rule the events pass uses, a test holding a second program's reads to the first one's cells, and the plan's account of the input probe's play. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/input.ts | 3 ++- docs/v4-plan.md | 13 +++++++++++++ tests/emitter.test.ts | 15 +++++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/compiler/backend/input.ts b/compiler/backend/input.ts index a01bdf1..38a6ab8 100644 --- a/compiler/backend/input.ts +++ b/compiler/backend/input.ts @@ -114,7 +114,8 @@ const KIND_STATE = 0, KIND_SYNC = 1, KIND_CHAT = 2; const global = (id: string): VarDecl => ({ id, name: "(input)", kind: "number", shared: true, routed: true, global: true, at: AT0 }); export const INPUT_GLOBALS: readonly VarDecl[] = [global(MADE), global(RING), global(NAME_LENGTH), global(LINE_LENGTH), global(TYPED)]; -export const isInputGlobal = (id: string) => INPUT_GLOBALS.some((d) => d.id === id); +/** An id of the input's: its arrays, globals and temps are the map's, one of each however many programs and runs read them (as the events' `@ev.`). */ +export const isInputGlobal = (id: string) => id.startsWith("@in."); /** The mouse of a player on the map, as the receive pass keeps it: what `underMouse()`'s pick measures from. */ export const MOUSE_CELLS = { x: MX, y: MY }; diff --git a/docs/v4-plan.md b/docs/v4-plan.md index c65c608..d2215b0 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -5702,6 +5702,19 @@ late release the send pass adds for Remastered's dropped frame, counted differen pack maps that read input were rebuilt (own-input, input, events, cutscene, tetris, key-held, local-select, snake, raycaster, local-ui); the other four are as they were. +**The second play found the second fault (2026-10-24).** The input probe, rebuilt and played: every key, click and +the mouse answered, and a typed `-help` did not. In the lifted game the same: the probe's typed lines (its third +program) and its per-player F7 (its fourth) heard nothing, while its keys (its second) did — and each of them alone, +or first, heard everything. The runs' naming: a run after the first gets every array and variable of its own, +`id#n`, but for what is the map's, and the input's arrays were not on that list (five of its globals were; the events' +pass keeps everything of its `@ev.` by prefix). So the second program that read input read `@in.pressed#1` and +`@in.chatf#1`, cells of its own that no pass wrote: on any map of our own, only the first program that read the +players' input ever heard it, since step 12 — the step 12 probe is one program, and so is every `game-*.ts` input +test. Every `@in.` id is the map's now, as `@ev.`; `tests/emitter.test.ts` holds a second program's reads to the +first one's cells. The input probe's lines, keys, typed lines and the per-player F7 all come in the lifted game, and +the pack's ten input maps were rebuilt once more. Of the `game-*.ts` tests failing in the game on 2026-10-23, `game-local`, `game-tests` and `game-cutscene` pass now with `key-tap` and `events-input`; still failing: `game-events` (one of four: a listener a player), `game-events-2` (on.killed and on.order counts: the action events, an A2 leftover), `game-lowering` (the C line), `game-key-held` (11 for 12), `game-local-players` (the harness itself) — step 17's list. (An hour of the day went to a `name(P1)` that printed nothing: the +lifted game's player is called "oracle", and the runner's filter dropped every line with that word in it.) + *Open:* - Step 16's leftovers (2026-10-23). *A function that calls itself with a `finally`* in it, refused (a text on the stack); the generator leaves the shape out until then. *A function that calls itself and gives back a text* — the diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index 784a0d0..e2cb02c 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -2387,6 +2387,21 @@ describe("a map of our own, input", () => { expect(got.sent).toEqual([]); }); + it("a second program that reads the players' input reads the first one's cells: nothing of the input's is renamed a run", () => { + // (Every input id is the map's, as the events' `@ev.` are. Renamed `@in.pressed#1` for the second run, that run read + // cells of its own that no pass wrote: the slice 4 probe's typed lines and per-player keys heard nothing, 2026-10-24.) + const r = compileScript(ts, { "main.ts": [ + 'program(async () => { while (true) { if (keyPressed(P1, "F7")) print("one"); await nextFrame(); } });', + 'program(async () => { while (true) { if (keyPressed(P1, "F8")) print("two"); if (chatted(P1, "-help")) print("help"); await nextFrame(); } });', + ].join("\n") }, defaultScriptNames(), { lib: defaultLib(), emitter: false }); + expect(r.diagnostics).toEqual([]); + const o = emitOwn(r, { slots: [0, 1], forces: [0, 1, 0, 0, 0, 0, 0, 0] }, 1, { humans: [0, 1], width: 64 * 32, height: 64 * 32, cuwp: 64 }); + if (typeof o === "string") throw new Error(o); + expect(o.runs).toHaveLength(2); + expect(JSON.stringify(o.runs[1].graph).match(/"@in\.[^"#]*#[^"]*"/g) ?? []).toEqual([]); + expect((o.runs[1].graph.cells ?? []).map((c) => c.id)).toEqual(expect.arrayContaining(["@in.pressed", "@in.chatf"])); + }); + it("a key that reads up for one frame of a hold stays held: no second press", () => { const got = run([ "program(async () => {", From 9eda5495b8df42b1646d8eee17510f807d611b8e Mon Sep 17 00:00:00 2001 From: Jeany Date: Sat, 24 Oct 2026 18:35:59 -0600 Subject: [PATCH 4/5] A died unit's fields read as they are, and a task's lines shown to the owner while the program sleeps A unit handed to a died handler has the order Die, which the guard every field read had asked to be otherwise: every field read as 0. A read asks now only that the place holds the record. And the tasks' turn comes while the program sleeps, from the last player's list with the current player theirs: a run with tasks sets it to its owner before its wake, every frame, so that what a task shows, the owner sees. Two tests, and the plan's account of the events map's play. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- compiler/backend/layout.ts | 11 +++++++++-- compiler/backend/units.ts | 10 +++++++++- docs/v4-plan.md | 18 ++++++++++++++++++ tests/emitter.test.ts | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 3 deletions(-) diff --git a/compiler/backend/layout.ts b/compiler/backend/layout.ts index 404786a..5fdb567 100644 --- a/compiler/backend/layout.ts +++ b/compiler/backend/layout.ts @@ -300,15 +300,21 @@ export function layout(input: LayoutInput): OwnMap { // past the program once woken. const wake = (i: number) => (i ? `@wake#${i}` : "@wake"), init = (i: number) => (i ? `@init#${i}` : "@init"); const stop = "@stop", run = "@run", entry = (p: number) => `@entry${p}`, gate = (p: number) => `@gate${p}`; + // A run with tasks: the current player set to its owner before its wake, every frame. The game set it to the player + // whose list carries the walk (the last one's), and the wake sets it to the owner only when the program runs; the + // tasks' turn comes while the program sleeps too, and what a task showed then, the owner did not see. (The first + // play of the events probe, 2026-10-24: "back", printed by a task two seconds after a death, never showed.) + const cur = (i: number) => (i ? `@cur#${i}` : "@cur"); + const before = (i: number) => (runs[i].graph.tasks ? cur(i) : wake(i)); // (A run whose graph holds the map's events pass goes through it first.) - const first = (i: number) => runs[i].graph.pass ?? wake(i); + const first = (i: number) => runs[i].graph.pass ?? before(i); // (A run whose graph holds the map's send pass: the walk goes through it after the last run, before it stops.) const sendPass = runs.find((r) => r.graph.after)?.graph.after; const after = (i: number) => (i + 1 < runs.length ? first(i + 1) : sendPass ?? stop); const list = (p: number) => TRIGGER_LISTS + 12 * p; const listEnd = (p: number) => u(~(list(p) + 4)); // (Where each run's graph ends: the next run.) - const ends = new Map(runs.flatMap((_, i): [string, string][] => [[i ? `${END}#${i}` : END, after(i)], [i ? `${PASS_END}#${i}` : PASS_END, wake(i)], [i ? `${AFTER_END}#${i}` : AFTER_END, stop]])); + const ends = new Map(runs.flatMap((_, i): [string, string][] => [[i ? `${END}#${i}` : END, after(i)], [i ? `${PASS_END}#${i}` : PASS_END, before(i)], [i ? `${AFTER_END}#${i}` : AFTER_END, stop]])); const endOf = (l: string) => ends.get(l) ?? l; // (`flags`: a node's execution flags as written, a trigger of the map's own; else preserved, or `off`.) type T = { label: string; conds: Cond[]; acts: Act[]; next?: string | number; prev?: number; off?: boolean; flags?: number }; @@ -323,6 +329,7 @@ export function layout(input: LayoutInput): OwnMap { return { label: own(p, k), prev: 0, conds: t.conds, acts: t.acts, next: chain[i + 1] ?? gate(p), flags: t.record.flags }; })); const triggers: T[] = runs.flatMap(({ graph, owner }, i) => [ + ...(graph.tasks ? [{ label: cur(i), conds: [], acts: [{ dword: { game: CURRENT_PLAYER }, modifier: "set", value: owner }], next: wake(i) } satisfies T] : []), { label: wake(i), conds: [{ dword: { var: NOW }, compare: "atLeast", value: 0 }, { dword: { game: list(owner) + 8 }, compare: "atMost", value: 0x7fffffff }], diff --git a/compiler/backend/units.ts b/compiler/backend/units.ts index 42cf07f..94ca3db 100644 --- a/compiler/backend/units.ts +++ b/compiler/backend/units.ts @@ -287,6 +287,14 @@ export function unitValues(program: Program, events?: { sources: Set { + if (isNone(u)) return { kind: "const", value: false }; + if (u.k === null) return u.none ? cmp(">=", u.e, k(1)) : { kind: "const", value: true }; + return all([cmp(">=", u.e, k(1)), cmp(">=", peek(of(u.e, SPRITE)), k(1)), cmp("==", bits(u.e, UID), ranged(bin("*", u.k, k(256)), 0, 0xff00))]); + }; /** The unit's uniqueness byte as it is now, for keeping (0 for none). */ const uidOf = (u: Unit, pre: Stmt[]): NumExpr => { if (u.k) return u.k; @@ -672,7 +680,7 @@ export function unitValues(program: Program, events?: { sources: Set { expect(got).toEqual(["3 made 0", "5 order 0 6 3", "7 morphed 38 from 37", "9 died 0", "9 killed 0 by 0", "11 made 32"]); }); + it("reads a died unit's type and place as they are, its order Die and its sprite still there", () => { + // (A unit handed to a died handler is on its way out: its record holds its type and place still, and a read of + // them asks only that the place holds the record — not that it is alive. The first play of the events probe, + // 2026-10-24, printed "died 0 at 0, 0" for every unit, Firebats included.) + const got = run([ + "program(async () => {", + " on.died((u) => { print(`died ${u.type} at ${u.x}, ${u.y}`); });", + "});", + ].join("\n"), 10, (f, w) => { + // (Deaths alone: the watch is gated, so the game's head of free places and Player 1's count move as the game moves them.) + if (f === 3) { w(place(1698) + 0x08, 40 * 256); w(place(1698) + 0x0c, 0x700001); w(place(1698) + 0x28, 100 | (200 << 16)); w(place(1698) + 0x4c, 0x0300); w(place(1698) + 0x64, 32); w(place(1698) + 0xa4, 7 * 256); w(0x628438, place(1697)); w(0x581de4, 3); } + if (f === 6) { w(place(1698) + 0x08, 0); w(place(1698) + 0x4c, 0x0000); w(0x581de4, 2); } + }); + expect(got).toEqual(["6 died 32 at 100, 200"]); + }); + it("starts a task for each unit a handler that waits is handed, and passes the watch by while the game's free list and counts stand still", () => { // (No orders or types watched: the watch is gated. The game's head of free places (0x628438) and Player 1's count // of units (0x581DE4) move as the game moves them when a unit is made; a unit put in a place with neither moved is @@ -2630,3 +2646,23 @@ describe("a map of our own, input: the mouse, clicks, sync() and a program for e ]); }); }); + +describe("a map of our own, the tasks' turn while the program sleeps", () => { + const laid = (script: string) => { + const r = compileScript(ts, { "main.ts": script }, defaultScriptNames(), { lib: defaultLib(), emitter: false }); + expect(r.diagnostics).toEqual([]); + const o = emitOwn(r, { slots: [0, 1], forces: [0, 1, 0, 0, 0, 0, 0, 0] }, 1, { humans: [0], width: 64 * 32, height: 64 * 32, cuwp: 64 }); + if (typeof o === "string") throw new Error(o); + return layout({ runs: o.runs.map((run) => ({ graph: run.graph, owner: run.player })), strings: 0, text: () => 1, arrays: o.values, template: () => 1 }); + }; + it("sets the current player to the owner before the wake of a run with tasks, and not where there are none", () => { + // (The walk runs from the last player's list, the current player theirs; the wake sets the owner only when the + // program runs, and a task's turn comes while it sleeps too: what the task showed then, the owner did not see. + // The first play of the events probe, 2026-10-24: "back", printed by a task two seconds after a death.) + const withTask = laid("program(async () => { async function later() { await sleep(frames(48)); print(\"later\"); } void later(); await sleep(frames(200)); });"); + expect(withTask.triggers.has("@cur")).toBe(true); + expect(withTask.triggers.has("@wake")).toBe(true); + const without = laid("program(async () => { print(\"now\"); await sleep(frames(200)); });"); + expect(without.triggers.has("@cur")).toBe(false); + }); +}); From b3cc98ce462ccdca4fc2f8d9e4cfe11fc74f120a Mon Sep 17 00:00:00 2001 From: Jeany Date: Sun, 25 Oct 2026 12:49:28 -0600 Subject: [PATCH 5/5] Plan: the gate's pack played out, twelve of fourteen as their sheets ask Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01En4SHqafb1QFJ4xgbUkAe5 --- docs/v4-plan.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 6db8cc5..8797744 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -5733,6 +5733,17 @@ both: the events map prints its made, died, back and E lines in the lifted game 4 and `game-cutscene` 3 of 3 still; `game-events` and `game-events-2` fail as before, on the action counts the harness has only from an eudplib build. The whole pack was rebuilt (every map with tasks or unit reads is touched). +**The pack, played out (2026-10-25).** Twelve of the fourteen play as their sheets ask on the build of our own, in +Remastered: tetris, snake, own-input, input, key-held, local-select, local-ui, events (every step, F included), +cutscene (the scene and the references), raycaster (Classic; the way out in 27 s), fog-lib (the disc and the heart +held), korobeiniki (A fit 1.000, B 0.995 against the files, no drift) and music-demo (the mixed song fit 0.999; the +note-by-note song follows it within 30 ms). ui-spike-2 was in the pack by mistake: its content was a hand-written +eudplib plugin, and built our way it says one line. One observation from the music demo for the sound library, not +the build: the note-by-note song is 2.5 dB quieter than the mixed one in every band, because a note's clip is a +mono WAV and Remastered plays a mono sound 2.4 dB quieter (the render is a stereo Ogg since ead7eab; the 2026-10-19 +play, before that, had them equal) — short clips should go in with two channels too. Four faults the plays found +are fixed above; the gate's part 2 is done. + *Open:* - Step 16's leftovers (2026-10-23). *A function that calls itself with a `finally`* in it, refused (a text on the stack); the generator leaves the shape out until then. *A function that calls itself and gives back a text* — the @@ -5758,7 +5769,9 @@ harness has only from an eudplib build. The whole pack was rebuilt (every map wi (`i + 2147483647` inside it is 2 147 483 648, then an error naming the result). *The lifted game's strings:* 1.16.1 names the first 64 KB, so `--game` puts three seeds (nine programs) to a map and says so when a map is past it. *`EUDPLIB=1`* is now unused by anything but `gate-sweep.mts` and `random-scripts.mts --eudplib`: step 17 removes it - with the Python. + with the Python. *A mono clip plays 2.4 dB quieter* than a stereo one in Remastered, and a note of the music library + goes in as a mono WAV while the rendered song is a stereo Ogg: the short clips should go in with two channels too + (the music demo's play, 2026-10-25). ## Part C — the game, pushed