diff --git a/compiler/backend/emit.ts b/compiler/backend/emit.ts index c13c09f..df420f2 100644 --- a/compiler/backend/emit.ts +++ b/compiler/backend/emit.ts @@ -24,7 +24,7 @@ import type { Range } from "../ranges"; import { PlayerGroup, type ActionRecord, type ConditionRecord } from "../../vendor/triggers"; import { actionDef } from "../../vendor/triggerDefs"; import { OVERFLOW_FN, errorLine } from "./errors"; -import { END, MOST_ACTS, MOST_CONDS, NOW, PASS_END, STACK, SWITCHES, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, checkGraph, nextOf, type Act, type ActPart, type Compare, type Cond, type Dword, type FixedFn, type Graph, type Num, type Region, type Trigger } from "./graph"; +import { AFTER_END, END, MOST_ACTS, MOST_CONDS, NOW, PASS_END, STACK, SWITCHES, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, checkGraph, nextOf, type Act, type ActPart, type Compare, type Cond, type Dword, type FixedFn, type Graph, type Num, type Region, type Trigger } from "./graph"; /** The game's tick (frames since the game started), and the fields of the trigger a map of our own wakes by. */ export const GAME_TICK = 0x57f23c; @@ -6166,7 +6166,7 @@ export const emitterCounts = { hoisted: 0, reciprocals: 0, jumps: 0, products: 0 * call of a function of the Python side's, and nothing read that the body did not write first (there is no one to * copy it in; a copy within the body is made of triggers by the layout). Returns the graph, or why it cannot be. */ -export function emitBody(body: Stmt[], decls: Map, key: string, functions = new Map(), arrays = new Map(), stackDepth = STACK_DEPTH, owner?: number, players: MapPlayers = ANY_PLAYERS, errors?: ProgramErrors, pass?: Stmt[]): Graph | string { +export function emitBody(body: Stmt[], decls: Map, key: string, functions = new Map(), arrays = new Map(), stackDepth = STACK_DEPTH, owner?: number, players: MapPlayers = ANY_PLAYERS, errors?: ProgramErrors, pass?: Stmt[], after?: Stmt[]): Graph | string { // (Every statement must be the emitter's here, so a rewriting it would not take is refused whichever way it goes: // no trial is needed.) const take = () => true; @@ -6217,11 +6217,15 @@ export function emitBody(body: Stmt[], decls: Map, key: string, // (The map's events pass, its statements as the program's: laid out apart, before the run's `@wake`.) const passed = pass?.length ? rw.stmt({ kind: "block", body: pass, at: pass[0].at } as Stmt) : []; const passStmt: Stmt | undefined = passed.length ? { kind: "block", body: passed, at: pass![0].at } : undefined; + // (The map's send pass, the input's: laid out apart too, after the last run's end.) + const aftered = after?.length ? rw.stmt({ kind: "block", body: after, at: after[0].at } as Stmt) : []; + const afterStmt: Stmt | undefined = aftered.length ? { kind: "block", body: aftered, at: after![0].at } : undefined; const scope = new Scope(known); rules(scope); scope.slots = planned.slots; scope.once = planned.once; if (passStmt && !scope.stmt(passStmt, false)) return scope.why || "the events pass"; + if (afterStmt && !scope.stmt(afterStmt, false)) return scope.why || "the send pass"; // (The map's own variables, which no program declares and no run's start sets: 0 as the map starts, one cell each.) for (const d of known.values()) if (d.global) scope.declared.add(d.id); if (!scope.stmt(s, false)) return scope.why || "?"; @@ -6272,6 +6276,12 @@ export function emitBody(body: Stmt[], decls: Map, key: string, e.stmt(passStmt); e.goto(PASS_END); } + const afterAt = `${key}:after`; + if (afterStmt) { + e.push({ label: afterAt, conds: [], items: [], keep: true }); + e.stmt(afterStmt); + e.goto(AFTER_END); + } e.settleThrown(); e.emitDigits(); if (start) { @@ -6289,6 +6299,7 @@ export function emitBody(body: Stmt[], decls: Map, key: string, if (typeof g !== "string" && e.templates.length) g.templates = e.templates; if (typeof g !== "string" && tasks) g.tasks = tasks; if (typeof g !== "string" && passStmt) g.pass = passAt; + if (typeof g !== "string" && afterStmt) g.after = afterAt; if (typeof g !== "string" && e.regions.size) g.cells = [...e.regions.values(), ...(g.cells ?? [])]; const cells = typeof g === "string" ? 0 : (g.cells ?? []).reduce((n, r) => n + r.cells, 0); if (cells > STACK_CELLS_MOST) return { e, g: `arrays and stacks of ${cells} cells, past the ${STACK_CELLS_MOST} a map of our own lays out (72 bytes a cell)` }; diff --git a/compiler/backend/graph.ts b/compiler/backend/graph.ts index 239075f..efe3a7b 100644 --- a/compiler/backend/graph.ts +++ b/compiler/backend/graph.ts @@ -201,11 +201,14 @@ export interface Entry { from: string; into: Dword; add: Num; scale?: number } */ /** * `pass`: where the map's events pass starts (a map of our own), which the walk goes through before the run's `@wake`; - * it ends at `PASS_END`. + * it ends at `PASS_END`. `after`: where the map's send pass starts (the input's, step 12), which the walk goes through + * after the last run's end; it ends at `AFTER_END`. */ -export interface Graph { entries: Entry[]; triggers: Trigger[]; arrays?: { id: string; stride: number }[]; cells?: Region[]; templates?: { id: string; bytes: number[]; at?: number; data?: boolean }[]; rows?: string[][]; tasks?: string; pass?: string } +export interface Graph { entries: Entry[]; triggers: Trigger[]; arrays?: { id: string; stride: number }[]; cells?: Region[]; templates?: { id: string; bytes: number[]; at?: number; data?: boolean }[]; rows?: string[][]; tasks?: string; pass?: string; after?: string } /** Where the events pass goes when it is done: its run's `@wake`. */ export const PASS_END = "@passed"; +/** Where the send pass goes when it is done: the walk's end. */ +export const AFTER_END = "@aftered"; export interface Region { id: string; cells: number; values?: number[] } /** @@ -237,7 +240,7 @@ export const actValue = (label: string, k: number): Dword => ({ at: label, field export function checkGraph(g: Graph): string[] { const labels = new Set(g.triggers.map((t) => t.label)); const out: string[] = []; - const known = (l: string) => l === END || l === PASS_END || labels.has(l); + const known = (l: string) => l === END || l === PASS_END || l === AFTER_END || labels.has(l); const dword = (d: Dword, where: string) => { if ("at" in d && !labels.has(d.at)) out.push(`${where}: no trigger ${d.at}`); }; diff --git a/compiler/backend/input.ts b/compiler/backend/input.ts new file mode 100644 index 0000000..3f41f42 --- /dev/null +++ b/compiler/backend/input.ts @@ -0,0 +1,783 @@ +/** + * The players' input in a map of our own (TrigScript 4, step 12 of the way off eudplib): keys, clicks, the mouse and + * `sync()`, carried between the players' computers by the map itself, in place of chatEvent and MSQC. + * + * A key is pressed on one computer. What a map can carry to every computer is a game command: the game sends each + * player's commands to all of them in turns and runs them in step, so what a command leaves in the game's memory is + * the same everywhere. The carrier is a unit, as MSQC's: for each human player a ring of hidden units of theirs, and + * a send is a Select of the next one and a targeted Move to a point — the point is the value, in the carrier's order + * target (`+0x58`) that same frame. Every computer, before the programs' turns (the receive pass, `receive`), reads + * each human's ring from its read cursor, takes what arrived in order, writes 0 back and moves the cursor on. After the + * programs' turns (the send pass, `send`), the computer the player sits at reads what they did and queues what changed, + * never more in flight than the ring holds, and then a Select of the player's own selection, so their orders go on + * reaching their units. A turn that brings several frames' sends keeps every one. + * + * Two rings a human: **points** (the mouse, raw, one a frame when it moved) and **messages**, each a header and then + * the raw values the header counts: a *state* (a bit a key the programs read, then the three mouse buttons; sent when + * it changed, paid out one a frame on arrival, so a key is held for as many frames as it was down, and a press is the + * frame its bit came on), a *sync* (the slot, then the value). The value of a point is `x − 1 + (y − 1) << XB` with + * `2^XB` the largest power of two at most the map's width in pixels less 2, which the game keeps a Move's target + * within (x in `[1, W − 2]`, y in `[1, H − 34]`): `bits` of a value, 20 on a 64×64 map. + * + * The carriers are made at the first frame, on every computer alike (`startUp`): the chosen type's dat entry patched + * as MSQC's (the Command Center's flingy so it cannot move, 1×1, no weapon, no sight, no right-click), the units + * created in transit — hidden, and selectable — through a unit-properties slot the build writes, given to Player 12 + * so they are in no human's list, and their owner byte put back to the player's so a Select and an order of that + * player reach them. The records' EPDs and tags are kept in cells of the map's. + * + * A typed line (part 2) is read where it was typed: the game shows each line as `name:\x07 text` in its thirteen text + * lines (0x640B60, 218 bytes each) and moves the byte at 0x640B58 on with every line. When that byte has moved, the + * send pass looks through the lines for one that begins with this computer's player's name (read at the first frame + * from the player table, 0x57EEEB + 36p), copies the text into cells, swaps the two bytes after the colon so the line is + * not found again (chatEvent's mark: the line reads the same), and matches the text against the patterns in order — + * text byte for byte, `{n}` digits, `{k:ore|gas}` and `{u:unit}` by a hash of the word or of the rest of the line + * (`hashOf`), a unit's name found among the names' hashes in order by halves. The first pattern that fits the whole + * 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 type { ArrayDecl, At, BoolExpr, NumExpr, Program, Stmt, VarDecl } from "../ir"; +import type { ActionRecord } from "../../vendor/triggers"; +import { MAX_CHAT_BYTES, MAX_CHAT_NUMBER, MOUSE_BUTTONS, inputsOf, parseChatPattern, virtualKey, type ChatPattern, type InputSource, type MouseButton } from "../input"; + +/** What the build says of the map: the human players' slots, the map in pixels, the unit-properties slot (1-based) it wrote as "in transit", and the carrier type. */ +export interface OwnMap { humans: number[]; width: number; height: number; cuwp: number; carrier?: number } + +export interface OwnInputPlan { + humans: number[]; + /** Bit `i` of a state is key `i`'s; the buttons' bits follow. */ + keys: string[]; + buttons: MouseButton[]; + mouse: boolean; + /** How many `sync()` slots the programs use, numbered across the build. */ + syncs: number; + /** The typed-line patterns the programs read, numbered from 1 in this order; the most values one carries; what a number in a line is at most (what a point carries). */ + chats: ChatPattern[]; + captures: number; + numberMost: number; + /** The names a `{…:unit}` capture knows: `hashOf` each name in rising order, and the type each means. */ + units: { hashes: number[]; ids: number[] }; + carrier: number; + cuwp: number; + /** Carriers a ring, and rings a human (the mouse's second). */ + ring: number; + rings: number; + /** The bits of x in a point's value, and the bits a value has. */ + xb: number; + bits: number; + /** The Move's target is kept within these (W − 2, H − 34). */ + box: { w: number; h: number }; +} + +/** The Valkyrie: what MSQC makes its carriers of too. */ +export const CARRIER = 58; +/** Carriers a ring. */ +export const RING_SIZE = 6; +/** Where the carriers stand, in pixels: the map's top left corner. */ +export const CARRIER_X = 64, CARRIER_Y = 64; +/** The most a `sync()` value is: what a point carries on the smallest map the contract holds for (64×64), less the 1 that tells a value from nothing. */ +export const SYNC_MOST = 2 ** 20 - 2; + +const DEATHS = 0x58a364; +const epd = (address: number) => (address - DEATHS) / 4; +const UNIT_TABLE = 0x59cca8, STRIDE = 336, UNIT_TABLE_EPD = epd(UNIT_TABLE); +const LOCAL = epd(0x512684), CURRENT = epd(0x6509b0), SELECTION = epd(0x6284b8), QUEUE = epd(0x654880), QUEUED = epd(0x654aa0), QUEUE_MOST = epd(0x57f0d8); +const FIRST_UNIT = epd(0x6283f8), MRGN = epd(0x58dc60), SCRATCH_LOCATION = 255; +const KEYS = 0x596a18, TYPING = epd(0x68c144), BUTTONS = epd(0x6cddc0), MOUSE_X = epd(0x6cddc4), MOUSE_Y = epd(0x6cddc8), SCREEN_X = epd(0x62848c), SCREEN_Y = epd(0x6284a8); +/** The text lines: the byte that moves with every line shown, the lines, a line's bytes, how many; the player table and where a name is in a record. */ +const TEXT_RING = epd(0x640b58), TEXT_LINES = epd(0x640b60), TEXT_LINE = 218, TEXT_LINE_COUNT = 13, PLAYERS = epd(0x57eee0), PLAYER_STRIDE = 36, NAME_OFFSET = 11, NAME_MOST = 24; +const BUTTON_BITS: Record = { left: 2, right: 8, middle: 32 }; +const NEXT_UNIT = 0x6c >> 2, ORDER_TARGET = 0x58 >> 2, OWNER_DWORD = 0x4c >> 2, STATUS = 0xdc >> 2, UID_DWORD = 0xa4 >> 2; +const SET_DEATHS = 45, SET_TO = 7, MASKED = 0x4353; +const CREATE_WITH_PROPERTIES = 11, GIVE_UNITS = 48, NEUTRAL = 11; +const AT0: At = { file: "", line: 0, column: 0 }; + +/* ── the cells ── */ +const CARRIERS = "@in.carrier", TAGS = "@in.tag", WRITE = "@in.wr", READ = "@in.rd"; +const STATE = "@in.state", PRESSED = "@in.pressed", SENT = "@in.sent", READ_1 = "@in.raw1", READ_2 = "@in.raw2"; +const MX = "@in.mx", MY = "@in.my", SENT_MX = "@in.smx", SENT_MY = "@in.smy"; +const EXPECT = "@in.expect", SLOT = "@in.slot"; +const SYNC = "@in.sync", PENDING = "@in.pend"; +/** A typed line, on arrival: the pattern that came (0 none), a flag a pattern, the values a pattern, the pattern whose values are coming and how many came. */ +const CHAT = "@in.chat", CHAT_FLAG = "@in.chatf", CAPTURES = "@in.cap", CHAT_PENDING = "@in.cpend", CAPTURE_AT = "@in.ci"; +/** A typed line, where it was typed: the text ring's byte as last seen, the name prefix and its length, the line's text and its length, the pattern matched and its values, the unit names' hashes and types. */ +const RING = "@in.ring", NAME = "@in.name", NAME_LENGTH = "@in.nlen", LINE = "@in.line", LINE_LENGTH = "@in.llen", TYPED = "@in.typed", TYPED_VALUES = "@in.tval", UNIT_HASHES = "@in.uh", UNIT_IDS = "@in.uu"; +const MADE = "@in.made"; +const SLOTS = 8; +const MESSAGES = 0, POINTS = 1; +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); +/** 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 }; + +/** The arrays of the map's the passes and the programs' reads share, which no program declares. */ +export function inputArrays(plan: OwnInputPlan): ArrayDecl[] { + const a = (id: string, length: number): ArrayDecl => ({ id, name: "(input)", kind: "number", length, shared: true, at: AT0 }); + const chats = plan.chats.length, captures = plan.captures, units = plan.units.hashes.length; + return [ + a(CARRIERS, SLOTS * plan.rings * plan.ring), a(TAGS, SLOTS * plan.rings * plan.ring), a(WRITE, SLOTS * plan.rings), a(READ, SLOTS * plan.rings), + a(STATE, SLOTS), a(PRESSED, SLOTS), a(SENT, SLOTS), a(READ_1, SLOTS), a(READ_2, SLOTS), a(EXPECT, SLOTS), a(SLOT, SLOTS), + ...(plan.mouse ? [a(MX, SLOTS), a(MY, SLOTS), a(SENT_MX, SLOTS), a(SENT_MY, SLOTS)] : []), + ...(plan.syncs ? [a(SYNC, SLOTS * plan.syncs), a(PENDING, plan.syncs)] : []), + ...(chats ? [a(CHAT, SLOTS), a(CHAT_FLAG, SLOTS * chats), a(CHAT_PENDING, SLOTS), a(CAPTURE_AT, SLOTS), a(NAME, NAME_MOST + 3), a(LINE, MAX_CHAT_BYTES + 1)] : []), + ...(chats && captures ? [a(CAPTURES, SLOTS * chats * captures), a(TYPED_VALUES, captures)] : []), + ...(units ? [{ ...a(UNIT_HASHES, units), unsigned: true, values: plan.units.hashes }, { ...a(UNIT_IDS, units), values: plan.units.ids }] : []), + ]; +} + +/** What a word or a unit's name hashes to, in a typed line: capitals A to Z as small letters, h = h × 31 + byte, in 32 bits. */ +export function hashOf(data: Uint8Array): number { + let h = 0; + for (let b of data) { + if (b >= 65 && b <= 90) b += 32; + h = (Math.imul(h, 31) + b) >>> 0; + } + return h; +} +const bytesOf = (s: string) => new TextEncoder().encode(s); + +/** Whether a program reads the players, or sends to them. */ +export function hasInput(x: unknown): boolean { + if (Array.isArray(x)) return x.some(hasInput); + if (!x || typeof x !== "object") return false; + const o = x as { kind?: string; mouse?: unknown }; + if (o.kind === "input" || o.kind === "syncSend") return true; + // (A pick near a player's mouse reads the mouse the receive pass keeps.) + if (o.kind === "pick" && o.mouse !== undefined) return true; + return Object.values(o).some((v) => v && typeof v === "object" && hasInput(v)); +} + +/** + * The plan for a build's programs: null when none reads the players; a string when the map cannot carry what they + * read, worded for the script's author. + */ +export function ownInputPlan(programs: Program[], map: OwnMap, unitNames: readonly (readonly [string, number])[] = []): OwnInputPlan | string | null { + const { sources, mouse } = inputsOf(programs); + if (!sources.length && !mouse) return null; + if (!map.humans.length) return "a program reads keys, clicks, the mouse, typed lines or sync(): this map has no human player to read them from"; + const keys: string[] = [], buttons: MouseButton[] = [], chats: ChatPattern[] = []; + for (const s of sources) { + if (s.source === "key" && !keys.includes(s.key)) keys.push(s.key); + else if (s.source === "click" && !buttons.includes(s.button)) buttons.push(s.button); + else if (s.source === "chat" && !chats.some((c) => c.pattern === s.pattern)) { + try { chats.push(parseChatPattern(s.pattern)); } catch (err) { return err instanceof Error ? err.message : String(err); } + } + } + for (const b of MOUSE_BUTTONS) if (!buttons.includes(b)) buttons.push(b); + const syncs = Math.max(0, ...sources.map((s) => (s.source === "sync" ? s.slot + 1 : 0))); + const box = { w: map.width - 2, h: map.height - 34 }; + const xb = Math.floor(Math.log2(box.w)); + const bits = Math.floor(Math.log2(2 ** xb * box.h)); + // (A state is a header: its kind takes two bits.) + if (keys.length + buttons.length > bits - 2) return `a script reads ${keys.length} keys: on a map this size it can read ${bits - 2 - buttons.length} at the most`; + if (bits < 16) return "the map is too small to carry the players' input: 32 by 32 tiles at the least"; + const captures = Math.max(0, ...chats.map((c) => c.captures.length)); + // (A unit's name by its hash, the first of a hash's names: `{…:unit}` gives the type of the name typed.) + const byHash = new Map(); + if (chats.some((c) => c.captures.some((x) => x.kind === "unit"))) for (const [name, id] of unitNames) { const h = hashOf(bytesOf(name)); if (!byHash.has(h)) byHash.set(h, id); } + const sorted = [...byHash].sort((a, b) => a[0] - b[0]); + const units = { hashes: sorted.map((x) => x[0]), ids: sorted.map((x) => x[1]) }; + return { humans: map.humans, keys, buttons, mouse, syncs, chats, captures, numberMost: Math.min(MAX_CHAT_NUMBER, 2 ** bits - 1), units, carrier: map.carrier ?? CARRIER, cuwp: map.cuwp, ring: RING_SIZE, rings: mouse ? 2 : 1, xb, bits, box }; +} + +/** What MSQC's `Respawn` writes into the dat tables for its carrier type: each a byte, word or dword at an address. */ +export function carrierDat(type: number): { address: number; bytes: 1 | 2 | 4; value: number }[] { + return [ + { address: 0x6644f8 + type, bytes: 1, value: 94 }, // flingy: the Command Center's, top speed 0 + { address: 0x6617c8 + type * 8, bytes: 4, value: 0x10001 }, { address: 0x6617cc + type * 8, bytes: 4, value: 0x10001 }, // dimensions 1×1 + { address: 0x662860 + type * 4, bytes: 4, value: 0 }, // building dimensions + { address: 0x6636b8 + type, bytes: 1, value: 130 }, { address: 0x6616e0 + type, bytes: 1, value: 130 }, // no ground or air weapon + { address: 0x662db8 + type, bytes: 1, value: 0 }, { address: 0x663238 + type, bytes: 1, value: 0 }, // seek range, sight range + { address: 0x662098 + type, bytes: 1, value: 6 }, // right-click action: none + { address: 0x664080 + type * 4, bytes: 4, value: 0x38000025 }, // special ability flags + { address: 0x6637a0 + type, bytes: 1, value: 0 }, // group flags + { address: 0x660fc8 + type, bytes: 1, value: 0xc5 }, { address: 0x663150 + type, bytes: 1, value: 0x13 }, // movement flags, elevation + ]; +} + +/** + * The program with its input as reads of the map's cells, and — when `passes` — the map's receive pass (before the + * events pass, in `Program.pass`) and send pass (`Program.after`). + */ +export function inputValues(program: Program, plan: OwnInputPlan, passes: boolean): Program { + let here: At = program.at; + let made = 0; + const fresh = (what: string) => `@in.${what}${made++}`; + const owner = !program.perPlayer && program.owners.length <= 1 && program.owner >= 0 && program.owner < 8 ? program.owner : undefined; + + // ── the IR's pieces ── + const at = () => ({ at: here, label: "" }); + const k = (value: number): NumExpr => ({ kind: "const", value }); + const V = (id: string): NumExpr => ({ kind: "var", id }); + const bin = (op: "+" | "-" | "*" | "&" | "^" | ">>>", a: NumExpr, b: NumExpr): NumExpr => { + if (a.kind === "const" && b.kind === "const") { + const [x, y] = [a.value >>> 0, b.value >>> 0]; + return k((op === "+" ? x + y : op === "-" ? x - y : op === "*" ? Number((BigInt(x) * BigInt(y)) & 0xffffffffn) : op === "&" ? x & y : op === "^" ? x ^ y : x >>> y) >>> 0); + } + return { kind: "binary", op, left: a, right: b, ...at() } as NumExpr; + }; + const div = (a: NumExpr, by: number): NumExpr => ({ kind: "binary", op: "/", left: a, right: k(by), unsigned: true, ...at() } as NumExpr); + const cmp = (op: "==" | "!=" | "<" | "<=" | ">" | ">=", a: NumExpr, b: NumExpr): BoolExpr => ({ kind: "compare", op, left: a, right: b, unsigned: true, ...at() }); + const ranged = (e: NumExpr, lo: number, hi: number): NumExpr => ({ ...e, range: [lo, hi] }) as unknown as NumExpr; + const num = (id: string, top?: number, keep?: "routed" | "everywhere"): VarDecl => ({ id, name: "(input)", kind: "number", shared: false, at: here, temp: true, ...(top !== undefined ? { top } : {}), ...(keep ? { [keep]: true } : {}) } as VarDecl); + const dec = (id: string, init: NumExpr, top?: number, keep?: "routed" | "everywhere"): Stmt => ({ kind: "declare", decl: num(id, top, keep), init, ...at() }); + const set = (target: string, value: NumExpr): Stmt => ({ kind: "assign", target, value, ...at() }); + const when = (cond: BoolExpr, then: Stmt[], otherwise: Stmt[] = []): Stmt[] => [{ kind: "if", cond, then, ...(otherwise.length ? { else: otherwise } : {}), ...at() }]; + const loop = (body: Stmt[]): Stmt => ({ kind: "while", cond: undefined, body, ...at() } as Stmt); + const brk = (): Stmt => ({ kind: "break", ...at() }); + const element = (array: string, index: NumExpr): NumExpr => ({ kind: "element", array, index, at: here }); + const store = (array: string, index: NumExpr, value: NumExpr): Stmt => ({ kind: "store", array, index, value, ...at() }); + /** The dword at an EPD, or its bits of the mask, moved down `shift`. */ + const peek = (at_: NumExpr, mask = 0xffffffff, shift = 0): NumExpr => { + const whole = mask >>> 0 === 0xffffffff; + const record = { location: whole ? 0 : mask >>> 0, player: 0, amount: 0, unitId: 0, comparison: 0, type: 15, resource: 0, flags: 0, mask: whole ? 0 : MASKED }; + const r: NumExpr = { kind: "read", read: { source: "condition", record, epd: at_ }, ...at() } as NumExpr; + return shift ? bin(">>>", r, k(shift)) : r; + }; + /** A Set Deaths of the dword at an EPD: the value written, or its bits of the mask. */ + const poke = (at_: NumExpr, value: NumExpr, mask = 0xffffffff): Stmt => { + const whole = mask >>> 0 === 0xffffffff; + const record: ActionRecord = { location: whole ? 0 : mask >>> 0, text: 0, wav: 0, time: 0, player: 0, target: value.kind === "const" ? value.value >>> 0 : 0, unitId: 0, type: SET_DEATHS, modifier: SET_TO, flags: 0, padding: 0, mask: whole ? 0 : MASKED }; + const variables = [{ field: "player" as const, bits: 32 as const, name: "at", expr: at_ }, ...(value.kind === "const" ? [] : [{ field: "target" as const, bits: 32 as const, name: "n", expr: value }])]; + return { kind: "action", record, variables, ...at() } as Stmt; + }; + /** A byte, word or dword at an address, as a Set Deaths of its dword's bits. */ + const pokeAt = (address: number, bytes: 1 | 2 | 4, value: number): Stmt => { + const shift = 8 * (address & 3), mask = bytes === 4 ? 0xffffffff : ((2 ** (8 * bytes) - 1) * 2 ** shift) >>> 0; + return poke(k(epd(address & ~3)), k((value * 2 ** shift) >>> 0), mask); + }; + /** The byte at an address, 0 to 255. */ + const byteAt = (address: number): NumExpr => ranged(peek(k(epd(address & ~3)), (0xff * 2 ** (8 * (address & 3))) >>> 0, 8 * (address & 3)), 0, 255); + const action = (record: ActionRecord): Stmt => ({ kind: "action", record, ...at() } as Stmt); + const local = (): NumExpr => ranged(peek(k(LOCAL)), 0, 255); + + /** The player a source names: a slot, or the current player — this run's when known when built, else read. */ + const playerOf = (p: number): NumExpr => { + if (p !== 13) return k(p); + if (owner !== undefined) return k(owner); + return ranged(peek(k(CURRENT), 0xff), 0, 255); + }; + const cell = (array: string, p: NumExpr, base = 0): NumExpr => element(array, base ? bin("+", k(base), p) : p); + const bitOf = (s: InputSource & { source: "key" | "click" }): number => (s.source === "key" ? plan.keys.indexOf(s.key) : plan.keys.length + plan.buttons.indexOf(s.button)); + + // ── the reads ── + const read = (s: InputSource): NumExpr => { + const p = playerOf(s.player); + switch (s.source) { + case "key": case "click": { + const bit = bitOf(s); + return ranged(bin(">>>", bin("&", cell(s.source === "key" && s.held ? STATE : PRESSED, p), k(2 ** bit)), k(bit)), 0, 1); + } + case "mouse": return ranged(cell(s.axis === "x" ? MX : MY, p), 0, 0xffff); + case "sync": return cell(SYNC, p, s.slot * SLOTS); + case "chat": { + const index = plan.chats.findIndex((c) => c.pattern === s.pattern); + if (index < 0) throw new Error(`the input plan has no pattern ${JSON.stringify(s.pattern)}`); + if (s.capture === null) return ranged(cell(CHAT_FLAG, p, index * SLOTS), 0, 1); + return cell(CAPTURES, p, captureAt(index, s.capture)); + } + } + }; + /** Where a pattern's capture is kept, by player: a cell a player after the base. */ + const captureAt = (pattern: number, capture: number, player = 0) => (pattern * plan.captures + capture) * SLOTS + player; + /** The byte `offset` bytes after an aligned EPD, read from its dword's quarter: the statements, and the variable the byte is in. */ + const byteFrom = (base: number, offset: NumExpr): { get: Stmt[]; b: string } => { + const o = fresh("o"), e = fresh("e"), r = fresh("r"), b = fresh("b"); + return { b, get: [ + dec(o, offset, 0xffff), dec(e, bin("+", k(base), bin(">>>", V(o), k(2))), 0x3fffffff, "routed"), dec(r, bin("&", V(o), k(3)), 3), dec(b, k(0), 255), + ...[0, 1, 2, 3].flatMap((q) => when(cmp("==", V(r), k(q)), [set(b, ranged(peek(V(e), (0xff * 2 ** (8 * q)) >>> 0, 8 * q), 0, 255))])), + ] }; + }; + /** A byte written `offset` bytes after an aligned EPD. */ + const pokeByte = (base: number, offset: NumExpr, value: number): Stmt[] => { + const o = fresh("o"), e = fresh("e"), r = fresh("r"); + return [ + dec(o, offset, 0xffff), dec(e, bin("+", k(base), bin(">>>", V(o), k(2))), 0x3fffffff, "routed"), dec(r, bin("&", V(o), k(3)), 3), + ...[0, 1, 2, 3].flatMap((q) => when(cmp("==", V(r), k(q)), [poke(V(e), k((value * 2 ** (8 * q)) >>> 0), (0xff * 2 ** (8 * q)) >>> 0)])), + ]; + }; + /** `syncSend`: on the player's own computer, the value kept to what a point carries, plus 1, left for the send pass. */ + const syncSend = (slot: number, player: number, value: NumExpr): Stmt[] => { + const v = fresh("v"); + return when(cmp("==", local(), playerOf(player)), [ + dec(v, value), + ...when(cmp(">=", V(v), k(0x80000000)), [set(v, k(0))]), + ...when(cmp(">=", V(v), k(SYNC_MOST + 1)), [set(v, k(SYNC_MOST))]), + store(PENDING, k(slot), bin("+", V(v), k(1))), + ]); + }; + + const fix = (x: unknown): unknown => { + if (Array.isArray(x)) return 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); + return Object.fromEntries(Object.entries(o).map(([key, v]) => [key, key === "at" || !v || typeof v !== "object" ? v : fix(v)])); + }; + const stmts = (list: Stmt[]): Stmt[] => list.flatMap(stmt); + const stmt = (s: Stmt): Stmt[] => { + if (!hasInput(s)) return [s]; + const was = here; + here = s.at ?? here; + try { + if (s.kind === "syncSend") return syncSend(s.slot, s.player, fix(s.value) as NumExpr); + return [fix(s) as Stmt]; + } finally { here = was; } + }; + + // ── the carriers ── + const ringBase = (h: number, ring: number) => (ring * SLOTS + h) * plan.ring; + const cursor = (h: number, ring: number) => k(ring * SLOTS + h); + /** A value into its point: x − 1 + (y − 1) << XB. */ + const xOf = (v: NumExpr): NumExpr => ranged(bin("+", bin("&", v, k(2 ** plan.xb - 1)), k(1)), 1, 2 ** plan.xb); + const yOf = (v: NumExpr): NumExpr => ranged(bin("+", bin(">>>", v, k(plan.xb)), k(1)), 1, plan.box.h); + + /** + * The first frame: the carriers made, on every computer alike. The dat entry patched; for each human, the scratch + * location a point at the corner, the ring's units created there in transit and given to Player 12, then found at the + * head of Player 12's list — the EPD and the tag of each kept, the owner byte put back to the player's, the unit made + * to stack and never collide. + */ + const startUp = (): Stmt[] => { + const out: Stmt[] = []; + for (const d of carrierDat(plan.carrier)) out.push(pokeAt(d.address, d.bytes, d.value)); + out.push(pokeAt(0x661518 + plan.carrier * 2, 2, 0x1cf)); // (StarEdit's availability, while they are made) + const side = (i: number, v: number) => poke(k(MRGN + 5 * (SCRATCH_LOCATION - 1) + i), k(v)); + out.push(side(0, CARRIER_X), side(1, CARRIER_Y), side(2, CARRIER_X), side(3, CARRIER_Y), poke(k(MRGN + 5 * (SCRATCH_LOCATION - 1) + 4), k(0), 0xffff0000)); + const n = plan.rings * plan.ring; + for (const h of plan.humans) { + for (let i = 0; i < n; i++) out.push(action({ location: SCRATCH_LOCATION, text: 0, wav: 0, time: 0, player: h, target: plan.cuwp, unitId: plan.carrier, type: CREATE_WITH_PROPERTIES, modifier: 1, flags: 0x1c, padding: 0, mask: 0 })); + out.push(action({ location: SCRATCH_LOCATION, text: 0, wav: 0, time: 0, player: h, target: NEUTRAL, unitId: plan.carrier, type: GIVE_UNITS, modifier: n, flags: 0x14, padding: 0, mask: 0 })); + // (The given units head Player 12's list, the last made first: a pointer's EPD is its address over 4, less the deaths table's.) + const p = fresh("p"); + out.push(dec(p, bin("-", peek(k(FIRST_UNIT + NEUTRAL), 0xfffffffc, 2), k(DEATHS / 4)), 0x3fffffff, "routed")); + for (let i = 0; i < n; i++) { + const slot = ringBase(h, Math.floor(i / plan.ring)) + (i % plan.ring); + const index = fresh("ix"), uid = fresh("uid"); + out.push( + store(CARRIERS, k(slot), V(p)), + dec(index, div(bin("-", V(p), k(UNIT_TABLE_EPD)), STRIDE / 4), 1699, "routed"), + dec(uid, peek(bin("+", V(p), k(UID_DWORD)), 0xff00, 8), 255, "routed"), + store(TAGS, k(slot), bin("+", bin("+", V(index), k(1)), bin("*", V(uid), k(2048)))), + poke(bin("+", V(p), k(OWNER_DWORD)), k(h), 0xff), + poke(bin("+", V(p), k(STATUS)), k(0xa00000), 0xa00000), + poke(bin("+", V(p), k(ORDER_TARGET)), k(0)), + ); + if (i + 1 < n) out.push(set(p, bin("-", peek(bin("+", V(p), k(NEXT_UNIT)), 0xfffffffc, 2), k(DEATHS / 4)))); + } + } + out.push(pokeAt(0x661518 + plan.carrier * 2, 2, 0)); + // (The mouse as last sent: where a point starts, so the first frame sends nothing.) + if (plan.mouse) for (const h of plan.humans) out.push(store(SENT_MX, k(h), k(1)), store(SENT_MY, k(h), k(1))); + // This computer's player's name, with the ":\x07 " the game shows after it: what a line of theirs begins with. + if (plan.chats.length) { + const lp = fresh("lp"), n = fresh("nl"); + const byte = byteFrom(PLAYERS, bin("+", k(NAME_OFFSET), bin("+", bin("*", V(lp), k(PLAYER_STRIDE)), V(n)))); + out.push(dec(lp, local(), 255), ...when(cmp("<", V(lp), k(SLOTS)), [ + dec(n, k(0), NAME_MOST), + loop([ + ...when(cmp(">=", V(n), k(NAME_MOST)), [brk()]), + ...byte.get, + ...when(cmp("==", V(byte.b), k(0)), [brk()]), + store(NAME, V(n), V(byte.b)), + set(n, bin("+", V(n), k(1))), + ]), + store(NAME, V(n), k(0x3a)), store(NAME, bin("+", V(n), k(1)), k(7)), store(NAME, bin("+", V(n), k(2)), k(0x20)), + set(NAME_LENGTH, bin("+", V(n), k(3))), + set(RING, ranged(peek(k(TEXT_RING), 0xff), 0, 255)), + ])); + } + out.push(set(MADE, k(1))); + return out; + }; + + // ── a typed line, where it was typed ── + /** + * The line's text (`LINE`, `LINE_LENGTH`) against each pattern in turn: the first that fits the whole line is `TYPED`, + * its values `TYPED_VALUES`. A pattern's block is a loop left at the first mismatch. + */ + const matchLine = (): Stmt[] => { + const out: Stmt[] = []; + const matched = fresh("m"); + out.push(dec(matched, k(0), 1)); + const lineByte = (at: NumExpr) => ranged(element(LINE, at), 0, 255); + plan.chats.forEach((c, index) => { + const body: Stmt[] = []; + let pos: number | string = 0; + const posVar = (): string => { + if (typeof pos === "string") return pos; + const p = fresh("pos"); + body.push(dec(p, k(pos), MAX_CHAT_BYTES + 1)); + pos = p; + return p; + }; + const values: string[] = []; + for (const seg of c.segments) { + if (typeof seg === "string") { + const data = bytesOf(seg); + data.forEach((byte, j) => { + const b = fresh("sb"); + body.push(dec(b, lineByte(typeof pos === "number" ? k(pos + j) : bin("+", V(pos), k(j))), 255), ...when(cmp("!=", V(b), k(byte)), [brk()])); + }); + if (typeof pos === "number") pos += data.length; + else body.push(set(pos, bin("+", V(pos), k(data.length)))); + continue; + } + const cap = c.captures[seg]; + const p = posVar(); + const value = fresh("cv"), ch = fresh("ch"); + values.push(value); + body.push(dec(value, k(0))); + if (cap.kind === "number") { + // Digits, the value kept to what a point carries. + const digits = fresh("dg"); + body.push(dec(digits, k(0), MAX_CHAT_BYTES), loop([ + ...when(cmp(">=", V(p), V(LINE_LENGTH)), [brk()]), + dec(ch, lineByte(V(p)), 255), + ...when(cmp("<", V(ch), k(48)), [brk()]), ...when(cmp(">", V(ch), k(57)), [brk()]), + set(value, bin("*", V(value), k(10))), set(value, bin("+", V(value), V(ch))), set(value, bin("-", V(value), k(48))), + ...when(cmp(">", V(value), k(plan.numberMost)), [set(value, k(plan.numberMost))]), + set(p, bin("+", V(p), k(1))), set(digits, bin("+", V(digits), k(1))), + ]), ...when(cmp("==", V(digits), k(0)), [brk()])); + continue; + } + // A word (to the next space) or the rest of the line, hashed as `hashOf`. + const h = fresh("h"), found = fresh("fd"); + body.push(dec(h, k(0)), loop([ + ...when(cmp(">=", V(p), V(LINE_LENGTH)), [brk()]), + dec(ch, lineByte(V(p)), 255), + ...(cap.kind === "word" ? when(cmp("==", V(ch), k(32)), [brk()]) : []), + ...when({ kind: "and", items: [cmp(">=", V(ch), k(65)), cmp("<=", V(ch), k(90))] }, [set(ch, bin("+", V(ch), k(32)))]), + set(h, bin("*", V(h), k(31))), set(h, bin("+", V(h), V(ch))), + set(p, bin("+", V(p), k(1))), + ]), dec(found, k(0), 1)); + if (cap.kind === "word") { + cap.words.forEach((w, i) => body.push(...when(cmp("==", V(h), k(hashOf(bytesOf(w)))), [set(value, k(i)), set(found, k(1))]))); + } else { + // The names' hashes in rising order, searched by halves. + const count = plan.units.hashes.length; + const lo = fresh("lo"), hi = fresh("hi"), mid = fresh("mid"), hm = fresh("hm"), hl = fresh("hl"); + body.push(dec(lo, k(0), count), dec(hi, k(count), count), loop([ + ...when(cmp(">=", V(lo), V(hi)), [brk()]), + dec(mid, bin(">>>", bin("+", V(lo), V(hi)), k(1)), count), + dec(hm, ranged(element(UNIT_HASHES, V(mid)), 0, 0xffffffff)), + ...when(cmp("<", V(hm), V(h)), [set(lo, bin("+", V(mid), k(1)))], [set(hi, V(mid))]), + ])); + if (count) body.push(...when(cmp("<", V(lo), k(count)), [dec(hl, ranged(element(UNIT_HASHES, V(lo)), 0, 0xffffffff)), ...when(cmp("==", V(hl), V(h)), [set(value, ranged(element(UNIT_IDS, V(lo)), 0, 0xffff)), set(found, k(1))])])); + } + body.push(...when(cmp("==", V(found), k(0)), [brk()])); + } + body.push(...when(cmp("!=", typeof pos === "number" ? k(pos) : V(pos), V(LINE_LENGTH)), [brk()])); + body.push(set(TYPED, k(index + 1))); + values.forEach((v, i) => body.push(store(TYPED_VALUES, k(i), V(v)))); + body.push(set(matched, k(1)), brk()); + out.push(...when(cmp("==", V(matched), k(0)), [loop(body)])); + }); + return out; + }; + + /** + * When the text ring's byte has moved: each of the thirteen lines that begins with this player's name prefix is + * theirs — its text copied into `LINE`, the two bytes after the colon swapped so it is not found again, and matched. + */ + const typedLines = (): Stmt[] => { + const cur = fresh("cur"), o = fresh("lo"), slot = fresh("ls"), j = fresh("j"), nb = fresh("nb"), i = fresh("i"); + const pre = byteFrom(TEXT_LINES, bin("+", V(o), V(j))); + const text = byteFrom(TEXT_LINES, bin("+", bin("+", V(o), V(NAME_LENGTH)), V(i))); + const mine: Stmt[] = [ + dec(i, k(0), MAX_CHAT_BYTES), + loop([ + ...when(cmp(">=", V(i), k(MAX_CHAT_BYTES)), [brk()]), + ...text.get, + ...when(cmp("==", V(text.b), k(0)), [brk()]), + store(LINE, V(i), V(text.b)), + set(i, bin("+", V(i), k(1))), + ]), + set(LINE_LENGTH, V(i)), + ...pokeByte(TEXT_LINES, bin("-", bin("+", V(o), V(NAME_LENGTH)), k(2)), 0x20), + ...pokeByte(TEXT_LINES, bin("-", bin("+", V(o), V(NAME_LENGTH)), k(1)), 0x07), + ...matchLine(), + ]; + return [ + dec(cur, ranged(peek(k(TEXT_RING), 0xff), 0, 255), 255), + ...when(cmp("!=", V(cur), V(RING)), [ + set(RING, V(cur)), + dec(o, k(0), TEXT_LINE * TEXT_LINE_COUNT), dec(slot, k(0), TEXT_LINE_COUNT), + loop([ + ...when(cmp(">=", V(slot), k(TEXT_LINE_COUNT)), [brk()]), + dec(j, k(0), NAME_MOST + 3), + loop([ + ...when(cmp(">=", V(j), V(NAME_LENGTH)), [brk()]), + ...pre.get, + dec(nb, ranged(element(NAME, V(j)), 0, 255), 255), + ...when(cmp("!=", V(pre.b), V(nb)), [brk()]), + set(j, bin("+", V(j), k(1))), + ]), + ...when(cmp("==", V(j), V(NAME_LENGTH)), mine), + set(o, bin("+", V(o), k(TEXT_LINE))), set(slot, bin("+", V(slot), k(1))), + ]), + ]), + ]; + }; + + // ── the receive pass ── + /** + * Each human's rings read from the read cursor: a carrier whose order target holds a point has a packet, taken and + * the target written back to 0. The points ring: the mouse, one a frame. The messages ring: a header (a state, paid out + * one a frame; a sync's slot) or the raw value a header said would follow. + */ + const receive = (): Stmt[] => { + const out: Stmt[] = []; + for (let h = 0; h < SLOTS; h++) { + if (!plan.humans.includes(h)) continue; + out.push(store(PRESSED, k(h), k(0))); + for (let s = 0; s < plan.syncs; s++) out.push(store(SYNC, k(s * SLOTS + h), k(0))); + // (A typed line that arrived the frame before: its flag down and its values cleared.) + if (plan.chats.length) { + const came = fresh("came"); + out.push(...when(cmp(">=", element(CHAT, k(h)), k(1)), [ + dec(came, element(CHAT, k(h)), plan.chats.length), + ...plan.chats.flatMap((c, i) => when(cmp("==", V(came), k(i + 1)), [store(CHAT_FLAG, k(i * SLOTS + h), k(0)), ...c.captures.map((_, ci) => store(CAPTURES, k(captureAt(i, ci, h)), k(0)))])), + store(CHAT, k(h), k(0)), + ])); + } + } + for (const h of plan.humans) { + for (let ring = 0; ring < plan.rings; ring++) { + const rd = fresh("rd"), c = fresh("c"), v = fresh("v"), value = fresh("val"), n = fresh("n"); + const body: Stmt[] = [ + dec(c, element(CARRIERS, bin("+", k(ringBase(h, ring)), V(rd))), 0x3fffffff, "routed"), + dec(v, peek(bin("+", V(c), k(ORDER_TARGET)))), + ...when(cmp("==", V(v), k(0)), [brk()]), + poke(bin("+", V(c), k(ORDER_TARGET)), k(0)), + set(rd, bin("+", V(rd), k(1))), + ...when(cmp(">=", V(rd), k(plan.ring)), [set(rd, k(0))]), + store(READ, cursor(h, ring), V(rd)), + ]; + if (ring === POINTS) { + body.push(store(MX, k(h), bin("&", V(v), k(0xffff))), store(MY, k(h), bin(">>>", V(v), k(16))), brk()); + } else { + const header: Stmt[] = []; + const kind = fresh("kind"), payload = fresh("pay"); + header.push(dec(kind, bin("&", V(value), k(3)), 3), dec(payload, bin(">>>", V(value), k(2)), 2 ** (plan.bits - 2) - 1)); + const old = fresh("old"); + header.push(...when(cmp("==", V(kind), k(KIND_STATE)), [ + dec(old, element(STATE, k(h)), 2 ** (plan.bits - 2) - 1), + store(STATE, k(h), V(payload)), + store(PRESSED, k(h), bin("&", V(payload), bin("^", V(old), k(2 ** (plan.bits - 2) - 1)))), + brk(), + ])); + if (plan.syncs) header.push(...when(cmp("==", V(kind), k(KIND_SYNC)), [store(SLOT, k(h), V(payload)), store(EXPECT, k(h), k(1))])); + // (A typed line: the pattern's number, then as many values as it has; one with none has arrived at once.) + if (plan.chats.length) { + header.push(...when(cmp("==", V(kind), k(KIND_CHAT)), [ + store(CHAT_PENDING, k(h), V(payload)), store(CAPTURE_AT, k(h), k(0)), + ...plan.chats.flatMap((c, i) => when(cmp("==", V(payload), k(i + 1)), c.captures.length + ? [store(EXPECT, k(h), k(c.captures.length))] + : [store(CHAT, k(h), k(i + 1)), store(CHAT_FLAG, k(i * SLOTS + h), k(1)), store(CHAT_PENDING, k(h), k(0))])), + ])); + } + const sync: Stmt[] = []; + if (plan.syncs) sync.push(store(SYNC, bin("+", bin("*", element(SLOT, k(h)), k(SLOTS)), k(h)), bin("+", V(value), k(1)))); + sync.push(store(EXPECT, k(h), bin("-", element(EXPECT, k(h)), k(1)))); + let raw = sync; + if (plan.chats.length) { + const came = fresh("cp"), ci = fresh("ci"); + raw = when(cmp(">=", element(CHAT_PENDING, k(h)), k(1)), [ + dec(came, element(CHAT_PENDING, k(h)), plan.chats.length), dec(ci, element(CAPTURE_AT, k(h)), plan.captures), + ...plan.chats.flatMap((c, i) => c.captures.flatMap((_, cj) => when({ kind: "and", items: [cmp("==", V(came), k(i + 1)), cmp("==", V(ci), k(cj))] }, [store(CAPTURES, k(captureAt(i, cj, h)), V(value))]))), + store(CAPTURE_AT, k(h), bin("+", V(ci), k(1))), + store(EXPECT, k(h), bin("-", element(EXPECT, k(h)), k(1))), + ...when(cmp("==", element(EXPECT, k(h)), k(0)), [ + store(CHAT, k(h), V(came)), + ...plan.chats.flatMap((_, i) => when(cmp("==", V(came), k(i + 1)), [store(CHAT_FLAG, k(i * SLOTS + h), k(1))])), + store(CHAT_PENDING, k(h), k(0)), + ]), + ], sync); + } + // (A point the send pass made: x and y are 1 at the least, which the subtractions lean on. Each a step of its + // own: folded into one sum, the emitter's bit-sliced copy of v got the constant wrong — 2026-10-23.) + const x = fresh("x"), y = fresh("y"); + body.push(dec(x, bin("&", V(v), k(0xffff)), 0xffff), set(x, bin("-", V(x), k(1))), dec(y, bin(">>>", V(v), k(16)), 0xffff), set(y, bin("-", V(y), k(1)))); + body.push(dec(value, bin("+", V(x), bin("*", V(y), k(2 ** plan.xb))), 2 ** plan.bits - 1)); + body.push(...when(cmp("==", element(EXPECT, k(h)), k(0)), header, raw)); + } + out.push(dec(rd, element(READ, cursor(h, ring)), plan.ring - 1), dec(n, k(0), plan.ring), loop([...when(cmp(">=", V(n), k(plan.ring)), [brk()]), set(n, bin("+", V(n), k(1))), ...body])); + } + } + return [...when(cmp("==", V(MADE), k(0)), startUp()), ...out]; + }; + + // ── the send pass ── + /** + * On this computer's player's own: the keys and buttons read into a state and sent when it changed; the mouse on the + * map sent when it moved; a pending sync sent as its header and value; then the player's own selection put back. + * A send is a Select of the ring's next carrier and a Move to the point, 16 bytes into the game's queue as four + * dwords — the queue brought to a dword first with KeepAlive bytes (0x05, which the game passes over). + */ + const send = (): Stmt[] => { + const out: Stmt[] = []; + for (const h of plan.humans) { + const body: Stmt[] = []; + const sent = fresh("sent"); + body.push(dec(sent, k(0), 1)); + /** `at`: the queue's used length, kept in a variable, brought to a multiple of 4 with KeepAlives. */ + const q = fresh("q"); + body.push(dec(q, peek(k(QUEUED)), 0x7fffffff, "routed")); + const align = (): Stmt[] => { + const r = fresh("r"); + return [ + dec(r, bin("&", V(q), k(3)), 3), + ...[1, 2, 3].flatMap((rr) => when(cmp("==", V(r), k(rr)), [poke(bin("+", k(QUEUE), bin(">>>", V(q), k(2))), k((0x05050505 * 2 ** (8 * rr)) >>> 0), (0xffffffff * 2 ** (8 * rr)) >>> 0), set(q, bin("+", V(q), k(4 - rr)))])), + ]; + }; + /** + * A packet to the ring's next carrier when the ring has room for `need` more and the queue for this one: 16 + * bytes; `flag` set to 1 when it went. + */ + const packet = (ring: number, x: NumExpr, y: NumExpr, flag: string, need = 1): Stmt[] => { + const wr = fresh("wr"), ahead = fresh("ahead"), tag = fresh("tag"), w = fresh("w"); + return [ + dec(wr, element(WRITE, cursor(h, ring)), plan.ring - 1), + // (In flight: what the write cursor is ahead of the read one by; a ring with no room waits.) + dec(ahead, bin("+", V(wr), k(plan.ring)), 2 * plan.ring), + set(ahead, bin("-", V(ahead), element(READ, cursor(h, ring)))), + ...when(cmp(">=", V(ahead), k(plan.ring)), [set(ahead, bin("-", V(ahead), k(plan.ring)))]), + ...when(cmp("<=", V(ahead), k(plan.ring - 1 - need)), [ + ...align(), + // (Room in the queue for every packet of the message and the restoring Select, so that none goes without the rest.) + ...when(cmp(">=", peek(k(QUEUE_MOST)), ranged(bin("+", V(q), k(16 * need + 28)), 0, 0x7fffffff)), [ + dec(tag, element(TAGS, bin("+", k(ringBase(h, ring)), V(wr))), 0xffff), + dec(w, bin("+", k(QUEUE), bin(">>>", V(q), k(2))), 0x3fffffff), + poke(V(w), bin("+", k(0x09 | (1 << 8)), bin("*", V(tag), k(0x10000)))), + poke(bin("+", V(w), k(1)), bin("+", bin("+", k(0x15), bin("*", x, k(0x100))), bin("*", bin("&", y, k(0xff)), k(0x1000000)))), + poke(bin("+", V(w), k(2)), bin("+", bin(">>>", y, k(8)), k(0xe4000000))), + poke(bin("+", V(w), k(3)), k(0x05000600)), + set(q, bin("+", V(q), k(16))), + poke(k(QUEUED), V(q)), + set(wr, bin("+", V(wr), k(1))), + ...when(cmp(">=", V(wr), k(plan.ring)), [set(wr, k(0))]), + store(WRITE, cursor(h, ring), V(wr)), + set(sent, k(1)), + set(flag, k(1)), + ]), + ]), + ]; + }; + const message = (value: NumExpr, flag: string, need = 1): Stmt[] => packet(MESSAGES, xOf(value), yOf(value), flag, need); + // The state: a bit a key, unless a line is being typed; then the buttons. + const now = fresh("now"); + body.push(dec(now, k(0), 2 ** (plan.bits - 2) - 1)); + if (plan.keys.length) { + body.push(...when(cmp("==", peek(k(TYPING)), k(0)), plan.keys.flatMap((key, i) => when(cmp(">=", byteAt(KEYS + virtualKey(key)!), k(1)), [set(now, bin("+", V(now), k(2 ** i)))])))); + } + plan.buttons.forEach((b, i) => body.push(...when(cmp(">=", peek(k(BUTTONS), BUTTON_BITS[b]), k(1)), [set(now, bin("+", V(now), k(2 ** (plan.keys.length + i))))]))); + // A bit that was up the two frames before is kept up for one frame it reads down: in Remastered the key table + // drops a held key for a frame when it begins to repeat (played 2026-10-23: a hold of F7 gave a second press + // after 17 frames), so a release goes one frame late and a one-frame gap goes unseen. + const raw = fresh("raw"), kept = fresh("kept"); + body.push( + dec(raw, V(now), 2 ** (plan.bits - 2) - 1), + dec(kept, bin("&", element(READ_1, k(h)), element(READ_2, k(h))), 2 ** (plan.bits - 2) - 1), + set(now, bin("+", V(now), bin("&", V(kept), bin("^", V(now), k(2 ** (plan.bits - 2) - 1))))), + store(READ_2, k(h), element(READ_1, k(h))), store(READ_1, k(h), V(raw)), + ); + const st = fresh("st"); + body.push(...when(cmp("!=", V(now), element(SENT, k(h))), [ + dec(st, k(0), 1), + ...message(bin("+", k(KIND_STATE), bin("*", V(now), k(4))), st), + // (Not sent for want of room: what was sent last is what the receivers hold, and the state goes when there is.) + ...when(cmp("==", V(st), k(1)), [store(SENT, k(h), V(now))]), + ])); + // The mouse on the map, kept within what a Move's target may be. + if (plan.mouse) { + const mx = fresh("mx"), my = fresh("my"); + body.push( + dec(mx, bin("+", peek(k(SCREEN_X)), peek(k(MOUSE_X))), 0xffff), dec(my, bin("+", peek(k(SCREEN_Y)), peek(k(MOUSE_Y))), 0xffff), + ...when(cmp("<", V(mx), k(1)), [set(mx, k(1))]), ...when(cmp(">", V(mx), k(plan.box.w)), [set(mx, k(plan.box.w))]), + ...when(cmp("<", V(my), k(1)), [set(my, k(1))]), ...when(cmp(">", V(my), k(plan.box.h)), [set(my, k(plan.box.h))]), + ); + const mv = fresh("mv"); + body.push(...when({ kind: "or", items: [cmp("!=", V(mx), element(SENT_MX, k(h))), cmp("!=", V(my), element(SENT_MY, k(h)))] }, [ + dec(mv, k(0), 1), + ...packet(POINTS, V(mx), V(my), mv), + ...when(cmp("==", V(mv), k(1)), [store(SENT_MX, k(h), V(mx)), store(SENT_MY, k(h), V(my))]), + ])); + } + // A typed line, matched: the header (the pattern's number), then its values — all or none. + if (plan.chats.length) { + body.push(...typedLines()); + const sh = fresh("sh"), t = fresh("t"); + body.push(...when(cmp(">=", V(TYPED), k(1)), [ + dec(sh, k(0), 1), dec(t, V(TYPED), plan.chats.length), + ...message(bin("+", k(KIND_CHAT), bin("*", V(t), k(4))), sh, 1 + plan.captures), + ...when(cmp("==", V(sh), k(1)), [ + ...plan.chats.flatMap((c, i) => c.captures.length ? when(cmp("==", V(t), k(i + 1)), c.captures.flatMap((_, ci) => { + const f = fresh("cf"), tv = fresh("tv"); + return [dec(f, k(0), 1), dec(tv, element(TYPED_VALUES, k(ci)), 2 ** plan.bits - 1), ...message(V(tv), f)]; + })) : []), + set(TYPED, k(0)), + ]), + ])); + } + // Pending syncs: the header (the slot), then the value — both or neither, so the ring needs room for two. + for (let s = 0; s < plan.syncs; s++) { + const pending = fresh("pend"), sh = fresh("sh"), sv = fresh("sv"); + body.push(dec(pending, element(PENDING, k(s)), SYNC_MOST + 1, "routed")); + body.push(...when(cmp(">=", V(pending), k(1)), [ + dec(sh, k(0), 1), dec(sv, k(0), 1), + ...message(bin("+", k(KIND_SYNC), k(s * 4)), sh, 2), + ...when(cmp("==", V(sh), k(1)), [...message(bin("-", V(pending), k(1)), sv), store(PENDING, k(s), k(0))]), + ])); + } + // The player's own selection put back: a Select of the units in this computer's twelve slots. + const restore: Stmt[] = []; + { + const n = fresh("n"), w = fresh("w"); + restore.push(dec(n, k(0), 12)); + const tags: string[] = []; + for (let i = 0; i < 12; i++) { + const ptr = fresh("sp"), tag = fresh("stag"), index = fresh("six"), uid = fresh("suid"); + tags.push(tag); + restore.push(dec(tag, k(0), 0xffff, "routed")); + // (The slot's pointer over 4: a unit's is at least the table's; its EPD is that less the deaths table's.) + restore.push(...when(cmp("==", V(n), k(i)), [ + dec(ptr, peek(k(SELECTION + i), 0xfffffffc, 2), 0x3fffffff, "routed"), + ...when(cmp(">=", V(ptr), k(UNIT_TABLE / 4)), [ + set(ptr, bin("-", V(ptr), k(DEATHS / 4))), + dec(index, div(bin("-", V(ptr), k(UNIT_TABLE_EPD)), STRIDE / 4), 1699, "routed"), + dec(uid, peek(bin("+", V(ptr), k(UID_DWORD)), 0xff00, 8), 255, "routed"), + set(tag, bin("+", bin("+", V(index), k(1)), bin("*", V(uid), k(2048)))), + set(n, k(i + 1)), + ]), + ])); + } + restore.push(...align()); + restore.push(...when(cmp(">=", peek(k(QUEUE_MOST)), ranged(bin("+", V(q), k(28)), 0, 0x7fffffff)), [ + dec(w, bin("+", k(QUEUE), bin(">>>", V(q), k(2))), 0x3fffffff), + // (The first dword: the command, the count, the first tag; then two tags a dword; KeepAlives after the last.) + poke(V(w), bin("+", bin("+", k(0x09), bin("*", V(n), k(0x100))), bin("*", V(tags[0]), k(0x10000)))), + ...[1, 2, 3, 4, 5, 6].map((d) => poke(bin("+", V(w), k(d)), d < 6 ? bin("+", V(tags[2 * d - 1]), bin("*", V(tags[2 * d]), k(0x10000))) : V(tags[11]))), + // (Where the count leaves a dword's bytes unused, KeepAlives: the Select is 2 + 2n bytes, brought to a multiple of 4.) + ...Array.from({ length: 13 }, (_, c) => c).flatMap((c) => { + const bytes = 2 + 2 * c, dwords = Math.ceil(bytes / 4), pad = dwords * 4 - bytes; + const fixes: Stmt[] = pad ? [poke(bin("+", V(w), k(dwords - 1)), k((0x05050505 * 2 ** (8 * (4 - pad))) >>> 0), (0xffffffff * 2 ** (8 * (4 - pad))) >>> 0)] : []; + return when(cmp("==", V(n), k(c)), [...fixes, set(q, bin("+", V(q), k(dwords * 4)))]); + }), + poke(k(QUEUED), V(q)), + ])); + } + body.push(...when(cmp("==", V(sent), k(1)), restore)); + out.push(...when(cmp("==", local(), k(h)), body)); + } + return out; + }; + + here = program.at; + const body = stmts(program.body); + const functions = program.functions?.map((f) => ({ ...f, body: stmts(f.body) })); + const passed = passes ? { pass: [...receive(), ...(program.pass ?? [])], after: send() } : {}; + return { ...program, body, ...(functions ? { functions } : {}), ...passed, arrays: [...(program.arrays ?? []), ...inputArrays(plan)] }; +} diff --git a/compiler/backend/layout.ts b/compiler/backend/layout.ts index cd6e8d4..907e8a4 100644 --- a/compiler/backend/layout.ts +++ b/compiler/backend/layout.ts @@ -30,7 +30,7 @@ * went on — a trigger of a few conditions and actions in a little over 200 bytes, not 2,408. A row a doubling trigger * walks (`Graph.rows`) is stacked as one, its triggers 2,408 bytes apart. */ -import { END, NOW, PASS_END, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, type Act, type Cond, type Dword, type Graph, type Value } from "./graph"; +import { AFTER_END, END, NOW, PASS_END, TRIGGER_BYTES, TRIGGER_OFF, TRIGGER_ON, type Act, type Cond, type Dword, type Graph, type Value } from "./graph"; import { TRIGGER_LISTS, WAKE_AT, WAKE_NEXT, WAKE_TICK } from "./emit"; import type { ScriptString } from "../runtime"; @@ -283,11 +283,13 @@ export function layout(input: LayoutInput): OwnMap { const stop = "@stop", run = "@run", entry = (p: number) => `@entry${p}`, gate = (p: number) => `@gate${p}`; // (A run whose graph holds the map's events pass goes through it first.) const first = (i: number) => runs[i].graph.pass ?? wake(i); - const after = (i: number) => (i + 1 < runs.length ? first(i + 1) : stop); + // (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)]])); + const ends = new Map(runs.flatMap((_, i): [string, string][] => [[i ? `${END}#${i}` : END, after(i)], [i ? `${PASS_END}#${i}` : PASS_END, wake(i)], [i ? `${AFTER_END}#${i}` : AFTER_END, stop]])); const endOf = (l: string) => ends.get(l) ?? l; type T = { label: string; conds: Cond[]; acts: Act[]; next?: string | number; prev?: number; off?: boolean }; const triggers: T[] = runs.flatMap(({ graph, owner }, i) => [ diff --git a/compiler/backend/own.ts b/compiler/backend/own.ts index 46fe1c6..1095846 100644 --- a/compiler/backend/own.ts +++ b/compiler/backend/own.ts @@ -16,6 +16,7 @@ import { NOW, type Act, type Cond, type Dword, type Graph, type Num, type Trigge import { TextPool, knownTexts } from "./texts"; import { HEAP_GLOBALS, growingArrays, heldVariables, isHeapGlobal, loopScoped } from "./heap"; import { EVENT_GLOBALS, UNIT_GLOBALS, eventSources, isEventGlobal, unitValues } from "./units"; +import { INPUT_GLOBALS, hasInput, inputValues, isInputGlobal, ownInputPlan, type OwnInputPlan, type OwnMap } from "./input"; import { ownErrors } from "./errors"; export interface OwnRun { @@ -36,8 +37,13 @@ export interface Own { * their numbers: the map's strings from `textBase` on are to be these, one after another. */ texts: string[]; + /** The players' input the map carries itself (`input.ts`): what the build makes the carriers from; absent when no program reads the players. */ + input?: OwnInputPlan; } +/** What `emitOwn` takes the map for when the build gave none: the players' slots all human, a 128 by 128 map, the last unit-properties slot. */ +const ANY_MAP: OwnMap = { humans: ANY_PLAYERS.slots, width: 128 * 32, height: 128 * 32, cuwp: 64 }; + /** The players a program runs for, as the Python side's `owner_slots`: each owner's slots, in the order given. */ function ownerSlots(program: Program, players: MapPlayers): number[] | string { const slots: number[] = []; @@ -54,7 +60,7 @@ function ownerSlots(program: Program, players: MapPlayers): number[] | string { * `textBase`: the string the first of `Own.texts` is to be among the map's (the next one the map adds; 1 when there is * no map). */ -export function emitOwn(r: Pick, players: MapPlayers = ANY_PLAYERS, textBase = 1): Own | string { +export function emitOwn(r: Pick & Partial>, players: MapPlayers = ANY_PLAYERS, textBase = 1, map: OwnMap = { ...ANY_MAP, humans: players === ANY_PLAYERS ? ANY_MAP.humans : players.slots }): Own | string { // (A map of our own is built without the map's triggers, so `trigger()` records would be lost without a word.) if (r.triggers.length) return `a map of our own keeps no trigger() or hyperTriggers() records yet; this script has ${r.triggers.length}`; const runs: OwnRun[] = []; @@ -69,7 +75,14 @@ export function emitOwn(r: Pick, p // (The map's events pass is one, laid out by the first program that has events, for what all of them loop over.) const sources = eventSources(known); const passer = known.findIndex((p) => eventSources(p).size); - const programs = known.map((p, k) => loopScoped(growingArrays(heldVariables(unitValues(p, { sources, pass: k === passer }))))); + // (The players' input likewise: its passes laid out by the first program that reads them.) + // (The names a `{…:unit}` capture of a typed line knows: the ones the compile listed for eudplib's build.) + const planned = ownInputPlan(known, map, r.input?.unitNames); + if (typeof planned === "string") return planned; + const input = planned; + const reader = known.findIndex(hasInput); + const programs = known.map((p, k) => loopScoped(growingArrays(heldVariables(withInput(unitValues(p, { sources, pass: k === passer }, input?.carrier), k))))); + function withInput(p: Program, k: number): Program { return input && (hasInput(p) || k === reader) ? inputValues(p, input, k === reader) : p; } for (const [k, program] of programs.entries()) { const slots = ownerSlots(program, players); if (typeof slots === "string") return slots; @@ -84,7 +97,7 @@ export function emitOwn(r: Pick, p }; collect(program); // (The heap's own variables, which no program declares, and the count of frames: one cell each for the map.) - for (const d of [...HEAP_GLOBALS, ...UNIT_GLOBALS, ...EVENT_GLOBALS, NOW_DECL]) decls.set(d.id, d); + for (const d of [...HEAP_GLOBALS, ...UNIT_GLOBALS, ...EVENT_GLOBALS, ...INPUT_GLOBALS, NOW_DECL]) decls.set(d.id, d); const functions = new Map((program.functions ?? []).map((f) => [f.id, f])); const arrays = new Map((program.arrays ?? []).map((a) => [a.id, a])); // The variables and arrays every run of the program has in common: one for every player of a per-player program. @@ -95,9 +108,9 @@ export function emitOwn(r: Pick, p for (const player of program.perPlayer ? [...slots].sort((a, b) => a - b) : slots.slice(0, 1)) { const n = runs.length; // (The first run keeps the emitter's names; each other one's are its own, but for what its program shares.) - const graph = emitBody(program.body, decls, n ? `own${n}` : "own", functions, arrays, undefined, player, players, program.errors, runs.length === firstRun ? program.pass : undefined); + const graph = emitBody(program.body, decls, n ? `own${n}` : "own", functions, arrays, undefined, player, players, program.errors, runs.length === firstRun ? program.pass : undefined, runs.length === firstRun ? program.after : undefined); if (typeof graph === "string") return graph; - const name = (id: string) => (!n || isHeapGlobal(id) || id === NOW || isEventGlobal(id) || UNIT_GLOBALS.some((d) => d.id === id) ? id : shared.has(id) ? (k ? `${id}#p${k}` : id) : `${id}#${n}`); + const name = (id: string) => (!n || isHeapGlobal(id) || id === NOW || isEventGlobal(id) || isInputGlobal(id) || UNIT_GLOBALS.some((d) => d.id === id) ? id : shared.has(id) ? (k ? `${id}#p${k}` : id) : `${id}#${n}`); const g = n ? renamed(graph, `#${n}`, name) : graph; // An action of the game's names its text and its sound file by the script's strings: given here, for the layout. const of = (i: number) => (i ? r.strings[i - 1] : undefined); @@ -107,7 +120,7 @@ export function emitOwn(r: Pick, p } } if (!runs.length) return "a script with no program"; - return { programs, runs, values, texts }; + return { programs, runs, values, texts, ...(input ? { input } : {}) }; } /** @@ -148,6 +161,7 @@ function renamed(g: Graph, suffix: string, name: (id: string) => string): Graph ...(g.rows ? { rows: g.rows.map((r) => r.map(label)) } : {}), ...(g.tasks ? { tasks: label(g.tasks) } : {}), ...(g.pass ? { pass: label(g.pass) } : {}), + ...(g.after ? { after: label(g.after) } : {}), }; } diff --git a/compiler/backend/units.ts b/compiler/backend/units.ts index c32fd05..869442e 100644 --- a/compiler/backend/units.ts +++ b/compiler/backend/units.ts @@ -20,6 +20,7 @@ import type { ArrayDecl, At, BoolExpr, Call, CompareOp, FuncDecl, NumExpr, Program, Stmt, UnitExpr, UnitFilter, UnitFlag, UnitNumField, VarDecl } from "../ir"; import type { ActionRecord, ConditionRecord } from "../../vendor/triggers"; import { STATEMENT_KINDS } from "./emit"; +import { MOUSE_CELLS } from "./input"; /** The deaths table: what a condition's or an action's player field 0 is, an EPD a dword after it. */ const DEATHS = 0x58a364; @@ -181,7 +182,11 @@ function isStmtList(x: unknown): x is Stmt[] { * `events`: the sources the map's programs' events loop over, and whether this program lays out the map's one events * pass (`Program.pass`) — the first program that has any. */ -export function unitValues(program: Program, events?: { sources: Set; pass: boolean }): Program { +/** + * `carrier`: the type of the input's carrier units (`input.ts`), which a walk of the table passes over — a hidden + * unit of Player 12's with a human's owner byte, which no script means. + */ +export function unitValues(program: Program, events?: { sources: Set; pass: boolean }, carrier?: number): Program { if (!hasUnits(program)) return program; let here: At = program.at; let made = 0; @@ -366,6 +371,9 @@ export function unitValues(program: Program, events?: { sources: Set>>", tyv, k(2)), 0x3fff), ...when(any([0, 1, 2, 3].map((r) => all([cmp("==", bin("&", tyv, k(3)), k(r)), cmp(">=", peek(bin("+", k(GROUPS), V(qt)), (bit * 2 ** (8 * r)) >>> 0), k(1))]))), inner)]; } + // (The input's carriers, hidden units with a human's owner byte, are no script's: a unit the filter lets through + // is asked its type once more, unless the filter asks for one type already.) + if (carrier !== undefined && (type === undefined || type >= 228)) inner = when(cmp("!=", read(q, TYPE), k(carrier)), inner); // (With a filter the order is asked only of the units it lets through: a place the walk passes costs a condition // less, and every condition is a dword the walk's step moves on.) const ask = [...also, ...asks(q)]; @@ -522,20 +530,27 @@ export function unitValues(program: Program, events?: { sources: Set, pre: Stmt[]): Unit => { - if (p.mouse !== undefined) throw new Leave(); // (Kept in its cell alone: written at each unit found, the walk's address would otherwise be read into every place.) const R = fresh("pick"); pre.push(dec(R, k(0), undefined, "routed")); const found = (q: NumExpr): Stmt => set(R, q); if (p.by === "first") pre.push(...scan(p.filter, pre, (q, stop) => [found(q), ...stop()])); else if (p.by === "nearest") { - if (p.near === undefined) throw new Leave(); - const [l, t, r, b] = sides(p.near, pre); + if (p.near === undefined && p.mouse === undefined) throw new Leave(); const cx = fresh("cx"), cy = fresh("cy"), least = fresh("least"); const X0 = fresh("bx"), X1 = fresh("bx"), Y0 = fresh("by"), Y1 = fresh("by"); const c = (id: string) => ranged(V(id), 0, 0xffff); // (Written seldom and read at every unit: kept in each place it is read, never copied there.) - pre.push(dec(cx, bin(">>>", bin("+", l, r), k(1)), 0xffff, "everywhere"), dec(cy, bin(">>>", bin("+", t, b), k(1)), 0xffff, "everywhere"), dec(least, k(0x20000), 0x20000, "everywhere")); + if (p.mouse !== undefined) { + // `underMouse()` (step 12): the player's mouse on the map as the receive pass keeps it (`input.ts`); `within` so + // many pixels, as |dx| + |dy|, or anywhere. + const who = playerOf(p.mouse, pre); + pre.push(dec(cx, { kind: "element", array: MOUSE_CELLS.x, index: who, at: here } as NumExpr, 0xffff, "everywhere"), dec(cy, { kind: "element", array: MOUSE_CELLS.y, index: who, at: here } as NumExpr, 0xffff, "everywhere")); + } else { + const [l, t, r, b] = sides(p.near!, pre); + pre.push(dec(cx, bin(">>>", bin("+", l, r), k(1)), 0xffff, "everywhere"), dec(cy, bin(">>>", bin("+", t, b), k(1)), 0xffff, "everywhere")); + } + pre.push(dec(least, k(p.within !== undefined ? Math.min(0x20000, p.within + 1) : 0x20000), 0x20000, "everywhere")); // A unit nearer than the nearest so far (by |dx| + |dy|) is inside the box |dx|, |dy| < that: four conditions on // its position, read through the current player, ask it of each unit; only one inside is measured. The box is // the whole map until the first. @@ -596,6 +611,7 @@ export function unitValues(program: Program, events?: { sources: Set; return flag(unit(e.unit, pre), e.flag, pre); } case "unitSame": { const e = o as unknown as Extract; return same(unit(e.left, pre), unit(e.right, pre), pre); } - case "selected": throw new Leave(); + case "selected": { + // `local.selected` (step 12): the unit in a slot of this computer's selection, re-checked before use as a kept + // unit is (its uniqueness byte taken when read). The slot holds a pointer, or 0 for none. + const e = o as unknown as Extract; + const p = fresh("sel"); + pre.push(dec(p, peek(k(SELECTION + e.index), POINTER, 2), 0x3fffffff), ...when(cmp(">=", V(p), k(IN_TABLE)), [set(p, bin("-", V(p), k(DEATHS / 4)))], [set(p, k(0))])); + return { e: ranged(V(p), 0, UNIT_TABLE + STRIDE * PLACES), k: null, none: true, killed: true }; + } } // (A call inside an expression: a statement of its own first when it takes or gives back units.) if (o.kind === "call" && o.call && typeof o.call === "object") { diff --git a/compiler/gameSim.ts b/compiler/gameSim.ts index cf5bda4..7357285 100644 --- a/compiler/gameSim.ts +++ b/compiler/gameSim.ts @@ -14,7 +14,7 @@ */ import { ActionType } from "../vendor/triggers"; import type { At } from "./ir"; -import { msqcKey } from "./input"; +import { virtualKey } from "./input"; import { programVariables, type VariableMap } from "./gameVariables"; import type { CostLines, GameCostTap, TestCost } from "./gameCost"; import { ExpectError, WORLD, type TestEvent, type TestWorldLike } from "./testing"; @@ -154,17 +154,6 @@ export function readUnitStats(game: GameHandle): Record = { SPACE: 0x20, ENTER: 0x0d, ESC: 0x1b, TAB: 0x09, SHIFT: 0x10, LCTRL: 0x11, LALT: 0x12, LEFT: 0x25, UP: 0x26, RIGHT: 0x27, DOWN: 0x28, BACK: 0x08, DELETE: 0x2e, INSERT: 0x2d, HOME: 0x24, END: 0x23, PGUP: 0x21, PGDN: 0x22 }; - if (k in named) return named[k]; - throw new ExpectError(`press: "${key}" is not a key the game reports`); -} - /** A line as a player reads it: the game's colour codes and eudplib's padding (carriage returns) taken out. */ const clean = (text: string) => text.replace(/[\x01-\x1f]/g, ""); @@ -378,7 +367,7 @@ export class GameSim implements TestWorldLike { return who === this.game.localPlayer; } /** A key down for one frame, as a press is (MSQC sees it down, then up). */ - press(key: string, player?: number): this { const k = virtualKey(key); if (!this.local(player, "press")) return this; this.game.u8[KEYS + k] = 1; this.game.wrote?.(KEYS + k, 1); this.held.push(k); return this; } + press(key: string, player?: number): this { const k = virtualKey(key); if (k === null) throw new ExpectError(`press: "${key}" is not a key the game reports`); if (!this.local(player, "press")) return this; this.game.u8[KEYS + k] = 1; this.game.wrote?.(KEYS + k, 1); this.held.push(k); return this; } click(button: "left" | "right" | "middle" = "left", player?: number): this { if (!this.local(player, "click")) return this; const m = this.game.m; diff --git a/compiler/input.ts b/compiler/input.ts index c981ca9..ef81a3a 100644 --- a/compiler/input.ts +++ b/compiler/input.ts @@ -57,6 +57,16 @@ export function msqcKey(key: string): string { return NAMED_KEYS[key] ?? key.toUpperCase(); } +/** The virtual key a key of `KEY_NAMES` is to the game (a byte of its key table, 0x596A18), or null for a name that is none. */ +export function virtualKey(key: string): number | null { + const k = msqcKey(key); + if (/^[A-Z0-9]$/.test(k)) return k.charCodeAt(0); + if (/^F([1-9]|1[0-2])$/.test(k)) return 0x6f + Number(k.slice(1)); + if (/^NUMPAD[0-9]$/.test(k)) return 0x60 + Number(k.slice(6)); + const named: Record = { SPACE: 0x20, ENTER: 0x0d, ESC: 0x1b, TAB: 0x09, SHIFT: 0x10, LCTRL: 0x11, LALT: 0x12, LEFT: 0x25, UP: 0x26, RIGHT: 0x27, DOWN: 0x28, BACK: 0x08, DELETE: 0x2e, INSERT: 0x2d, HOME: 0x24, END: 0x23, PGUP: 0x21, PGDN: 0x22 }; + return k in named ? named[k] : null; +} + /* ── Chat patterns ── */ /** The most values a typed line can carry: each is one of MSQC's command units per player. */ diff --git a/compiler/ir.ts b/compiler/ir.ts index ec1f0b5..36b7456 100644 --- a/compiler/ir.ts +++ b/compiler/ir.ts @@ -722,6 +722,11 @@ export interface Program { * an order or turned into another type since the pass before — run before the programs' turns, once for the map. */ pass?: Stmt[]; + /** + * A map of our own only (`compiler/backend/input.ts`): the frame's send pass — what this computer's player did, + * sent to every computer as game commands — run after the programs' turns, once for the map. + */ + after?: Stmt[]; body: Stmt[]; at: At; } diff --git a/docs/ir.md b/docs/ir.md index 7bd23d9..96f42e6 100644 --- a/docs/ir.md +++ b/docs/ir.md @@ -754,6 +754,17 @@ clears its cells every frame, so an input lasts the frame it arrives in. It make command units of one unit type (58, the Valkyrie) owned by Player 12, which the map must leave alone. +A map of our own (`compiler/backend/input.ts`, TrigScript 4, step 12) carries the same +inputs itself, with no plugin: the `input` reads become reads of cells the map's receive +pass fills before the programs' turns, from a ring of hidden carrier units a human player +(a Select and a Move to a point each send, the point the value), and a send pass after the +programs' turns (`Program.after`) queues what this computer's player did. Keys and clicks +travel as a state that is sent when it changes and paid out a frame at a time; the mouse +as a point of its own; `syncSend` leaves its value for the send pass. A typed line is read +from the game's text lines on the computer it was typed on, matched there against the +patterns, and sent as the pattern's number and its values. The trip is two frames in the +lifted game. + ## Numbers 32 bits. A `number` reads them signed, −2³¹ to 2³¹ − 1; a `u32` (`VarDecl.unsigned`) from 0 up. diff --git a/docs/v4-plan.md b/docs/v4-plan.md index 5d9b27e..62028c2 100644 --- a/docs/v4-plan.md +++ b/docs/v4-plan.md @@ -4033,6 +4033,16 @@ the ledger is the list it can be written again from at 4.0. The handler reads a changed place's values a bit at a time, about 4 µs for a unit made: through the current player, pointed at the record once, instead of a copy of the record's EPD for every read. The 1,700 places are two triggers each (1.84 MB against eudplib's 1.27 for the probe without `on.order`): a stub of fewer actions packs tighter. +- **Input on a map of our own (step 12), what is left:** a carrier the map kills — a "Kill Unit for + Player 12" of a map's own triggers would — is not made again (MSQC's QCDebug respawns; ours should watch one + carrier a human a frame). Typed lines lean on the text ring's byte (0x640B58) moving with every line shown; if + Remastered's emulator holds it still, the scan of the thirteen lines must run every frame instead (the mark after the + colon keeps it right either way) — the probe settles it. Two lines typed in one frame keep the later one. The carriers stand at (64, 64): a `Bring` of the map's own triggers over a location at the + corner counts them for the human. The passes' cost a frame is not weighed. A sync() value is at most 2^20 − 2 on + any map of 64 by 64 or more, as before; on a smaller map the point carries fewer bits (18 on 32 by 32), and the + value is cut to that. The emitter folds a sum of bit-slices of one read with a negative constant wrongly (above, + "Found on the way"): to find in the emitter's sliced copies and fix, with a random-program test that mixes `&`, + `>>>` and a subtraction. - A variable set from a constant less itself (`j = 1 - j`) is copied a bit at a time — 32 triggers, about 0.5 µs, where eudplib's is two actions: the game's subtract stops at 0, so the emitter copies a variable read times −1 (found weighing the errors probe, 2026-10-22). @@ -5099,6 +5109,150 @@ the same frame and "ok". Played in Remastered (2026-10-22, recording 21.59.29, the lists' build): all 7 lines word for word and in order, each at the lifted game's frame (2, 4, 6, 12, 15, 29); the Marines made and killed as the script says; no hitch. +Played again as merged, the watch (2026-10-22, recording 22.58.21, `own-step11-events-watch.scx`): the same 7 lines in +order at the same frames, to the video frame; the Marines made and killed as before; no hitch. (The lines would be the +same if Remastered read past a condition of kind 0, only slower: what a dead unit's place costs there is not measured +yet.) + +### Input on our own backend (2026-10-23) + +Step 12 (`compiler/backend/input.ts`, the emitter, the layout, `scripts/build-own.mts`): the keys, clicks, the mouse, +typed lines and `sync()` carried between the players' computers by the map itself, in place of chatEvent and MSQC. + +**What the command dispatch says (the lifted game, 2026-10-23).** What a map can carry from one computer to all of +them is a game command: the game sends each player's commands to every computer in turns and runs them in step, so +whatever a command leaves in the game's memory is the same everywhere. Each command of the dispatch (0x05–0x5C, +`ledger-tables.md` §7) was queued in the lifted game and the memory diffed a frame later, for the trace it leaves and +what else it does: + +- A queued command runs two frames later (one turn of latency alone); several queued in one frame run in that one + turn, in order, before the frame's triggers — so a Select, an order and a Select putting the selection back all land + together, and nothing of the player's can come between them. +- **Select** (0x09, up to 12 unit tags) writes the player's command selection (`0x6284E8 + 48 × player`, every + computer's, readable in Remastered). Of units the player does not own only the first is kept, and none of them beside + own units: a carrier has to be the sender's. +- **Hotkey** (0x13, set group g) copies that selection into the player's group (`0x57FE60 + 480 × player + 48 × g`, + 12 tags of 16 bits, readable and writable in Remastered). A carrier that clobbers a control group was weighed and + put aside: it would have to be saved and put back around every send. +- **Right-click** (0x14) on a point does nothing to a unit whose dat entry has no right-click action — which is what + MSQC's carrier has; MSQC's "right-click" is a **targeted Move** (0x15, order 6): the handler writes the point into + each selected unit's order target (`+0x58`, read and write allowed in Remastered) that same frame, and the movement + code copies it into the waypoint (`+0x10`, MSQC's cell) the frame after. The point is clamped to the map: x in + `[1, W − 2]`, y in `[1, H − 34]` (W and H the map in pixels); a point outside the map is dropped. Two Moves to one + unit in one turn leave only the last. +- **Minimap ping** (0x58) reaches only computers allied to the sender; **vision** (0x0D) and **alliance** (0x0E) are + 4 and 8 bits of players who are not there, and a turn keeps only the last; **chat** (0x5C) is 78 bytes and a line on + everyone's screen. Nothing carries bits to every computer and does nothing else: the carrier is a unit, as MSQC's. + +**The channel.** For each human player, a ring of hidden units of theirs — the carriers — and a cursor at each end. +A send is a Select of the next carrier and a Move to a point; the point is the value (`x − 1 + (W − 2) × (y − 1)`: +`B = floor(log2((W − 2)(H − 34)))` bits, 21 on a 64×64 map, 23 on 128×128, 25 on 256×256; MSQC's `val` is 22 on +128×128). A value arrives in the carrier's `+0x58`, 0 when nothing has (a sent x is never 0). Every computer, before +the programs' turns, reads each human's ring from its read cursor, takes what arrived in order, writes 0 back and +moves the cursor on; the sender keeps its write cursor and never has more in flight than the ring holds (its own +computer reads its ring like any other, so it sees what has landed). A turn that brings several frames' sends keeps +every one — MSQC's one carrier per value kept the last, which cost S2 its key presses and the Python side a bank of +held keys and a grace. Two rings: **points** (the mouse, raw, one send a frame when it moved) and **messages**, +each a header and then raw values the header counts: a **state** (one packet: a bit a key the programs read, then the +three mouse buttons; sent when it changed, paid out one a frame on arrival, so a key is held for as many frames as it +was down, and a press is the frame its bit came on), a **sync** (the slot, then the value), a **line** (the pattern, then +its captures). After a frame's sends the player's own selection (`0x6284B8`, this computer's) goes out as a Select, as +MSQC's `RestoreSelUnits` does, so their orders go on reaching their units. + +**The carriers** are made at the first frame, on every computer alike: the chosen type's dat entry patched as MSQC's +(the Command Center's flingy so it cannot move, 1×1, no weapon, no sight, no right-click), the units created in +transit — hidden, and selectable — through a unit-properties slot the build writes, given to Player 12 so they are in +no human's list (no supply, score, Command or Bring of theirs), and their owner byte put back to the player's so a +Select and an order of that player reach them. Their records' EPDs and tags are kept in cells of the map's. The type +is the Valkyrie unless the map uses it, in which case the build takes the first type the map's units, triggers and +script never name. A carrier the map kills is made again. + +**What the map gives up**, against MSQC's location, Valkyrie entry and Player 12 units: one unit-properties slot and +one unit type's dat entry (chosen so the map does not use it), the scratch location the own backend already keeps for +orders, and `ring × humans` hidden units of Player 12's (6 a human for messages, 6 more where the mouse is read) at +the map's top left corner. No location for the mouse: `mouse(p)` is a value. + +**Reads on the sender's computer**, as MSQC's and chatEvent's: the key bytes (`0x596A18`) unless a line is being +typed (`0x68C144`), the buttons (`0x6CDDC0`), the mouse on the screen (`0x6CDDC4`) and the screen on the map +(`0x62848C`); a typed line from the text lines (`0x640B60`, the ring's position at `0x640B58`), the line beginning with +this computer's player's name, matched against the programs' patterns there and sent as its number and values. The +test harness (`GameSim`) already types through the game's chat command and writes keys and the mouse where the map +reads them: nothing of it moves. The simulator keeps its model (a value arrives the frame after); a built map's trip +is two frames in the lifted game where MSQC's was five. + +**The passes.** The receive pass joins the events pass before the programs' turns; the send pass is a block of its own +after the last run's end (`Graph.after`, the layout's `@after`), so a `sync()` sent in a frame goes out that frame. +`syncSend` puts its value in a pending cell the send pass takes; `input` reads are reads of the map's cells; `select()` +and `local.selected` were step 12's too and are taken with it. `underMouse(p)` is the pick nearest to the point. + +**As built, part 1 (2026-10-23, branch `input`).** `compiler/backend/input.ts` (the plan, the reads rewritten, the +receive and send passes, the carriers' first frame), the emitter's and the layout's `after` block (`Graph.after`, +`AFTER_END`), `units.ts` (`local.selected`; `underMouse()` as the pick nearest the kept mouse; every walk of the +table passing the carrier type over), `own.ts` (`OwnMap` from the build: the humans, the map in pixels, the slot, the +type), `scripts/build-own.mts` (the slot written in transit through the editor's own code; the type the Valkyrie +unless the map's units or triggers name it). Not yet in part 1: typed lines; a carrier the map kills is not made again. + +- **Tests** (`tests/emitter.test.ts`, "a map of our own, input"): the harness plays the game's side — Give Units puts + a human's carriers at the head of Player 12's list, the key bytes and the mouse are written where the map reads them, + and what the send pass queued is read back as the game would run it two frames on. Keys go out the frame they + change and arrive two frames on; a key held 3 frames is held 3 frames; the mouse goes out when it moves, on its own + ring; a click is the frame the button came down; `sync()` resolves two frames on, two values together; a program + for every player reads each player's own keys. +- **The lifted game** (`probes/own-input.ts`, keys, the mouse and clicks written at set frames by a runner): every + line as the script says — `sync 0 1048574 12345 67890 ok` (the smallest and the largest value a point carries, exact), + a press 2 frames after the key, "held 5 frames" for a key down 5 frames, the click at the pixel, a right-click on a + Marine found and one on bare ground not, two 1-frame taps two presses. The same script through eudplib and MSQC: the + sync 6 frames later than ours, "held 7 frames" for the 5 (the bank's grace), the click at 320,2864 for 300,2600 and + "moved 2 times" for 4 (MSQC's mouse through its location), the two taps one press (the grace again). +- **Sizes and costs:** 12 carriers a human with the mouse read, 6 without; the probe's payload 2.14 MB (eudplib's + 0.8: the own build's size is step 14's business). The passes are not weighed yet. +- **Found on the way:** the emitter folds a sum whose terms are bit-slices of one read and a negative constant wrongly + (`(v & 0xffff) − 1 + ((v >>> 16) − 1) × 1024` came out as `v_low − 65536 + …`: the constant's high half went into + the sliced copy); the decode is written as separate steps, and the fault is in the Open list below. + +**As built, part 2 — typed lines (2026-10-23, branch `input`).** A line is read where it was typed. The game shows every +line in its thirteen text lines (0x640B60, 218 bytes each) as `name:\x07 text`, and moves the byte at 0x640B58 on +with each (the next slot: 10 at the start of a game and wrapping at 11 in the lifted game — the modulus is not leaned +on, only the change). The send pass, on this computer's player's turn, reads that byte; when it has moved, it looks +through the thirteen lines for one beginning with the player's name and `:\x07 ` (the prefix read once at the first +frame from the player table, 0x57EEEB + 36p, into cells), copies the text after it into cells, swaps the two bytes +after the colon (chatEvent's mark: `name: \x07text` shows the same and is not found again), and matches the text +against the patterns in the order the programs read them: written text byte for byte, `{n}` as digits kept to what a +point carries, `{k:ore|gas}` by a hash of the word to the next space and `{u:unit}` by a hash of the rest of the line +(`h = h × 31 + byte`, capitals as small letters, as the Python side's) found among the unit names' hashes in rising +order by halves (a table of 455 names in cells with values, from the compile's `unitNames`). The first pattern that +fits the whole line is kept as a pending line, and goes as a *chat* message of the ring: a header (kind 2, the +pattern's number) and then its values, all or none (the ring needs room for every packet, and the queue too — the +packet's room check now covers the whole message, which the sync's needed as well). On arrival a pattern's flag is up +for a frame, and its values are in cells of its own, so `chatted()` is a cell read and `on.chat` the same test in a +loop. One matched line a frame is sent (a person types one at a time); the three addresses are read-allowed in +Remastered's emulator (0x640B58 "read back"), and the line's bytes write-allowed, so the mark is kept there too. + +- **Tests:** "a typed line is matched where it was typed and arrives as its pattern and values, two frames on": the + harness shows lines as the game does (the name "oracle", the ring's byte 10 wrapping at 11) — `-help`, `-give 5000 + gas`, `-spawn Terran Marine`, `-give 99999999 ORE` (1,048,575), `-spawn zerg zergling` (37), `-ping 7` through + `on.chat`; `hello there`, `-give x ore` and `-spawn nothing of the kind` match nothing; fourteen more lines after the + ring went round each arrive once. +- **The lifted game** (the probe's runner typing lines through the game's own chat command): both builds print the + same eight lines on the same frames — ours and eudplib's chatEvent+MSQC alike, two frames after the line shows. + The probe's payload 3.08 MB with the four patterns and the name table (2.14 without). +- The ledger: step 12's 5 chat rows and `on.chat` taken; **26 of 26**. + +**Played in Remastered (2026-10-23, recording 10.29.27, single player).** Every line of the sheet as expected: the +sync corners, presses two frames on, clicks at the pixel (the corner click at 87,65), `underMouse()` a Marine and +the Command Center, `local.selected`, two quick taps two presses, the Marines moving on right-clicks with the +selection kept, no stray unit, no error — and every typed line: `-help`, `-give 42 gas`, `-spawn Terran Marine`, +`-ping 7` through `on.chat`, `hello there` nothing, `-give 99999999 ORE` as 1,048,575, `-spawn zerg zergling` as 37, +and thirteen more `-help` each answered at once as the text ring went round, the typed line showing unchanged after +the mark. So the text ring's byte moves in Remastered too. **One fault:** a hold of F7 of about a second gave "F7 +pressed 2, held 17 frames before" during the hold — Remastered's key table drops a held key for one frame when the +key begins to repeat (MSQC's grace hid this). Fixed: the send pass keeps a bit up through one frame it reads down +when it was up the two frames before (`@in.raw1`, `@in.raw2`), so a release goes one frame late and a one-frame gap +is passed over; a tap of one frame is still one frame. The probe's "held 5 frames" reads 6 now; a hold of three +seconds is the re-test. A chat line stays on screen longer than a printed line in Remastered (older `-help` lines +stood alone while their answers had faded). **Re-test played (recording 10.44.13):** a hold of F7 one press and +"held 56 frames", two quick taps two presses, `-help` answered. Step 12 played out. + ### Triggers stacked (2026-10-20) diff --git a/ledger/input/chatted-number.ts b/ledger/input/chatted-number.ts index 2f41171..2927d7a 100644 --- a/ledger/input/chatted-number.ts +++ b/ledger/input/chatted-number.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (chat) +// ledger: step 12, takes // A number read out of a typed line: `const m = chatted(P1, "-give {n}"); if (m) … m.n …`. program(async () => { while (true) { diff --git a/ledger/input/chatted-unit-type.ts b/ledger/input/chatted-unit-type.ts index 53f7433..f2d8769 100644 --- a/ledger/input/chatted-unit-type.ts +++ b/ledger/input/chatted-unit-type.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (chat) +// ledger: step 12, takes // A unit type read out of a typed line by its name: `chatted(P1, "-spawn {what:unit}")`. program(async () => { while (true) { diff --git a/ledger/input/chatted-word.ts b/ledger/input/chatted-word.ts index 28a5e23..b73ab26 100644 --- a/ledger/input/chatted-word.ts +++ b/ledger/input/chatted-word.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (chat) +// ledger: step 12, takes // One of a list of words read out of a typed line, as its place in the list: `chatted(P1, "-take {kind:ore|gas}")`. program(async () => { while (true) { diff --git a/ledger/input/chatted.ts b/ledger/input/chatted.ts index e5a03f7..acb545e 100644 --- a/ledger/input/chatted.ts +++ b/ledger/input/chatted.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (chat) +// ledger: step 12, takes // A line a player typed, matched whole: `if (chatted(P1, "-help"))`. program(async () => { while (true) { diff --git a/ledger/input/clicked-middle.ts b/ledger/input/clicked-middle.ts index 0842f0f..602e3fe 100644 --- a/ledger/input/clicked-middle.ts +++ b/ledger/input/clicked-middle.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (click) +// ledger: step 12, takes // A click of the middle mouse button: `clicked(P1, "middle")`. program(async () => { while (true) { diff --git a/ledger/input/clicked-right.ts b/ledger/input/clicked-right.ts index 28956ec..bef65ed 100644 --- a/ledger/input/clicked-right.ts +++ b/ledger/input/clicked-right.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (click) +// ledger: step 12, takes // A click of the right mouse button: `clicked(P1, "right")`. program(async () => { while (true) { diff --git a/ledger/input/clicked.ts b/ledger/input/clicked.ts index c336af4..ebdc71c 100644 --- a/ledger/input/clicked.ts +++ b/ledger/input/clicked.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (click) +// ledger: step 12, takes // A click of the left mouse button, true on the frame it arrives: `clicked(P1)`. program(async () => { while (true) { diff --git a/ledger/input/key-held.ts b/ledger/input/key-held.ts index 3e9557a..4bff5aa 100644 --- a/ledger/input/key-held.ts +++ b/ledger/input/key-held.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (key) +// ledger: step 12, takes // A key held down, true on every frame it is down: `keyHeld(P1, "D")`. program(async () => { let x = 0; diff --git a/ledger/input/key-pressed-current-player.ts b/ledger/input/key-pressed-current-player.ts index c9fc495..6e605ee 100644 --- a/ledger/input/key-pressed-current-player.ts +++ b/ledger/input/key-pressed-current-player.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (key) +// ledger: step 12, takes // A key pressed by the player the program runs as: `keyPressed(CurrentPlayer, "Space")`. program(async () => { while (true) { diff --git a/ledger/input/key-pressed.ts b/ledger/input/key-pressed.ts index 8d77c8d..cb502bd 100644 --- a/ledger/input/key-pressed.ts +++ b/ledger/input/key-pressed.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (key) +// ledger: step 12, takes // A key a player pressed, true on the frame the press arrives: `keyPressed(P1, "F7")`, looked for every frame. program(async () => { while (true) { diff --git a/ledger/input/local-selected-selected-again.ts b/ledger/input/local-selected-selected-again.ts index a4c2299..e44497a 100644 --- a/ledger/input/local-selected-selected-again.ts +++ b/ledger/input/local-selected-selected-again.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: select() +// ledger: step 12, takes // The unit this computer has selected, made the player's selection on every computer: `select(P1, local.selected)`. program(() => { select(P1, local.selected!); diff --git a/ledger/input/local-selected-unit.ts b/ledger/input/local-selected-unit.ts index 87d9bb7..d5a3da5 100644 --- a/ledger/input/local-selected-unit.ts +++ b/ledger/input/local-selected-unit.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: a unit kept in a variable +// ledger: step 12, takes // The unit this computer has selected, kept and its hit points shown: `const u = local.selected; if (u) … u.hp`. program(() => { const u = local.selected; diff --git a/ledger/input/local-selected.ts b/ledger/input/local-selected.ts index 458efc9..2d14a06 100644 --- a/ledger/input/local-selected.ts +++ b/ledger/input/local-selected.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: a unit +// ledger: step 12, takes // Whether this computer has a unit selected: `local.selected`, shown but not acted on. program(() => { if (local.selected) print("selected"); diff --git a/ledger/input/mouse-places-a-location.ts b/ledger/input/mouse-places-a-location.ts index f0d96ca..80ca6c3 100644 --- a/ledger/input/mouse-places-a-location.ts +++ b/ledger/input/mouse-places-a-location.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (mouse) +// ledger: step 12, takes // A location moved to where a player's mouse is: `centerLocation(spot, at.x, at.y)` after `const at = mouse(P1)`. const spot = reserveLocation("where the mouse is"); program(async () => { diff --git a/ledger/input/mouse.ts b/ledger/input/mouse.ts index b372153..c65fefc 100644 --- a/ledger/input/mouse.ts +++ b/ledger/input/mouse.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (mouse) +// ledger: step 12, takes // Where a player's mouse is on the map, the same on every computer: `mouse(P1).x`, `mouse(P1).y`. program(async () => { while (true) { diff --git a/ledger/input/on-chat.ts b/ledger/input/on-chat.ts index 865057d..7edfeab 100644 --- a/ledger/input/on-chat.ts +++ b/ledger/input/on-chat.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (chat) +// ledger: step 12, takes // A function run each time a player types a line that fits a pattern: `on.chat("-give {n}", (p, { n }) => …)`. program(async () => { on.chat("-give {n}", (p, { n }) => { diff --git a/ledger/input/on-key-from.ts b/ledger/input/on-key-from.ts index 6aae70d..31f1b30 100644 --- a/ledger/input/on-key-from.ts +++ b/ledger/input/on-key-from.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (key) +// ledger: step 12, takes // A function run each time one player presses a key: `on.key("Space", (p) => …, { from: P1 })`. program(async () => { let n = 0; diff --git a/ledger/input/on-key.ts b/ledger/input/on-key.ts index 90ed392..33e45bd 100644 --- a/ledger/input/on-key.ts +++ b/ledger/input/on-key.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (key) +// ledger: step 12, takes // A function run each time a player presses a key: `on.key("F7", (p) => …)`. program(async () => { on.key("F7", (p) => { diff --git a/ledger/input/sync-several.ts b/ledger/input/sync-several.ts index 7e04ebe..58beddf 100644 --- a/ledger/input/sync-several.ts +++ b/ledger/input/sync-several.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: sync() +// ledger: step 12, takes // Several of one player's values sent together, read on the same frame: `const [x, y] = await sync(P1, a, b)`. program(async () => { const [x, y] = await sync(P1, local.camera.x + local.mouse.x, local.camera.y + local.mouse.y); diff --git a/ledger/input/sync.ts b/ledger/input/sync.ts index 3014956..7c8ff55 100644 --- a/ledger/input/sync.ts +++ b/ledger/input/sync.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: sync() +// ledger: step 12, takes // One player's value only their computer knows, sent to every computer: `const x = await sync(P1, local.camera.x)`. program(async () => { const x = await sync(P1, local.camera.x); diff --git a/ledger/input/under-mouse-filter.ts b/ledger/input/under-mouse-filter.ts index 6ca5a28..ddccb5f 100644 --- a/ledger/input/under-mouse-filter.ts +++ b/ledger/input/under-mouse-filter.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: a unit +// ledger: step 12, takes // The nearest unit to a player's mouse that a filter matches, within so many pixels: `underMouse(P1, { owner: P2, within: 32 })`. program(async () => { while (true) { diff --git a/ledger/input/under-mouse.ts b/ledger/input/under-mouse.ts index 31c11db..34ff382 100644 --- a/ledger/input/under-mouse.ts +++ b/ledger/input/under-mouse.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: a unit +// ledger: step 12, takes // The unit nearest a player's mouse, or none: `underMouse(P1)`. program(async () => { while (true) { diff --git a/ledger/programs/owner-all-players-keys.ts b/ledger/programs/owner-all-players-keys.ts index 2519de9..edd3a3e 100644 --- a/ledger/programs/owner-all-players-keys.ts +++ b/ledger/programs/owner-all-players-keys.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (key) +// ledger: step 12, takes // Each player's own keys in a program for every player: `keyPressed(CurrentPlayer, "Space")` with `{ owner: AllPlayers }`. program(async () => { while (true) { diff --git a/ledger/tasks/cutscene-play.ts b/ledger/tasks/cutscene-play.ts index 0df4135..ce9636c 100644 --- a/ledger/tasks/cutscene-play.ts +++ b/ledger/tasks/cutscene-play.ts @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (key) +// ledger: step 12, takes // trigscript/cutscene: a scene started as a task, which the player may skip with Escape: `await play(P1, start(scene()))`. import { play } from "trigscript/cutscene"; async function scene() { diff --git a/ledger/texts/ui-dialog.tsx b/ledger/texts/ui-dialog.tsx index 2bd28ac..8cc9abc 100644 --- a/ledger/texts/ui-dialog.tsx +++ b/ledger/texts/ui-dialog.tsx @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (key) +// ledger: step 12, takes // Someone speaking to a player, the words typed in, closed with Space, from `trigscript/ui`: // `await say(P1, talk, "Raynor", "…")` and ``. import { DialogBox, say } from "trigscript/ui"; diff --git a/ledger/texts/ui-menu.tsx b/ledger/texts/ui-menu.tsx index c3bb5c3..9b8f6f1 100644 --- a/ledger/texts/ui-menu.tsx +++ b/ledger/texts/ui-menu.tsx @@ -1,4 +1,4 @@ -// ledger: step 12, refused: the players' input (key) +// ledger: step 12, takes // A menu a player chooses from with W, S and Space, shown in the text lines, from `trigscript/ui`: // `await choose(P1, ITEMS.length, shop)` and ``. import { MenuList, choose } from "trigscript/ui"; diff --git a/probes/own-input.ts b/probes/own-input.ts new file mode 100644 index 0000000..869e8f7 --- /dev/null +++ b/probes/own-input.ts @@ -0,0 +1,48 @@ +// TrigScript 4, the backend: the players' input in a map of our own (scripts/build-own.mts) — step 12 of the way off +// eudplib. Keys, clicks, the mouse and sync() carried between the players' computers by the map itself: a ring of +// hidden carrier units a player, a Select and a Move to a point each send, the point read back from the carrier's order +// target on every computer. Nothing of chatEvent's or MSQC's. +// Built both ways (through eudplib, and as a map of our own), the lifted game should print the same lines for the same +// keys, clicks and mouse; a line ending "WRONG" names the check that failed. The trip takes two frames in a map of our +// own and about five through MSQC, so the frames differ. Typed lines (part 2) are read from the text lines on the +// computer they were typed on and matched there; a pattern's number and its values make the trip. +// npx tsx scripts/build-own.mts probes/own-input.ts /tmp/own-input.scx +// npx tsx scripts/build-fixture.mts probes/own-input.ts /tmp/own-input-eud.scx +import { program, print, keyPressed, keyHeld, clicked, mouse, sync, local, underMouse, chatted, nextFrame } from "trigscript"; + +program(async () => { + print("Own input: the payload runs."); + on.chat("-ping {n}", (p, { n }) => { print(`on.chat: ping ${n}`); }); + // The smallest and the largest value a point carries, one in the middle, and two sent on the same frame. + const a = await sync(P1, 0); + const b = await sync(P1, 1048574); + const [c, d] = await sync(P1, 12345, 67890); + print(`sync ${a} ${b} ${c} ${d}` + (a == 0 && b == 1048574 && c == 12345 && d == 67890 ? " ok" : " WRONG")); + let held = 0, presses = 0, clicks = 0, moves = 0, lastX = 0, lastY = 0; + while (true) { + if (keyPressed(P1, "F7")) { presses += 1; print(`F7 pressed ${presses}, held ${held} frames before`); } + if (keyHeld(P1, "F7")) held += 1; + if (keyPressed(P1, "Space")) print(`Space: F7 was held ${held} frames`); + if (clicked(P1)) { clicks += 1; const at = mouse(P1); print(`click ${clicks} at ${at.x},${at.y}`); } + if (clicked(P1, "right")) { + const u = underMouse(P1, { within: 64 }); + if (u) print(`right-click on a unit of type ${u.type}`); + else print("right-click on nothing"); + } + const at = mouse(P1); + if (at.x != lastX || at.y != lastY) { moves += 1; lastX = at.x; lastY = at.y; } + if (keyPressed(P1, "F8")) { + print(`mouse at ${at.x},${at.y}, moved ${moves} times`); + const s = local.selected; + if (s) print(`selected: a unit of type ${s.type}`); + else print("nothing selected"); + } + // Typed lines: whole, a number (kept to 1048575), a word of a list, a unit by its name. + if (chatted(P1, "-help")) print("typed: help"); + const g = chatted(P1, "-give {n} {what:ore|gas}"); + if (g) print(`typed: give ${g.n} of ${g.what}` + (g.what == 1 ? " (gas)" : " (ore)")); + const sp = chatted(P1, "-spawn {what:unit}"); + if (sp) print(`typed: spawn type ${sp.what}` + (sp.what == units.TerranMarine ? " (a marine)" : "")); + await nextFrame(); + } +}); diff --git a/scripts/build-own.mts b/scripts/build-own.mts index 945fc89..3bd8b59 100644 --- a/scripts/build-own.mts +++ b/scripts/build-own.mts @@ -41,6 +41,7 @@ const { parseScenario, serializeScenario, markDirty, setMapVersion, setScenarioN const { internString, patchPlayer, readPlayerSettings } = await import(join(EDITOR, "src", "editor", "settings.ts")); const { setString } = await import(join(EDITOR, "src", "formats", "chk", "sections", "strings.ts")); const { addUnits, applyUnitChanges, makeUnit, nextSerial } = await import(join(EDITOR, "src", "editor", "units.ts")); +const { cuwpUsage, patchCuwp } = await import(join(EDITOR, "src", "editor", "cuwp.ts")); const loaded = await loadMap(new Uint8Array(readFileSync(map))); const scn = parseScenario(loaded.chk); setMapVersion(scn, "remastered"); @@ -57,7 +58,15 @@ const players: MapPlayers = { slots: [0, 1, 2, 3, 4, 5, 6, 7].filter((p) => sett // The texts the programs choose between while the game runs: the map's next strings, one after another, in the order // of their numbers (`compiler/backend/texts.ts`). const textBase = scn.strings.strings.length; -const lowered = emitOwn(r, players, textBase); +// The players' input, when a program reads it (`compiler/backend/input.ts`): the humans, the map in pixels, a +// unit-properties slot the map does not use — written "in transit" below when the programs need carriers — and a +// carrier type the map's units and triggers never name (the Valkyrie unless they do). +const humans = [0, 1, 2, 3, 4, 5, 6, 7].filter((p) => settings.types[p] === 6); +const cuwpFree = cuwpUsage(scn) as number[]; +const cuwp = 1 + Math.max(0, ...[...Array(64).keys()].filter((i) => !cuwpFree[i])); +const used = new Set([...scn.units.map((u: { unitId: number }) => u.unitId), ...scn.triggers.flatMap((t: { conditions: { unitId: number }[]; actions: { unitId: number }[] }) => [...t.conditions.map((c) => c.unitId), ...t.actions.map((a) => a.unitId)])]); +const carrier = used.has(58) ? [...Array(228).keys()].find((t) => !used.has(t))! : 58; +const lowered = emitOwn(r, players, textBase, { humans, width: scn.width * 32, height: scn.height * 32, cuwp, carrier }); if (typeof lowered === "string") { console.error(`the emitter cannot lower this program: ${lowered}`); process.exit(1); } lowered.texts.forEach((text, i) => { if (setString(scn.strings, 0, text) !== textBase + i) throw new Error("the pool of texts did not go into the map's strings one after another"); @@ -97,6 +106,11 @@ for (const t of graphs.flatMap((g) => g.templates ?? [])) { const name = `(TrigScript text ${t.id})`; templates.set(t.id, internString(scn, name + "\r".repeat(Math.max(0, t.bytes.length + 3 - name.length)))); } +if (lowered.input) { + // (The slot written "in transit": the sections it touched are marked dirty by the editor's code.) + patchCuwp(scn, cuwp - 1, { inTransit: true }, true); + console.log(`input: ${lowered.input.keys.length} keys, ${lowered.input.mouse ? "the mouse, " : ""}${lowered.input.syncs} sync slots; carriers of type ${carrier}, ${lowered.input.ring} a ring, ${lowered.input.rings} rings, for players ${humans.map((h) => h + 1).join(", ")}; unit-properties slot ${cuwp}; ${lowered.input.bits} bits a value`); +} markDirty(scn, "TRIG", "UNIT", "STRx"); const chk = serializeScenario(scn); diff --git a/tests/emitter.test.ts b/tests/emitter.test.ts index 846dfde..3fb76a4 100644 --- a/tests/emitter.test.ts +++ b/tests/emitter.test.ts @@ -17,6 +17,7 @@ import ts from "typescript"; import { compileScript } from "../compiler/compiler"; import { defaultScriptNames } from "../compiler/names"; import { emitOwn } from "../compiler/backend/own"; +import { virtualKey } from "../compiler/input"; import { simulatePrograms } from "../compiler/simulateIr"; import { Simulation } from "../compiler/simulate"; import type { SimBounds, SimUnitInit } from "../compiler/world"; @@ -149,6 +150,8 @@ const CALLS = { whole: false }; */ /** `frames`: the frame each of `got` was shown in, by a walk of several (`runOwn`'s `frames`). */ const PRINTED: { want: string[]; got: string[]; frames: number[] } = { want: [], got: [], frames: [] }; +/** The game's actions the harness does not model (Create Unit with Properties, Give Units): a test's world plays them. */ +const GAME: { action?: (kind: number, field: (offset: number) => number, read: (addr: number) => number, write: (addr: number, value: number) => void) => void } = {}; const shown = (bytes: number[]) => bytes.filter((b) => b !== 0x0d).map((b) => b.toString(16).padStart(2, "0")).join(""); /** * The statements the evaluator may still run for the program in hand (a hundred thousand), its calls' bodies included: a program whose @@ -1072,7 +1075,7 @@ const laidOut = new WeakMap; own: ReturnT * one more) when more than one, the memory kept from one to the next; each text shown noted with its frame. */ /** `between`: what the game does before each frame — memory written, as units come and go. */ -function runOwn(g: Graph, memory?: Map, arrays = new Map(), runs?: { graph: Graph; owner: number }[], frames = 1, between?: (frame: number, write: (addr: number, value: number) => void) => void): Env { +function runOwn(g: Graph, memory?: Map, arrays = new Map(), runs?: { graph: Graph; owner: number }[], frames = 1, between?: (frame: number, write: (addr: number, value: number) => void, read: (addr: number) => number) => void): Env { // (Laid out once a graph: a test may walk one many times.) let done = laidOut.get(g); if (!done || (done.arrays !== arrays && (done.arrays.size || arrays.size))) { @@ -1098,7 +1101,7 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map u(DEATHS + 4 * (epd === 13 ? rd(0x6509b0) : epd)); PRINTED.frames = []; for (let frame = 1; frame <= frames; frame++) { - between?.(frame, wr); + between?.(frame, wr, rd); let pc = frames > 1 ? own.triggers.get("@run")! : own.address; const shownBefore = PRINTED.got.length; for (let steps = 0; !(pc & 0x80000000); steps++) { @@ -1137,6 +1140,8 @@ function runOwn(g: Graph, memory?: Map, arrays = new Map (off === 27 ? byte(o + 27) : rd(o + off)), rd, wr); continue; } if (kind !== 45) throw new Error(`action kind ${kind}`); const dest = at(rd(o + 16)), value = rd(o + 20), modifier = byte(o + 27), old = rd(dest); if (dv.getUint16(o + 30 - base, true) === 0x4353) { @@ -2180,3 +2185,319 @@ describe("a map of our own, unit events", () => { expect(got).toEqual(["5 late 5", "5 late 5"]); }); }); + +describe("a map of our own, input", () => { + /** + * The game's side of the channel, played by the test: Player 12's list holds the carriers the start-up finds (six a + * ring), the local player's keys and mouse are its memory, and what the send pass queues is read back as the game + * would run it two frames on — a Select names the carrier, a Move writes its order target. + */ + const TABLE = 0x59cca8, place = (k: number) => TABLE + 336 * k; + const LOCAL = 0x512684, QUEUE = 0x654880, QUEUED = 0x654aa0, QUEUE_MOST = 0x57f0d8, FIRST_UNIT = 0x6283f8, KEYS = 0x596a18, BUTTONS = 0x6cddc0, MOUSE = 0x6cddc4, SCREEN_X = 0x62848c, SCREEN_Y = 0x6284a8, SELECTION = 0x6284b8; + type World = { keys: Map; buttons: number; mouse: [number, number]; screen: [number, number]; selection: number[]; pokes: [number, number][] }; + const run = (script: string, frames: number, then: (frame: number, world: World) => void, mouse = false): { lines: string[]; sent: 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, 1], width: 64 * 32, height: 64 * 32, cuwp: 64 }); + if (typeof o === "string") throw new Error(o); + const memory = new Map(); + memory.set(LOCAL, 0); + memory.set(QUEUE_MOST, 496); + // The units the game hands each human's Create Unit (a pool a human, from place 1600 on): Give Units puts the + // count asked at the head of Player 12's list, the newest first, as the game does. + const perHuman = (mouse ? 12 : 6); + const pools = new Map([[0, []], [1, []]]); + let next = 1600; + for (const h of [0, 1]) for (let i = 0; i < perHuman; i++) { + const k = next++; + memory.set(place(k) + 0x0c, 0x700000 + k); + memory.set(place(k) + 0x4c, 3 * 256 + h); + memory.set(place(k) + 0x64, 58); + memory.set(place(k) + 0xa4, (i + 1) * 256); + pools.get(h)!.push(k); + } + GAME.action = (kind, field, rd, w) => { + if (kind !== 48) return; + const h = field(16), count = field(27); + for (let i = 0; i < count; i++) { + const k = pools.get(h)!.shift()!; + w(place(k) + 0x6c, rd(FIRST_UNIT + 11 * 4)); + w(place(k) + 0x4c, 3 * 256 + 11); + w(FIRST_UNIT + 11 * 4, place(k)); + } + }; + const world: World = { keys: new Map(), buttons: 0, mouse: [0, 0], screen: [0, 0], selection: [], pokes: [] }; + const sent: string[] = []; + // (What the queue holds at the end of a frame, applied two frames on.) + const due = new Map(); + PRINTED.got = []; + const env = runOwn(o.runs[0].graph, memory, o.values, undefined, frames, (f, w, rd) => { + // The commands of the frame before, as the game sends them: each Select names units, a Move gives the selection a target. + const n = rd(QUEUED); + const bytes: number[] = []; + for (let a = QUEUE; a < QUEUE + n; a += 4) { const d = rd(a); bytes.push(d & 0xff, (d >> 8) & 0xff, (d >> 16) & 0xff, (d >>> 24) & 0xff); } + bytes.length = n; + let selected: number[] = []; + for (let i = 0; i < bytes.length;) { + const c = bytes[i]; + if (c === 0x05) { i++; continue; } + if (c === 0x09) { const count = bytes[i + 1]; selected = []; for (let t = 0; t < count; t++) selected.push((bytes[i + 2 + 2 * t] | (bytes[i + 3 + 2 * t] << 8)) & 0x7ff); sent.push(`${f - 1} select ${selected.map((t) => t - 1).join(",")}`); i += 2 + 2 * count; continue; } + if (c === 0x15) { const x = bytes[i + 1] | (bytes[i + 2] << 8), y = bytes[i + 3] | (bytes[i + 4] << 8); sent.push(`${f - 1} move ${x},${y}`); for (const t of selected) due.set(f + 1, [...(due.get(f + 1) ?? []), { slot: t - 1, x, y }]); i += 12; continue; } + throw new Error(`a command the test does not know: 0x${c.toString(16)} at ${i} of ${bytes.length}`); + } + w(QUEUED, 0); + for (const d of due.get(f) ?? []) w(place(d.slot) + 0x58, (d.x | (d.y << 16)) >>> 0); + if (process.env.IN_DEBUG) console.log(`frame ${f}: made ${rd(0x58a364 + 4 * 0)} c0+58 ${rd(place(1600) + 0x58)} c1+58 ${rd(place(1601) + 0x58)} first12 ${rd(FIRST_UNIT + 44).toString(16)}`); + then(f, world); + for (const [a, v] of world.pokes.splice(0)) w(a, v); + // The local machine's memory as the world says. + const keyWords = new Map(); + for (const [vk, down] of world.keys) if (down) keyWords.set((KEYS + vk) & ~3, (keyWords.get((KEYS + vk) & ~3) ?? 0) | (1 << (8 * ((KEYS + vk) & 3)))); + for (let a = KEYS; a < KEYS + 256; a += 4) w(a, keyWords.get(a) ?? 0); + w(BUTTONS, world.buttons); + w(MOUSE, world.mouse[0]); w(MOUSE + 4, world.mouse[1]); w(SCREEN_X, world.screen[0]); w(SCREEN_Y, world.screen[1]); + for (let i = 0; i < 12; i++) w(SELECTION + 4 * i, world.selection[i] !== undefined ? place(world.selection[i]) : 0); + }); + if (process.env.IN_DEBUG) console.log([...env].filter(([id]) => id.startsWith("@in")).map(([id, v]) => `${id}=${v}`).join(" ")); + return { lines: PRINTED.got.map((t, i) => `${PRINTED.frames[i]} ${Buffer.from(t, "hex").toString()}`), sent }; + }; + const vk = (key: string) => virtualKey(key)!; + + it("makes the carriers at the first frame: Player 12's units, the owner byte the player's, nothing sent", () => { + const owners: number[] = []; + const got = run("program(async () => { while (true) { if (keyPressed(P1, \"F7\")) print(\"help\"); await nextFrame(); } });", 3, (f, world) => { + if (f === 3) world.pokes.push([0, 0]); + }); + void owners; + expect(got.lines).toEqual([]); + expect(got.sent).toEqual([]); + }); + + it("a key that reads up for one frame of a hold stays held: no second press", () => { + const got = run([ + "program(async () => {", + " let held = 0;", + " while (true) {", + " if (keyPressed(P1, \"F7\")) print(`pressed after ${held}`);", + " if (keyHeld(P1, \"F7\")) held += 1;", + " await nextFrame();", + " }", + "});", + ].join("\n"), 16, (f, world) => { + if (f === 2) world.keys.set(vk("F7"), 1); + if (f === 6) world.keys.set(vk("F7"), 0); + if (f === 7) world.keys.set(vk("F7"), 1); + if (f === 10) world.keys.set(vk("F7"), 0); + if (f === 11) world.keys.set(vk("F7"), 0); + if (f === 12) world.keys.set(vk("F7"), 1); + }); + // (Down 2–5, up for 6 alone, down 7–9, up 10–11, down from 12: one gap passed over, the two-frame one a release.) + expect(got.sent.filter((s) => s.includes("move"))).toEqual(["2 move 5,1", "11 move 1,1", "12 move 5,1"]); + expect(got.lines).toEqual(["4 pressed after 0", "14 pressed after 9"]); + }); + + it("sends a key the frame it goes down and the frame it comes up, and the press arrives two frames on", () => { + const got = run([ + "program(async () => {", + " let held = 0;", + " while (true) {", + " if (keyPressed(P1, \"F7\")) print(\"pressed\");", + " if (keyHeld(P1, \"F7\")) held += 1;", + " if (keyPressed(P1, \"Space\")) print(`space ${held}`);", + " await nextFrame();", + " }", + "});", + ].join("\n"), 14, (f, world) => { + if (f === 3) world.keys.set(vk("F7"), 1); + if (f === 6) world.keys.set(vk("F7"), 0); + if (f === 9) world.keys.set(vk("Space"), 1); + if (f === 10) world.keys.set(vk("Space"), 0); + }); + // (The ring's carriers in the order Player 12's list holds them: the last given first.) + // (A release goes a frame late: a bit up the two frames before is kept through one frame it reads down.) + expect(got.sent).toEqual(["3 select 1605", "3 move 5,1", "3 select ", "7 select 1604", "7 move 1,1", "7 select ", "9 select 1603", "9 move 9,1", "9 select ", "10 select 1602", "10 move 1,1", "10 select "]); + expect(got.lines).toEqual(["5 pressed", "11 space 4"]); + }); +}); + +describe("a map of our own, input: the mouse, clicks, sync() and a program for every player", () => { + // (The world and the game's side as in the describe above.) + const TABLE = 0x59cca8, place = (k: number) => TABLE + 336 * k; + const LOCAL = 0x512684, QUEUE = 0x654880, QUEUED = 0x654aa0, QUEUE_MOST = 0x57f0d8, FIRST_UNIT = 0x6283f8, KEYS = 0x596a18, BUTTONS = 0x6cddc0, MOUSE = 0x6cddc4, SCREEN_X = 0x62848c, SCREEN_Y = 0x6284a8, SELECTION = 0x6284b8; + type World = { keys: Map; buttons: number; mouse: [number, number]; screen: [number, number]; selection: number[]; camera: [number, number]; typed: string[] }; + const run = (script: string, frames: number, then: (frame: number, world: World) => void, mouse = false, runIndex = 0): { lines: string[]; sent: 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, 1], width: 64 * 32, height: 64 * 32, cuwp: 64 }); + if (typeof o === "string") throw new Error(o); + const memory = new Map(); + memory.set(LOCAL, 0); + memory.set(QUEUE_MOST, 496); + // (Players 1 and 2 people in the game: the game's player type 2, a trigger list that is not empty.) + memory.set(0x57f1b4, 2 | (2 << 8)); + for (const p of [0, 1]) memory.set(0x51a280 + 12 * p + 8, 1); + // (The local player's name, "oracle" as the lifted game's; the text lines' ring, as 1.16.1 keeps it: the next slot's byte at + // 0x640B58, 10 to begin with and wrapping at 11, a line shown as "name:\x07 text".) + const NAME_AT = 0x57eeeb, TEXT_RING = 0x640b58, TEXT_LINES = 0x640b60; + const text = new Uint8Array(13 * 218 + 4); + const dwords = (base: number, bytes: Uint8Array, from: number, to: number, w: (a: number, v: number) => void) => { + for (let a = from & ~3; a < to; a += 4) w(base + a, (bytes[a] | (bytes[a + 1] << 8) | (bytes[a + 2] << 16) | (bytes[a + 3] << 24)) >>> 0); + }; + const name = Buffer.from("oracle\0", "latin1"); + const table = new Uint8Array(8 + name.length + 4); + table.set(name, NAME_AT - (NAME_AT & ~3)); + dwords(NAME_AT & ~3, table, 0, table.length, (a, v) => memory.set(a, v)); + let ring = 10; + memory.set(TEXT_RING, ring); + const perHuman = (mouse ? 12 : 6); + const pools = new Map([[0, []], [1, []]]); + let next = 1600; + for (const h of [0, 1]) for (let i = 0; i < perHuman; i++) { + const k = next++; + memory.set(place(k) + 0x0c, 0x700000 + k); memory.set(place(k) + 0x4c, 3 * 256 + h); memory.set(place(k) + 0x64, 58); memory.set(place(k) + 0xa4, (i + 1) * 256); + pools.get(h)!.push(k); + } + GAME.action = (kind, field, rd, w) => { + if (kind !== 48) return; + const h = field(16), count = field(27); + for (let i = 0; i < count; i++) { const k = pools.get(h)!.shift()!; w(place(k) + 0x6c, rd(FIRST_UNIT + 11 * 4)); w(place(k) + 0x4c, 3 * 256 + 11); w(FIRST_UNIT + 11 * 4, place(k)); } + }; + const world: World = { keys: new Map(), buttons: 0, mouse: [0, 0], screen: [0, 0], selection: [], camera: [0, 0], typed: [] }; + const sent: string[] = []; + const due = new Map(); + PRINTED.got = []; + const runs = o.runs.map((run) => ({ graph: run.graph, owner: run.player })); + runOwn(o.runs[runIndex].graph, memory, o.values, runs.length > 1 ? runs : undefined, frames, (f, w, rd) => { + const n = rd(QUEUED); + const bytes: number[] = []; + for (let a = QUEUE; a < QUEUE + n; a += 4) { const d = rd(a); bytes.push(d & 0xff, (d >> 8) & 0xff, (d >> 16) & 0xff, (d >>> 24) & 0xff); } + bytes.length = n; + let selected: number[] = []; + for (let i = 0; i < bytes.length;) { + const c = bytes[i]; + if (c === 0x05) { i++; continue; } + if (c === 0x09) { const count = bytes[i + 1]; selected = []; for (let t = 0; t < count; t++) selected.push((bytes[i + 2 + 2 * t] | (bytes[i + 3 + 2 * t] << 8)) & 0x7ff); sent.push(`${f - 1} select ${selected.map((t) => t - 1).join(",")}`); i += 2 + 2 * count; continue; } + if (c === 0x15) { const x = bytes[i + 1] | (bytes[i + 2] << 8), y = bytes[i + 3] | (bytes[i + 4] << 8); sent.push(`${f - 1} move ${x},${y}`); for (const t of selected) due.set(f + 1, [...(due.get(f + 1) ?? []), { slot: t - 1, x, y }]); i += 12; continue; } + throw new Error(`a command the test does not know: 0x${c.toString(16)} at ${i} of ${bytes.length}`); + } + w(QUEUED, 0); + w(0x57f23c, f); + for (const d of due.get(f) ?? []) w(place(d.slot) + 0x58, (d.x | (d.y << 16)) >>> 0); + then(f, world); + for (const line of world.typed.splice(0)) { + const at = ring * 218, bytes = Buffer.from(`oracle:\x07 ${line}\0`, "latin1"); + text.fill(0, at, at + 218); + text.set(bytes.subarray(0, 218), at); + dwords(TEXT_LINES, text, at, at + 218, w); + ring = (ring + 1) % 11; + w(TEXT_RING, ring); + } + const keyWords = new Map(); + for (const [vk, down] of world.keys) if (down) keyWords.set((KEYS + vk) & ~3, (keyWords.get((KEYS + vk) & ~3) ?? 0) | (1 << (8 * ((KEYS + vk) & 3)))); + for (let a = KEYS; a < KEYS + 256; a += 4) w(a, keyWords.get(a) ?? 0); + w(BUTTONS, world.buttons); + w(MOUSE, world.mouse[0]); w(MOUSE + 4, world.mouse[1]); w(SCREEN_X, world.screen[0]); w(SCREEN_Y, world.screen[1]); + for (let i = 0; i < 12; i++) w(SELECTION + 4 * i, world.selection[i] !== undefined ? place(world.selection[i]) : 0); + }); + return { lines: PRINTED.got.map((t, i) => `${PRINTED.frames[i]} ${Buffer.from(t, "hex").toString()}`), sent }; + }; + + it("sends the mouse when it moves, on its own ring, and mouse() reads it two frames on", () => { + const got = run([ + "program(async () => {", + " while (true) {", + " const at = mouse(P1);", + " if (at.x || at.y) print(`${at.x},${at.y}`);", + " await nextFrame();", + " }", + "});", + ].join("\n"), 9, (f, world) => { + if (f === 2) { world.screen = [100, 200]; world.mouse = [30, 40]; } + if (f === 4) world.mouse = [31, 40]; + if (f === 6) world.screen = [100, 2100]; + }, true); + expect(got.sent.map((s) => s.replace(/select .*/, "select"))).toEqual(["2 select", "2 move 130,240", "2 select", "4 select", "4 move 131,240", "4 select", "6 select", "6 move 131,2014", "6 select"]); + expect(got.lines).toEqual(["4 130,240", "5 130,240", "6 131,240", "7 131,240", "8 131,2014", "9 131,2014"]); + }); + + it("a click is the frame the button went down", () => { + const got = run([ + "program(async () => {", + " while (true) {", + " if (clicked(P1)) print(\"left\");", + " if (clicked(P1, \"right\")) print(\"right\");", + " await nextFrame();", + " }", + "});", + ].join("\n"), 10, (f, world) => { + if (f === 2) world.buttons = 2; + if (f === 5) world.buttons = 0; + if (f === 6) world.buttons = 8; + if (f === 7) world.buttons = 0; + }); + expect(got.lines).toEqual(["4 left", "8 right"]); + }); + + it("sync() sends the value from the player's computer and resolves two frames on, everywhere", () => { + const got = run([ + "program(async () => {", + " const before = memory.game.frame;", + " const x = await sync(P1, local.camera.x + 7);", + " print(`got ${x} after ${memory.game.frame - before}`);", + " const [a, b] = await sync(P1, 1000000, 5);", + " print(`${a} ${b}`);", + "});", + ].join("\n"), 12, (f, world) => { if (f === 1) world.screen = [300, 0]; }); + expect(got.lines).toEqual(["3 got 307 after 2", "5 1000000 5"]); + }); + + it("a program for every player reads each player's own keys", () => { + const got = run([ + "program(async () => {", + " while (true) {", + " if (keyPressed(CurrentPlayer, \"Space\")) print(\"jump\");", + " await nextFrame();", + " }", + "}, { owner: AllPlayers });", + ].join("\n"), 8, (f, world) => { + if (f === 2) world.keys.set(virtualKey("Space")!, 1); + if (f === 3) world.keys.set(virtualKey("Space")!, 0); + }); + expect(got.lines).toEqual(["4 jump"]); + }); + + it("a typed line is matched where it was typed and arrives as its pattern and values, two frames on", () => { + const got = run([ + "program(async () => {", + " on.chat(\"-ping {n}\", (p, { n }) => { print(`ping ${n}`); });", + " while (true) {", + " if (chatted(P1, \"-help\")) print(\"help\");", + " const g = chatted(P1, \"-give {n} {what:ore|gas}\");", + " if (g) print(`give ${g.n} ${g.what}`);", + " const s = chatted(P1, \"-spawn {what:unit}\");", + " if (s) print(`spawn ${s.what}` + (s.what == units.TerranMarine ? \" (a marine)\" : \"\"));", + " await nextFrame();", + " }", + "});", + ].join("\n"), 32, (f, world) => { + // (A line a frame at the most: a person types one at a time, and the map keeps one matched line until it has gone.) + if (f === 2) world.typed.push("-help"); + if (f === 4) world.typed.push("-give 5000 gas"); + if (f === 6) world.typed.push("hello there", "-spawn Terran Marine"); + if (f === 8) world.typed.push("-give x ore", "-give 99999999 ORE"); + if (f === 10) world.typed.push("-spawn nothing of the kind"); + if (f === 12) world.typed.push("-spawn zerg zergling"); + if (f === 13) world.typed.push("-ping 7"); + // (Thirteen more lines: the ring goes round, and a line dealt with is not dealt with again.) + if (f >= 14 && f <= 26) world.typed.push("-help"); + if (f === 28) world.typed.push("-help"); + }); + // (A header is the pattern's number, in the order the programs are read: on.chat's first. A point is the value plus + // one in each of x and y: "-give 5000 gas" is 2 + 4 × 3, then 5000 — x 905, y 5 on a map 64 tiles wide — then gas, 1.) + expect(got.sent.filter((s) => s.includes("move")).slice(0, 6)).toEqual(["2 move 11,1", "4 move 15,1", "4 move 905,5", "4 move 2,1", "6 move 19,1", "6 move 1,1"]); + expect(got.lines).toEqual([ + "4 help", "6 give 5000 1", "8 spawn 0 (a marine)", "10 give 1048575 0", "14 spawn 37", "15 ping 7", + ...Array.from({ length: 13 }, (_, i) => `${16 + i} help`), "30 help", + ]); + }); +});