diff --git a/aot/.gitignore b/aot/.gitignore deleted file mode 100644 index 440167bb..00000000 --- a/aot/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# Build artifacts -dist/ -# The cartridge blob is generated by the compiler and linked into the ROM. -runtime/gen_cart.c -# Test harness binary (built from mgba_runner.c) -test/harness/mgba_runner -# Transient transpiled game modules from the static evaluator -demo/*.__pjgb.*.mjs diff --git a/aot/README.md b/aot/README.md deleted file mode 100644 index 5c4ad496..00000000 --- a/aot/README.md +++ /dev/null @@ -1,133 +0,0 @@ -# @pocketjs/aot - -**Write cartridge RPGs in TypeScript and JSX; ship GBA-native tiles, sprites, palettes, and bytecode.** - -`@pocketjs/aot` is a first-class PocketJS architecture that coexists with `@pocketjs/framework`. Where the framework runs a live Solid/Vue reactive UI on PSP-class hardware, `@pocketjs/aot` goes *below the runtime line*: it **partially evaluates** a TypeScript/JSX game program at build time and emits a small fixed GBA runtime plus binary game data. No JS engine, no Solid, no VDOM, no CSS runtime ships on the cartridge. - -> TypeScript and JSX are the authoring language. Partial evaluation is the compiler strategy. GBA-native tile/sprite data and bytecode are the runtime artifact. - -This is not a TypeScript-to-GBA compiler. It is a domain-aware partial evaluator for a constrained RPG DSL (design: `pocketjs_gba_partial_evaluation_design.md`). - -## Status - -A complete **vertical slice** runs end-to-end: a retro monster-RPG-style overworld authored in TSX compiles to a real `.gba` ROM and passes a headless mGBA E2E suite (19 assertions on real emulated hardware) covering boot, grid movement, collision, NPC dialogue, a choice menu, a battle→flag→item reward, and warping between maps. - -| ![town](docs/town.png) | ![dialogue](docs/dialogue.png) | ![choice](docs/choice.png) | -|---|---|---| - -*Left: the Littleroot-style town (houses, pond, sign, NPCs, player). Middle: NPC dialogue in a palette-shaded, subpixel-covered textbox. Right: the `choose()` menu with cursor. All rendered by the C runtime from compiled PJGB data — no JS on the cartridge.* - -## Architecture - -``` -Source TS/TSX (@pocketjs/aot DSL) - -> evaluate static JSX/declaration zone is EXECUTED at build time (compiler/evaluate.ts) - -> bake tilesets/sprites/font -> GBA 4bpp tiles + BGR555 pals (compiler/bake.ts) - -> residualize script(function*(){...}) ASTs -> stack-VM bytecode (compiler/script.ts) - -> model JSX scene trees -> concrete maps/actors/warps (compiler/model.ts) - -> lower validate + emit PJGB chunks (compiler/lower.ts) - -> pack chunks -> PJGB cartridge blob (compiler/pack.ts) - -> rom link the fixed C runtime with arm-none-eabi-gcc -> .gba (compiler/rom.ts) - -> mGBA headless libmgba harness drives input + asserts state (test/) -``` - -The **two zones** (design §8): - -- **Static declaration zone** — `defineGame`/`defineMap`/``/``/``… is *executed* at build time. JSX builds a scene tree, not a UI tree; pure components expand and never reach the GBA. -- **Residual script zone** — `script(function*(){ yield say(...) })` is *never executed*. The compiler reads the generator AST and lowers supported statements to bytecode, folding static values and residualizing runtime values (flags, choices, battle results) into branches. - -## The DSL - -```tsx -/** @jsxImportSource @pocketjs/aot */ -import { ascii, defineGame, defineMap, tile, - Entrance, Npc, PlayerSpawn, Sign, Warp, - script, say, choose, hasFlag, setFlag, battle, giveItem, - lockPlayer, releasePlayer, facePlayer } from "@pocketjs/aot"; -import { hero, town } from "./assets"; - -const RivalTalk = script(function* () { - yield lockPlayer(); - yield facePlayer("rival"); - if (yield hasFlag("beat_rival_1")) { - yield say("The road ahead is tougher than it looks."); - } else { - yield say("You made it! Want to test your first build?"); - switch (yield choose(["Battle", "Maybe later"] as const)) { - case "Battle": - yield battle("rival_1"); - yield setFlag("beat_rival_1"); - yield giveItem("potion", 1); - yield say("Take this Potion. You will need it."); - break; - case "Maybe later": - yield say("No problem. I will be right here."); - break; - } - } - yield releasePlayer(); -}); - -function LittlerootEntities() { - return ( - <> - - - - - - - ); -} - -const Littleroot = defineMap("littleroot") - .tileset(town) - .layer( - ascii` - ######## - #......# - #..HH..# - ######## - `.legend({ - "#": tile("tree"), - ".": tile("grass"), - H: tile("wall"), - }), - ) - .entities() - .done(); - -export default defineGame({ title: "POCKET TOWN", start: "littleroot:spawn", maps: [Littleroot] }); -``` - -Tile layers stay on the builder path so `.tileset(town).layer(...)` can type-check -legend tile names against the selected tileset. JSX is used one layer later for -build-time scene prefabs: pure components expand into static `Npc`/`Sign`/`Warp` -nodes and never ship to the cartridge. - -See `demo/game.tsx` (the town + route), `demo/assets.ts` (DSL declarations), and `demo/imagegen/` (the source sheet plus deterministic GBA 4bpp extractor). - -## Binary contract - -`spec/pjgb.ts` is the single source of truth for the **PJGB cartridge format**, the **script VM ISA**, and the **debug block**. `spec/gen-c.ts` generates `runtime/pjgb_gen.h` so the C runtime can never drift from the TS compiler (mirrors the repo's `spec/gen-rust.ts` convention). The runtime reads fixed-width tables and offsets — it never parses JSON. - -## The GBA runtime (`runtime/`) - -A small, no-allocation native engine in C: bare-metal `crt0.s` + `gba.ld`, a chunk loader, Mode-0 tiled BG + hardware-sprite OAM with VBlank DMA, grid movement/collision, a suspendable stack-VM (`script_vm.c`), and a subpixel-covered tile textbox/choice menu. It writes live game state into a fixed EWRAM debug block so the emulator harness can assert without symbols. Cross-compiles with `arm-none-eabi-gcc`; boots in mGBA (and is structured for real hardware, pending a Nintendo-logo header pass — see design §26.6). - -## Build & test - -```bash -# prerequisites: bun, arm-none-eabi-gcc + binutils, mgba (libmgba) -bun aot/spec/gen-c.ts # regenerate runtime/pjgb_gen.h -bash aot/test/harness/build.sh # build the headless mGBA runner (once) -cd aot -bun run build # refresh imagegen assets + build ROM -bun run test # refresh assets + run the headless mGBA E2E suite -``` - -Outputs: `dist/pocket-town.gba`, `.pjgb` (cartridge blob), `.ir.json` (inspectable IR), `.debug.json` (flag/text/map symbol map for the harness). - -## v1 scope & follow-ups - -Deliberately narrow (design §5, §26): tile-based overworld with scripts. v1 uses 8×8 BG tiles (16×16 metatiles are a documented follow-up), one tileset per game, 16×16 sprites, ≤32×32 maps (single screenblock), and stubbed `battle`/`giveItem`. Not yet: TMX/Aseprite import, audio, save media, and the real-hardware header/logo pass. diff --git a/aot/compiler/bake.ts b/aot/compiler/bake.ts deleted file mode 100644 index f823a16e..00000000 --- a/aot/compiler/bake.ts +++ /dev/null @@ -1,119 +0,0 @@ -// aot/compiler/bake.ts — Stage 7a: lower tilesets/sprites/font to GBA 4bpp -// tiles + BGR555 palettes. Fills ctx.bgTiles/objTiles/palettes/tileNameToId/ -// spriteProtos/fontBase/boxTile. - -import { rgb555 } from "../spec/pjgb.ts"; -import { FIRST_CHAR, LAST_CHAR, glyphPixels } from "./font.ts"; -import type { Ctx } from "./context.ts"; -import type { Registry } from "../dsl/index.ts"; - -// GBA 4bpp tile: 32 bytes, 4 bytes/row, low nibble = left pixel. -export function tile4(px: number[]): Uint8Array { - const out = new Uint8Array(32); - for (let row = 0; row < 8; row++) { - for (let c = 0; c < 4; c++) { - const lo = px[row * 8 + c * 2] & 0xf; - const hi = px[row * 8 + c * 2 + 1] & 0xf; - out[row * 4 + c] = lo | (hi << 4); - } - } - return out; -} - -function parseRows(rows: readonly string[], w: number, h: number): number[] { - if (rows.length !== h) throw new Error(`tile grid: expected ${h} rows, got ${rows.length}`); - const px: number[] = []; - for (let y = 0; y < h; y++) { - const r = rows[y]; - if (r.length !== w) throw new Error(`tile grid row ${y}: expected ${w} cols, got ${r.length} ("${r}")`); - for (let x = 0; x < w; x++) px.push(parseInt(r[x], 16) & 0xf); - } - return px; -} - -// Textbox palette (BG bank 15): 1..5 are subpixel coverage ink shades, -// 6 is the opaque textbox background. -const TEXT_INK_START = 1; -const TEXT_INK_LEVELS = 5; -const TEXT_BG = 6; - -export function bake(ctx: Ctx, registry: Registry): void { - const game = registry.game!; - // v1: every map shares one tileset. - const tilesetNames = new Set(registry.maps.map((m) => m.tileset)); - if (tilesetNames.size !== 1) { - throw new Error(`v1 supports one tileset per game (found: ${[...tilesetNames].join(", ")})`); - } - const tileset = registry.tilesets.get([...tilesetNames][0]); - if (!tileset) throw new Error(`tileset "${[...tilesetNames][0]}" not defined`); - - // --- BG tiles: blank, tileset tiles, font glyphs, box fill --- - ctx.bgTiles.push(tile4(new Array(64).fill(0))); // id 0 blank - for (const [name, decl] of Object.entries(tileset.tiles)) { - const px = parseRows(decl.px, 8, 8); - ctx.tileNameToId.set(name, ctx.bgTiles.length); - ctx.bgTiles.push(tile4(px)); - } - - ctx.fontBase = ctx.bgTiles.length; - for (let ch = FIRST_CHAR; ch <= LAST_CHAR; ch++) { - ctx.bgTiles.push(tile4(glyphPixels(ch, TEXT_INK_START, TEXT_BG, TEXT_INK_LEVELS))); - } - ctx.boxTile = ctx.bgTiles.length; - ctx.bgTiles.push(tile4(new Array(64).fill(TEXT_BG))); - - // --- BG palette: bank 0 = tileset; bank 15 = textbox --- - tileset.palette.forEach((rgb, i) => { - if (i < 16) ctx.bgPalette[i] = rgb555(rgb[0], rgb[1], rgb[2]); - }); - ctx.bgPalette[240 + 0] = rgb555(0, 0, 0); - ctx.bgPalette[240 + 1] = rgb555(76, 88, 132); - ctx.bgPalette[240 + 2] = rgb555(116, 128, 168); - ctx.bgPalette[240 + 3] = rgb555(160, 170, 204); - ctx.bgPalette[240 + 4] = rgb555(206, 214, 238); - ctx.bgPalette[240 + 5] = rgb555(248, 248, 248); - ctx.bgPalette[240 + TEXT_BG] = rgb555(24, 32, 72); - - // --- sprites: OBJ tiles + palette bank per sprite --- - let spriteIdx = 0; - for (const [name, decl] of registry.sprites) { - const [w, h] = decl.size; - if (w !== 16 || h !== 16) throw new Error(`v1 sprites must be 16x16 ("${name}" is ${w}x${h})`); - // v1 gives each sprite its own OBJ palette bank; hardware has only 16. - if (spriteIdx >= 16) throw new Error(`v1 supports at most 16 sprites (one OBJ palette bank each); "${name}" is #${spriteIdx}`); - const palbank = spriteIdx; // one OBJ palette bank per sprite - decl.palette.forEach((rgb, i) => { - if (i < 16) ctx.objPalette[palbank * 16 + i] = rgb555(rgb[0], rgb[1], rgb[2]); - }); - const tileBase = ctx.objTiles.length; - const dirs = ["down", "up", "left", "right"] as const; - const frames = decl.facings.down.length; - for (const d of dirs) { - const fr = decl.facings[d]; - if (fr.length !== frames) throw new Error(`sprite "${name}" facing ${d}: frame count mismatch`); - for (const frame of fr) { - const grid = parseRows(frame, 16, 16); - // split 16x16 into 4 tiles: TL, TR, BL, BR (1D OBJ mapping order) - for (const [ox, oy] of [ - [0, 0], - [8, 0], - [0, 8], - [8, 8], - ]) { - const t: number[] = []; - for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) t.push(grid[(oy + y) * 16 + (ox + x)]); - ctx.objTiles.push(tile4(t)); - } - } - } - ctx.spriteProtos.push({ name, id: spriteIdx, w, h, palbank, frames, tileBase }); - ctx.spriteIds.set(name, spriteIdx); - spriteIdx++; - } - - // Pre-seed declared flags/items/battles/vars so ids are stable. - (game.flags ?? []).forEach((f) => ctx.flagId(f)); - (game.vars ?? []).forEach((v) => ctx.varIdOf(v)); - (game.items ?? []).forEach((it) => ctx.items.intern(it)); - (game.battles ?? []).forEach((b) => ctx.battles.intern(b)); -} diff --git a/aot/compiler/cli.ts b/aot/compiler/cli.ts deleted file mode 100644 index 4740f010..00000000 --- a/aot/compiler/cli.ts +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bun -// aot/compiler/cli.ts — `pocket-aot build src/game.tsx --out dist/game.gba` -// (design §21). Also emits ir.json / debug.json for the emulator test harness. - -import { resolve } from "node:path"; -import { compile, debugInfo, irJson } from "./index.ts"; -import { buildRom } from "./rom.ts"; - -function usage(): never { - console.error("usage: pocket-aot build [--out ] [--no-rom]"); - process.exit(2); -} - -const [cmd, entryArg, ...rest] = process.argv.slice(2); -if (cmd !== "build" || !entryArg) usage(); - -let out = "aot/dist/pocket-town.gba"; -let doRom = true; -for (let i = 0; i < rest.length; i++) { - if (rest[i] === "--out") out = rest[++i]; - else if (rest[i] === "--no-rom") doRom = false; - else usage(); -} - -const entry = resolve(entryArg); -const outAbs = resolve(out); -const base = outAbs.replace(/\.gba$/, ""); - -const t0 = performance.now(); -const built = await compile(entry); -const di = debugInfo(built) as Record; - -await Bun.write(base + ".ir.json", JSON.stringify(irJson(built), null, 2)); -await Bun.write(base + ".debug.json", JSON.stringify(di, null, 2)); -await Bun.write(base + ".pjgb", built.blob); - -console.log(`PocketJS-AOT build: ${built.game.title}`); -console.log(` maps: ${built.model.maps.length}`); -console.log(` scripts: ${built.ctx.scripts.length} texts: ${built.ctx.texts.size} flags: ${built.ctx.flags.size}`); -console.log(` BG tiles: ${built.ctx.bgTiles.length} OBJ tiles: ${built.ctx.objTiles.length} sprites: ${built.ctx.spriteProtos.length}`); -console.log(` cartridge: ${built.blob.length} bytes`); - -if (doRom) { - const r = await buildRom(built.blob, outAbs); - console.log(` ROM: ${r.gba} (${r.size} bytes)`); -} -console.log(` done in ${(performance.now() - t0).toFixed(0)}ms`); diff --git a/aot/compiler/context.ts b/aot/compiler/context.ts deleted file mode 100644 index 62d85341..00000000 --- a/aot/compiler/context.ts +++ /dev/null @@ -1,87 +0,0 @@ -// aot/compiler/context.ts — shared compile state (interned banks + id maps) -// threaded through bake -> model -> script -> ir. - -class NameInterner { - private m = new globalThis.Map(); - private _list: string[] = []; - intern(name: string): number { - let id = this.m.get(name); - if (id === undefined) { - id = this._list.length; - this.m.set(name, id); - this._list.push(name); - } - return id; - } - get(name: string): number | undefined { - return this.m.get(name); - } - list(): readonly string[] { - return this._list; - } - get size(): number { - return this._list.length; - } -} - -export interface SpriteProto { - name: string; - id: number; - w: number; - h: number; - palbank: number; - frames: number; - tileBase: number; // OBJ tile index of first tile -} - -export interface ScriptOut { - id: number; - name: string; - bytecode: number[]; -} - -export class Ctx { - texts = new NameInterner(); - flags = new NameInterner(); - vars = new NameInterner(); - items = new NameInterner(); - battles = new NameInterner(); - - // filled by bake - bgPalette = new Uint16Array(256); - objPalette = new Uint16Array(256); - bgTiles: Uint8Array[] = []; - objTiles: Uint8Array[] = []; - tileNameToId = new globalThis.Map(); - spriteProtos: SpriteProto[] = []; - spriteIds = new globalThis.Map(); - fontBase = 0; - boxTile = 0; - - // filled by model - mapIndex = new globalThis.Map(); - - // filled by script (+ synthetic sign scripts) - scripts: ScriptOut[] = []; - - internText(s: string): number { - return this.texts.intern(s); - } - flagId(name: string): number { - return this.flags.intern(name); - } - varIdOf(name: string): number { - return this.vars.intern(name); - } - spriteId(name: string): number { - const id = this.spriteIds.get(name); - if (id === undefined) throw new Error(`unknown sprite "${name}"`); - return id; - } - /** Allocate the next script id (dense, appended after AST scripts). */ - addScript(name: string, bytecode: number[]): number { - const id = this.scripts.length; - this.scripts.push({ id, name, bytecode }); - return id; - } -} diff --git a/aot/compiler/evaluate.ts b/aot/compiler/evaluate.ts deleted file mode 100644 index 7598ec81..00000000 --- a/aot/compiler/evaluate.ts +++ /dev/null @@ -1,112 +0,0 @@ -// aot/compiler/evaluate.ts — Stage 3: static declaration evaluation (design §11.3). -// -// The static/JSX zone is EXECUTED at build time; the residual script zone is -// NOT. We bridge the two by rewriting every `script(function*(){...})` call to -// `script()` (recording the generator AST for the residualizer), then -// transpiling + importing the rewritten module so the DSL builders fill the -// shared REGISTRY. - -import ts from "typescript"; -import { pathToFileURL } from "node:url"; - -const DSL_DIR = new URL("../dsl", import.meta.url).pathname; // for jsxImportSource -const DSL_INDEX = new URL("../dsl/index.ts", import.meta.url).pathname; - -export interface ScriptSite { - id: number; - body: ts.FunctionExpression | ts.ArrowFunction; - file: ts.SourceFile; -} - -export interface EvalResult { - registry: import("../dsl/index.ts").Registry; - scripts: ScriptSite[]; - checker: ts.TypeChecker; -} - -/** Find `script(function*(){...})` calls; returns them in source order. */ -function findScriptCalls(sf: ts.SourceFile): ts.CallExpression[] { - const out: ts.CallExpression[] = []; - const visit = (n: ts.Node): void => { - if ( - ts.isCallExpression(n) && - ts.isIdentifier(n.expression) && - n.expression.text === "script" && - n.arguments.length === 1 - ) { - out.push(n); - return; // do not recurse into the generator body (no nested script()) - } - ts.forEachChild(n, visit); - }; - visit(sf); - // deterministic: by start position - out.sort((a, b) => a.getStart() - b.getStart()); - return out; -} - -export async function evaluateGame(entryPath: string): Promise { - const source = await Bun.file(entryPath).text(); - const sf = ts.createSourceFile(entryPath, source, ts.ScriptTarget.ES2020, true, ts.ScriptKind.TSX); - - const calls = findScriptCalls(sf); - const scripts: ScriptSite[] = []; - // Rewrite generator args -> id numbers, from the end to preserve offsets. - let rewritten = source; - const edits: { start: number; end: number; text: string }[] = []; - calls.forEach((call, id) => { - const arg = call.arguments[0]; - if (!ts.isFunctionExpression(arg) && !ts.isArrowFunction(arg)) { - throw new Error(`script() argument must be a function expression (script #${id})`); - } - scripts.push({ id, body: arg, file: sf }); - edits.push({ start: arg.getStart(sf), end: arg.getEnd(), text: String(id) }); - }); - edits.sort((a, b) => b.start - a.start); - for (const e of edits) rewritten = rewritten.slice(0, e.start) + e.text + rewritten.slice(e.end); - - // Point the @pocketjs/aot import at the concrete DSL module so the executed - // module shares REGISTRY with the compiler. - rewritten = rewritten.replace(/(["'])@pocketjs\/aot\1/g, JSON.stringify(DSL_INDEX)); - - const js = ts.transpileModule(rewritten, { - fileName: entryPath, - compilerOptions: { - jsx: ts.JsxEmit.ReactJSX, - jsxImportSource: DSL_DIR, - module: ts.ModuleKind.ESNext, - target: ts.ScriptTarget.ES2020, - esModuleInterop: true, - }, - }).outputText - .replace(/(["'])@pocketjs\/aot\/jsx-runtime\1/g, JSON.stringify(DSL_DIR + "/jsx-runtime.ts")) - .replace(/(["'])@pocketjs\/aot\/jsx-dev-runtime\1/g, JSON.stringify(DSL_DIR + "/jsx-dev-runtime.ts")); - - // Execute: write to a sibling temp file so relative/abs imports resolve, then - // import it. Import the DSL via the SAME absolute path to share REGISTRY. - const dsl = (await import(DSL_INDEX)) as typeof import("../dsl/index.ts"); - dsl.__resetRegistry(); - - const tmp = entryPath + `.__pjgb.${process.pid}.mjs`; - await Bun.write(tmp, js); - try { - await import(pathToFileURL(tmp).href + `?t=${Date.now()}`); - } finally { - await Bun.file(tmp) - .exists() - .then((e) => (e ? Bun.$`rm -f ${tmp}`.quiet() : null)) - .catch(() => {}); - } - - const registry = dsl.__getRegistry(); - if (!registry.game) throw new Error("no defineGame() found in " + entryPath); - - // A throwaway program just to expose a checker for the residualizer. - const program = ts.createProgram([entryPath], { - jsx: ts.JsxEmit.Preserve, - allowJs: true, - noEmit: true, - skipLibCheck: true, - }); - return { registry, scripts, checker: program.getTypeChecker() }; -} diff --git a/aot/compiler/font.ts b/aot/compiler/font.ts deleted file mode 100644 index 026e01fa..00000000 --- a/aot/compiler/font.ts +++ /dev/null @@ -1,183 +0,0 @@ -// aot/compiler/font.ts — compile-time Inter glyph rasterizer for GBA dialogue. -// -// Runtime text remains tile-based, but the tile pixels are no longer a 1-bit -// bitmap font. Each glyph is rasterized from Inter outlines with horizontally -// biased supersampling, then quantized into several palette coverage levels. - -import { readFileSync } from "node:fs"; -import { parse as parseFont, type Font, type Path } from "opentype.js"; - -/** First encoded code point (space). */ -export const FIRST_CHAR = 0x20; -/** Last encoded code point (tilde). */ -export const LAST_CHAR = 0x7e; -/** Glyph dimensions: one GBA tile. */ -export const GLYPH_WIDTH = 8; -export const GLYPH_HEIGHT = 8; -/** Number of glyphs in the table. */ -export const GLYPH_COUNT = LAST_CHAR - FIRST_CHAR + 1; - -const FONT_PATH = new URL("../../assets/fonts/Inter-Bold.ttf", import.meta.url).pathname; -const FONT_SIZE = 7.8; -const BASELINE = 6.85; -const PEN_X = 0.25; -const CURVE_STEPS = 8; -const SS_X = 9; -const SS_Y = 3; - -type Pt = { x: number; y: number }; -type Contour = Pt[]; - -let font: Font | null = null; -const coverageCache = new Map>(); - -function loadFont(): Font { - if (!font) { - const buf = readFileSync(FONT_PATH); - const ab = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); - font = parseFont(ab); - } - return font; -} - -/** Flatten an opentype path (already in y-down px space) into closed contours. */ -function flatten(path: Path): Contour[] { - const contours: Contour[] = []; - let cur: Contour = []; - let sx = 0; - let sy = 0; - let cx = 0; - let cy = 0; - const close = () => { - if (cur.length > 1) contours.push(cur); - cur = []; - }; - for (const cmd of path.commands) { - switch (cmd.type) { - case "M": - close(); - sx = cx = cmd.x; - sy = cy = cmd.y; - cur.push({ x: cx, y: cy }); - break; - case "L": - cx = cmd.x; - cy = cmd.y; - cur.push({ x: cx, y: cy }); - break; - case "Q": - for (let i = 1; i <= CURVE_STEPS; i++) { - const t = i / CURVE_STEPS; - const u = 1 - t; - cur.push({ - x: u * u * cx + 2 * u * t * cmd.x1 + t * t * cmd.x, - y: u * u * cy + 2 * u * t * cmd.y1 + t * t * cmd.y, - }); - } - cx = cmd.x; - cy = cmd.y; - break; - case "C": - for (let i = 1; i <= CURVE_STEPS; i++) { - const t = i / CURVE_STEPS; - const u = 1 - t; - cur.push({ - x: u * u * u * cx + 3 * u * u * t * cmd.x1 + 3 * u * t * t * cmd.x2 + t * t * t * cmd.x, - y: u * u * u * cy + 3 * u * u * t * cmd.y1 + 3 * u * t * t * cmd.y2 + t * t * t * cmd.y, - }); - } - cx = cmd.x; - cy = cmd.y; - break; - case "Z": - cur.push({ x: sx, y: sy }); - cx = sx; - cy = sy; - close(); - break; - } - } - close(); - return contours; -} - -function rasterize(contours: Contour[]): Uint8Array { - const out = new Uint8Array(GLYPH_WIDTH * GLYPH_HEIGHT); - if (contours.length === 0) return out; - - const sw = GLYPH_WIDTH * SS_X; - const samplesPerPixel = SS_X * SS_Y; - const counts = new Uint16Array(GLYPH_WIDTH); - const xs: number[] = []; - for (let row = 0; row < GLYPH_HEIGHT; row++) { - counts.fill(0); - for (let sub = 0; sub < SS_Y; sub++) { - const y = row + (sub + 0.5) / SS_Y; - xs.length = 0; - for (const c of contours) { - for (let i = 0; i < c.length - 1; i++) { - const p0 = c[i]; - const p1 = c[i + 1]; - if ((p0.y <= y && p1.y > y) || (p1.y <= y && p0.y > y)) { - xs.push(p0.x + ((y - p0.y) * (p1.x - p0.x)) / (p1.y - p0.y)); - } - } - } - if (xs.length < 2) continue; - xs.sort((a, b) => a - b); - for (let k = 0; k + 1 < xs.length; k += 2) { - let s0 = Math.ceil(xs[k] * SS_X - 0.5); - let s1 = Math.floor(xs[k + 1] * SS_X - 0.5); - if (s0 < 0) s0 = 0; - if (s1 >= sw) s1 = sw - 1; - for (let s = s0; s <= s1; s++) { - const center = (s + 0.5) / SS_X; - if (center >= xs[k] && center < xs[k + 1]) counts[(s / SS_X) | 0]++; - } - } - } - for (let col = 0; col < GLYPH_WIDTH; col++) { - out[row * GLYPH_WIDTH + col] = Math.round((counts[col] * 255) / samplesPerPixel); - } - } - return out; -} - -function glyphCoverage(c: number): Uint8Array { - const cached = coverageCache.get(c); - if (cached) return cached; - - let coverage: Uint8Array = new Uint8Array(GLYPH_WIDTH * GLYPH_HEIGHT); - if (c >= FIRST_CHAR && c <= LAST_CHAR && c !== 0x20) { - const f = loadFont(); - const glyphIndex = f.charToGlyphIndex(String.fromCharCode(c)); - if (glyphIndex > 0) { - const glyph = f.glyphs.get(glyphIndex); - coverage = rasterize(flatten(glyph.getPath(PEN_X, BASELINE, FONT_SIZE))); - } - } - coverageCache.set(c, coverage); - return coverage; -} - -/** - * Rasterize char code `c` into an 8x8 GBA palette-index cell. - * - * `inkStart` is the first coverage shade. `levels` shades are used in ascending - * order from faint edge coverage to full ink. `bg` fills zero-coverage pixels. - */ -export function glyphPixels(c: number, inkStart: number, bg: number, levels = 1): number[] { - const cov = glyphCoverage(c); - const out = new Array(GLYPH_WIDTH * GLYPH_HEIGHT); - for (let i = 0; i < out.length; i++) { - const v = cov[i]; - if (v === 0 || levels <= 0) { - out[i] = bg; - } else { - const boosted = Math.pow(v / 255, 0.68); - const shade = Math.min(levels - 1, Math.max(0, Math.ceil(boosted * levels) - 1)); - out[i] = inkStart + shade; - } - } - return out; -} diff --git a/aot/compiler/index.ts b/aot/compiler/index.ts deleted file mode 100644 index a6d9b614..00000000 --- a/aot/compiler/index.ts +++ /dev/null @@ -1,90 +0,0 @@ -// aot/compiler/index.ts — the @pocketjs/aot compile pipeline (design §11). -// Source TS/TSX -// -> evaluate (static JSX zone) -> Registry + script ASTs -// -> bake (assets) -> tiles/palettes/sprites/font -// -> script residualizer -> bytecode -// -> model -> concrete maps/actors/warps -// -> lower (+ validate) -> PJGB chunks -// -> pack -> cartridge blob - -import { evaluateGame } from "./evaluate.ts"; -import { Ctx } from "./context.ts"; -import { bake } from "./bake.ts"; -import { compileScript } from "./script.ts"; -import { buildModel, type GameModel } from "./model.ts"; -import { lower } from "./lower.ts"; -import { packCart, type Chunk } from "./pack.ts"; -import { DBG, DEBUG_ADDR } from "../spec/pjgb.ts"; -import type { GameDecl } from "../dsl/index.ts"; - -export interface CompileOutput { - ctx: Ctx; - model: GameModel; - chunks: Chunk[]; - blob: Uint8Array; - game: GameDecl; -} - -export async function compile(entry: string): Promise { - const ev = await evaluateGame(entry); - const ctx = new Ctx(); - bake(ctx, ev.registry); - - // AST scripts occupy ids 0..N-1 (matching the ScriptRefs the actors carry). - for (const site of ev.scripts) { - const bc = compileScript(site, ctx); - const id = ctx.addScript(`script_${site.id}`, bc); - if (id !== site.id) throw new Error(`internal: script id ${id} != site ${site.id}`); - } - - const model = buildModel(ctx, ev.registry); // sign scripts append at ids N+ - const chunks = lower(ctx, model, ev.registry.game!); - const blob = packCart(chunks); - return { ctx, model, chunks, blob, game: ev.registry.game! }; -} - -/** Debug map for the emulator test harness: names -> ids/addresses. */ -export function debugInfo(out: CompileOutput): unknown { - const { ctx, model } = out; - const flags: Record = {}; - ctx.flags.list().forEach((name, id) => { - flags[name] = { id, byteAddr: DEBUG_ADDR + DBG.FLAGS + (id >> 3), bit: id & 7 }; - }); - const maps: Record = {}; - ctx.mapIndex.forEach((i, name) => (maps[name] = i)); - return { - title: out.game.title, - start: model.start, - debugAddr: DEBUG_ADDR, - fields: DBG, - flags, - maps, - texts: ctx.texts.list(), - scripts: ctx.scripts.map((s) => ({ id: s.id, name: s.name, bytes: s.bytecode.length })), - sprites: ctx.spriteProtos, - bgTiles: ctx.bgTiles.length, - objTiles: ctx.objTiles.length, - blobSize: out.blob.length, - }; -} - -/** Compact IR snapshot for `dist/game.ir.json` (design §11.8). */ -export function irJson(out: CompileOutput): unknown { - return { - title: out.game.title, - start: out.model.start, - maps: out.model.maps.map((m) => ({ - name: m.name, - index: m.index, - size: [m.w, m.h], - actors: m.actors.map((a) => ({ name: a.name, at: [a.x, a.y], sprite: a.spriteId, onTalk: a.onTalk })), - warps: m.warps.map((w) => ({ at: [w.x, w.y], to: `${w.destMap}:${w.destEntrance}`, dest: [w.destMapIdx, w.destX, w.destY] })), - entrances: [...m.entrances.entries()], - })), - scripts: out.ctx.scripts.map((s) => ({ id: s.id, name: s.name, bytes: s.bytecode.length })), - texts: out.ctx.texts.list(), - flags: out.ctx.flags.list(), - }; -} - -export { packCart }; diff --git a/aot/compiler/lower.ts b/aot/compiler/lower.ts deleted file mode 100644 index d9bbc505..00000000 --- a/aot/compiler/lower.ts +++ /dev/null @@ -1,136 +0,0 @@ -// aot/compiler/lower.ts — Stage 6+7: validate the Game IR, then lower it to -// PJGB chunks (design §11.6-11.7). The GameModel + Ctx together are the IR. - -import { - BUDGET, - ByteWriter, - CHUNK, - GAME_TITLE_LEN, -} from "../spec/pjgb.ts"; -import type { Ctx } from "./context.ts"; -import type { GameModel } from "./model.ts"; -import type { GameDecl } from "../dsl/index.ts"; -import type { Chunk } from "./pack.ts"; - -export function validate(ctx: Ctx, model: GameModel): void { - const err: string[] = []; - if (model.maps.length > BUDGET.MAX_MAPS) err.push(`too many maps (${model.maps.length} > ${BUDGET.MAX_MAPS})`); - if (ctx.spriteProtos.length > BUDGET.MAX_SPRITES) err.push(`too many sprites`); - if (ctx.flags.size > BUDGET.MAX_FLAGS) err.push(`too many flags (${ctx.flags.size} > ${BUDGET.MAX_FLAGS})`); - if (ctx.vars.size > BUDGET.MAX_VARS) err.push(`too many vars (${ctx.vars.size} > ${BUDGET.MAX_VARS})`); - if (ctx.texts.size > BUDGET.MAX_TEXTS) err.push(`too many texts`); - if (ctx.scripts.length > BUDGET.MAX_SCRIPTS) err.push(`too many scripts`); - if (ctx.bgTiles.length > BUDGET.MAX_BG_TILES) err.push(`BG tiles ${ctx.bgTiles.length} > ${BUDGET.MAX_BG_TILES}`); - if (ctx.objTiles.length > BUDGET.MAX_OBJ_TILES) err.push(`OBJ tiles ${ctx.objTiles.length} > ${BUDGET.MAX_OBJ_TILES}`); - for (const m of model.maps) { - if (m.w > 32 || m.h > 32) err.push(`map "${m.name}" ${m.w}x${m.h} exceeds 32x32 (v1 single-screenblock limit)`); - if (m.actors.length > BUDGET.MAX_ACTORS_PER_MAP) err.push(`map "${m.name}" has ${m.actors.length} actors`); - for (const a of m.actors) { - if (a.onTalk !== 0xffff && a.onTalk >= ctx.scripts.length) err.push(`actor "${a.name}" -> bad script ${a.onTalk}`); - } - for (const wp of m.warps) { - if (wp.destMapIdx === undefined) err.push(`unresolved warp on "${m.name}"`); - } - } - if (err.length) throw new Error("IR validation failed:\n - " + err.join("\n - ")); -} - -function u16buf(a: Uint16Array): Uint8Array { - const w = new ByteWriter(); - for (const v of a) w.u16(v); - return w.toUint8Array(); -} -function catTiles(ts: Uint8Array[]): Uint8Array { - const out = new Uint8Array(ts.length * 32); - ts.forEach((t, i) => out.set(t, i * 32)); - return out; -} - -function gameHeader(ctx: Ctx, model: GameModel, game: GameDecl): Uint8Array { - const w = new ByteWriter(); - w.ascii(game.title, GAME_TITLE_LEN); - w.u8(model.start.map).u8(model.start.dir).u16(model.start.x).u16(model.start.y); - w.u8(model.maps.length).u8(ctx.spriteProtos.length); - w.u16(ctx.flags.size).u16(ctx.texts.size).u16(ctx.scripts.length); - w.u16(ctx.fontBase).u16(ctx.boxTile).u16(0); - return w.toUint8Array(); -} - -function spriteTable(ctx: Ctx): Uint8Array { - const w = new ByteWriter(); - for (const s of ctx.spriteProtos) { - w.u16(s.tileBase).u8(s.w).u8(s.h).u8(s.palbank).u8(s.frames).u16(0); - } - return w.toUint8Array(); -} - -function mapChunk(m: GameModel["maps"][number]): Uint8Array { - const HDR = 28; - const tilesOff = HDR; - const collOff = tilesOff + m.tiles.length * 2; - const actorsOff = (collOff + m.collision.length + 3) & ~3; - const warpsOff = actorsOff + m.actors.length * 12; - const w = new ByteWriter(); - w.u16(m.w).u16(m.h).u16(m.actors.length).u16(m.warps.length); - w.u8(m.palbank).u8(0xff).u16(0); - w.u32(tilesOff).u32(collOff).u32(actorsOff).u32(warpsOff); - for (const t of m.tiles) w.u16(t); - for (const c of m.collision) w.u8(c); - w.align4(); - for (const a of m.actors) { - w.u16(a.x).u16(a.y).u8(a.spriteId).u8(a.facing).u8(a.movement).u8(a.flags).u16(a.onTalk).u16(0); - } - for (const wp of m.warps) { - w.u16(wp.x).u16(wp.y).u8(wp.destMapIdx!).u8(wp.destDir!).u16(wp.destX!).u16(wp.destY!).u16(0); - } - return w.toUint8Array(); -} - -function textBank(ctx: Ctx): Uint8Array { - const strs = ctx.texts.list(); - const enc = strs.map((s) => { - const b = new Uint8Array(s.length + 1); - for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i) & 0x7f; - return b; - }); - const headerSize = 4 + strs.length * 4; - let cur = headerSize; - const offsets = enc.map((b) => { - const o = cur; - cur += b.length; - return o; - }); - const w = new ByteWriter(); - w.u16(strs.length).u16(0); - for (const o of offsets) w.u32(o); - for (const b of enc) w.bytes(b); - return w.toUint8Array(); -} - -function scriptChunks(ctx: Ctx): { code: Uint8Array; table: Uint8Array } { - const code = new ByteWriter(); - const table = new ByteWriter(); - for (const s of ctx.scripts) { - table.u32(code.length); - code.bytes(s.bytecode); - } - return { code: code.toUint8Array(), table: table.toUint8Array() }; -} - -export function lower(ctx: Ctx, model: GameModel, game: GameDecl): Chunk[] { - validate(ctx, model); - const { code, table } = scriptChunks(ctx); - const chunks: Chunk[] = [ - { kind: CHUNK.GAME, id: 0, data: gameHeader(ctx, model, game) }, - { kind: CHUNK.PAL_BG, id: 0, data: u16buf(ctx.bgPalette) }, - { kind: CHUNK.PAL_OBJ, id: 0, data: u16buf(ctx.objPalette) }, - { kind: CHUNK.TILES_BG, id: 0, data: catTiles(ctx.bgTiles) }, - { kind: CHUNK.TILES_OBJ, id: 0, data: catTiles(ctx.objTiles) }, - { kind: CHUNK.SPRITE_TABLE, id: 0, data: spriteTable(ctx) }, - { kind: CHUNK.TEXT_BANK, id: 0, data: textBank(ctx) }, - { kind: CHUNK.SCRIPT_CODE, id: 0, data: code }, - { kind: CHUNK.SCRIPT_TABLE, id: 0, data: table }, - ]; - for (const m of model.maps) chunks.push({ kind: CHUNK.MAP, id: m.index, data: mapChunk(m) }); - return chunks; -} diff --git a/aot/compiler/model.ts b/aot/compiler/model.ts deleted file mode 100644 index 278f961e..00000000 --- a/aot/compiler/model.ts +++ /dev/null @@ -1,178 +0,0 @@ -// aot/compiler/model.ts — Stage 3b: normalize the executed JSX scene trees into -// a concrete GameModel (tile grids, collision, actors, warps, entrances). Signs -// expand into a solid tile + a synthetic one-line text script (design §11.3). - -import { DIR_NAMES, MOVE_NAMES, OP, ACTOR_FLAG, DIR } from "../spec/pjgb.ts"; -import type { Ctx } from "./context.ts"; -import type { PjgbNode, Registry } from "../dsl/index.ts"; - -const NO_SPRITE = 0xff; - -export interface ActorModel { - name: string; - x: number; - y: number; - spriteId: number; - facing: number; - movement: number; - flags: number; - onTalk: number; // script id or 0xffff -} -export interface WarpModel { - x: number; - y: number; - destMap: string; - destEntrance: string; - // resolved in a second pass: - destMapIdx?: number; - destX?: number; - destY?: number; - destDir?: number; -} -export interface MapModel { - name: string; - index: number; - w: number; - h: number; - tiles: number[]; - collision: number[]; - palbank: number; - actors: ActorModel[]; - warps: WarpModel[]; - entrances: globalThis.Map; -} -export interface GameModel { - maps: MapModel[]; - start: { map: number; x: number; y: number; dir: number }; -} - -const prop = (n: PjgbNode, k: string): unknown => n.props[k]; -const at = (n: PjgbNode): [number, number] => { - const a = n.props.at as [number, number] | undefined; - if (!a) throw new Error(`<${n.host}> missing at={[x,y]}`); - return a; -}; -const dirOf = (v: unknown, dflt = DIR.DOWN): number => - v == null ? dflt : DIR_NAMES[String(v)] ?? dflt; - -export function buildModel(ctx: Ctx, registry: Registry): GameModel { - const game = registry.game!; - registry.maps.forEach((m, i) => ctx.mapIndex.set(m.name, i)); - - const maps: MapModel[] = registry.maps.map((mapDecl, index) => { - const tileset = registry.tilesets.get(mapDecl.tileset)!; - const children = mapDecl.root.children; - const layer = children.find((c) => c.host === "Layer"); - if (!layer) throw new Error(`map "${mapDecl.name}" has no `); - - const rows = layer.props.rows as string[]; - const legend = layer.props.legend as Record; - const h = rows.length; - const w = rows[0].length; - const tiles: number[] = new Array(w * h).fill(0); - const collision: number[] = new Array(w * h).fill(0); - for (let y = 0; y < h; y++) { - if (rows[y].length !== w) throw new Error(`map "${mapDecl.name}" row ${y} width ${rows[y].length} != ${w}`); - for (let x = 0; x < w; x++) { - const name = legend[rows[y][x]]; - if (!name) throw new Error(`map "${mapDecl.name}": legend has no entry for "${rows[y][x]}"`); - const id = ctx.tileNameToId.get(name); - if (id === undefined) throw new Error(`map "${mapDecl.name}": tile "${name}" not in tileset`); - tiles[y * w + x] = id; - collision[y * w + x] = tileset.tiles[name].solid ? 1 : 0; - } - } - - const entrances = new globalThis.Map(); - const actors: ActorModel[] = []; - const warps: WarpModel[] = []; - - for (const c of children) { - switch (c.host) { - case "Layer": - break; - case "PlayerSpawn": - case "Entrance": { - const [x, y] = at(c); - const id = (prop(c, "id") as string) ?? (c.host === "PlayerSpawn" ? "spawn" : "entrance"); - entrances.set(id, { x, y, dir: dirOf(prop(c, "facing")) }); - break; - } - case "Npc": { - const [x, y] = at(c); - const ref = prop(c, "onTalk") as { __pjgbScript: number } | undefined; - actors.push({ - name: (prop(c, "id") as string) ?? `npc_${actors.length}`, - x, - y, - spriteId: ctx.spriteId(String(prop(c, "sprite"))), - facing: dirOf(prop(c, "facing")), - movement: MOVE_NAMES[String(prop(c, "movement") ?? "static")] ?? 0, - flags: ACTOR_FLAG.SOLID, - onTalk: ref ? ref.__pjgbScript : 0xffff, - }); - break; - } - case "Sign": { - const [x, y] = at(c); - const text = String(prop(c, "text") ?? ""); - // synthetic script: TEXT ; END - const textId = ctx.internText(text); - const bc = [OP.TEXT, textId & 0xff, (textId >> 8) & 0xff, OP.END]; - const sid = ctx.addScript(`sign_${index}_${x}_${y}`, bc); - const signTile = ctx.tileNameToId.get("sign"); - if (signTile !== undefined) { - tiles[y * w + x] = signTile; - } - collision[y * w + x] = 1; - actors.push({ - name: `sign_${x}_${y}`, - x, - y, - spriteId: NO_SPRITE, - facing: DIR.DOWN, - movement: 0, - flags: ACTOR_FLAG.SOLID, - onTalk: sid, - }); - break; - } - case "Warp": { - const [x, y] = at(c); - const to = String(prop(c, "to")); - const [destMap, destEntrance] = to.split(":"); - warps.push({ x, y, destMap, destEntrance: destEntrance ?? "spawn" }); - break; - } - default: - throw new Error(`map "${mapDecl.name}": unsupported element <${c.host}>`); - } - } - - return { name: mapDecl.name, index, w, h, tiles, collision, palbank: 0, actors, warps, entrances }; - }); - - // resolve warps against destination entrances - const byName = new globalThis.Map(maps.map((m) => [m.name, m])); - for (const m of maps) { - for (const wp of m.warps) { - const dm = byName.get(wp.destMap); - if (!dm) throw new Error(`warp on "${m.name}" -> unknown map "${wp.destMap}"`); - const ent = dm.entrances.get(wp.destEntrance); - if (!ent) throw new Error(`warp on "${m.name}" -> "${wp.destMap}" has no entrance "${wp.destEntrance}"`); - wp.destMapIdx = dm.index; - wp.destX = ent.x; - wp.destY = ent.y; - wp.destDir = ent.dir; - } - } - - // resolve start - const [startMap, startEnt] = game.start.split(":"); - const sm = byName.get(startMap); - if (!sm) throw new Error(`start map "${startMap}" not found`); - const se = sm.entrances.get(startEnt ?? "spawn"); - if (!se) throw new Error(`start "${game.start}": no entrance "${startEnt}"`); - - return { maps, start: { map: sm.index, x: se.x, y: se.y, dir: se.dir } }; -} diff --git a/aot/compiler/pack.ts b/aot/compiler/pack.ts deleted file mode 100644 index ebd73401..00000000 --- a/aot/compiler/pack.ts +++ /dev/null @@ -1,65 +0,0 @@ -// aot/compiler/pack.ts — assemble chunks into the PJGB container blob and emit -// it as a C byte array (gen_cart.c) for linking into the ROM. - -import { - ByteWriter, - PJGB_CHUNK_ENTRY_SIZE, - PJGB_HEADER_SIZE, - PJGB_MAGIC_BYTES, - PJGB_VERSION, -} from "../spec/pjgb.ts"; - -export interface Chunk { - kind: number; - id: number; - data: Uint8Array; -} - -const align4 = (n: number): number => (n + 3) & ~3; - -/** Pack chunks into a PJGB blob: [header][chunk table][chunk data...]. */ -export function packCart(chunks: Chunk[]): Uint8Array { - const tableOff = PJGB_HEADER_SIZE; - const dataStart = align4(tableOff + chunks.length * PJGB_CHUNK_ENTRY_SIZE); - - let off = dataStart; - const offsets = chunks.map((c) => { - const o = off; - off += align4(c.data.length); - return o; - }); - const total = off; - - const w = new ByteWriter(); - w.bytes(PJGB_MAGIC_BYTES).u16(PJGB_VERSION).u16(chunks.length).u32(tableOff).u32(total); - chunks.forEach((c, i) => { - w.u32(c.kind).u32(c.id).u32(offsets[i]).u32(c.data.length); - }); - w.align4(); - chunks.forEach((c) => { - w.bytes(c.data).align4(); - }); - - const out = w.toUint8Array(); - if (out.length !== total) { - throw new Error(`pack: size mismatch ${out.length} != ${total}`); - } - return out; -} - -/** Emit the blob as a linkable C source file. */ -export function emitCartC(blob: Uint8Array): string { - const rows: string[] = []; - for (let i = 0; i < blob.length; i += 16) { - const slice = Array.from(blob.slice(i, i + 16)).map((b) => `0x${b.toString(16).padStart(2, "0")}`); - rows.push(" " + slice.join(",") + ","); - } - return [ - "// GENERATED by @pocketjs/aot — the PJGB cartridge blob linked into the ROM.", - "const unsigned char __attribute__((aligned(4))) pjgb_cart[] = {", - ...rows, - "};", - `const unsigned int pjgb_cart_len = ${blob.length};`, - "", - ].join("\n"); -} diff --git a/aot/compiler/rom.ts b/aot/compiler/rom.ts deleted file mode 100644 index 57659fdd..00000000 --- a/aot/compiler/rom.ts +++ /dev/null @@ -1,54 +0,0 @@ -// aot/compiler/rom.ts — Stage 8: link the PJGB blob into a real .gba ROM. -// Emits gen_cart.c, cross-compiles the fixed C runtime with arm-none-eabi-gcc, -// objcopies to a raw ROM, and patches the GBA header complement checksum. - -import { $ } from "bun"; -import { emitCartC } from "./pack.ts"; - -const ROOT = new URL("../..", import.meta.url).pathname; // repo root -const RT = ROOT + "aot/runtime"; -const DIST = ROOT + "aot/dist"; - -/** GBA header complement checksum over bytes 0xA0..0xBC, written at 0xBD. */ -function patchHeaderChecksum(rom: Uint8Array): void { - if (rom.length < 0xc0) throw new Error("ROM too small for a GBA header"); - let sum = 0; - for (let a = 0xa0; a <= 0xbc; a++) sum += rom[a]; - rom[0xbd] = (-(0x19 + sum)) & 0xff; -} - -export interface BuildRomResult { - gba: string; - elf: string; - size: number; -} - -export async function buildRom(blob: Uint8Array, outPath: string): Promise { - await Bun.write(RT + "/gen_cart.c", emitCartC(blob)); - - const elf = DIST + "/game.elf"; - const CFLAGS = [ - "-mcpu=arm7tdmi", - "-marm", - "-ffreestanding", - "-nostdlib", - "-O2", - "-fno-strict-aliasing", - "-Wall", - ]; - const sources = [ - `${RT}/crt0.s`, - ...["cart", "video", "bg", "obj", "input", "map", "player", "actor", "camera", "script_vm", "textbox", "debug", "main", "gen_cart"].map( - (m) => `${RT}/${m}.c`, - ), - ]; - - await $`arm-none-eabi-gcc ${CFLAGS} -I${RT} -T${RT}/gba.ld ${sources} -lgcc -o ${elf}`.quiet(); - await $`arm-none-eabi-objcopy -O binary ${elf} ${outPath}`.quiet(); - - const rom = new Uint8Array(await Bun.file(outPath).arrayBuffer()); - patchHeaderChecksum(rom); - await Bun.write(outPath, rom); - - return { gba: outPath, elf, size: rom.length }; -} diff --git a/aot/compiler/script.ts b/aot/compiler/script.ts deleted file mode 100644 index 124cfc67..00000000 --- a/aot/compiler/script.ts +++ /dev/null @@ -1,286 +0,0 @@ -// aot/compiler/script.ts — Stage 4/5: compile script generator ASTs to bytecode -// (design §11.4-11.5). Static expressions fold; runtime values (flags, choices, -// battle results) residualize into branches over the stack VM. - -import ts from "typescript"; -import { OP } from "../spec/pjgb.ts"; -import type { Ctx } from "./context.ts"; -import type { ScriptSite } from "./evaluate.ts"; - -const FACE_SELF = 0xff; // FACE_PLAYER operand meaning "the actor that started me" - -class ScriptError extends Error { - constructor(node: ts.Node, sf: ts.SourceFile, msg: string) { - const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); - super(`PJGB script error at ${sf.fileName}:${line + 1}:${character + 1}: ${msg}\n ${node.getText(sf).slice(0, 80)}`); - } -} - -// Op wrappers that leave a value on the VM stack. -const VALUE_OPS = new Set(["hasFlag", "choose", "battle", "getVar"]); - -class Emitter { - code: number[] = []; - constructor(private ctx: Ctx, private sf: ts.SourceFile) {} - - private u8(v: number): void { - this.code.push(v & 0xff); - } - private u16(v: number): void { - this.code.push(v & 0xff, (v >> 8) & 0xff); - } - private i16(v: number): void { - this.u16(v & 0xffff); - } - /** Emit op+placeholder rel16; returns operand index to patch. */ - private emitJump(op: number): number { - this.u8(op); - const at = this.code.length; - this.i16(0); - return at; - } - private patch(at: number): void { - this.patchTo(at, this.code.length); - } - /** Patch a rel16 operand at `at` to jump to absolute code offset `target`. */ - private patchTo(at: number, target: number): void { - const rel = target - (at + 2); // rel is measured from AFTER the 2-byte operand - this.code[at] = rel & 0xff; - this.code[at + 1] = (rel >> 8) & 0xff; - } - - // --- static evaluation --------------------------------------------------- - private staticVal(node: ts.Expression): unknown { - if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) return this.staticVal(node.expression); - if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text; - if (ts.isNumericLiteral(node)) return Number(node.text); - if (node.kind === ts.SyntaxKind.TrueKeyword) return true; - if (node.kind === ts.SyntaxKind.FalseKeyword) return false; - if (ts.isPrefixUnaryExpression(node) && node.operator === ts.SyntaxKind.MinusToken) - return -(this.staticVal(node.operand) as number); - if (ts.isArrayLiteralExpression(node)) return node.elements.map((e) => this.staticVal(e)); - throw new ScriptError(node, this.sf, "expected a compile-time constant here"); - } - - private opCall(node: ts.Expression): { name: string; args: unknown[]; call: ts.CallExpression } { - let e = node; - if (ts.isAsExpression(e) || ts.isParenthesizedExpression(e)) e = e.expression; - if (!ts.isCallExpression(e) || !ts.isIdentifier(e.expression)) { - throw new ScriptError(node, this.sf, "yield must call a @pocketjs/aot op (say, choose, hasFlag, ...)"); - } - return { name: e.expression.text, args: e.arguments.map((a) => this.staticVal(a)), call: e }; - } - - /** Emit an op call. Returns true if it leaves a value on the stack. */ - emitOp(name: string, args: unknown[], node: ts.Node): boolean { - switch (name) { - case "say": - this.u8(OP.TEXT); - this.u16(this.ctx.internText(String(args[0]))); - return false; - case "lockPlayer": - this.u8(OP.LOCK_PLAYER); - return false; - case "releasePlayer": - this.u8(OP.RELEASE_PLAYER); - return false; - case "facePlayer": - this.u8(OP.FACE_PLAYER); - this.u8(FACE_SELF); - return false; - case "setFlag": - this.u8(OP.SET_FLAG); - this.u16(this.ctx.flagId(String(args[0]))); - return false; - case "clearFlag": - this.u8(OP.CLEAR_FLAG); - this.u16(this.ctx.flagId(String(args[0]))); - return false; - case "hasFlag": - this.u8(OP.PUSH_FLAG); - this.u16(this.ctx.flagId(String(args[0]))); - return true; - case "giveItem": - this.u8(OP.GIVE_ITEM); - this.u16(this.ctx.items.intern(String(args[0]))); - this.u8(Number(args[1] ?? 1)); - return false; - case "battle": - this.u8(OP.BATTLE); - this.u16(this.ctx.battles.intern(String(args[0]))); - return true; - case "wait": - this.u8(OP.WAIT); - this.u16(Number(args[0])); - return false; - case "setVar": - this.u8(OP.SET_VAR); - this.u16(this.ctx.varIdOf(String(args[0]))); - this.i16(Number(args[1])); - return false; - case "addVar": - this.u8(OP.ADD_VAR); - this.u16(this.ctx.varIdOf(String(args[0]))); - this.i16(Number(args[1])); - return false; - case "getVar": - this.u8(OP.PUSH_VAR); - this.u16(this.ctx.varIdOf(String(args[0]))); - return true; - case "playSfx": - this.u8(OP.PLAY_SFX); - this.u16(0); - return false; - default: - throw new ScriptError(node, this.sf, `unsupported script op "${name}"`); - } - } - - private emitChoice(options: string[], node: ts.Node): void { - // The textbox renders choice rows TEXT_ROW0(13)..BOX_ROW1(19) = 7 rows, so - // an 8th option would be selectable but never shown. Cap at 7. - if (options.length < 1 || options.length > 7) { - throw new ScriptError(node, this.sf, `choose() needs 1..7 options (got ${options.length})`); - } - this.u8(OP.CHOICE); - this.u8(options.length); - for (const o of options) this.u16(this.ctx.internText(o)); - } - - // --- statements ---------------------------------------------------------- - compileBlock(stmts: readonly ts.Statement[]): void { - for (let i = 0; i < stmts.length; i++) { - const s = stmts[i]; - // pattern: const X = yield choose([...]); switch (X) { ... } - const choice = this.matchChoiceDecl(s); - if (choice) { - const next = stmts[i + 1]; - if (!next || !ts.isSwitchStatement(next) || !this.isRef(next.expression, choice.name)) { - throw new ScriptError(s, this.sf, "`const x = yield choose(...)` must be immediately followed by `switch (x)`"); - } - this.emitChoice(choice.options, s); - this.compileChoiceSwitch(next, choice.options); - i++; // consume the switch - continue; - } - this.compileStatement(s); - } - } - - private compileStatement(s: ts.Statement): void { - if (ts.isExpressionStatement(s) && ts.isYieldExpression(s.expression)) { - const y = s.expression.expression!; - const { name, args, call } = this.opCall(y); - const produced = this.emitOp(name, args, call); - if (produced) this.u8(OP.POP); - return; - } - if (ts.isIfStatement(s)) return this.compileIf(s); - if (ts.isBlock(s)) return this.compileBlock(s.statements); - if (s.kind === ts.SyntaxKind.EmptyStatement) return; - throw new ScriptError(s, this.sf, "unsupported statement in a script (v1: yield-ops, if/else, choose+switch)"); - } - - private compileIf(s: ts.IfStatement): void { - if (!ts.isYieldExpression(s.expression)) { - throw new ScriptError(s.expression, this.sf, "if condition must be `yield ` (e.g. yield hasFlag(...))"); - } - const { name, args, call } = this.opCall(s.expression.expression!); - if (!VALUE_OPS.has(name)) throw new ScriptError(call, this.sf, `"${name}" does not yield a testable value`); - this.emitOp(name, args, call); // leaves value - const toElse = this.emitJump(OP.JUMP_IF_FALSE); - this.compileStatement(s.thenStatement); - if (s.elseStatement) { - const toEnd = this.emitJump(OP.JUMP); - this.patch(toElse); - this.compileStatement(s.elseStatement); - this.patch(toEnd); - } else { - this.patch(toElse); - } - } - - // Switch over a choice index (value already on stack); consumes it. - // - // Proper switch lowering so `default` may appear anywhere and empty - // fall-through case labels share the next clause's body (JS semantics): - // dispatch: for each case DUP; PUSH idx; NE; JUMP_IF_FALSE bodyN (jump if ==) - // default -> JUMP bodyDefault ; else -> JUMP end - // bodies: clause statements in source order (break -> JUMP end; - // no break -> falls into the next clause's body) - // end: POP (drop the choice index) - private compileChoiceSwitch(sw: ts.SwitchStatement, options: string[]): void { - const clauses = sw.caseBlock.clauses; - const caseJumps = new globalThis.Map(); // clauseIndex -> JUMP_IF_FALSE operand - let defaultIndex = -1; - - // Dispatch: test every CASE (default is skipped here — it is the fallthrough). - clauses.forEach((clause, i) => { - if (ts.isDefaultClause(clause)) { - if (defaultIndex >= 0) throw new ScriptError(clause, this.sf, "duplicate default clause"); - defaultIndex = i; - return; - } - const idx = options.indexOf(String(this.staticVal(clause.expression))); - if (idx < 0) throw new ScriptError(clause, this.sf, `case is not one of the choose() options`); - this.u8(OP.DUP); - this.u8(OP.PUSH_CONST); - this.i16(idx); - this.u8(OP.NE); - caseJumps.set(i, this.emitJump(OP.JUMP_IF_FALSE)); // NE==0 (equal) -> jump to this body - }); - // No case matched -> default body if present, else end. - const fallJump = this.emitJump(OP.JUMP); - - // Bodies in source order; a clause without `break` falls into the next. - const endJumps: number[] = []; - clauses.forEach((clause, i) => { - const bodyOffset = this.code.length; - const cj = caseJumps.get(i); - if (cj !== undefined) this.patchTo(cj, bodyOffset); - if (i === defaultIndex) this.patchTo(fallJump, bodyOffset); - for (const st of clause.statements) { - if (ts.isBreakStatement(st)) { - endJumps.push(this.emitJump(OP.JUMP)); - break; - } - this.compileStatement(st); - } - }); - - const end = this.code.length; - if (defaultIndex < 0) this.patchTo(fallJump, end); // no default -> fall through to end - for (const e of endJumps) this.patchTo(e, end); - this.u8(OP.POP); // drop the choice index - } - - // --- pattern helpers ----------------------------------------------------- - private matchChoiceDecl(s: ts.Statement): { name: string; options: string[] } | null { - if (!ts.isVariableStatement(s)) return null; - const d = s.declarationList.declarations[0]; - if (!d || !d.initializer || !ts.isIdentifier(d.name)) return null; - if (!ts.isYieldExpression(d.initializer)) return null; - const inner = d.initializer.expression; - if (!inner) return null; - let call = inner; - if (ts.isAsExpression(call) || ts.isParenthesizedExpression(call)) call = call.expression; - if (!ts.isCallExpression(call) || !ts.isIdentifier(call.expression) || call.expression.text !== "choose") return null; - const arr = this.staticVal(call.arguments[0]) as unknown[]; - return { name: d.name.text, options: arr.map(String) }; - } - - private isRef(e: ts.Expression, name: string): boolean { - return ts.isIdentifier(e) && e.text === name; - } -} - -export function compileScript(site: ScriptSite, ctx: Ctx): number[] { - const em = new Emitter(ctx, site.file); - const body = site.body.body; - if (!body || !ts.isBlock(body)) { - throw new Error(`script #${site.id}: expected a block body`); - } - em.compileBlock(body.statements); - em.code.push(OP.END); - return em.code; -} diff --git a/aot/demo/assets.generated.ts b/aot/demo/assets.generated.ts deleted file mode 100644 index 36433363..00000000 --- a/aot/demo/assets.generated.ts +++ /dev/null @@ -1,305 +0,0 @@ -// GENERATED by build-assets.ts from retro-rpg-sheet-source.png. -// Do not edit by hand; adjust the source sheet/crop regions and rerun the generator. - -import type { Direction } from "@pocketjs/aot"; - -export const TOWN_PALETTE: [number, number, number][] = [[24,24,32],[100,188,76],[50,132,58],[220,176,104],[112,72,40],[32,92,44],[110,206,76],[34,126,204],[112,202,244],[222,214,188],[214,62,56],[126,32,34],[176,106,50],[72,46,30],[244,238,214],[24,24,28]]; - -export const TOWN_TILES = { - "grass": { - "px": [ - "11111111", - "12211111", - "11111111", - "11111111", - "11121111", - "12111111", - "11112111", - "11111111" - ] - }, - "grass2": { - "px": [ - "11111111", - "22211211", - "11112111", - "11222111", - "12211212", - "11211121", - "11121111", - "11121111" - ] - }, - "path": { - "px": [ - "33333333", - "33333333", - "33333333", - "33333333", - "33333333", - "33333333", - "33333333", - "33333333" - ] - }, - "tree": { - "px": [ - "11111111", - "91666221", - "12666151", - "51222225", - "52212555", - "25555552", - "12555529", - "625dd529" - ], - "solid": true - }, - "water": { - "px": [ - "77777777", - "77777777", - "77777787", - "77777777", - "77777777", - "78877777", - "77778877", - "77777777" - ], - "solid": true - }, - "wall": { - "px": [ - "44444444", - "39999993", - "9e311ce3", - "3ec22ce3", - "3ec22c93", - "3933c333", - "41111114", - "44444444" - ], - "solid": true - }, - "roof": { - "px": [ - "bbbbbbbb", - "bbbbbbbb", - "babaabab", - "bbbbbbbb", - "bbabbabb", - "bbbbbbbb", - "babaabab", - "bbbbbbbb" - ], - "solid": true - }, - "door": { - "px": [ - "4cccccc4", - "4dddddd4", - "4d4444d4", - "4d4444d4", - "4dc444d4", - "4d4444d4", - "4d4444d4", - "44444444" - ], - "solid": true - }, - "sign": { - "px": [ - "61111111", - "62c44c41", - "14cccc41", - "64cccc41", - "14cccc41", - "112dd221", - "612dd211", - "61112111" - ], - "solid": true - }, - "flower": { - "px": [ - "11111111", - "13313911", - "2cc21921", - "1221c221", - "131cc211", - "c3322772", - "2c212772", - "11111121" - ] - }, - "fence": { - "px": [ - "11111111", - "1c1441c1", - "44444444", - "444444c4", - "44244244", - "44444444", - "24245245", - "11111111" - ], - "solid": true - } -}; - -export const HERO_PALETTE: [number, number, number][] = [[0,0,0],[244,184,132],[50,116,196],[24,54,104],[246,246,230],[94,60,34],[18,22,30],[44,48,52],[24,28,28],[218,158,52],[122,144,164],[92,162,230],[170,104,58],[232,210,150],[70,92,132],[254,254,246]]; - -export const HERO_FACINGS: Record = { - "down": [ - [ - "0000000000000000", - "0000a60400600000", - "000aea0000a3a000", - "00062000040e6000", - "0006e40330036000", - "0005732222355000", - "008786666665d700", - "0001c961161c1000", - "000a1d8d17d9e000", - "000075d11d630000", - "0006eca66d5e6000", - "00ad66ee2e88d000", - "0006633e23366000", - "0000666786660000", - "0000d75ea5560000", - "0000000000000000" - ], - [ - "0000000000000000", - "00006e0000600000", - "00002e0000260000", - "0006204000027000", - "0006240ee00e7000", - "006533222e355000", - "0086566666751700", - "0006616995916000", - "00069d6d15d60000", - "000069d9dd660000", - "007de8a660526000", - "00796322826dd000", - "000a082263366000", - "0000a55666700000", - "0000066065700000", - "0000000000000000" - ] - ], - "up": [ - [ - "0000000000000000", - "00006300007e0000", - "000a200000ae0000", - "0006200000026000", - "0006e400004e6000", - "0005e000000e5000", - "00e66e000a366700", - "00e6555555558700", - "0008585555850000", - "0000666556670000", - "0006ec99999e6000", - "000d65111156d700", - "0006669cc9876a00", - "0000636666860000", - "0000655665560000", - "0000000000000000" - ], - [ - "0000000000000000", - "0000630000600000", - "000e24000023e000", - "0006200000026000", - "0006e000040e6000", - "0065e00000035000", - "00866e0000355e00", - "0006555555555000", - "0000555555860000", - "0000366586660000", - "008d3c9111526000", - "008965111971d000", - "0000369999366000", - "00006766667a0000", - "0000a88065700000", - "0000000000000000" - ] - ], - "left": [ - [ - "0000006636600000", - "0000000422226000", - "000060002222e000", - "003ee044222ee000", - "007222383eee3000", - "000a6ccc55555800", - "0000711151658000", - "00006ddd18860000", - "0000061166680000", - "00000037ab399000", - "00000836ad8c9000", - "000007e211366000", - "0000655767550000", - "0000066000660000", - "0000000000000000", - "0000062220060000" - ], - [ - "0000006363600000", - "0000600022220000", - "0000e00022226000", - "00ee3000222e6000", - "00e222683eee6000", - "0000c99c58555600", - "000011d16d586000", - "0000d1dd98560000", - "000006d966660000", - "00000626226c8000", - "000006e6d9696000", - "000656d686360000", - "00065e7667e70000", - "0000060006670000", - "0000000000000000", - "000063ee00600000" - ] - ], - "right": [ - [ - "0000073373600000", - "00062222a0480000", - "00ae2222a0006000", - "00aee222a0037600", - "00aeeeeea03e2260", - "0a65555586666600", - "0758556559696000", - "000656d6cd6d6000", - "00006869ddd60000", - "00089636c1500000", - "0061932232e00000", - "006996d262600000", - "000666d133600000", - "0008566667550000", - "0006776085770000", - "0000000000000000" - ], - [ - "0000033333000000", - "0003222240060000", - "0062222200000000", - "006e222200036600", - "006eeeee00322270", - "0685555588666600", - "00655565998c0000", - "000856d8d18d0000", - "000a856ddd170000", - "0005566699600000", - "008c532a26000000", - "006c6d26aea00000", - "0006611577687000", - "000e766667557000", - "0007776067760000", - "0000000000000000" - ] - ] -}; diff --git a/aot/demo/assets.ts b/aot/demo/assets.ts deleted file mode 100644 index ba14da91..00000000 --- a/aot/demo/assets.ts +++ /dev/null @@ -1,17 +0,0 @@ -// aot/demo/assets.ts — imagegen-backed tileset + hero sprite declarations. -// The source PNG lives under demo/imagegen and is converted to GBA 4bpp DSL -// rows by demo/imagegen/build-assets.ts. - -import { defineSprite, defineTileset } from "@pocketjs/aot"; -import { HERO_FACINGS, HERO_PALETTE, TOWN_PALETTE, TOWN_TILES } from "./assets.generated.ts"; - -export const town = defineTileset("town", { - palette: TOWN_PALETTE, - tiles: TOWN_TILES, -}); - -export const hero = defineSprite("hero", { - size: [16, 16], - palette: HERO_PALETTE, - facings: HERO_FACINGS, -}); diff --git a/aot/demo/game.tsx b/aot/demo/game.tsx deleted file mode 100644 index 11a742af..00000000 --- a/aot/demo/game.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/** @jsxImportSource @pocketjs/aot */ -// aot/demo/game.tsx — a Pokemon-like overworld vertical slice, authored in TSX. -// Compiled by @pocketjs/aot into a GBA-native ROM (no JS engine on the cart). -import { - ascii, - battle, - choose, - defineGame, - defineMap, - Entrance, - facePlayer, - giveItem, - hasFlag, - lockPlayer, - Npc, - PlayerSpawn, - releasePlayer, - say, - script, - Sign, - setFlag, - tile, - Warp, -} from "@pocketjs/aot"; -import { hero, town } from "./assets.ts"; - -// --- scripts (residual zone: compiled from AST to bytecode) ----------------- -const RivalTalk = script(function* () { - yield lockPlayer(); - yield facePlayer("rival"); - if (yield hasFlag("beat_rival_1")) { - yield say("The road ahead is tougher than it looks."); - } else { - yield say("You made it! Want to test your first build?"); - const answer = yield choose(["Battle", "Maybe later"] as const); - switch (answer) { - case "Battle": - yield battle("rival_1"); - yield setFlag("beat_rival_1"); - yield giveItem("potion", 1); - yield say("Take this Potion. You will need it."); - break; - case "Maybe later": - yield say("No problem. I will be right here."); - break; - } - } - yield releasePlayer(); -}); - -const MomTalk = script(function* () { - yield lockPlayer(); - yield facePlayer("mom"); - yield say("Be careful out there on Route 101!"); - yield releasePlayer(); -}); - -const RouteNpcTalk = script(function* () { - yield say("Wild grass rustles to the north."); -}); - -function LittlerootEntities() { - return ( - <> - - - - - - - - ); -} - -function Route101Entities() { - return ( - <> - - - - - - ); -} - -// --- town map --------------------------------------------------------------- -// legend: . grass , grass2 * flower # tree ~ water = path -// H wall ^ roof D door F fence -export const Littleroot = defineMap("littleroot") - .tileset(town) - .layer( - ascii` - #################### - #....,.....,......## - #.^^HH^^..,...####.# - #.HHDHH.......#~~#.# - #.....=......,#~~#.# - #..*..=..,......,..# - #.,...=....^^HH^^..# - #.....=....HHDHH...# - #..,..======......,# - #........,..=....*.# - #.,....*....=..,...# - #....F.F.F..=......# - #..,........=...,..# - #......,....=......# - #.*......,..=..*...# - #..........=.......# - #,........===......# - #########==######### - `.legend({ - ".": tile("grass"), - ",": tile("grass2"), - "*": tile("flower"), - "#": tile("tree"), - "~": tile("water"), - "=": tile("path"), - H: tile("wall"), - "^": tile("roof"), - D: tile("door"), - F: tile("fence"), - }), - ) - .entities() - .done(); - -// --- route map -------------------------------------------------------------- -export const Route101 = defineMap("route101") - .tileset(town) - .layer( - ascii` - #########==######### - #........==.......,# - #..###...==...###..# - #..###...==...###..# - #.,......==........# - #....*...==...*....# - #........==........# - #..~~~...==...,....# - #..~~~...==.......,# - #..~~~...=====....## - #...,........==....# - #.......,.....==*..# - #..*.........,==...# - #,..............==,# - #..###...,....###..# - #################### - `.legend({ - ".": tile("grass"), - ",": tile("grass2"), - "*": tile("flower"), - "#": tile("tree"), - "~": tile("water"), - "=": tile("path"), - }), - ) - .entities() - .done(); - -export default defineGame({ - title: "POCKET TOWN", - start: "littleroot:spawn", - maps: [Littleroot, Route101], - sprites: ["hero"], - items: ["potion"], - battles: ["rival_1"], - flags: ["beat_rival_1", "intro_done"], -}); diff --git a/aot/demo/imagegen/build-assets.ts b/aot/demo/imagegen/build-assets.ts deleted file mode 100644 index f526f776..00000000 --- a/aot/demo/imagegen/build-assets.ts +++ /dev/null @@ -1,269 +0,0 @@ -#!/usr/bin/env bun -// Convert the project-bound imagegen source sheet into GBA DSL asset data. - -import { basename, dirname, join } from "node:path"; -import { decodePng, type DecodedImage } from "../../../compiler/pak.ts"; -import type { Direction } from "../../dsl/index.ts"; - -type RGB = [number, number, number]; -type Rect = { x: number; y: number; w: number; h: number }; -type TileSpec = { name: string; rect: Rect; solid?: boolean; backgroundIndex?: number }; - -const HERE = dirname(new URL(import.meta.url).pathname); -const SOURCE = join(HERE, "retro-rpg-sheet-source.png"); -const OUT = join(dirname(HERE), "assets.generated.ts"); - -// BG palette bank 0. Index 0 stays the backdrop color; generated map tiles use -// indices 1..15 so every map cell remains opaque. -const TOWN_PALETTE: RGB[] = [ - [24, 24, 32], - [100, 188, 76], - [50, 132, 58], - [220, 176, 104], - [112, 72, 40], - [32, 92, 44], - [110, 206, 76], - [34, 126, 204], - [112, 202, 244], - [222, 214, 188], - [214, 62, 56], - [126, 32, 34], - [176, 106, 50], - [72, 46, 30], - [244, 238, 214], - [24, 24, 28], -]; - -// OBJ palette bank 0. Index 0 is transparent for sprites. -const HERO_PALETTE: RGB[] = [ - [0, 0, 0], - [244, 184, 132], - [50, 116, 196], - [24, 54, 104], - [246, 246, 230], - [94, 60, 34], - [18, 22, 30], - [44, 48, 52], - [24, 28, 28], - [218, 158, 52], - [122, 144, 164], - [92, 162, 230], - [170, 104, 58], - [232, 210, 150], - [70, 92, 132], - [254, 254, 246], -]; - -const TILE_SPECS: TileSpec[] = [ - { name: "grass", rect: { x: 50, y: 48, w: 181, h: 183 } }, - { name: "grass2", rect: { x: 278, y: 48, w: 199, h: 183 } }, - { name: "path", rect: { x: 526, y: 48, w: 178, h: 183 } }, - { name: "tree", rect: { x: 745, y: 27, w: 194, h: 204 }, solid: true, backgroundIndex: 1 }, - { name: "water", rect: { x: 973, y: 48, w: 176, h: 184 }, solid: true }, - { name: "wall", rect: { x: 50, y: 270, w: 181, h: 183 }, solid: true }, - { name: "roof", rect: { x: 278, y: 270, w: 199, h: 183 }, solid: true }, - { name: "door", rect: { x: 526, y: 270, w: 178, h: 183 }, solid: true }, - { name: "sign", rect: { x: 746, y: 270, w: 178, h: 183 }, solid: true }, - { name: "flower", rect: { x: 973, y: 270, w: 176, h: 183 } }, - { name: "fence", rect: { x: 50, y: 489, w: 181, h: 149 }, solid: true }, -]; - -const SPRITE_RECTS: Record = { - down: [ - { x: 244, y: 664, w: 116, h: 152 }, - { x: 433, y: 664, w: 116, h: 152 }, - ], - up: [ - { x: 244, y: 825, w: 116, h: 142 }, - { x: 433, y: 825, w: 116, h: 142 }, - ], - left: [ - { x: 244, y: 977, w: 116, h: 137 }, - { x: 433, y: 977, w: 116, h: 137 }, - ], - right: [ - { x: 244, y: 1113, w: 116, h: 132 }, - { x: 433, y: 1113, w: 116, h: 132 }, - ], -}; - -function px(img: DecodedImage, x: number, y: number): RGB { - const sx = Math.max(0, Math.min(img.width - 1, x)); - const sy = Math.max(0, Math.min(img.height - 1, y)); - const i = (sy * img.width + sx) * 4; - return [img.rgba[i], img.rgba[i + 1], img.rgba[i + 2]]; -} - -function colorDistance2(a: RGB, b: RGB): number { - const dr = a[0] - b[0]; - const dg = a[1] - b[1]; - const db = a[2] - b[2]; - return dr * dr + dg * dg + db * db; -} - -function nearestPalette(rgb: RGB, pal: RGB[], start = 0): number { - let best = start; - let bestD = Number.POSITIVE_INFINITY; - for (let i = start; i < pal.length; i++) { - const d = colorDistance2(rgb, pal[i]); - if (d < bestD) { - bestD = d; - best = i; - } - } - return best; -} - -function averageRegion(img: DecodedImage, rect: Rect, ox: number, oy: number, ow: number, oh: number): RGB { - const x0 = Math.floor(rect.x + (ox / ow) * rect.w); - const y0 = Math.floor(rect.y + (oy / oh) * rect.h); - const x1 = Math.max(x0 + 1, Math.floor(rect.x + ((ox + 1) / ow) * rect.w)); - const y1 = Math.max(y0 + 1, Math.floor(rect.y + ((oy + 1) / oh) * rect.h)); - let r = 0; - let g = 0; - let b = 0; - let n = 0; - for (let y = y0; y < y1; y++) { - for (let x = x0; x < x1; x++) { - const c = px(img, x, y); - r += c[0]; - g += c[1]; - b += c[2]; - n++; - } - } - return [Math.round(r / n), Math.round(g / n), Math.round(b / n)]; -} - -function generatedSheetBackground(rgb: RGB): boolean { - const neutral = Math.abs(rgb[0] - rgb[1]) < 12 && Math.abs(rgb[1] - rgb[2]) < 12; - return neutral && rgb[0] > 190 && rgb[1] > 190 && rgb[2] > 190; -} - -function tileRows(img: DecodedImage, spec: TileSpec): string[] { - const rows: string[] = []; - for (let y = 0; y < 8; y++) { - let row = ""; - for (let x = 0; x < 8; x++) { - const rgb = averageRegion(img, spec.rect, x, y, 8, 8); - const idx = spec.backgroundIndex !== undefined && generatedSheetBackground(rgb) - ? spec.backgroundIndex - : nearestPalette(rgb, TOWN_PALETTE, 1); - row += idx.toString(16); - } - rows.push(row); - } - return rows; -} - -function borderAverage(img: DecodedImage, rect: Rect): RGB { - let r = 0; - let g = 0; - let b = 0; - let n = 0; - const add = (x: number, y: number) => { - const c = px(img, x, y); - r += c[0]; - g += c[1]; - b += c[2]; - n++; - }; - for (let x = rect.x; x < rect.x + rect.w; x += 2) { - add(x, rect.y); - add(x, rect.y + rect.h - 1); - } - for (let y = rect.y; y < rect.y + rect.h; y += 2) { - add(rect.x, y); - add(rect.x + rect.w - 1, y); - } - return [Math.round(r / n), Math.round(g / n), Math.round(b / n)]; -} - -function isBackground(rgb: RGB, bg: RGB): boolean { - const neutral = Math.abs(rgb[0] - rgb[1]) < 14 && Math.abs(rgb[1] - rgb[2]) < 14; - return colorDistance2(rgb, bg) < 34 * 34 || (neutral && rgb[0] > 190 && rgb[1] > 190 && rgb[2] > 190); -} - -function spriteRows(img: DecodedImage, rect: Rect): string[] { - const bg = borderAverage(img, rect); - let minX = rect.x + rect.w; - let minY = rect.y + rect.h; - let maxX = rect.x; - let maxY = rect.y; - for (let y = rect.y; y < rect.y + rect.h; y++) { - for (let x = rect.x; x < rect.x + rect.w; x++) { - if (!isBackground(px(img, x, y), bg)) { - minX = Math.min(minX, x); - minY = Math.min(minY, y); - maxX = Math.max(maxX, x); - maxY = Math.max(maxY, y); - } - } - } - const bw = maxX - minX + 1; - const bh = maxY - minY + 1; - const side = Math.ceil(Math.max(bw, bh) * 1.08); - const cx = (minX + maxX + 1) / 2; - const cy = (minY + maxY + 1) / 2; - const crop: Rect = { - x: Math.round(cx - side / 2), - y: Math.round(cy - side / 2), - w: side, - h: side, - }; - - const rows: string[] = []; - for (let oy = 0; oy < 16; oy++) { - let row = ""; - for (let ox = 0; ox < 16; ox++) { - const sx = Math.floor(crop.x + ((ox + 0.5) / 16) * crop.w); - const sy = Math.floor(crop.y + ((oy + 0.5) / 16) * crop.h); - const rgb = px(img, sx, sy); - row += (isBackground(rgb, bg) ? 0 : nearestPalette(rgb, HERO_PALETTE, 1)).toString(16); - } - rows.push(row); - } - return rows; -} - -function emitArray(name: string, pal: RGB[]): string { - return `export const ${name}: [number, number, number][] = ${JSON.stringify(pal)};\n`; -} - -async function main(): Promise { - const img = decodePng(await Bun.file(SOURCE).bytes()); - const tiles = Object.fromEntries( - TILE_SPECS.map((spec) => [ - spec.name, - { - px: tileRows(img, spec), - ...(spec.solid ? { solid: true } : {}), - }, - ]), - ); - const facings = Object.fromEntries( - (Object.entries(SPRITE_RECTS) as [Direction, Rect[]][]).map(([dir, rects]) => [ - dir, - rects.map((rect) => spriteRows(img, rect)), - ]), - ) as Record; - - const body = - `// GENERATED by ${basename(new URL(import.meta.url).pathname)} from ${basename(SOURCE)}.\n` + - "// Do not edit by hand; adjust the source sheet/crop regions and rerun the generator.\n\n" + - 'import type { Direction } from "@pocketjs/aot";\n\n' + - emitArray("TOWN_PALETTE", TOWN_PALETTE) + - "\n" + - `export const TOWN_TILES = ${JSON.stringify(tiles, null, 2)};\n\n` + - emitArray("HERO_PALETTE", HERO_PALETTE) + - "\n" + - `export const HERO_FACINGS: Record = ${JSON.stringify(facings, null, 2)};\n`; - - await Bun.write(OUT, body); - console.log(`generated ${OUT} from ${SOURCE}`); - console.log(` source: ${img.width}x${img.height}`); - console.log(` tiles: ${TILE_SPECS.length}`); - console.log(` hero frames: ${Object.values(SPRITE_RECTS).reduce((n, frames) => n + frames.length, 0)}`); -} - -await main(); diff --git a/aot/demo/imagegen/retro-rpg-sheet-source.png b/aot/demo/imagegen/retro-rpg-sheet-source.png deleted file mode 100644 index 0bf2f124..00000000 Binary files a/aot/demo/imagegen/retro-rpg-sheet-source.png and /dev/null differ diff --git a/aot/docs/choice.png b/aot/docs/choice.png deleted file mode 100644 index 12309186..00000000 Binary files a/aot/docs/choice.png and /dev/null differ diff --git a/aot/docs/dialogue.png b/aot/docs/dialogue.png deleted file mode 100644 index c222057b..00000000 Binary files a/aot/docs/dialogue.png and /dev/null differ diff --git a/aot/docs/route.png b/aot/docs/route.png deleted file mode 100644 index 22427c35..00000000 Binary files a/aot/docs/route.png and /dev/null differ diff --git a/aot/docs/town.png b/aot/docs/town.png deleted file mode 100644 index 6fd6e47a..00000000 Binary files a/aot/docs/town.png and /dev/null differ diff --git a/aot/dsl/index.ts b/aot/dsl/index.ts deleted file mode 100644 index 6622b21d..00000000 --- a/aot/dsl/index.ts +++ /dev/null @@ -1,575 +0,0 @@ -// aot/dsl/index.ts — the @pocketjs/aot public authoring surface. -// -// Two zones (design §8): -// - Static declaration zone: defineTileset / defineSprite / defineMap / -// defineGame + host elements. EXECUTED at build time; fills REGISTRY. -// - Residual script zone: script(function*(){ yield say(...) ... }). NOT -// executed — the compiler rewrites each script(...) to script() and -// lowers the generator body from its AST (aot/compiler/script.ts). -// -// The op wrappers (say/choose/hasFlag/...) exist for typing + AST recognition; -// their runtime bodies never run on host or GBA. - -import { - hostElement, - normalizeSceneChildren, - type EntranceProps, - type LayerProps, - type NpcProps, - type PjgbChild, - type PjgbNode, - type PlayerSpawnProps, - type SignProps, - type WarpProps, -} from "./jsx-runtime.ts"; -import type { - BattleId, - Direction, - FlagId, - ItemId, - MapId, - MovementKind, - ScriptRef, - SpriteId, - TileCoord, - VarId, -} from "./types.ts"; - -export * from "./types.ts"; -export { Fragment } from "./jsx-runtime.ts"; -export type { - EntranceProps, - LayerProps, - NpcProps, - PjgbChild, - PjgbNode, - PlayerSpawnProps, - SignProps, - WarpProps, -} from "./jsx-runtime.ts"; - -type Rgb = readonly [number, number, number]; -type Whitespace = " " | "\t" | "\r"; - -type TrimLineLeft = S extends `${Whitespace}${infer Rest}` ? TrimLineLeft : S; -type TrimLineRight = S extends `${infer Rest}${Whitespace}` ? TrimLineRight : S; -type TrimLine = TrimLineRight>; -type SplitLines = S extends `${infer Head}\n${infer Rest}` ? [Head, ...SplitLines] : [S]; -type NonEmptyTrimmedLines = Lines extends readonly [ - infer Head extends string, - ...infer Rest extends readonly string[], -] - ? TrimLine extends "" - ? NonEmptyTrimmedLines - : [TrimLine, ...NonEmptyTrimmedLines] - : []; -type RowsOf = NonEmptyTrimmedLines>; -type Chars = S extends `${infer Head}${infer Rest}` ? Head | Chars : never; -type RowChars = Rows[number] extends infer Row extends string ? Chars : never; -type RowLength = S extends `${infer _Head}${infer Rest}` - ? RowLength - : Acc["length"]; -type SameWidth = Rows extends readonly [ - infer Head extends string, - ...infer Rest extends readonly string[], -] - ? Width extends number - ? RowLength extends Width - ? [Head, ...SameWidth] - : never - : [Head, ...SameWidth>] - : []; -type RowsForLegend = string extends Source ? readonly string[] : SameWidth>; -type TileNameOf = Tileset extends TilesetDecl ? Extract : string; -type LayerTileNames = Layer extends LayerSpec ? TileName : string; -type CompatibleLayer = string extends LayerTileNames - ? Layer - : Exclude, TileNameOf> extends never - ? Layer - : never; - -// --------------------------------------------------------------------------- -// Registry — module-level, shared between the executed game module and the -// compiler (both import this exact module instance). -// --------------------------------------------------------------------------- -export interface TileDecl { - px: readonly string[]; // 8 rows of 8 hex-nibble palette indices (0-f) - solid?: boolean; -} -export interface TilesetDecl< - Name extends string = string, - Tiles extends Record = Record, -> { - name: Name; - palette: readonly Rgb[]; // up to 16 rgb triples; index 0 = transparent/backdrop - tiles: Tiles; -} -export interface SpriteDecl { - name: Name; - size: [number, number]; - palette: readonly Rgb[]; - // one entry per facing (down,up,left,right); each is `frames` grids of - // `w*h` hex-nibble palette indices (rows joined). v1: 16x16, 1-2 frames. - facings: Record; -} -export interface MapDecl { - name: string; - tileset: string; - root: PjgbNode; // the element tree - size?: [number, number]; -} -export interface GameDecl { - title: string; - start: string; // "map:entrance" - maps: MapDecl[]; - sprites?: string[]; - items?: string[]; - battles?: string[]; - flags?: string[]; - vars?: string[]; -} - -export interface Registry { - tilesets: Map; - sprites: Map; - maps: MapDecl[]; - game: GameDecl | null; - // scriptId -> nothing at runtime; the AST is recovered by the compiler. - scriptCount: number; -} - -// NOTE: this module exports a host element named `Map`, which shadows the JS -// global. Use globalThis.Map for the real collections. -const REGISTRY: Registry = { - tilesets: new globalThis.Map(), - sprites: new globalThis.Map(), - maps: [], - game: null, - scriptCount: 0, -}; - -/** Compiler entry point: reset before executing a fresh game module. */ -export function __resetRegistry(): void { - REGISTRY.tilesets.clear(); - REGISTRY.sprites.clear(); - REGISTRY.maps = []; - REGISTRY.game = null; - REGISTRY.scriptCount = 0; -} -/** Compiler entry point: read what the executed module declared. */ -export function __getRegistry(): Registry { - return REGISTRY; -} - -// --------------------------------------------------------------------------- -// Host elements (design §18). These are markers; the IR builder walks them. -// --------------------------------------------------------------------------- -export const Map = hostElement("Map"); -export const Layer = hostElement("Layer"); -export const Npc = hostElement("Npc"); -export const Warp = hostElement("Warp"); -export const Sign = hostElement("Sign"); -export const PlayerSpawn = hostElement("PlayerSpawn"); -export const Entrance = hostElement("Entrance"); -export const Trigger = hostElement("Trigger"); -export const Collision = hostElement("Collision"); - -// --------------------------------------------------------------------------- -// Static builders + strongly-typed map DSL -// --------------------------------------------------------------------------- -export interface TileRef { - readonly __pjgbTile: true; - readonly name: Name; -} -type TileInput = TileRef | Name; - -export interface LayerSpec { - readonly __pjgbLayer: true; - readonly rows: Rows; - readonly legend: Record; -} - -export interface AsciiLayer { - readonly __pjgbAscii: true; - readonly source: Source; - readonly rows: string[]; - legend>, TileInput>>( - legend: RowsForLegend extends never ? never : Legend, - ): LayerSpec, ExtractTileNames>; -} - -type ExtractTileNames = Extract< - { - [K in keyof Legend]: Legend[K] extends TileRef ? Name : Legend[K] extends string ? Legend[K] : never; - }[keyof Legend], - string ->; -const ENTITY_HOSTS = new Set(["PlayerSpawn", "Entrance", "Npc", "Sign", "Warp"]); - -function node(host: string, props: Record, children: PjgbNode[] = []): PjgbNode { - return { host, props, children }; -} - -function isTileRef(v: unknown): v is TileRef { - return typeof v === "object" && v !== null && (v as TileRef).__pjgbTile === true; -} - -function tileName(v: TileInput): string { - return isTileRef(v) ? v.name : String(v); -} - -function normalizeAscii(source: string): string[] { - const lines = source.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n"); - while (lines.length && lines[0]!.trim() === "") lines.shift(); - while (lines.length && lines[lines.length - 1]!.trim() === "") lines.pop(); - if (!lines.length) throw new Error("ascii map must contain at least one row"); - - let indent = Infinity; - for (const line of lines) { - if (line.trim() === "") continue; - indent = Math.min(indent, line.match(/^[ \t]*/)![0].length); - } - const cut = Number.isFinite(indent) ? indent : 0; - return lines.map((line) => line.slice(cut).trimEnd()); -} - -function makeLayerSpec(rows: readonly string[], legendInput: Record): LayerSpec { - const width = rows[0]?.length ?? 0; - if (!width) throw new Error("ascii map must contain non-empty rows"); - rows.forEach((row, i) => { - if (row.length !== width) throw new Error(`ascii map row ${i} width ${row.length} != ${width}`); - }); - - const legend: Record = {}; - for (const [symbol, value] of Object.entries(legendInput)) { - if (symbol.length !== 1) throw new Error(`ascii legend key "${symbol}" must be exactly one character`); - legend[symbol] = tileName(value); - } - for (const row of rows) { - for (const symbol of row) { - if (legend[symbol] === undefined) throw new Error(`ascii legend missing entry for "${symbol}"`); - } - } - return { __pjgbLayer: true, rows: [...rows], legend }; -} - -class AsciiLayerImpl implements AsciiLayer { - readonly __pjgbAscii = true; - readonly rows: string[]; - - constructor(readonly source: Source) { - this.rows = normalizeAscii(source); - } - - legend>, TileInput>>( - legend: RowsForLegend extends never ? never : Legend, - ): LayerSpec, ExtractTileNames> { - return makeLayerSpec(this.rows, legend as Record) as LayerSpec< - RowsForLegend, - ExtractTileNames - >; - } -} - -export function tile(name: Name): TileRef { - return { __pjgbTile: true, name }; -} - -export function ascii( - strings: TemplateStringsArray, - ...values: never[] -): AsciiLayer { - if (values.length) throw new Error("ascii maps do not support interpolation"); - return new AsciiLayerImpl(String.raw({ raw: strings.raw }) as Source); -} - -class MapBuilderStart { - constructor(private readonly name: Name) {} - - tileset(tileset: Tileset): MapBuilder { - return new MapBuilder(this.name, tileset); - } -} - -class FacingPlacement { - private position: [number, number] | null = null; - - constructor( - private readonly parent: Parent, - private readonly host: string, - private readonly props: Record, - private readonly append: (node: PjgbNode) => void, - ) {} - - at(x: number, y: number): this { - this.position = [x, y]; - return this; - } - - facing(dir: Direction): Parent { - if (!this.position) throw new Error(`<${this.host}> needs .at(x, y) before .facing(...)`); - this.append(node(this.host, { ...this.props, at: this.position, facing: dir })); - return this.parent; - } -} - -class AtPlacement { - constructor( - private readonly parent: Parent, - private readonly host: string, - private readonly props: Record, - private readonly append: (node: PjgbNode) => void, - ) {} - - at(x: number, y: number): Parent { - this.append(node(this.host, { ...this.props, at: [x, y] })); - return this.parent; - } -} - -class NpcBuilder { - private spriteName: string | null = null; - - constructor( - private readonly parent: Parent, - private readonly id: string, - private readonly append: (node: PjgbNode) => void, - ) {} - - sprite(spriteRef: SpriteId | string): NpcAfterSprite { - this.spriteName = String(spriteRef); - return new NpcAfterSprite(this.parent, this.id, this.spriteName, this.append); - } -} - -class NpcAfterSprite { - private position: [number, number] | null = null; - - constructor( - private readonly parent: Parent, - private readonly id: string, - private readonly spriteName: string, - private readonly append: (node: PjgbNode) => void, - ) {} - - at(x: number, y: number): NpcAfterAt { - this.position = [x, y]; - return new NpcAfterAt(this.parent, this.id, this.spriteName, this.position, this.append); - } -} - -class NpcAfterAt { - constructor( - private readonly parent: Parent, - private readonly id: string, - private readonly spriteName: string, - private readonly position: [number, number], - private readonly append: (node: PjgbNode) => void, - ) {} - - facing(dir: Direction): NpcAfterFacing { - return new NpcAfterFacing(this.parent, { - id: this.id, - sprite: this.spriteName, - at: this.position, - facing: dir, - }, this.append); - } -} - -class NpcAfterFacing { - constructor( - private readonly parent: Parent, - private readonly props: Record, - private readonly append: (node: PjgbNode) => void, - ) {} - - movement(kind: MovementKind): this { - this.props.movement = kind; - return this; - } - - talk(scriptRef: ScriptRef): Parent { - this.append(node("Npc", { ...this.props, onTalk: scriptRef })); - return this.parent; - } -} - -export class MapBuilder { - private readonly children: PjgbNode[] = []; - - constructor( - private readonly name: Name, - private readonly tilesetDecl: Tileset, - ) {} - - private append = (child: PjgbNode): void => { - this.children.push(child); - }; - - layer(layer: CompatibleLayer): this { - this.append(node("Layer", { rows: [...layer.rows], legend: { ...layer.legend } })); - return this; - } - - entities(...children: PjgbChild[]): this { - for (const child of normalizeSceneChildren(children)) { - if (!ENTITY_HOSTS.has(child.host)) { - throw new Error( - `defineMap("${this.name}").entities(...) does not accept <${child.host}>; use .layer(...) for tile layers`, - ); - } - this.append(child); - } - return this; - } - - spawn(_id: Id): FacingPlacement { - return new FacingPlacement(this, "PlayerSpawn", { id: _id }, this.append); - } - - entrance(id: Id): FacingPlacement { - return new FacingPlacement(this, "Entrance", { id }, this.append); - } - - npc(id: Id): NpcBuilder { - return new NpcBuilder(this, id, this.append); - } - - sign(text: string): AtPlacement { - return new AtPlacement(this, "Sign", { text }, this.append); - } - - warp(to: `${string}:${string}` | string): AtPlacement { - return new AtPlacement(this, "Warp", { to }, this.append); - } - - done(): MapDecl { - const firstLayer = this.children.find((c) => c.host === "Layer"); - const rows = firstLayer?.props.rows as string[] | undefined; - const root = node("Map", {}, this.children); - const decl: MapDecl = { - name: this.name, - tileset: this.tilesetDecl.name, - root, - size: rows ? [rows[0]?.length ?? 0, rows.length] : undefined, - }; - REGISTRY.maps.push(decl); - return decl; - } -} - -export function defineTileset< - const Name extends string, - const Tiles extends Record, ->(name: Name, decl: { palette: readonly Rgb[]; tiles: Tiles }): TilesetDecl { - const full: TilesetDecl = { name, ...decl }; - REGISTRY.tilesets.set(name, full); - return full as TilesetDecl; -} - -export function defineSprite(name: Name, decl: Omit, "name">): SpriteId { - REGISTRY.sprites.set(name, { name, ...decl }); - return name as unknown as SpriteId; -} - -export function defineMap(name: Name): MapBuilderStart; -export function defineMap( - name: Name, - opts: { size?: [number, number]; tileset: string }, - build: () => PjgbNode, -): MapDecl; -export function defineMap( - name: string, - opts?: { size?: [number, number]; tileset: string }, - build?: () => PjgbNode, -): MapDecl | MapBuilderStart { - if (!opts || !build) return new MapBuilderStart(name); - const root = build(); - const decl: MapDecl = { name, tileset: opts.tileset, root, size: opts.size }; - REGISTRY.maps.push(decl); - return decl; -} - -export function defineGame(decl: Omit & { maps: MapDecl[] }): GameDecl { - REGISTRY.game = decl; - return decl; -} - -// --------------------------------------------------------------------------- -// Branded id helpers (identity at runtime; brands for the type checker). -// --------------------------------------------------------------------------- -export const flag = (id: T): FlagId => id as unknown as FlagId; -export const sprite = (id: T): SpriteId => id as unknown as SpriteId; -export const mapId = (id: T): MapId => id as unknown as MapId; -export const varId = (id: T): VarId => id as unknown as VarId; - -// --------------------------------------------------------------------------- -// Scripts + ops. The compiler rewrites `script(function*(){...})` to -// `script()`, so at runtime we only ever see the id. -// --------------------------------------------------------------------------- -export type ScriptBody = () => Generator; - -export function script(bodyOrId: ScriptBody | number): ScriptRef { - // The compiler rewrites every `script(function*(){...})` to `script()` - // before executing this module, so we only ever see a number here. - if (typeof bodyOrId === "number") return { __pjgbScript: bodyOrId }; - throw new Error( - "script() must be compiled by @pocketjs/aot, not executed directly — the generator body is lowered from its AST.", - ); -} - -// Op wrappers — recognized by name in the residualizer. Runtime bodies unused. -export function say(_text: string): unknown { - return undefined; -} -export function choose(_options: T): T[number] { - return _options[0]; -} -export function hasFlag(_id: FlagId | string): boolean { - return false; -} -export function setFlag(_id: FlagId | string): unknown { - return undefined; -} -export function clearFlag(_id: FlagId | string): unknown { - return undefined; -} -export function lockPlayer(): unknown { - return undefined; -} -export function releasePlayer(): unknown { - return undefined; -} -export function facePlayer(_actor: string): unknown { - return undefined; -} -export function warpTo(_dest: string): unknown { - return undefined; -} -export function battle(_id: BattleId | string): boolean { - return true; -} -export function giveItem(_id: ItemId | string, _count?: number): unknown { - return undefined; -} -export function takeItem(_id: ItemId | string, _count?: number): unknown { - return undefined; -} -export function wait(_frames: number): unknown { - return undefined; -} -export function getVar(_id: VarId | string): number { - return 0; -} -export function setVar(_id: VarId | string, _value: number): unknown { - return undefined; -} -export function addVar(_id: VarId | string, _delta: number): unknown { - return undefined; -} -export function playSfx(_id: string): unknown { - return undefined; -} - -export type { Direction, MovementKind, TileCoord }; diff --git a/aot/dsl/jsx-dev-runtime.ts b/aot/dsl/jsx-dev-runtime.ts deleted file mode 100644 index fa5ea2d2..00000000 --- a/aot/dsl/jsx-dev-runtime.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./jsx-runtime.ts"; diff --git a/aot/dsl/jsx-runtime.ts b/aot/dsl/jsx-runtime.ts deleted file mode 100644 index 129a5d10..00000000 --- a/aot/dsl/jsx-runtime.ts +++ /dev/null @@ -1,140 +0,0 @@ -// aot/dsl/jsx-runtime.ts — the BUILD-TIME JSX factory (automatic runtime). -// -// The demo's TSX is transpiled with jsxImportSource = this module, then executed -// in-process by the compiler's static evaluator (design §11.3). JSX does NOT -// build a UI tree — it builds a static "scene element" tree that the IR builder -// walks. Pure user components (e.g. TownSign) are just called and expanded here; -// they never ship to the GBA. - -import type { Direction, MovementKind, ScriptRef, SpriteId, TileCoord } from "./types.ts"; - -export interface PjgbNode { - host: string; // host element name, or "#fragment" - props: Record; - children: PjgbNode[]; -} - -export type PjgbChild = PjgbNode | readonly PjgbChild[] | boolean | null | undefined; - -/** Marker for a host element (Map, Layer, Npc, ...). */ -export interface HostMarker> { - readonly __pjgbHost: true; - readonly hostName: string; - (props: Props & { children?: PjgbChild }): PjgbNode; -} - -export interface LayerProps { - rows: readonly string[]; - legend: Record; -} - -export interface PlayerSpawnProps { - id?: string; - at: TileCoord; - facing: Direction; -} - -export interface EntranceProps { - id: string; - at: TileCoord; - facing: Direction; -} - -export interface NpcProps { - id: string; - sprite: SpriteId | string; - at: TileCoord; - facing: Direction; - movement?: MovementKind; - onTalk?: ScriptRef; -} - -export interface SignProps { - text: string; - at: TileCoord; -} - -export interface WarpProps { - to: `${string}:${string}` | string; - at: TileCoord; -} - -export const Fragment = hostElement("#fragment"); - -export function hostElement>( - name: string, -): HostMarker { - const marker = ((props: Props & { children?: PjgbChild }) => { - const { children, ...rest } = props ?? {}; - const kids = normalizeChildren(children); - if (name === "#fragment") return { host: "#fragment", props: {}, children: kids }; - return { host: name, props: rest, children: kids }; - }) as HostMarker; - Object.defineProperties(marker, { - __pjgbHost: { value: true }, - hostName: { value: name }, - }); - return marker; -} - -function isHost(t: unknown): t is HostMarker { - return (typeof t === "object" || typeof t === "function") && t !== null && (t as HostMarker).__pjgbHost === true; -} - -/** Flatten fragments/arrays/falsy into a flat list of real element nodes. */ -function normalizeChildren(raw: unknown): PjgbNode[] { - const out: PjgbNode[] = []; - const push = (c: unknown): void => { - if (c === null || c === undefined || c === false || c === true) return; - if (Array.isArray(c)) { - for (const x of c) push(x); - return; - } - const node = c as PjgbNode; - if (node && typeof node === "object" && node.host === "#fragment") { - for (const x of node.children) push(x); - return; - } - if (node && typeof node === "object" && typeof node.host === "string") { - out.push(node); - return; - } - // Strings/numbers are not valid scene children in v1. - throw new Error(`pjgb jsx: unexpected child ${JSON.stringify(c)}`); - }; - push(raw); - return out; -} - -export function normalizeSceneChildren(raw: unknown): PjgbNode[] { - return normalizeChildren(raw); -} - -export function jsx(type: unknown, props: Record): PjgbNode { - const { children, ...rest } = props ?? {}; - const kids = normalizeChildren(children); - - if (isHost(type)) { - if (type.hostName === "#fragment") return { host: "#fragment", props: {}, children: kids }; - return { host: type.hostName, props: rest, children: kids }; - } - if (typeof type === "function") { - // Pure build-time component: execute and expand. - const result = (type as (p: Record) => unknown)({ ...rest, children: kids }); - const normalized = normalizeChildren(result); - return normalized.length === 1 - ? normalized[0] - : { host: "#fragment", props: {}, children: normalized }; - } - throw new Error(`pjgb jsx: unsupported element type ${String(type)}`); -} - -export const jsxs = jsx; -export const jsxDEV = jsx; - -export namespace JSX { - export type Element = PjgbNode; - export interface ElementChildrenAttribute { - children: {}; - } -} diff --git a/aot/dsl/types.ts b/aot/dsl/types.ts deleted file mode 100644 index 7cafb72c..00000000 --- a/aot/dsl/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -// aot/dsl/types.ts — authoring-time types: branded ids and shared enums. -// Types are erased; the compiler re-validates against the Game IR (design §9). - -export type Brand = T & { readonly __brand: K }; -export type MapId = Brand; -export type SpriteId = Brand; -export type FlagId = Brand; -export type VarId = Brand; -export type ItemId = Brand; -export type BattleId = Brand; -export type ScriptId = Brand; - -export type Direction = "down" | "up" | "left" | "right"; -export type MovementKind = "static" | "wander" | "patrolH" | "patrolV"; -export type TileCoord = readonly [x: number, y: number]; - -// A compiled reference to a script's generator body. `script()` returns this; -// JSX props like `onTalk={RivalTalk}` carry it into the map IR. -export interface ScriptRef { - readonly __pjgbScript: number; -} diff --git a/aot/package.json b/aot/package.json deleted file mode 100644 index 599d58bf..00000000 --- a/aot/package.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "@pocketjs/aot", - "version": "0.1.0", - "private": true, - "type": "module", - "description": "Ahead-of-time partial-evaluation pipeline: author tile-based RPGs in TypeScript/JSX, ship GBA-native tiles, sprites, palettes and bytecode. A first-class PocketJS architecture alongside @pocketjs/framework.", - "exports": { - ".": "./dsl/index.ts", - "./jsx-runtime": "./dsl/jsx-runtime.ts", - "./jsx-dev-runtime": "./dsl/jsx-dev-runtime.ts", - "./compiler": "./compiler/index.ts", - "./spec": "./spec/pjgb.ts" - }, - "bin": { - "pocket-aot": "./compiler/cli.ts" - }, - "scripts": { - "assets": "bun demo/imagegen/build-assets.ts", - "build": "bun run assets && bun compiler/cli.ts build demo/game.tsx --out dist/pocket-town.gba", - "play": "bash play.sh", - "gen": "bun spec/gen-c.ts", - "handcart": "bun test/handcart.ts", - "test": "bun run assets && bun test/e2e.ts" - }, - "devDependencies": { - "typescript": "^5" - } -} diff --git a/aot/play.sh b/aot/play.sh deleted file mode 100755 index c16a32a7..00000000 --- a/aot/play.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# aot/play.sh — build the demo ROM from TSX and open it in the mGBA window so -# you can play it yourself with the keyboard. -# -# bash aot/play.sh # build + launch the town demo -# bash aot/play.sh path/to/game.tsx # build + launch a different game -# -# Controls (mGBA defaults): -# Arrow keys .... walk -# X ............. A (talk / confirm / advance text / pick menu item) -# Z ............. B (cancel) -# Enter ......... Start Backspace ... Select -set -euo pipefail - -cd "$(cd "$(dirname "$0")/.." && pwd)" # repo root -ENTRY="${1:-aot/demo/game.tsx}" -ROM="$PWD/aot/dist/pocket-town.gba" - -echo "▸ Compiling $ENTRY → $ROM" -bun aot/compiler/cli.ts build "$ENTRY" --out "$ROM" - -APP="$(brew --prefix mgba 2>/dev/null)/mGBA.app" -echo "▸ Launching mGBA — arrows to walk, X = A (talk/confirm), Z = B, Enter = Start" -if [ -d "$APP" ]; then - open -n "$APP" --args "$ROM" -else - mgba "$ROM" # fall back to the CLI binary if the .app isn't where we expect -fi diff --git a/aot/runtime/actor.c b/aot/runtime/actor.c deleted file mode 100644 index fdcbd4f4..00000000 --- a/aot/runtime/actor.c +++ /dev/null @@ -1,13 +0,0 @@ -// aot/runtime/actor.c — per-frame NPC updates. -// -// v1 ships MOVE_STATIC actors (the world's NPCs stand still and are turned -// only by scripts via FACE_PLAYER). Wander/patrol movement kinds are reserved -// and currently treated as static so collision/interaction stay deterministic. -#include "runtime.h" - -void actors_update(void) { - for (int i = 0; i < g.n_actors && i < BUDGET_MAX_ACTORS_PER_MAP; i++) { - // MOVE_STATIC: nothing to do. (Other move kinds reserved for v2.) - (void)i; - } -} diff --git a/aot/runtime/bg.c b/aot/runtime/bg.c deleted file mode 100644 index 9e085988..00000000 --- a/aot/runtime/bg.c +++ /dev/null @@ -1,21 +0,0 @@ -// aot/runtime/bg.c — BG0 tilemap (the world map) + scroll. -#include "runtime.h" - -void bg_load_map(void) { - u16 *sb = SCREENBLOCK(PJ_MAP_SBB); - for (int cy = 0; cy < 32; cy++) { - for (int cx = 0; cx < 32; cx++) { - u16 se; - if (cx < g.map_w && cy < g.map_h) - se = SE(g.map_tiles[cy * g.map_w + cx], g.bg_palbank); - else - se = SE(0, 0); - sb[cy * 32 + cx] = se; - } - } -} - -void bg_set_scroll(void) { - REG_BG0HOFS = (u16)g.cam_x; - REG_BG0VOFS = (u16)g.cam_y; -} diff --git a/aot/runtime/build.sh b/aot/runtime/build.sh deleted file mode 100755 index a9ca3eb9..00000000 --- a/aot/runtime/build.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash -# aot/runtime/build.sh — build the PocketJS-AOT GBA ROM. -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -cd "$ROOT" - -mkdir -p aot/dist - -arm-none-eabi-gcc -mcpu=arm7tdmi -marm -ffreestanding -nostdlib -O2 -fno-strict-aliasing \ - -Wall -Wextra -Iaot/runtime -Taot/runtime/gba.ld \ - aot/runtime/crt0.s aot/runtime/cart.c aot/runtime/video.c aot/runtime/bg.c aot/runtime/obj.c \ - aot/runtime/input.c aot/runtime/map.c aot/runtime/player.c aot/runtime/actor.c aot/runtime/camera.c \ - aot/runtime/script_vm.c aot/runtime/textbox.c aot/runtime/debug.c aot/runtime/main.c aot/runtime/gen_cart.c \ - -lgcc -o aot/dist/game.elf - -arm-none-eabi-objcopy -O binary aot/dist/game.elf aot/dist/game.gba - -echo "built aot/dist/game.gba ($(wc -c < aot/dist/game.gba) bytes)" diff --git a/aot/runtime/camera.c b/aot/runtime/camera.c deleted file mode 100644 index 1766b686..00000000 --- a/aot/runtime/camera.c +++ /dev/null @@ -1,18 +0,0 @@ -// aot/runtime/camera.c — follow the player, clamped to the map bounds. -#include "runtime.h" - -static s32 clampi(s32 v, s32 lo, s32 hi) { - if (v < lo) return lo; - if (v > hi) return hi; - return v; -} - -void camera_follow(void) { - s32 max_x = (s32)g.map_w * 8 - PJGB_SCREEN_W; - s32 max_y = (s32)g.map_h * 8 - PJGB_SCREEN_H; - if (max_x < 0) max_x = 0; // map narrower than the screen -> pin to 0 - if (max_y < 0) max_y = 0; - - g.cam_x = clampi(g.player.px + 4 - PJGB_SCREEN_W / 2, 0, max_x); - g.cam_y = clampi(g.player.py + 4 - PJGB_SCREEN_H / 2, 0, max_y); -} diff --git a/aot/runtime/cart.c b/aot/runtime/cart.c deleted file mode 100644 index bcf7ed50..00000000 --- a/aot/runtime/cart.c +++ /dev/null @@ -1,30 +0,0 @@ -// aot/runtime/cart.c — PJGB cartridge container access. -// -// The blob layout (little-endian): -// header (PJGB_HEADER_SIZE bytes): magic[4], u16 version, u16 chunk_count, -// u32 chunk_table_offset, u32 total_size -// chunk table: chunk_count entries of { u32 kind, u32 id, u32 offset, u32 size } -// ...chunk payloads (offset is measured from the blob base). -#include "runtime.h" - -static const u8 *cart_base; - -void cart_load(const void *blob) { cart_base = (const u8 *)blob; } - -const u8 *cart_chunk(u32 kind, u32 id, u32 *out_size) { - u16 count = *(const u16 *)(cart_base + 6); - u32 table_off = *(const u32 *)(cart_base + 8); - const u8 *entry = cart_base + table_off; - for (u16 i = 0; i < count; i++, entry += PJGB_CHUNK_ENTRY_SIZE) { - u32 k = *(const u32 *)(entry + 0); - u32 eid = *(const u32 *)(entry + 4); - if (k == kind && eid == id) { - u32 off = *(const u32 *)(entry + 8); - u32 sz = *(const u32 *)(entry + 12); - if (out_size) *out_size = sz; - return cart_base + off; - } - } - if (out_size) *out_size = 0; - return 0; -} diff --git a/aot/runtime/crt0.s b/aot/runtime/crt0.s deleted file mode 100644 index 9e558b6d..00000000 --- a/aot/runtime/crt0.s +++ /dev/null @@ -1,54 +0,0 @@ -@ aot/runtime/crt0.s — GBA cartridge header + bare-metal startup. -@ Header bytes 0x04..0x9F (Nintendo logo) and 0xBD (complement) are patched by -@ the compiler's ROM writer (aot/compiler/rom.ts) after objcopy. - .section .init, "ax" - .global _start - .cpu arm7tdmi - .align 2 - .arm -_start: - b .Lstart @ 0x00: branch past the 0xC0-byte header - .space 156, 0 @ 0x04: Nintendo logo (patched) - .ascii "POCKET TOWN " @ 0xA0: game title (12) - .ascii "PJAE" @ 0xAC: game code (4) - .ascii "PJ" @ 0xB0: maker code (2) - .byte 0x96 @ 0xB2: fixed value - .byte 0x00 @ 0xB3: main unit code - .byte 0x00 @ 0xB4: device type - .space 7, 0 @ 0xB5: reserved (7) - .byte 0x00 @ 0xBC: software version - .byte 0x00 @ 0xBD: complement check (patched) - .space 2, 0 @ 0xBE: reserved (2) -.Lstart: @ 0xC0 - @ IRQ-mode stack (IRQ+FIQ disabled) - msr cpsr_c, #0xD2 - ldr sp, =0x03007FA0 - @ system-mode stack (IRQ+FIQ disabled) - msr cpsr_c, #0xDF - ldr sp, =0x03007F00 - - @ copy .data (ROM LMA -> IWRAM VMA), word-wise - ldr r0, =__data_lma - ldr r1, =__data_start - ldr r2, =__data_end -.Lcopy: - cmp r1, r2 - ldrlo r3, [r0], #4 - strlo r3, [r1], #4 - blo .Lcopy - - @ zero .bss - ldr r1, =__bss_start - ldr r2, =__bss_end - mov r3, #0 -.Lbss: - cmp r1, r2 - strlo r3, [r1], #4 - blo .Lbss - - @ main() - ldr r0, =main - mov lr, pc - bx r0 -.Lhang: - b .Lhang diff --git a/aot/runtime/debug.c b/aot/runtime/debug.c deleted file mode 100644 index 2d18c5ea..00000000 --- a/aot/runtime/debug.c +++ /dev/null @@ -1,29 +0,0 @@ -// aot/runtime/debug.c — mirror game state into the fixed EWRAM debug block -// so the headless mGBA harness can bus-read it. -#include "runtime.h" - -#define DBG8(off) (*(volatile u8 *)(PJGB_DEBUG_ADDR + (off))) -#define DBG16(off) (*(volatile u16 *)(PJGB_DEBUG_ADDR + (off))) -#define DBG32(off) (*(volatile u32 *)(PJGB_DEBUG_ADDR + (off))) - -void debug_init(void) { - DBG32(DBG_MAGIC) = DEBUG_MAGIC; - DBG8(DBG_BOOTED) = 1; -} - -void debug_update(void) { - DBG32(DBG_MAGIC) = DEBUG_MAGIC; - DBG16(DBG_PLAYER_X) = (u16)(g.player.px >> 3); - DBG16(DBG_PLAYER_Y) = (u16)(g.player.py >> 3); - DBG8(DBG_PLAYER_DIR) = g.player.dir; - DBG8(DBG_CUR_MAP) = g.map_id; - DBG8(DBG_TEXT_ACTIVE) = g.text_active; - DBG8(DBG_SCRIPT_ACTIVE) = (u8)vm_active(); - DBG32(DBG_FRAME) = g.frame; - DBG16(DBG_CUR_TEXT) = g.text_active ? g.cur_text : 0xFFFF; - DBG8(DBG_CHOICE_CURSOR) = g.choice_cursor; - DBG8(DBG_BOOTED) = 1; - for (int i = 0; i < 16; i++) DBG8(DBG_FLAGS + i) = g.flags[i]; - for (int i = 0; i < 16; i++) - *(volatile s16 *)(PJGB_DEBUG_ADDR + DBG_VARS + i * 2) = g.vars[i]; -} diff --git a/aot/runtime/gba.h b/aot/runtime/gba.h deleted file mode 100644 index 7f1ff67b..00000000 --- a/aot/runtime/gba.h +++ /dev/null @@ -1,117 +0,0 @@ -// aot/runtime/gba.h — minimal GBA hardware definitions (Tonc-style). -// Freestanding, no libc. Register addresses per GBATEK / Tonc. -#ifndef PJGB_GBA_H -#define PJGB_GBA_H -#include - -typedef uint8_t u8; -typedef uint16_t u16; -typedef uint32_t u32; -typedef int8_t s8; -typedef int16_t s16; -typedef int32_t s32; -typedef volatile uint16_t vu16; -typedef volatile uint32_t vu32; - -// --- Memory regions --------------------------------------------------------- -#define MEM_IO 0x04000000 -#define MEM_PAL 0x05000000 -#define MEM_VRAM 0x06000000 -#define MEM_OAM 0x07000000 - -#define REG_DISPCNT (*(vu32 *)(MEM_IO + 0x0000)) -#define REG_DISPSTAT (*(vu16 *)(MEM_IO + 0x0004)) -#define REG_VCOUNT (*(vu16 *)(MEM_IO + 0x0006)) -#define REG_BG0CNT (*(vu16 *)(MEM_IO + 0x0008)) -#define REG_BG1CNT (*(vu16 *)(MEM_IO + 0x000a)) -#define REG_BG0HOFS (*(vu16 *)(MEM_IO + 0x0010)) -#define REG_BG0VOFS (*(vu16 *)(MEM_IO + 0x0012)) -#define REG_BG1HOFS (*(vu16 *)(MEM_IO + 0x0014)) -#define REG_BG1VOFS (*(vu16 *)(MEM_IO + 0x0016)) -#define REG_KEYINPUT (*(vu16 *)(MEM_IO + 0x0130)) - -// DISPCNT bits -#define DCNT_MODE0 0x0000 -#define DCNT_BG0 0x0100 -#define DCNT_BG1 0x0200 -#define DCNT_OBJ 0x1000 -#define DCNT_OBJ_1D 0x0040 - -// BGxCNT bits -#define BG_4BPP 0x0000 -#define BG_8BPP 0x0080 -#define BG_REG_32x32 0x0000 -#define BG_PRIO(n) ((n) & 3) -#define BG_CBB(n) (((n) & 3) << 2) // char base block (charblock) -#define BG_SBB(n) (((n) & 0x1f) << 8) // screen base block (screenblock) - -// --- VRAM helpers ----------------------------------------------------------- -// Charblock = 0x4000 bytes (512 4bpp tiles); screenblock = 0x800 bytes. -#define CHARBLOCK(n) ((u16 *)(MEM_VRAM + (n) * 0x4000)) -#define SCREENBLOCK(n) ((u16 *)(MEM_VRAM + (n) * 0x800)) -#define OBJ_VRAM ((u16 *)(MEM_VRAM + 0x10000)) // OBJ tiles (charblock 4) -#define BG_PAL ((u16 *)MEM_PAL) // 256 entries (16 banks of 16) -#define OBJ_PAL ((u16 *)(MEM_PAL + 0x200)) // 256 entries - -// Layout used by the PocketJS-AOT runtime: -#define PJ_BG_CBB 0 // BG char base charblock (tiles 0..) -#define PJ_MAP_SBB 8 // BG0 map screenblock -#define PJ_TEXT_SBB 9 // BG1 textbox screenblock - -// A 4bpp BG/screen entry: bits 0-9 tile index, 10 hflip, 11 vflip, 12-15 palbank -#define SE(tile, palbank) (((tile) & 0x3ff) | (((palbank) & 0xf) << 12)) - -// --- Keys (active-low in REG_KEYINPUT; bit index matches mGBA GBAKey) -------- -#define KEY_A 0x0001 -#define KEY_B 0x0002 -#define KEY_SELECT 0x0004 -#define KEY_START 0x0008 -#define KEY_RIGHT 0x0010 -#define KEY_LEFT 0x0020 -#define KEY_UP 0x0040 -#define KEY_DOWN 0x0080 -#define KEY_R 0x0100 -#define KEY_L 0x0200 - -// --- OAM -------------------------------------------------------------------- -typedef struct { - u16 attr0; - u16 attr1; - u16 attr2; - u16 fill; -} ObjAttr; // 8 bytes; 128 in OAM - -#define OAM ((ObjAttr *)MEM_OAM) - -// attr0 -#define ATTR0_Y(y) ((y) & 0xff) -#define ATTR0_SQUARE 0x0000 -#define ATTR0_HIDE 0x0200 -#define ATTR0_4BPP 0x0000 -// attr1 -#define ATTR1_X(x) ((x) & 0x1ff) -#define ATTR1_SIZE_16 0x4000 // with ATTR0_SQUARE => 16x16 -// attr2 -#define ATTR2_TILE(t) ((t) & 0x3ff) -#define ATTR2_PRIO(p) (((p) & 3) << 10) -#define ATTR2_PALBANK(n) (((n) & 0xf) << 12) - -// --- DMA (channel 3, used to blit OAM shadow at VBlank) --------------------- -#define REG_DMA3SAD (*(vu32 *)(MEM_IO + 0x00d4)) -#define REG_DMA3DAD (*(vu32 *)(MEM_IO + 0x00d8)) -#define REG_DMA3CNT (*(vu32 *)(MEM_IO + 0x00dc)) -#define DMA_ENABLE 0x80000000 -#define DMA_32 0x04000000 - -static inline void dma3_copy32(void *dst, const void *src, u32 words) { - REG_DMA3SAD = (u32)src; - REG_DMA3DAD = (u32)dst; - REG_DMA3CNT = words | DMA_ENABLE | DMA_32; -} - -static inline void vblank_wait(void) { - while (REG_VCOUNT >= 160) {} // wait for end of any current vblank - while (REG_VCOUNT < 160) {} // wait for start of vblank -} - -#endif // PJGB_GBA_H diff --git a/aot/runtime/gba.ld b/aot/runtime/gba.ld deleted file mode 100644 index f96694ba..00000000 --- a/aot/runtime/gba.ld +++ /dev/null @@ -1,42 +0,0 @@ -/* aot/runtime/gba.ld — GBA link map. - text + rodata + the linked cart blob live in ROM (0x08000000). - .data/.bss/stack live in IWRAM (0x03000000, 32 KB) so EWRAM (0x02000000) - stays free for the runtime's fixed debug block + any decompression scratch. */ -OUTPUT_FORMAT("elf32-littlearm") -OUTPUT_ARCH(arm) -ENTRY(_start) - -MEMORY { - rom (rx) : ORIGIN = 0x08000000, LENGTH = 32M - iwram (rwx) : ORIGIN = 0x03000000, LENGTH = 32K - ewram (rwx) : ORIGIN = 0x02000000, LENGTH = 256K -} - -SECTIONS { - .init : { - KEEP(*(.init)) - *(.text .text.*) - *(.rodata .rodata.*) - . = ALIGN(4); - } > rom - - __data_lma = .; - .data : AT (__data_lma) { - . = ALIGN(4); - __data_start = .; - *(.data .data.*) - . = ALIGN(4); - __data_end = .; - } > iwram - - .bss (NOLOAD) : { - . = ALIGN(4); - __bss_start = .; - *(.bss .bss.*) - *(COMMON) - . = ALIGN(4); - __bss_end = .; - } > iwram - - /DISCARD/ : { *(.comment) *(.note*) *(.ARM.attributes) } -} diff --git a/aot/runtime/input.c b/aot/runtime/input.c deleted file mode 100644 index 1d9b9b6d..00000000 --- a/aot/runtime/input.c +++ /dev/null @@ -1,13 +0,0 @@ -// aot/runtime/input.c — key polling with edge detection. -#include "runtime.h" - -void input_poll(void) { - g.keys_prev = g.keys; - g.keys = (u16)((~REG_KEYINPUT) & 0x3ff); // active-low -> active-high held mask -} - -int key_held(int mask) { return (g.keys & mask) != 0; } - -int key_pressed(int mask) { - return (g.keys & mask) != 0 && (g.keys_prev & mask) == 0; -} diff --git a/aot/runtime/main.c b/aot/runtime/main.c deleted file mode 100644 index 4b3770f4..00000000 --- a/aot/runtime/main.c +++ /dev/null @@ -1,34 +0,0 @@ -// aot/runtime/main.c — the runtime entry point + per-frame game loop. -#include "runtime.h" - -// The single definition of the global game state (all other modules extern it). -Game g; - -int main(void) { - cart_load(pjgb_cart); - g.game = (const GameHeader *)cart_chunk(CHUNK_GAME, 0, 0); - - video_init(); - textbox_init(); - debug_init(); - - map_enter(g.game->start_map, g.game->start_x, g.game->start_y, g.game->start_dir); - - for (;;) { - input_poll(); - vm_tick(); - if (!vm_active()) player_update(); - textbox_tick(); - choice_tick(); - actors_update(); - camera_follow(); - bg_set_scroll(); - obj_reset(); - obj_draw_scene(); - debug_update(); - vblank_wait(); - obj_commit(); - g.frame++; - } - return 0; -} diff --git a/aot/runtime/map.c b/aot/runtime/map.c deleted file mode 100644 index c34ddc2e..00000000 --- a/aot/runtime/map.c +++ /dev/null @@ -1,53 +0,0 @@ -// aot/runtime/map.c — map load, collision, actor lookup. -#include "runtime.h" - -void map_enter(int map_id, int tx, int ty, int dir) { - const u8 *chunk = cart_chunk(CHUNK_MAP, (u32)map_id, 0); - const MapHeader *mh = (const MapHeader *)chunk; - - g.map_id = (u8)map_id; - g.map_w = mh->width; - g.map_h = mh->height; - g.map_tiles = (const u16 *)(chunk + mh->tiles_off); - g.map_collision = chunk + mh->collision_off; - g.actors = (const ActorRec *)(chunk + mh->actors_off); - g.n_actors = mh->num_actors; - g.warps = (const WarpRec *)(chunk + mh->warps_off); - g.n_warps = mh->num_warps; - g.bg_palbank = mh->bg_palbank; - - for (int i = 0; i < g.n_actors && i < BUDGET_MAX_ACTORS_PER_MAP; i++) { - g.actor_dir[i] = g.actors[i].facing; - g.actor_frame[i] = 0; - } - - g.player.px = tx * 8; - g.player.py = ty * 8; - g.player.dir = (u8)dir; - g.player.moving = 0; - g.player.anim_frame = 0; - g.player.anim_timer = 0; - g.player.sprite_id = 0; - - bg_load_map(); - camera_follow(); - bg_set_scroll(); -} - -int map_solid(int tx, int ty) { - if (tx < 0 || ty < 0 || tx >= (int)g.map_w || ty >= (int)g.map_h) return 1; - if (g.map_collision[ty * g.map_w + tx]) return 1; - for (int i = 0; i < g.n_actors; i++) { - if ((g.actors[i].flags & ACTOR_FLAG_SOLID) && - g.actors[i].x == tx && g.actors[i].y == ty) - return 1; - } - return 0; -} - -int map_actor_at(int tx, int ty) { - for (int i = 0; i < g.n_actors; i++) { - if (g.actors[i].x == tx && g.actors[i].y == ty) return i; - } - return -1; -} diff --git a/aot/runtime/obj.c b/aot/runtime/obj.c deleted file mode 100644 index 16fb7866..00000000 --- a/aot/runtime/obj.c +++ /dev/null @@ -1,61 +0,0 @@ -// aot/runtime/obj.c — sprite (OBJ) rendering via a shadow OAM buffer. -#include "runtime.h" - -static ObjAttr shadow[128]; - -void obj_reset(void) { - for (int i = 0; i < 128; i++) shadow[i].attr0 = ATTR0_HIDE; -} - -void obj_commit(void) { - dma3_copy32(OAM, shadow, sizeof(shadow) / 4); -} - -void obj_put(int slot, int sx, int sy, int tile, int palbank) { - if (slot < 0 || slot >= 128) return; - ObjAttr *o = &shadow[slot]; - o->attr0 = ATTR0_Y(sy) | ATTR0_SQUARE | ATTR0_4BPP; - o->attr1 = ATTR1_X(sx) | ATTR1_SIZE_16; - o->attr2 = ATTR2_TILE(tile) | ATTR2_PALBANK(palbank) | ATTR2_PRIO(0); - o->fill = 0; -} - -// A 16x16 tile-occupant's sprite tile for a facing/frame. -static int sprite_tile(const SpriteRec *sp, int dir, int frame) { - int frames = sp->frames ? sp->frames : 1; - return sp->tile_base + dir * (frames * 4) + (frame % frames) * 4; -} - -// Fully offscreen (sprite spans [s, s+16) on each axis)? -static int offscreen(int sx, int sy) { - return sx + 16 <= 0 || sx >= PJGB_SCREEN_W || sy + 16 <= 0 || sy >= PJGB_SCREEN_H; -} - -void obj_draw_scene(void) { - const SpriteRec *table = (const SpriteRec *)cart_chunk(CHUNK_SPRITE_TABLE, 0, 0); - - // Player -> slot 0. - { - const SpriteRec *sp = &table[g.player.sprite_id]; - int sx = (int)g.player.px - (int)g.cam_x - 4; - int sy = (int)g.player.py - (int)g.cam_y - 8; - if (!offscreen(sx, sy)) { - int frames = sp->frames ? sp->frames : 1; - int tile = sprite_tile(sp, g.player.dir, g.player.anim_frame % frames); - obj_put(0, sx, sy, tile, sp->palbank); - } - } - - // Actors -> slots 1..n. - for (int i = 0; i < g.n_actors && i < BUDGET_MAX_ACTORS_PER_MAP; i++) { - const ActorRec *a = &g.actors[i]; - if (a->sprite == 0xff) continue; // 0xFF = no sprite (e.g. a sign) - const SpriteRec *sp = &table[a->sprite]; - int wx = a->x * 8, wy = a->y * 8; - int sx = wx - (int)g.cam_x - 4; - int sy = wy - (int)g.cam_y - 8; - if (offscreen(sx, sy)) continue; - int tile = sprite_tile(sp, g.actor_dir[i], g.actor_frame[i]); - obj_put(1 + i, sx, sy, tile, sp->palbank); - } -} diff --git a/aot/runtime/pjgb_gen.h b/aot/runtime/pjgb_gen.h deleted file mode 100644 index 67cb7b7e..00000000 --- a/aot/runtime/pjgb_gen.h +++ /dev/null @@ -1,132 +0,0 @@ -// GENERATED by aot/spec/gen-c.ts from aot/spec/pjgb.ts — DO NOT EDIT. -#ifndef PJGB_GEN_H -#define PJGB_GEN_H -#include - -/* ---------------------------------------------------------------------- - * Screen / tiles - * ---------------------------------------------------------------------- */ - -#define PJGB_SCREEN_W 0xf0 -#define PJGB_SCREEN_H 0xa0 -#define PJGB_SCREEN_TILES_W 0x1e -#define PJGB_SCREEN_TILES_H 0x14 -#define PJGB_TILE_4BPP_BYTES 0x20 - -/* ---------------------------------------------------------------------- - * Container header + chunks - * ---------------------------------------------------------------------- */ - -#define PJGB_VERSION 0x1 -#define PJGB_HEADER_SIZE 0x10 -#define PJGB_CHUNK_ENTRY_SIZE 0x10 -#define CHUNK_GAME 0x1 -#define CHUNK_PAL_BG 0x2 -#define CHUNK_PAL_OBJ 0x3 -#define CHUNK_TILES_BG 0x4 -#define CHUNK_TILES_OBJ 0x5 -#define CHUNK_MAP 0x6 -#define CHUNK_TEXT_BANK 0x7 -#define CHUNK_SCRIPT_CODE 0x8 -#define CHUNK_SCRIPT_TABLE 0x9 -#define CHUNK_SPRITE_TABLE 0xa - -/* ---------------------------------------------------------------------- - * Struct sizes / field constants - * ---------------------------------------------------------------------- */ - -#define PJGB_GAME_TITLE_LEN 0x18 -#define PJGB_GAME_HEADER_SIZE 0x2c -#define PJGB_MAP_HEADER_SIZE 0x1c -#define PJGB_ACTOR_INSTANCE_SIZE 0xc -#define PJGB_WARP_SIZE 0xc -#define PJGB_SPRITE_PROTO_SIZE 0x8 -#define PJGB_SCRIPT_NONE 0xffff -#define PJGB_COLLISION_WALKABLE 0x0 -#define PJGB_COLLISION_SOLID 0x1 - -/* ---------------------------------------------------------------------- - * Directions / movement / actor flags - * ---------------------------------------------------------------------- */ - -#define DIR_DOWN 0x0 -#define DIR_UP 0x1 -#define DIR_LEFT 0x2 -#define DIR_RIGHT 0x3 -#define MOVE_STATIC 0x0 -#define MOVE_WANDER 0x1 -#define MOVE_PATROL_H 0x2 -#define MOVE_PATROL_V 0x3 -#define ACTOR_FLAG_NONE 0x0 -#define ACTOR_FLAG_SOLID 0x1 - -/* ---------------------------------------------------------------------- - * Script VM - * ---------------------------------------------------------------------- */ - -#define OP_END 0x0 -#define OP_NOP 0x1 -#define OP_TEXT 0x2 -#define OP_SET_FLAG 0x3 -#define OP_CLEAR_FLAG 0x4 -#define OP_PUSH_FLAG 0x5 -#define OP_PUSH_CONST 0x6 -#define OP_POP 0x7 -#define OP_DUP 0x8 -#define OP_EQ 0x9 -#define OP_NE 0xa -#define OP_NOT 0xb -#define OP_JUMP 0xc -#define OP_JUMP_IF_FALSE 0xd -#define OP_CHOICE 0xe -#define OP_LOCK_PLAYER 0xf -#define OP_RELEASE_PLAYER 0x10 -#define OP_FACE_PLAYER 0x11 -#define OP_WARP 0x12 -#define OP_SET_VAR 0x13 -#define OP_ADD_VAR 0x14 -#define OP_PUSH_VAR 0x15 -#define OP_GIVE_ITEM 0x16 -#define OP_BATTLE 0x17 -#define OP_WAIT 0x18 -#define OP_PLAY_SFX 0x19 -#define PJGB_VM_MAX_STACK 0x10 - -/* ---------------------------------------------------------------------- - * Debug block (fixed EWRAM address for the mGBA harness) - * ---------------------------------------------------------------------- */ - -#define PJGB_EWRAM_BASE 0x2000000 -#define PJGB_DEBUG_ADDR 0x2000000 -#define PJGB_DEBUG_BLOCK_SIZE 0x44 -#define DEBUG_MAGIC 0x504a4442 -#define DBG_MAGIC 0x0 -#define DBG_PLAYER_X 0x4 -#define DBG_PLAYER_Y 0x6 -#define DBG_PLAYER_DIR 0x8 -#define DBG_CUR_MAP 0x9 -#define DBG_TEXT_ACTIVE 0xa -#define DBG_SCRIPT_ACTIVE 0xb -#define DBG_FRAME 0xc -#define DBG_CUR_TEXT 0x10 -#define DBG_CHOICE_CURSOR 0x12 -#define DBG_BOOTED 0x13 -#define DBG_FLAGS 0x14 -#define DBG_VARS 0x24 - -/* ---------------------------------------------------------------------- - * Budgets - * ---------------------------------------------------------------------- */ - -#define BUDGET_MAX_ACTORS_PER_MAP 0x18 -#define BUDGET_MAX_MAPS 0x20 -#define BUDGET_MAX_SPRITES 0x10 -#define BUDGET_MAX_FLAGS 0x80 -#define BUDGET_MAX_VARS 0x10 -#define BUDGET_MAX_TEXTS 0x200 -#define BUDGET_MAX_SCRIPTS 0x100 -#define BUDGET_MAX_BG_TILES 0x200 -#define BUDGET_MAX_OBJ_TILES 0x400 -#define BUDGET_MAX_MAP_TILES 0x4000 - -#endif /* PJGB_GEN_H */ diff --git a/aot/runtime/player.c b/aot/runtime/player.c deleted file mode 100644 index 04d80830..00000000 --- a/aot/runtime/player.c +++ /dev/null @@ -1,63 +0,0 @@ -// aot/runtime/player.c — grid movement, collision, and A-to-interact. -#include "runtime.h" - -#define SPEED 2 // pixels per frame while sliding between tiles -#define ANIM_RATE 6 - -// Indexed by DIR_DOWN, DIR_UP, DIR_LEFT, DIR_RIGHT. -static const int DX[4] = {0, 0, -1, 1}; -static const int DY[4] = {1, -1, 0, 0}; - -void player_update(void) { - Player *p = &g.player; - - // Decide a new action only when standing still (and not script-locked). - if (!p->moving && !p->locked) { - // A pressed (edge) -> interact with the tile we face. - if (key_pressed(KEY_A)) { - int tx = (int)(p->px >> 3), ty = (int)(p->py >> 3); - int fx = tx + DX[p->dir], fy = ty + DY[p->dir]; - int slot = map_actor_at(fx, fy); - if (slot >= 0 && g.actors[slot].on_talk != PJGB_SCRIPT_NONE) { - vm_start(g.actors[slot].on_talk, slot); - return; - } - } - - // Directional input: always turn, move if the target tile is free. - int dir = -1; - if (key_held(KEY_DOWN)) dir = DIR_DOWN; - else if (key_held(KEY_UP)) dir = DIR_UP; - else if (key_held(KEY_LEFT)) dir = DIR_LEFT; - else if (key_held(KEY_RIGHT)) dir = DIR_RIGHT; - - if (dir >= 0) { - p->dir = (u8)dir; - int nx = (int)(p->px >> 3) + DX[dir]; - int ny = (int)(p->py >> 3) + DY[dir]; - if (!map_solid(nx, ny)) p->moving = 1; - } - } - - // Slide toward the target tile; land exactly on the 8px grid. - if (p->moving) { - p->px += DX[p->dir] * SPEED; - p->py += DY[p->dir] * SPEED; - if (++p->anim_timer >= ANIM_RATE) { - p->anim_timer = 0; - p->anim_frame++; - } - if ((p->px & 7) == 0 && (p->py & 7) == 0) { - p->moving = 0; - // Stepping onto a warp tile transports the player. - int tx = (int)(p->px >> 3), ty = (int)(p->py >> 3); - for (int i = 0; i < g.n_warps; i++) { - if (g.warps[i].x == tx && g.warps[i].y == ty) { - const WarpRec *w = &g.warps[i]; - map_enter(w->dest_map, w->dest_x, w->dest_y, w->dest_dir); - return; - } - } - } - } -} diff --git a/aot/runtime/runtime.h b/aot/runtime/runtime.h deleted file mode 100644 index 52633be3..00000000 --- a/aot/runtime/runtime.h +++ /dev/null @@ -1,201 +0,0 @@ -// aot/runtime/runtime.h — internal contract for the PocketJS-AOT GBA runtime. -// -// This is the shared header every runtime .c module includes. It defines the -// global game state, the ROM record structs (mirroring the PJGB binary layout -// from pjgb_gen.h), and every module's function signatures. The runtime never -// allocates during gameplay; all state lives in these globals (EWRAM/IWRAM). -// -// MEMORY MAP / VRAM PLAN (see gba.h): -// Mode 0. BG0 = map (32x32 screen, 4bpp, charblock 0, screenblock 8). -// BG1 = textbox/menu (charblock 0, screenblock 9, higher priority). -// BG char data (map tiles + font glyphs) -> charblock 0 (tiles 0..511). -// OBJ tiles (sprites) -> OBJ_VRAM, 1D mapping, 16x16 sprites (4 tiles each). -// BG palette bank 0 = map tileset; bank 15 = textbox/font. -// OBJ palette bank 0 = sprites. -// v1 caps maps at 32x32 tiles so the whole map fits one screenblock. -#ifndef PJGB_RUNTIME_H -#define PJGB_RUNTIME_H -#include "gba.h" -#include "pjgb_gen.h" - -// The cartridge blob, emitted by the compiler as gen_cart.c and linked in. -extern const unsigned char pjgb_cart[]; - -// --- ROM record structs (must match the PJGB binary layout) ----------------- -typedef struct { - char title[24]; - u8 start_map, start_dir; - u16 start_x, start_y; - u8 map_count, sprite_count; - u16 flag_count, text_count, script_count; - u16 font_base, box_tile, rsv; -} GameHeader; -_Static_assert(sizeof(GameHeader) == PJGB_GAME_HEADER_SIZE, "GameHeader size"); - -typedef struct { - u16 width, height, num_actors, num_warps; - u8 bg_palbank, on_load; - u16 rsv; - u32 tiles_off, collision_off, actors_off, warps_off; -} MapHeader; -_Static_assert(sizeof(MapHeader) == PJGB_MAP_HEADER_SIZE, "MapHeader size"); - -typedef struct { - u16 x, y; - u8 sprite, facing, move, flags; - u16 on_talk, rsv; -} ActorRec; -_Static_assert(sizeof(ActorRec) == PJGB_ACTOR_INSTANCE_SIZE, "ActorRec size"); - -typedef struct { - u16 x, y; - u8 dest_map, dest_dir; - u16 dest_x, dest_y, rsv; -} WarpRec; -_Static_assert(sizeof(WarpRec) == PJGB_WARP_SIZE, "WarpRec size"); - -typedef struct { - u16 tile_base; - u8 w, h, palbank, frames; - u16 rsv; -} SpriteRec; -_Static_assert(sizeof(SpriteRec) == PJGB_SPRITE_PROTO_SIZE, "SpriteRec size"); - -// --- Runtime state ---------------------------------------------------------- -typedef struct { - u8 active; // a script is running (incl. suspended) - u8 suspend; // VM_SUSP_* - const u8 *code; - u32 ip; - s16 stack[PJGB_VM_MAX_STACK]; - u8 sp; - u16 wait_frames; - s16 actor_slot; // actor that started the script (for FACE_PLAYER), or -1 -} VM; - -enum { VM_SUSP_NONE = 0, VM_SUSP_TEXT, VM_SUSP_CHOICE, VM_SUSP_WAIT }; - -typedef struct { - // player, in tile coords + sub-tile pixel progress for smooth walking - s32 px; // pixel position (world) x = tile*8 + progress - s32 py; - u8 dir; - u8 moving; // 1 while sliding between tiles - u8 anim_frame; - u8 anim_timer; - u8 locked; // LOCK_PLAYER - u8 sprite_id; // player sprite -} Player; - -typedef struct { - const GameHeader *game; - // current map - u8 map_id; - u16 map_w, map_h; - const u16 *map_tiles; // u16[w*h] - const u8 *map_collision; // u8[w*h] - const ActorRec *actors; - u16 n_actors; - const WarpRec *warps; - u16 n_warps; - u8 bg_palbank; - u8 actor_dir[BUDGET_MAX_ACTORS_PER_MAP]; // runtime facing override (FACE_PLAYER) - u8 actor_frame[BUDGET_MAX_ACTORS_PER_MAP]; // runtime anim frame - - Player player; - s32 cam_x, cam_y; // pixel scroll (top-left of view in world px) - - VM vm; - - // textbox - u8 text_active; - u16 cur_text; - // choice menu - u8 choice_active; - u8 choice_n; - u16 choice_ids[8]; - u8 choice_cursor; - s16 choice_result; // -1 until chosen - - // persistent state - u8 flags[16]; // 128 bits - s16 vars[16]; - - u16 keys, keys_prev; - u32 frame; -} Game; - -extern Game g; - -// --- cart.c ----------------------------------------------------------------- -void cart_load(const void *blob); -// Returns pointer to chunk data (or 0) and writes its size. -const u8 *cart_chunk(u32 kind, u32 id, u32 *out_size); - -// --- video.c ---------------------------------------------------------------- -void video_init(void); // DISPCNT, BG regs, load OBJ tiles + palettes once -void video_load_palettes(void); -void video_load_obj_tiles(void); - -// --- bg.c ------------------------------------------------------------------- -void bg_load_map(void); // load current map's tiles->charblock + screenblock + BG palette -void bg_set_scroll(void); // write BG0 HOFS/VOFS from camera - -// --- obj.c ------------------------------------------------------------------ -void obj_reset(void); // hide all sprites in shadow OAM -void obj_commit(void); // DMA shadow OAM -> hardware OAM (at vblank) -void obj_draw_scene(void); // place player + visible actors into shadow OAM -// low-level: set one 16x16 sprite slot (screen px). tile = OBJ tile index. -void obj_put(int slot, int sx, int sy, int tile, int palbank); - -// --- input.c ---------------------------------------------------------------- -void input_poll(void); -int key_held(int mask); -int key_pressed(int mask); // held this frame, not last - -// --- map.c ------------------------------------------------------------------ -void map_enter(int map_id, int tx, int ty, int dir); // switch map + place player -int map_solid(int tx, int ty); // 1 if tile blocks, incl. actors + bounds -int map_actor_at(int tx, int ty); // actor slot index at tile, or -1 - -// --- player.c / movement.c -------------------------------------------------- -void player_update(void); // input -> grid move w/ collision, camera follow, A=interact - -// --- actor.c ---------------------------------------------------------------- -void actors_update(void); - -// --- camera.c --------------------------------------------------------------- -void camera_follow(void); // clamp camera to player + map bounds - -// --- script_vm.c ------------------------------------------------------------ -void vm_start(int script_id, int actor_slot); -void vm_tick(void); // run/resume the VM this frame (until suspended/END) -int vm_active(void); - -// --- textbox.c -------------------------------------------------------------- -void textbox_init(void); -void textbox_show(int text_id); -void textbox_hide(void); -int textbox_active(void); -void textbox_tick(void); // handle A to advance; clears active when dismissed -// choice menu (rendered in the textbox area) -void choice_show(int n, const u16 *text_ids); -int choice_active(void); -void choice_tick(void); // up/down + A; sets result and clears active when chosen -int choice_result(void); - -// text bank access -const char *text_get(int text_id); - -// --- flags/vars ------------------------------------------------------------- -static inline int flag_get(int id) { return (g.flags[id >> 3] >> (id & 7)) & 1; } -static inline void flag_set(int id, int v) { - if (v) g.flags[id >> 3] |= (1 << (id & 7)); - else g.flags[id >> 3] &= ~(1 << (id & 7)); -} - -// --- debug.c ---------------------------------------------------------------- -void debug_init(void); -void debug_update(void); // mirror game state into the fixed EWRAM debug block - -#endif // PJGB_RUNTIME_H diff --git a/aot/runtime/script_vm.c b/aot/runtime/script_vm.c deleted file mode 100644 index 77e4c833..00000000 --- a/aot/runtime/script_vm.c +++ /dev/null @@ -1,230 +0,0 @@ -// aot/runtime/script_vm.c — the event-script stack machine. -#include "runtime.h" - -// --- operand readers (little-endian, advance ip) --------------------------- -static u8 rd_u8(void) { return g.vm.code[g.vm.ip++]; } -static u16 rd_u16(void) { - u16 v = (u16)(g.vm.code[g.vm.ip] | (g.vm.code[g.vm.ip + 1] << 8)); - g.vm.ip += 2; - return v; -} -static s16 rd_i16(void) { return (s16)rd_u16(); } - -// --- s16 operand stack (clamped) ------------------------------------------- -static void push(s16 v) { - if (g.vm.sp < PJGB_VM_MAX_STACK) g.vm.stack[g.vm.sp++] = v; -} -static s16 pop(void) { - if (g.vm.sp > 0) return g.vm.stack[--g.vm.sp]; - return 0; -} - -// Set actor `slot` to face the player: opposite of the player->actor -// direction, derived from the tile delta. -static void face_player(int slot) { - if (slot < 0 || slot >= g.n_actors) return; - int ax = g.actors[slot].x, ay = g.actors[slot].y; - int px = (int)(g.player.px >> 3), py = (int)(g.player.py >> 3); - int dx = px - ax, dy = py - ay; - int adx = dx < 0 ? -dx : dx; - int ady = dy < 0 ? -dy : dy; - int dir; - if (adx >= ady) dir = dx > 0 ? DIR_RIGHT : DIR_LEFT; - else dir = dy > 0 ? DIR_DOWN : DIR_UP; - g.actor_dir[slot] = (u8)dir; -} - -void vm_start(int script_id, int actor_slot) { - const u32 *table = (const u32 *)cart_chunk(CHUNK_SCRIPT_TABLE, 0, 0); - const u8 *code = cart_chunk(CHUNK_SCRIPT_CODE, 0, 0); - g.vm.code = code + table[script_id]; - g.vm.ip = 0; - g.vm.sp = 0; - g.vm.active = 1; - g.vm.suspend = VM_SUSP_NONE; - g.vm.wait_frames = 0; - g.vm.actor_slot = (s16)actor_slot; -} - -int vm_active(void) { return g.vm.active; } - -void vm_tick(void) { - if (!g.vm.active) return; - - // Resume from a suspension, if the resume condition is met. - switch (g.vm.suspend) { - case VM_SUSP_WAIT: - if (--g.vm.wait_frames == 0) g.vm.suspend = VM_SUSP_NONE; - else return; - break; - case VM_SUSP_TEXT: - if (!textbox_active()) g.vm.suspend = VM_SUSP_NONE; - else return; - break; - case VM_SUSP_CHOICE: - if (choice_result() >= 0) { - push((s16)choice_result()); - g.vm.suspend = VM_SUSP_NONE; - } else { - return; - } - break; - default: - break; - } - - // Execute ops until END or a new suspension is set. - for (;;) { - u8 op = rd_u8(); - switch (op) { - case OP_END: - g.vm.active = 0; - return; - case OP_NOP: - break; - case OP_TEXT: { - u16 t = rd_u16(); - textbox_show(t); - g.vm.suspend = VM_SUSP_TEXT; - return; - } - case OP_SET_FLAG: { - u16 f = rd_u16(); - flag_set(f, 1); - break; - } - case OP_CLEAR_FLAG: { - u16 f = rd_u16(); - flag_set(f, 0); - break; - } - case OP_PUSH_FLAG: { - u16 f = rd_u16(); - push((s16)flag_get(f)); - break; - } - case OP_PUSH_CONST: { - s16 v = rd_i16(); - push(v); - break; - } - case OP_POP: - pop(); - break; - case OP_DUP: { - s16 v = pop(); - push(v); - push(v); - break; - } - case OP_EQ: { - s16 b = pop(), a = pop(); - push(a == b ? 1 : 0); - break; - } - case OP_NE: { - s16 b = pop(), a = pop(); - push(a != b ? 1 : 0); - break; - } - case OP_NOT: { - s16 a = pop(); - push(a ? 0 : 1); - break; - } - case OP_JUMP: { - s16 rel = rd_i16(); // measured from after the operand bytes - g.vm.ip = (u32)((s32)g.vm.ip + rel); - break; - } - case OP_JUMP_IF_FALSE: { - s16 rel = rd_i16(); - if (!pop()) g.vm.ip = (u32)((s32)g.vm.ip + rel); - break; - } - case OP_CHOICE: { - u8 n = rd_u8(); - u16 ids[8]; - for (int i = 0; i < n; i++) { - u16 id = rd_u16(); - if (i < 8) ids[i] = id; - } - if (n > 8) n = 8; - choice_show(n, ids); - g.vm.suspend = VM_SUSP_CHOICE; - return; - } - case OP_LOCK_PLAYER: - g.player.locked = 1; - break; - case OP_RELEASE_PLAYER: - g.player.locked = 0; - break; - case OP_FACE_PLAYER: { - u8 slot = rd_u8(); - // 0xFF = "the actor that started this script" (compiler emits this for - // facePlayer(""), avoiding cross-map slot resolution). - if (slot == 0xff) { - if (g.vm.actor_slot < 0) break; - slot = (u8)g.vm.actor_slot; - } - face_player(slot); - break; - } - case OP_WARP: { - u8 m = rd_u8(); - u16 x = rd_u16(); - u16 y = rd_u16(); - u8 d = rd_u8(); - map_enter(m, x, y, d); - break; - } - case OP_SET_VAR: { - u16 i = rd_u16(); - s16 v = rd_i16(); - if (i < BUDGET_MAX_VARS) g.vars[i] = v; - break; - } - case OP_ADD_VAR: { - u16 i = rd_u16(); - s16 v = rd_i16(); - if (i < BUDGET_MAX_VARS) g.vars[i] += v; - break; - } - case OP_PUSH_VAR: { - u16 i = rd_u16(); - push(i < BUDGET_MAX_VARS ? g.vars[i] : 0); - break; - } - case OP_GIVE_ITEM: { - u16 item = rd_u16(); - u8 qty = rd_u8(); - (void)item; - (void)qty; // stub - break; - } - case OP_BATTLE: { - u16 id = rd_u16(); - (void)id; - push(1); // stub: "won" - break; - } - case OP_WAIT: { - u16 n = rd_u16(); - if (n == 0) break; - g.vm.wait_frames = n; - g.vm.suspend = VM_SUSP_WAIT; - return; - } - case OP_PLAY_SFX: { - u16 id = rd_u16(); - (void)id; // stub - break; - } - default: - // Unknown opcode: fail safe by ending the script. - g.vm.active = 0; - return; - } - } -} diff --git a/aot/runtime/textbox.c b/aot/runtime/textbox.c deleted file mode 100644 index fb0e1c36..00000000 --- a/aot/runtime/textbox.c +++ /dev/null @@ -1,116 +0,0 @@ -// aot/runtime/textbox.c — BG1 textbox + choice menu (screenblock PJ_TEXT_SBB). -#include "runtime.h" - -#define BOX_ROW0 12 -#define BOX_ROW1 19 -#define TEXT_COL0 1 -#define TEXT_ROW0 13 -#define TEXT_COLMAX 28 - -const char *text_get(int text_id) { - const u8 *chunk = cart_chunk(CHUNK_TEXT_BANK, 0, 0); - // u16 count, u16 rsv, u32 offsets[count] (from chunk start), then strings. - const u32 *offs = (const u32 *)(chunk + 4); - return (const char *)(chunk + offs[text_id]); -} - -static void box_fill(void) { - u16 *sb = SCREENBLOCK(PJ_TEXT_SBB); - for (int row = BOX_ROW0; row <= BOX_ROW1; row++) - for (int col = 0; col < 30; col++) - sb[row * 32 + col] = SE(g.game->box_tile, 15); -} - -static void put_char(u16 *sb, int row, int col, unsigned char c) { - if (c >= 0x20) sb[row * 32 + col] = SE(g.game->font_base + (c - 0x20), 15); -} - -void textbox_init(void) { - g.text_active = 0; - g.choice_active = 0; - g.choice_result = -1; -} - -void textbox_show(int text_id) { - g.cur_text = (u16)text_id; - g.text_active = 1; - - u16 *sb = SCREENBLOCK(PJ_TEXT_SBB); - box_fill(); - - const char *t = text_get(text_id); - int col = TEXT_COL0, row = TEXT_ROW0; - for (const char *c = t; *c; c++) { - if (*c == '\n') { - row++; - col = TEXT_COL0; - if (row > BOX_ROW1) break; - continue; - } - if (col > TEXT_COLMAX) { - row++; - col = TEXT_COL0; - } - if (row > BOX_ROW1) break; - put_char(sb, row, col, (unsigned char)*c); - col++; - } - - REG_DISPCNT |= DCNT_BG1; -} - -void textbox_hide(void) { - g.text_active = 0; - REG_DISPCNT &= ~DCNT_BG1; -} - -int textbox_active(void) { return g.text_active; } - -void textbox_tick(void) { - if (g.text_active && !g.choice_active && key_pressed(KEY_A)) textbox_hide(); -} - -// --- choice menu ----------------------------------------------------------- -static void choice_render(void) { - u16 *sb = SCREENBLOCK(PJ_TEXT_SBB); - box_fill(); - for (int i = 0; i < g.choice_n; i++) { - int row = TEXT_ROW0 + i; - if (row > BOX_ROW1) break; - if (i == g.choice_cursor) put_char(sb, row, TEXT_COL0, '>'); - const char *t = text_get(g.choice_ids[i]); - int col = TEXT_COL0 + 2; - for (const char *c = t; *c && col <= TEXT_COLMAX; c++, col++) - put_char(sb, row, col, (unsigned char)*c); - } -} - -void choice_show(int n, const u16 *text_ids) { - g.choice_active = 1; - g.choice_n = (u8)n; - g.choice_cursor = 0; - g.choice_result = -1; - for (int i = 0; i < n && i < 8; i++) g.choice_ids[i] = text_ids[i]; - choice_render(); - REG_DISPCNT |= DCNT_BG1; -} - -int choice_active(void) { return g.choice_active; } - -int choice_result(void) { return g.choice_result; } - -void choice_tick(void) { - if (!g.choice_active) return; - if (key_pressed(KEY_UP) && g.choice_cursor > 0) { - g.choice_cursor--; - choice_render(); - } else if (key_pressed(KEY_DOWN) && g.choice_cursor < g.choice_n - 1) { - g.choice_cursor++; - choice_render(); - } - if (key_pressed(KEY_A)) { - g.choice_result = g.choice_cursor; - g.choice_active = 0; - textbox_hide(); - } -} diff --git a/aot/runtime/video.c b/aot/runtime/video.c deleted file mode 100644 index 9791adf4..00000000 --- a/aot/runtime/video.c +++ /dev/null @@ -1,34 +0,0 @@ -// aot/runtime/video.c — display setup + one-time VRAM/palette upload. -#include "runtime.h" - -static void copy16(u16 *dst, const u16 *src, u32 bytes) { - for (u32 i = 0; i < bytes / 2; i++) dst[i] = src[i]; -} - -void video_load_palettes(void) { - u32 sz; - const u16 *bg = (const u16 *)cart_chunk(CHUNK_PAL_BG, 0, &sz); - if (bg) copy16(BG_PAL, bg, sz); - const u16 *ob = (const u16 *)cart_chunk(CHUNK_PAL_OBJ, 0, &sz); - if (ob) copy16(OBJ_PAL, ob, sz); -} - -void video_load_obj_tiles(void) { - u32 sz; - const u16 *t = (const u16 *)cart_chunk(CHUNK_TILES_OBJ, 0, &sz); - if (t) copy16(OBJ_VRAM, t, sz); -} - -void video_init(void) { - REG_DISPCNT = DCNT_MODE0 | DCNT_BG0 | DCNT_OBJ | DCNT_OBJ_1D; - REG_BG0CNT = BG_CBB(PJ_BG_CBB) | BG_SBB(PJ_MAP_SBB) | BG_4BPP | BG_REG_32x32 | BG_PRIO(1); - REG_BG1CNT = BG_CBB(PJ_BG_CBB) | BG_SBB(PJ_TEXT_SBB) | BG_4BPP | BG_REG_32x32 | BG_PRIO(0); - - video_load_palettes(); - video_load_obj_tiles(); - - // BG character data (map tileset + font glyphs) -> BG charblock. - u32 sz; - const u16 *bgt = (const u16 *)cart_chunk(CHUNK_TILES_BG, 0, &sz); - if (bgt) copy16(CHARBLOCK(PJ_BG_CBB), bgt, sz); -} diff --git a/aot/spec/gen-c.ts b/aot/spec/gen-c.ts deleted file mode 100644 index 7219d9a1..00000000 --- a/aot/spec/gen-c.ts +++ /dev/null @@ -1,104 +0,0 @@ -// aot/spec/gen-c.ts — generate runtime/pjgb_gen.h from spec/pjgb.ts so the C -// runtime can never drift from the TS compiler. Mirrors ../../spec/gen-rust.ts. -// -// bun aot/spec/gen-c.ts (writes aot/runtime/pjgb_gen.h) - -import { - ACTOR_FLAG, - ACTOR_INSTANCE_SIZE, - BUDGET, - CHUNK, - COLLISION_SOLID, - COLLISION_WALKABLE, - DBG, - DEBUG_ADDR, - DEBUG_BLOCK_SIZE, - DEBUG_MAGIC, - DIR, - EWRAM_BASE, - GAME_HEADER_SIZE, - GAME_TITLE_LEN, - MAP_HEADER_SIZE, - MOVE, - OP, - PJGB_CHUNK_ENTRY_SIZE, - PJGB_HEADER_SIZE, - PJGB_VERSION, - SCREEN_H, - SCREEN_TILES_H, - SCREEN_TILES_W, - SCREEN_W, - SCRIPT_NONE, - SPRITE_PROTO_SIZE, - TILE_4BPP_BYTES, - VM_MAX_STACK, - WARP_SIZE, -} from "./pjgb.ts"; - -function section(title: string): string { - return `\n/* ${"-".repeat(70)}\n * ${title}\n * ${"-".repeat(70)} */\n`; -} -function defs(prefix: string, obj: Record): string { - return Object.entries(obj) - .map(([k, v]) => `#define ${prefix}${k} ${v >= 0 ? `0x${(v >>> 0).toString(16)}` : v}`) - .join("\n"); -} - -const lines: string[] = []; -lines.push("// GENERATED by aot/spec/gen-c.ts from aot/spec/pjgb.ts — DO NOT EDIT."); -lines.push("#ifndef PJGB_GEN_H"); -lines.push("#define PJGB_GEN_H"); -lines.push("#include "); - -lines.push(section("Screen / tiles")); -lines.push(defs("PJGB_", { - SCREEN_W, - SCREEN_H, - SCREEN_TILES_W, - SCREEN_TILES_H, - TILE_4BPP_BYTES, -})); - -lines.push(section("Container header + chunks")); -lines.push(defs("PJGB_", { - VERSION: PJGB_VERSION, - HEADER_SIZE: PJGB_HEADER_SIZE, - CHUNK_ENTRY_SIZE: PJGB_CHUNK_ENTRY_SIZE, -})); -lines.push(defs("CHUNK_", CHUNK)); - -lines.push(section("Struct sizes / field constants")); -lines.push(defs("PJGB_", { - GAME_TITLE_LEN, - GAME_HEADER_SIZE, - MAP_HEADER_SIZE, - ACTOR_INSTANCE_SIZE, - WARP_SIZE, - SPRITE_PROTO_SIZE, - SCRIPT_NONE, - COLLISION_WALKABLE, - COLLISION_SOLID, -})); - -lines.push(section("Directions / movement / actor flags")); -lines.push(defs("DIR_", DIR)); -lines.push(defs("MOVE_", MOVE)); -lines.push(defs("ACTOR_FLAG_", ACTOR_FLAG)); - -lines.push(section("Script VM")); -lines.push(defs("OP_", OP)); -lines.push(defs("PJGB_", { VM_MAX_STACK })); - -lines.push(section("Debug block (fixed EWRAM address for the mGBA harness)")); -lines.push(defs("PJGB_", { EWRAM_BASE, DEBUG_ADDR, DEBUG_BLOCK_SIZE })); -lines.push(`#define DEBUG_MAGIC 0x${(DEBUG_MAGIC >>> 0).toString(16)}`); -lines.push(defs("DBG_", DBG)); - -lines.push(section("Budgets")); -lines.push(defs("BUDGET_", BUDGET)); - -lines.push("\n#endif /* PJGB_GEN_H */\n"); - -const out = new URL("../runtime/pjgb_gen.h", import.meta.url).pathname; -await Bun.write(out, lines.join("\n")); -console.log("wrote", out); diff --git a/aot/spec/pjgb.ts b/aot/spec/pjgb.ts deleted file mode 100644 index da4abc0d..00000000 --- a/aot/spec/pjgb.ts +++ /dev/null @@ -1,348 +0,0 @@ -// aot/spec/pjgb.ts — THE single source of truth for the PJGB cartridge format, -// the script bytecode ISA, and the runtime debug block. -// -// Both sides derive from this file: -// - the compiler (aot/compiler/*) ENCODES these layouts, -// - the C runtime (aot/runtime/*) DECODES them, via generated pjgb_gen.h -// (aot/spec/gen-c.ts emits the #defines so C can never drift from TS). -// -// Conventions (non-negotiable, matches the repo-wide rule in ../../spec/spec.ts): -// - Little-endian EVERYWHERE (GBA ARM7TDMI is LE). -// - All offsets in comments are from the start of the containing blob/chunk. -// - GBA colors are 15-bit BGR555: bit0-4 R, 5-9 G, 10-14 B, bit15 unused. - -// --------------------------------------------------------------------------- -// Screen / hardware -// --------------------------------------------------------------------------- -export const SCREEN_W = 240; -export const SCREEN_H = 160; -export const TILE_PX = 8; // one GBA tile is 8x8 px -export const SCREEN_TILES_W = SCREEN_W / TILE_PX; // 30 -export const SCREEN_TILES_H = SCREEN_H / TILE_PX; // 20 -export const TILE_4BPP_BYTES = 32; // 8x8 @ 4bpp - -// --------------------------------------------------------------------------- -// Cartridge container: "PJGB" -// --------------------------------------------------------------------------- -export const PJGB_MAGIC = 0x424a5250; // 'P''J''G''B' read as LE u32 -> "PJGB" bytes -export const PJGB_MAGIC_BYTES = [0x50, 0x4a, 0x47, 0x42] as const; // 'P','J','G','B' -export const PJGB_VERSION = 1; - -// Header (16 bytes), at blob offset 0: -// 0 u8[4] magic "PJGB" -// 4 u16 version -// 6 u16 chunk_count -// 8 u32 chunk_table_offset (offset of the chunk directory) -// 12 u32 total_size -export const PJGB_HEADER_SIZE = 16; - -// Chunk directory entry (16 bytes each): -// 0 u32 kind (CHUNK.*) -// 4 u32 id (per-kind index, e.g. map index) -// 8 u32 offset (from blob start) -// 12 u32 size -export const PJGB_CHUNK_ENTRY_SIZE = 16; - -export const CHUNK = { - GAME: 1, // GameHeader (see below) - PAL_BG: 2, // u16[] BGR555 (16 * n colors) - PAL_OBJ: 3, // u16[] BGR555 - TILES_BG: 4, // 4bpp tile data (32 bytes/tile), tile 0 = blank - TILES_OBJ: 5, // 4bpp OBJ tile data (sprites + font glyphs) - MAP: 6, // one per map, id = map index (MapChunk) - TEXT_BANK: 7, // string table (TextBank) - SCRIPT_CODE: 8, // raw bytecode bytes for all scripts, concatenated - SCRIPT_TABLE: 9, // u32[] byte-offsets into SCRIPT_CODE, indexed by script id - SPRITE_TABLE: 10, // SpriteProto[] indexed by sprite id -} as const; -export type ChunkKind = (typeof CHUNK)[keyof typeof CHUNK]; - -// --------------------------------------------------------------------------- -// GameHeader chunk (CHUNK.GAME, id 0) -// 0 u8[24] title (ascii, null-padded) -// 24 u8 start_map -// 25 u8 start_dir -// 26 u16 start_x (tile) -// 28 u16 start_y (tile) -// 30 u8 map_count -// 31 u8 sprite_count -// 32 u16 flag_count -// 34 u16 text_count -// 36 u16 script_count -// 38 u16 font_base (BG tile index of ASCII 0x20; glyph = font_base+ch-0x20) -// 40 u16 box_tile (BG tile index of the opaque textbox fill tile) -// 42 u16 reserved -// = 44 bytes -// --------------------------------------------------------------------------- -export const GAME_TITLE_LEN = 24; -export const GAME_HEADER_SIZE = 44; - -// --------------------------------------------------------------------------- -// MapChunk (CHUNK.MAP, id = map index). Self-describing; all *_off are -// relative to the START of this chunk. -// 0 u16 width (tiles) -// 2 u16 height (tiles) -// 4 u16 num_actors -// 6 u16 num_warps -// 8 u8 bg_palbank (which 16-color BG palette bank the map tiles use) -// 9 u8 on_load_script (0xFF = none) -- reserved for future -// 10 u16 reserved -// 12 u32 tiles_off -> u16[width*height] BG screen-entry tile indices -// 16 u32 collision_off -> u8[width*height] (0 = walkable, 1 = solid) -// 20 u32 actors_off -> ActorInstance[num_actors] -// 24 u32 warps_off -> Warp[num_warps] -// = 28 byte header, then the referenced tables. -// --------------------------------------------------------------------------- -export const MAP_HEADER_SIZE = 28; -export const COLLISION_WALKABLE = 0; -export const COLLISION_SOLID = 1; - -// ActorInstance (12 bytes): -// 0 u16 x (tile) -// 2 u16 y (tile) -// 4 u8 sprite_id -// 5 u8 facing (DIR.*) -// 6 u8 movement (MOVE.*) -// 7 u8 flags (ACTOR_FLAG.*) -// 8 u16 on_talk (script id, 0xFFFF = none) -// 10 u16 reserved -export const ACTOR_INSTANCE_SIZE = 12; -export const SCRIPT_NONE = 0xffff; - -export const ACTOR_FLAG = { - NONE: 0, - SOLID: 1 << 0, // blocks the player tile -} as const; - -// Warp (12 bytes) — the compiler resolves "map:entrance" to concrete coords: -// 0 u16 x (tile on the source map that triggers the warp) -// 2 u16 y -// 4 u8 dest_map -// 5 u8 dest_dir -// 6 u16 dest_x -// 8 u16 dest_y -// 10 u16 reserved -export const WARP_SIZE = 12; - -// SpriteProto (SPRITE_TABLE, 8 bytes) — v1: fixed 16x16 sprites, 4 facings, -// each facing = one OBJ tile block. tile_base indexes TILES_OBJ in 8x8 units. -// 0 u16 tile_base (first OBJ tile index; layout below) -// 2 u8 w_px -// 3 u8 h_px -// 4 u8 palbank (OBJ palette bank) -// 5 u8 frames (walk frames per direction) -// 6 u16 reserved -// OBJ tile layout per sprite (16x16 = 4 tiles/frame, order: 4 dirs * frames): -// tile_base + (dir * frames + frame) * tiles_per_frame -export const SPRITE_PROTO_SIZE = 8; - -// --------------------------------------------------------------------------- -// TextBank (CHUNK.TEXT_BANK, id 0): -// 0 u16 count -// 2 u16 reserved -// 4 u32[count] offsets (relative to chunk start) to null-terminated ascii -// ... string bytes -// Text uses the runtime font charset (ASCII 0x20..0x7E). '\n' (0x0A) = newline. -// --------------------------------------------------------------------------- -export const TEXTBANK_HEADER_SIZE = 4; - -// --------------------------------------------------------------------------- -// Directions / movement -// --------------------------------------------------------------------------- -export const DIR = { DOWN: 0, UP: 1, LEFT: 2, RIGHT: 3 } as const; -export type DirName = keyof typeof DIR; -export const DIR_NAMES: Record = { - down: DIR.DOWN, - up: DIR.UP, - left: DIR.LEFT, - right: DIR.RIGHT, -}; -// dx/dy per direction (tile units) -export const DIR_DX = [0, 0, -1, 1] as const; // down,up,left,right -export const DIR_DY = [1, -1, 0, 0] as const; - -export const MOVE = { STATIC: 0, WANDER: 1, PATROL_H: 2, PATROL_V: 3 } as const; -export const MOVE_NAMES: Record = { - static: MOVE.STATIC, - wander: MOVE.WANDER, - patrolH: MOVE.PATROL_H, - patrolV: MOVE.PATROL_V, -}; - -// --------------------------------------------------------------------------- -// Script VM — a small stack machine. u8 opcode, then little-endian operands. -// The residualizer (aot/compiler/script.ts) emits these; script_vm.c runs them. -// --------------------------------------------------------------------------- -export const OP = { - END: 0x00, // terminate script - NOP: 0x01, // - TEXT: 0x02, // u16 textId show text box, SUSPEND until A - SET_FLAG: 0x03, // u16 flagId flags[flagId] = 1 - CLEAR_FLAG: 0x04, // u16 flagId flags[flagId] = 0 - PUSH_FLAG: 0x05, // u16 flagId push flags[flagId] (0/1) - PUSH_CONST: 0x06, // i16 value push value - POP: 0x07, // discard top - DUP: 0x08, // duplicate top - EQ: 0x09, // b=pop,a=pop, push (a==b) - NE: 0x0a, // push (a!=b) - NOT: 0x0b, // a=pop, push (a==0) - JUMP: 0x0c, // i16 rel ip += rel (rel is from AFTER the operand) - JUMP_IF_FALSE: 0x0d, // i16 rel a=pop; if a==0 ip += rel - CHOICE: 0x0e, // u8 n, u16 t0..t(n-1) menu of n text options; SUSPEND, push chosen index - LOCK_PLAYER: 0x0f, // - RELEASE_PLAYER: 0x10, // - FACE_PLAYER: 0x11, // u8 actorSlot actor turns to face the player - WARP: 0x12, // u8 map,u16 x,u16 y,u8 dir change map + reposition player - SET_VAR: 0x13, // u16 varId, i16 val - ADD_VAR: 0x14, // u16 varId, i16 val - PUSH_VAR: 0x15, // u16 varId - GIVE_ITEM: 0x16, // u16 itemId, u8 count (v1 stub: increments an inventory var) - BATTLE: 0x17, // u16 battleId (v1 stub: shows "* battle *" text, push 1=win) - WAIT: 0x18, // u16 frames SUSPEND for N frames - PLAY_SFX: 0x19, // u16 sfxId (v1 stub, no-op) -} as const; -export type OpName = keyof typeof OP; - -// Operand widths per opcode (bytes AFTER the u8 opcode). CHOICE is variable and -// handled specially (0xFF sentinel here). -export const OP_OPERAND_BYTES: Record = { - [OP.END]: 0, - [OP.NOP]: 0, - [OP.TEXT]: 2, - [OP.SET_FLAG]: 2, - [OP.CLEAR_FLAG]: 2, - [OP.PUSH_FLAG]: 2, - [OP.PUSH_CONST]: 2, - [OP.POP]: 0, - [OP.DUP]: 0, - [OP.EQ]: 0, - [OP.NE]: 0, - [OP.NOT]: 0, - [OP.JUMP]: 2, - [OP.JUMP_IF_FALSE]: 2, - [OP.CHOICE]: -1, // variable: 1 + 2*n - [OP.LOCK_PLAYER]: 0, - [OP.RELEASE_PLAYER]: 0, - [OP.FACE_PLAYER]: 1, - [OP.WARP]: 6, // u8 + u16 + u16 + u8 - [OP.SET_VAR]: 4, - [OP.ADD_VAR]: 4, - [OP.PUSH_VAR]: 2, - [OP.GIVE_ITEM]: 3, - [OP.BATTLE]: 2, - [OP.WAIT]: 2, - [OP.PLAY_SFX]: 2, -}; - -export const VM_MAX_STACK = 16; - -// --------------------------------------------------------------------------- -// Runtime debug block — written by the runtime into a FIXED EWRAM address so -// the mGBA test harness can read game state without symbols. Layout must match -// runtime/debug.c. Addresses are absolute GBA bus addresses. -// --------------------------------------------------------------------------- -export const EWRAM_BASE = 0x02000000; -export const DEBUG_ADDR = EWRAM_BASE; // debug block sits at the top of EWRAM -// Offsets within the debug block: -export const DBG = { - MAGIC: 0x00, // u32 'PJDB' - PLAYER_X: 0x04, // u16 tile - PLAYER_Y: 0x06, // u16 tile - PLAYER_DIR: 0x08, // u8 - CUR_MAP: 0x09, // u8 - TEXT_ACTIVE: 0x0a, // u8 (1 if a text box is on screen) - SCRIPT_ACTIVE: 0x0b, // u8 (1 if a script is running) - FRAME: 0x0c, // u32 frame counter - CUR_TEXT: 0x10, // u16 currently-shown text id (0xFFFF none) - CHOICE_CURSOR: 0x12, // u8 highlighted choice index while a CHOICE menu is up - BOOTED: 0x13, // u8 (1 once the main loop has started — liveness marker) - FLAGS: 0x14, // u8[16] = 128 flag bits (flag n -> byte n>>3, bit n&7) - VARS: 0x24, // i16[16] general vars -} as const; -export const DEBUG_MAGIC = 0x50_4a_44_42; // 'PJDB' as u32 (bytes P,J,D,B LE) -export const DEBUG_MAGIC_BYTES = [0x50, 0x4a, 0x44, 0x42] as const; -export const DEBUG_BLOCK_SIZE = 0x44; - -// Convenience: absolute address of a debug field. -export const dbgAddr = (field: keyof typeof DBG): number => DEBUG_ADDR + DBG[field]; -export const flagAddr = (flagId: number): { addr: number; bit: number } => ({ - addr: DEBUG_ADDR + DBG.FLAGS + (flagId >> 3), - bit: flagId & 7, -}); - -// --------------------------------------------------------------------------- -// Compile-time budgets (design §16). Exceeding -> build error. -// --------------------------------------------------------------------------- -export const BUDGET = { - MAX_ACTORS_PER_MAP: 24, - MAX_MAPS: 32, - MAX_SPRITES: 16, // one OBJ palette bank per sprite; hardware has 16 banks - MAX_FLAGS: 128, - MAX_VARS: 16, - MAX_TEXTS: 512, - MAX_SCRIPTS: 256, - // BG char data lives in charblock 0 (512 4bpp tiles); the map screenblock - // sits at SBB 8 = the start of charblock 1, so BG tiles must fit in 512. - MAX_BG_TILES: 512, - MAX_OBJ_TILES: 1024, // OBJ VRAM 0x06010000..0x06018000 = 1024 4bpp tiles - MAX_MAP_TILES: 128 * 128, -} as const; - -// --------------------------------------------------------------------------- -// Color: pack an 8-bit RGB triple into GBA BGR555. -// --------------------------------------------------------------------------- -export function rgb555(r: number, g: number, b: number): number { - const R = (r >> 3) & 0x1f; - const G = (g >> 3) & 0x1f; - const B = (b >> 3) & 0x1f; - return (B << 10) | (G << 5) | R; -} - -// --------------------------------------------------------------------------- -// Little-endian byte writer used by the compiler backend (pack.ts / lower.ts). -// --------------------------------------------------------------------------- -export class ByteWriter { - private buf: number[] = []; - get length(): number { - return this.buf.length; - } - u8(v: number): this { - this.buf.push(v & 0xff); - return this; - } - u16(v: number): this { - this.buf.push(v & 0xff, (v >> 8) & 0xff); - return this; - } - i16(v: number): this { - return this.u16(v & 0xffff); - } - u32(v: number): this { - this.buf.push(v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff); - return this; - } - bytes(b: ArrayLike): this { - for (let i = 0; i < b.length; i++) this.buf.push(b[i] & 0xff); - return this; - } - ascii(s: string, fixedLen?: number): this { - const n = fixedLen ?? s.length; - for (let i = 0; i < n; i++) this.buf.push(i < s.length ? s.charCodeAt(i) & 0xff : 0); - return this; - } - /** Pad to a 4-byte boundary. */ - align4(): this { - while (this.buf.length & 3) this.buf.push(0); - return this; - } - patchU32(at: number, v: number): this { - this.buf[at] = v & 0xff; - this.buf[at + 1] = (v >> 8) & 0xff; - this.buf[at + 2] = (v >> 16) & 0xff; - this.buf[at + 3] = (v >> 24) & 0xff; - return this; - } - toUint8Array(): Uint8Array { - return Uint8Array.from(this.buf); - } -} diff --git a/aot/test/dsl-jsx-types.tsx b/aot/test/dsl-jsx-types.tsx deleted file mode 100644 index 6eae2d2c..00000000 --- a/aot/test/dsl-jsx-types.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/** @jsxImportSource @pocketjs/aot */ -import { - ascii, - defineMap, - defineTileset, - Npc, - PlayerSpawn, - script, - tile, -} from "../dsl/index.ts"; - -const rows8 = (v: string): string[] => Array.from({ length: 8 }, () => v); - -const typedTown = defineTileset("typed-jsx-town", { - palette: [[0, 0, 0]], - tiles: { - grass: { px: rows8("11111111") }, - wall: { solid: true, px: rows8("22222222") }, - }, -}); - -const talk = script(0); - -defineMap("typed-jsx-ok") - .tileset(typedTown) - .layer( - ascii` - #. - `.legend({ - "#": tile("wall"), - ".": tile("grass"), - }), - ) - .entities( - , - , - ); - -defineMap("typed-jsx-bad-facing") - .tileset(typedTown) - .layer( - ascii` - . - `.legend({ - ".": tile("grass"), - }), - ) - .entities( - // @ts-expect-error "north" is not a GBA direction. - , - ); - -defineMap("typed-jsx-bad-script") - .tileset(typedTown) - .layer( - ascii` - . - `.legend({ - ".": tile("grass"), - }), - ) - .entities( - // @ts-expect-error onTalk must be a compiled ScriptRef. - , - ); diff --git a/aot/test/dsl-jsx.test.tsx b/aot/test/dsl-jsx.test.tsx deleted file mode 100644 index 1b0da61f..00000000 --- a/aot/test/dsl-jsx.test.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/** @jsxImportSource @pocketjs/aot */ -import { describe, expect, test } from "bun:test"; -import { - __resetRegistry, - ascii, - defineMap, - defineSprite, - defineTileset, - Entrance, - Layer, - Npc, - PlayerSpawn, - script, - Sign, - tile, - Warp, -} from "../dsl/index.ts"; - -const rows8 = (v: string): string[] => Array.from({ length: 8 }, () => v); -const rows16 = (v: string): string[] => Array.from({ length: 16 }, () => v); - -describe("AOT JSX entity composition", () => { - test("MapBuilder.entities expands pure JSX prefabs into scene nodes", () => { - __resetRegistry(); - - const town = defineTileset("town", { - palette: [[0, 0, 0]], - tiles: { - grass: { px: rows8("11111111") }, - wall: { solid: true, px: rows8("22222222") }, - }, - }); - const hero = defineSprite("hero", { - size: [16, 16], - palette: [[0, 0, 0]], - facings: { - down: [rows16("0000000000000000")], - up: [rows16("0000000000000000")], - left: [rows16("0000000000000000")], - right: [rows16("0000000000000000")], - }, - }); - const talk = script(0); - - function TownEntities() { - return ( - <> - - - - - - - ); - } - - const map = defineMap("littleroot") - .tileset(town) - .layer( - ascii` - ### - #.# - ### - `.legend({ - "#": tile("wall"), - ".": tile("grass"), - }), - ) - .entities() - .done(); - - expect(map.root.children.map((child) => child.host)).toEqual([ - "Layer", - "PlayerSpawn", - "Entrance", - "Npc", - "Sign", - "Warp", - ]); - expect(map.root.children[3]!.props).toMatchObject({ - id: "rival", - sprite: "hero", - at: [2, 1], - facing: "left", - movement: "static", - onTalk: talk, - }); - }); - - test("MapBuilder.entities keeps tile layers on the typed builder path", () => { - const town = defineTileset("town-entities-reject-layer", { - palette: [[0, 0, 0]], - tiles: { - grass: { px: rows8("11111111") }, - }, - }); - - expect(() => - defineMap("bad-entities") - .tileset(town) - .entities(), - ).toThrow('defineMap("bad-entities").entities(...) does not accept '); - }); -}); diff --git a/aot/test/dsl-types.ts b/aot/test/dsl-types.ts deleted file mode 100644 index a8cbf135..00000000 --- a/aot/test/dsl-types.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { ascii, defineMap, defineTileset, tile } from "../dsl/index.ts"; - -const rows8 = (v: string): string[] => Array.from({ length: 8 }, () => v); - -const typedTown = defineTileset("typed-town", { - palette: [[0, 0, 0]], - tiles: { - grass: { px: rows8("11111111") }, - wall: { solid: true, px: rows8("22222222") }, - }, -}); - -defineMap("typed-ok") - .tileset(typedTown) - .layer( - ascii` - #. - `.legend({ - "#": tile("wall"), - ".": tile("grass"), - }), - ); - -defineMap("typed-bad") - .tileset(typedTown) - .layer( - // @ts-expect-error "water" is not a tile in typedTown. - ascii` - ~ - `.legend({ - "~": tile("water"), - }), - ); - -const constTown = defineTileset("const-town", { - palette: [[0, 0, 0]], - tiles: { - grass: { - px: [ - "11111111", - "11111111", - "11111111", - "11111111", - "11111111", - "11111111", - "11111111", - "11111111", - ], - }, - }, -} as const); - -defineMap("const-ok") - .tileset(constTown) - .layer( - ascii` - . - `.legend({ - ".": tile("grass"), - }), - ); diff --git a/aot/test/dsl.test.ts b/aot/test/dsl.test.ts deleted file mode 100644 index 2f210cab..00000000 --- a/aot/test/dsl.test.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - __resetRegistry, - ascii, - defineMap, - defineSprite, - defineTileset, - script, - tile, -} from "../dsl/index.ts"; - -const rows8 = (v: string): string[] => Array.from({ length: 8 }, () => v); -const rows16 = (v: string): string[] => Array.from({ length: 16 }, () => v); - -describe("AOT map builder DSL", () => { - test("ascii normalizes indentation and validates legend coverage", () => { - const layer = ascii` - ### - #.# - ### - `.legend({ - "#": tile("wall"), - ".": tile("grass"), - }); - - expect(layer.rows).toEqual(["###", "#.#", "###"]); - expect(layer.legend).toEqual({ "#": "wall", ".": "grass" }); - - expect(() => - ascii` - ## - #. - `.legend({ - "#": tile("wall"), - }), - ).toThrow('ascii legend missing entry for "."'); - }); - - test("defineMap builder emits the same host scene nodes the compiler walks", () => { - __resetRegistry(); - - const town = defineTileset("town", { - palette: [[0, 0, 0]], - tiles: { - grass: { px: rows8("11111111") }, - wall: { solid: true, px: rows8("22222222") }, - }, - }); - const hero = defineSprite("hero", { - size: [16, 16], - palette: [[0, 0, 0]], - facings: { - down: [rows16("0000000000000000")], - up: [rows16("0000000000000000")], - left: [rows16("0000000000000000")], - right: [rows16("0000000000000000")], - }, - }); - const talk = script(0); - - const map = defineMap("littleroot") - .tileset(town) - .layer( - ascii` - ### - #.# - ### - `.legend({ - "#": tile("wall"), - ".": tile("grass"), - }), - ) - .spawn("spawn").at(1, 1).facing("down") - .entrance("south").at(1, 2).facing("up") - .npc("rival").sprite(hero).at(2, 1).facing("left").movement("static").talk(talk) - .sign("HELLO").at(1, 0) - .warp("route101:north").at(1, 2) - .done(); - - expect(map.name).toBe("littleroot"); - expect(map.tileset).toBe("town"); - expect(map.size).toEqual([3, 3]); - expect(map.root.children.map((child) => child.host)).toEqual([ - "Layer", - "PlayerSpawn", - "Entrance", - "Npc", - "Sign", - "Warp", - ]); - expect(map.root.children[3]!.props).toMatchObject({ - id: "rival", - sprite: "hero", - at: [2, 1], - facing: "left", - movement: "static", - onTalk: talk, - }); - }); -}); diff --git a/aot/test/e2e.ts b/aot/test/e2e.ts deleted file mode 100644 index 9ebe59dd..00000000 --- a/aot/test/e2e.ts +++ /dev/null @@ -1,144 +0,0 @@ -// aot/test/e2e.ts — end-to-end test: build the demo ROM, drive it headlessly in -// mGBA (via the libmgba runner), and assert real game state (player position, -// map, flags, dialogue) after scripted input. This is the "runs on GBA" proof. -// -// bun aot/test/e2e.ts - -import { $ } from "bun"; -import { compile, debugInfo } from "../compiler/index.ts"; -import { buildRom } from "../compiler/rom.ts"; -import { DBG, DEBUG_ADDR } from "../spec/pjgb.ts"; - -const ROOT = new URL("../..", import.meta.url).pathname; -const RUNNER = ROOT + "aot/test/harness/mgba_runner"; -const ROM = ROOT + "aot/dist/pocket-town.gba"; -const SHOTS = ROOT + "aot/dist/shots"; - -const addr = (field: keyof typeof DBG): number => DEBUG_ADDR + DBG[field]; - -type Step = - | { op: "advance"; frames: number } - | { op: "press"; buttons: string[]; frames: number; release?: number } - | { op: "read"; name: string; addr: number; size: 1 | 2 | 4 } - | { op: "screenshot"; path: string }; - -async function run(steps: Step[]): Promise> { - const scenario = ROOT + `aot/dist/scenario.json`; - await Bun.write(scenario, JSON.stringify({ steps })); - const out = await $`${RUNNER} ${ROM} ${scenario}`.text(); - const line = out - .trim() - .split("\n") - .reverse() - .find((l) => l.trim().startsWith("{")); - if (!line) throw new Error("runner produced no JSON:\n" + out); - const parsed = JSON.parse(line); - if (!parsed.ok) throw new Error("runner error: " + JSON.stringify(parsed)); - return parsed.reads ?? {}; -} - -// --- assertions ------------------------------------------------------------- -let passed = 0; -let failed = 0; -function check(name: string, got: unknown, want: unknown): void { - const ok = got === want; - console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: got ${got}${ok ? "" : `, want ${want}`}`); - ok ? passed++ : failed++; -} - -const R = { X: addr("PLAYER_X"), Y: addr("PLAYER_Y"), DIR: addr("PLAYER_DIR"), MAP: addr("CUR_MAP"), TEXT: addr("TEXT_ACTIVE"), SCRIPT: addr("SCRIPT_ACTIVE"), CUR: addr("CUR_TEXT"), BOOT: addr("BOOTED") }; -const rd = (name: string, a: number, size: 1 | 2 | 4): Step => ({ op: "read", name, addr: a, size }); -const press = (b: string, frames: number, release = 4): Step => ({ op: "press", buttons: [b], frames, release }); -const shot = (n: string): Step => ({ op: "screenshot", path: `${SHOTS}/${n}.ppm` }); - -async function main(): Promise { - console.log("Building demo ROM..."); - const built = await compile(ROOT + "aot/demo/game.tsx"); - const di = debugInfo(built) as { flags: Record; texts: string[]; maps: Record }; - const rom = await buildRom(built.blob, ROM); - await $`mkdir -p ${SHOTS}`.quiet(); - console.log(`ROM: ${rom.size} bytes\n`); - - const tid = (s: string): number => { - const i = di.texts.indexOf(s); - if (i < 0) throw new Error("text not found: " + s); - return i; - }; - const flagBeat = di.flags["beat_rival_1"]; - - // === Scenario 1: boot === - console.log("Scenario 1 — boot & spawn"); - { - const r = await run([{ op: "advance", frames: 15 }, rd("boot", R.BOOT, 1), rd("x", R.X, 2), rd("y", R.Y, 2), rd("map", R.MAP, 1), shot("01_boot")]); - check("booted", r.boot, 1); - check("spawn x", r.x, built.model.start.x); - check("spawn y", r.y, built.model.start.y); - check("start map", r.map, 0); - } - - // === Scenario 2: walk to rival, dialogue, choose Battle, flag + item === - console.log("Scenario 2 — talk to rival, choose Battle, win, flag set"); - { - const r = await run([ - { op: "advance", frames: 10 }, - press("RIGHT", 8), // -> (11,14) - press("UP", 20), // -> (11,9) - press("RIGHT", 4), // face the rival (blocked, SOLID) - rd("faceDir", R.DIR, 1), - rd("faceX", R.X, 2), - press("A", 1, 8), // interact -> "You made it!" - rd("d1_script", R.SCRIPT, 1), - rd("d1_text", R.TEXT, 1), - rd("d1_cur", R.CUR, 2), - shot("02_dialogue"), - press("A", 1, 8), // dismiss -> choice menu - rd("choice_script", R.SCRIPT, 1), - rd("choice_text", R.TEXT, 1), - shot("03_choice"), - press("A", 1, 20), // pick Battle -> battle, setFlag, giveItem, "Take this Potion" - rd("won_flag", flagBeat.byteAddr, 1), - rd("won_cur", R.CUR, 2), - rd("won_text", R.TEXT, 1), - shot("04_reward"), - press("A", 1, 12), // dismiss -> END - rd("end_script", R.SCRIPT, 1), - rd("end_text", R.TEXT, 1), - // talk AGAIN: beat_rival_1 is now set, so the folded THEN branch runs. - press("A", 1, 8), - rd("again_cur", R.CUR, 2), - rd("again_text", R.TEXT, 1), - ]); - check("faces right at rival", r.faceDir, 3); - check("blocked (x stays 11)", r.faceX, 11); - check("dialogue: script active", r.d1_script, 1); - check("dialogue: textbox up", r.d1_text, 1); - check('dialogue: shows "You made it!"', r.d1_cur, tid("You made it! Want to test your first build?")); - check("choice: script still active", r.choice_script, 1); - check("choice: textbox flag cleared", r.choice_text, 0); - check("Battle set beat_rival_1", (r.won_flag >> flagBeat.bit) & 1, 1); - check('reward shows "Take this Potion"', r.won_cur, tid("Take this Potion. You will need it.")); - check("script ends after reward", r.end_script, 0); - check("textbox closed at end", r.end_text, 0); - check("re-talk: flag branch shows post-win line", r.again_cur, tid("The road ahead is tougher than it looks.")); - check("re-talk: textbox up", r.again_text, 1); - } - - // === Scenario 3: warp town -> route101 === - console.log("Scenario 3 — warp from Littleroot to Route 101"); - { - const r = await run([ - { op: "advance", frames: 10 }, - press("DOWN", 16), // (9,14) -> warp tile (9,17) -> route101 - rd("map", R.MAP, 1), - rd("x", R.X, 2), - shot("05_route"), - ]); - check("warped to route101 (map 1)", r.map, di.maps["route101"]); - check("entered near entrance x", r.x, 9); - } - - console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); - process.exit(failed === 0 ? 0 : 1); -} - -await main(); diff --git a/aot/test/handcart.ts b/aot/test/handcart.ts deleted file mode 100644 index 35dfa178..00000000 --- a/aot/test/handcart.ts +++ /dev/null @@ -1,253 +0,0 @@ -// aot/test/handcart.ts — hand-built minimal PJGB cartridge -> aot/runtime/gen_cart.c -// -// Not the real compiler; a fixture that (a) validates the binary spec end-to-end -// and (b) gives the C runtime a bootable scene to verify against headlessly: -// an 8x8 grassy map bordered by trees, one solid NPC, and a script that faces -// the player, shows two text pages, and sets flag #1. -// -// bun aot/test/handcart.ts - -import { - ByteWriter, - CHUNK, - DIR, - ACTOR_FLAG, - OP, - SCRIPT_NONE, - rgb555, -} from "../spec/pjgb.ts"; -import { emitCartC, packCart, type Chunk } from "../compiler/pack.ts"; - -// --- tile / palette helpers ------------------------------------------------- -// GBA 4bpp tile: 32 bytes, row-major, 4 bytes/row, low nibble = left pixel. -function tile4(px: number[]): Uint8Array { - const out = new Uint8Array(32); - for (let row = 0; row < 8; row++) { - for (let c = 0; c < 4; c++) { - const lo = px[row * 8 + c * 2] & 0xf; - const hi = px[row * 8 + c * 2 + 1] & 0xf; - out[row * 4 + c] = lo | (hi << 4); - } - } - return out; -} -const fill = (idx: number): number[] => new Array(64).fill(idx); - -// --- BG palette (256 entries = 16 banks of 16) ------------------------------ -const bgPal = new Uint16Array(256); -bgPal[0] = rgb555(24, 24, 32); // backdrop -bgPal[1] = rgb555(104, 192, 96); // grass light -bgPal[2] = rgb555(72, 152, 72); // grass dark -bgPal[3] = rgb555(96, 64, 40); // trunk -bgPal[4] = rgb555(48, 120, 56); // leaves -// textbox bank 15 -bgPal[240] = rgb555(0, 0, 0); -bgPal[241] = rgb555(248, 248, 248); // text -bgPal[242] = rgb555(24, 32, 72); // box bg - -// --- OBJ palette (bank 0) --------------------------------------------------- -const objPal = new Uint16Array(16); -objPal[0] = 0; // transparent -objPal[1] = rgb555(240, 200, 160); // skin -objPal[2] = rgb555(200, 64, 64); // shirt -objPal[3] = rgb555(40, 32, 40); // dark -objPal[4] = rgb555(248, 248, 248); // white - -// --- BG tiles --------------------------------------------------------------- -const bgTiles: Uint8Array[] = []; -bgTiles.push(tile4(fill(0))); // 0 blank -// 1 grass (dither of 1/2) -{ - const px = new Array(64); - for (let i = 0; i < 64; i++) px[i] = (i + (i >> 3)) % 5 === 0 ? 2 : 1; - bgTiles.push(tile4(px)); -} -// 2 tree (trunk + leaves) -{ - const px = new Array(64).fill(4); - for (let y = 5; y < 8; y++) for (let x = 3; x < 5; x++) px[y * 8 + x] = 3; - bgTiles.push(tile4(px)); -} -const FONT_BASE = bgTiles.length; // 3 -// 96 procedural glyphs (ASCII 0x20..0x7F): box bg (idx2) + a mark (idx1). -for (let ch = 0x20; ch < 0x80; ch++) { - const px = new Array(64).fill(2); - if (ch !== 0x20) { - for (let y = 1; y < 7; y++) { - px[y * 8 + 1] = 1; - px[y * 8 + 5] = 1; - } - for (let x = 1; x <= 5; x++) { - px[1 * 8 + x] = 1; - px[6 * 8 + x] = 1; - } - } - bgTiles.push(tile4(px)); -} -const BOX_TILE = bgTiles.length; // fill tile for the textbox -bgTiles.push(tile4(fill(2))); -const bgTileCount = bgTiles.length; - -// --- OBJ tiles: one 16x16 sprite, 4 facings * 1 frame = 16 tiles ------------ -// 16x16 sprite = 2x2 tiles in 1D order TL,TR,BL,BR. -function sprite16(dir: number): Uint8Array { - // 16x16 pixel grid -> 4 tiles - const grid = new Array(16 * 16).fill(0); - const set = (x: number, y: number, v: number) => { - if (x >= 0 && x < 16 && y >= 0 && y < 16) grid[y * 16 + x] = v; - }; - // body (shirt) rows 8..14, head (skin) rows 2..7 - for (let y = 8; y < 15; y++) for (let x = 4; x < 12; x++) set(x, y, 2); - for (let y = 2; y < 8; y++) for (let x = 5; x < 11; x++) set(x, y, 1); - // hair - for (let x = 5; x < 11; x++) set(x, 2, 3); - // eyes vary a touch by facing so directions are distinguishable - if (dir !== DIR.UP) { - const ey = 5; - if (dir === DIR.LEFT) set(6, ey, 3); - else if (dir === DIR.RIGHT) set(9, ey, 3); - else { - set(6, ey, 3); - set(9, ey, 3); - } - } - // split into 4 tiles TL,TR,BL,BR - const out = new Uint8Array(4 * 32); - const tiles = [ - [0, 0], - [8, 0], - [0, 8], - [8, 8], - ]; - tiles.forEach(([ox, oy], t) => { - const px = new Array(64); - for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) px[y * 8 + x] = grid[(oy + y) * 16 + (ox + x)]; - out.set(tile4(px), t * 32); - }); - return out; -} -const objTiles: Uint8Array[] = []; -for (const d of [DIR.DOWN, DIR.UP, DIR.LEFT, DIR.RIGHT]) objTiles.push(sprite16(d)); - -// --- SPRITE_TABLE ----------------------------------------------------------- -const spriteTable = new ByteWriter(); -spriteTable.u16(0).u8(16).u8(16).u8(0).u8(1).u16(0); // tile_base 0, 16x16, palbank0, 1 frame - -// --- MAP 0: 8x8 ------------------------------------------------------------- -const MW = 8, - MH = 8; -const mapTiles = new Uint16Array(MW * MH); -const mapColl = new Uint8Array(MW * MH); -for (let y = 0; y < MH; y++) { - for (let x = 0; x < MW; x++) { - const border = x === 0 || y === 0 || x === MW - 1 || y === MH - 1; - mapTiles[y * MW + x] = border ? 2 : 1; - mapColl[y * MW + x] = border ? 1 : 0; - } -} -// actors: 1 NPC at (5,4), solid, on_talk=script 0 -const actors = new ByteWriter(); -actors.u16(5).u16(4).u8(0).u8(DIR.DOWN).u8(0).u8(ACTOR_FLAG.SOLID).u16(0).u16(0); -const N_ACTORS = 1; -const N_WARPS = 0; - -function buildMapChunk(): Uint8Array { - const header = new ByteWriter(); - // 28-byte header; offsets are from chunk start. - const hdrSize = 28; - const tilesOff = hdrSize; - const collOff = tilesOff + mapTiles.length * 2; - const actorsOff = (collOff + mapColl.length + 3) & ~3; - const warpsOff = actorsOff + N_ACTORS * 12; - header - .u16(MW).u16(MH).u16(N_ACTORS).u16(N_WARPS) - .u8(0).u8(0xff).u16(0) - .u32(tilesOff).u32(collOff).u32(actorsOff).u32(warpsOff); - const w = new ByteWriter(); - w.bytes(header.toUint8Array()); - for (const t of mapTiles) w.u16(t); - w.bytes(mapColl); - while (w.length < actorsOff) w.u8(0); - w.bytes(actors.toUint8Array()); - return w.toUint8Array(); -} - -// --- TEXT_BANK -------------------------------------------------------------- -const texts = ["HELLO FROM POCKETJS AOT!", "THIS RUNS AS A REAL GBA ROM."]; -function buildTextBank(strs: string[]): Uint8Array { - const enc = strs.map((s) => { - const b = new Uint8Array(s.length + 1); - for (let i = 0; i < s.length; i++) b[i] = s.charCodeAt(i) & 0x7f; - return b; - }); - const headerSize = 4 + strs.length * 4; - const offsets: number[] = []; - let cur = headerSize; - for (const b of enc) { - offsets.push(cur); - cur += b.length; - } - const w = new ByteWriter(); - w.u16(strs.length).u16(0); - for (const o of offsets) w.u32(o); - for (const b of enc) w.bytes(b); - return w.toUint8Array(); -} - -// --- SCRIPT 0 --------------------------------------------------------------- -// LOCK_PLAYER; FACE_PLAYER 0; TEXT 0; SET_FLAG 1; TEXT 1; RELEASE_PLAYER; END -const code = new ByteWriter(); -code.u8(OP.LOCK_PLAYER); -code.u8(OP.FACE_PLAYER).u8(0); -code.u8(OP.TEXT).u16(0); -code.u8(OP.SET_FLAG).u16(1); -code.u8(OP.TEXT).u16(1); -code.u8(OP.RELEASE_PLAYER); -code.u8(OP.END); -const scriptTable = new ByteWriter(); -scriptTable.u32(0); // script 0 at byte 0 - -// --- GAME header ------------------------------------------------------------ -const game = new ByteWriter(); -game - .ascii("POCKET TEST", 24) - .u8(0).u8(DIR.DOWN).u16(4).u16(4) - .u8(1).u8(1).u16(4).u16(texts.length).u16(1) - .u16(FONT_BASE).u16(BOX_TILE).u16(0); - -// --- assemble --------------------------------------------------------------- -function u16buf(a: Uint16Array): Uint8Array { - const w = new ByteWriter(); - for (const v of a) w.u16(v); - return w.toUint8Array(); -} -function catTiles(ts: Uint8Array[]): Uint8Array { - const out = new Uint8Array(ts.reduce((n, t) => n + t.length, 0)); - let o = 0; - for (const t of ts) { - out.set(t, o); - o += t.length; - } - return out; -} - -const chunks: Chunk[] = [ - { kind: CHUNK.GAME, id: 0, data: game.toUint8Array() }, - { kind: CHUNK.PAL_BG, id: 0, data: u16buf(bgPal) }, - { kind: CHUNK.PAL_OBJ, id: 0, data: u16buf(objPal) }, - { kind: CHUNK.TILES_BG, id: 0, data: catTiles(bgTiles) }, - { kind: CHUNK.TILES_OBJ, id: 0, data: catTiles(objTiles) }, - { kind: CHUNK.SPRITE_TABLE, id: 0, data: spriteTable.toUint8Array() }, - { kind: CHUNK.MAP, id: 0, data: buildMapChunk() }, - { kind: CHUNK.TEXT_BANK, id: 0, data: buildTextBank(texts) }, - { kind: CHUNK.SCRIPT_CODE, id: 0, data: code.toUint8Array() }, - { kind: CHUNK.SCRIPT_TABLE, id: 0, data: scriptTable.toUint8Array() }, -]; - -const blob = packCart(chunks); -const c = emitCartC(blob); -await Bun.write(new URL("../runtime/gen_cart.c", import.meta.url).pathname, c); -console.log( - `handcart: ${blob.length} bytes, ${chunks.length} chunks, ${bgTileCount} BG tiles, ` + - `font_base=${FONT_BASE}, box_tile=${BOX_TILE}, script uses SCRIPT_NONE=${SCRIPT_NONE}`, -); diff --git a/aot/test/harness/build.sh b/aot/test/harness/build.sh deleted file mode 100755 index 0fe3ced5..00000000 --- a/aot/test/harness/build.sh +++ /dev/null @@ -1,33 +0,0 @@ -#!/usr/bin/env bash -# -# Build the headless mGBA test runner (mgba_runner) against the installed -# libmgba. Requires mGBA to be installed via Homebrew: brew install mgba -# -set -euo pipefail - -# Resolve this script's directory so the build works from anywhere. -HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Locate the mGBA install prefix (Homebrew), with a sane fallback. -if command -v brew >/dev/null 2>&1; then - MGBA="$(brew --prefix mgba)" -else - MGBA="/opt/homebrew/Cellar/mgba/0.10.5_2" -fi - -if [ ! -f "$MGBA/include/mgba/gba/core.h" ]; then - echo "error: libmgba headers not found under $MGBA/include" >&2 - echo " install mGBA first: brew install mgba" >&2 - exit 1 -fi - -echo "Using libmgba prefix: $MGBA" - -clang -O2 -Wall -Wextra \ - -o "$HERE/mgba_runner" \ - "$HERE/mgba_runner.c" \ - -I"$MGBA/include" \ - -L"$MGBA/lib" -lmgba \ - -Wl,-rpath,"$MGBA/lib" - -echo "Built: $HERE/mgba_runner" diff --git a/aot/test/harness/mgba_runner.c b/aot/test/harness/mgba_runner.c deleted file mode 100644 index 32d1e57d..00000000 --- a/aot/test/harness/mgba_runner.c +++ /dev/null @@ -1,614 +0,0 @@ -/* - * mgba_runner.c — headless GBA emulator test runner for PocketJS-AOT. - * - * Links against libmgba 0.10.5 (Homebrew). Runs a ROM under a scripted - * "scenario" of input/advance/read/screenshot steps and prints a JSON - * result to stdout. - * - * Usage: mgba_runner - * - * Scenario shape (see README of the harness): - * { "steps": [ - * { "op": "advance", "frames": 30 }, - * { "op": "press", "buttons": ["UP"], "frames": 16 }, - * { "op": "press", "buttons": ["A"], "frames": 1, "release": 30 }, - * { "op": "read", "name": "player_x", "addr": 33554432, "size": 2 }, - * { "op": "screenshot", "path": "/abs/out.ppm" } - * ] } - * - * Output on success: {"reads": {"player_x": 8, ...}, "ok": true} - * Output on failure: {"ok": false, "error": "..."} (exit code 1) - * - * Dependency-free apart from libc + libmgba. - */ - -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include - -/* ------------------------------------------------------------------ * - * GBA key bit indices (from , enum GBAKey). - * The core's setKeys() takes a bitmask of (1 << index). - * ------------------------------------------------------------------ */ -#define GBA_KEY_A_BIT 0 -#define GBA_KEY_B_BIT 1 -#define GBA_KEY_SELECT_BIT 2 -#define GBA_KEY_START_BIT 3 -#define GBA_KEY_RIGHT_BIT 4 -#define GBA_KEY_LEFT_BIT 5 -#define GBA_KEY_UP_BIT 6 -#define GBA_KEY_DOWN_BIT 7 -#define GBA_KEY_R_BIT 8 -#define GBA_KEY_L_BIT 9 - -/* ------------------------------------------------------------------ * - * Error reporting: emit the failure JSON and bail out. - * ------------------------------------------------------------------ */ -static void die(const char* fmt, ...) { - char msg[512]; - va_list ap; - va_start(ap, fmt); - vsnprintf(msg, sizeof(msg), fmt, ap); - va_end(ap); - - /* Escape quotes/backslashes so the JSON stays well-formed. */ - fputs("{\"ok\": false, \"error\": \"", stdout); - for (const char* p = msg; *p; ++p) { - if (*p == '"' || *p == '\\') { - putchar('\\'); - putchar(*p); - } else if (*p == '\n' || *p == '\r' || *p == '\t') { - putchar(' '); - } else { - putchar(*p); - } - } - fputs("\"}\n", stdout); - fflush(stdout); - exit(1); -} - -/* ================================================================== * - * Minimal JSON parser (objects, arrays, strings, numbers, bool, null) - * Produces a small owned DOM. Allocations are intentionally never - * freed — this is a short-lived, one-shot CLI process. - * ================================================================== */ - -enum jtype { J_NULL, J_BOOL, J_NUM, J_STR, J_ARR, J_OBJ }; - -typedef struct JVal { - enum jtype type; - double num; /* J_NUM, and 0/1 for J_BOOL */ - char* str; /* J_STR (NUL-terminated, owned) */ - struct JVal** items; /* J_ARR */ - int nitems; - char** keys; /* J_OBJ */ - struct JVal** vals; /* J_OBJ */ - int nfields; -} JVal; - -typedef struct { - const char* p; - bool err; -} JParser; - -static JVal* jparse_value(JParser* J); - -static void jskipws(JParser* J) { - while (*J->p == ' ' || *J->p == '\t' || *J->p == '\n' || *J->p == '\r') { - J->p++; - } -} - -static JVal* jnew(enum jtype t) { - JVal* v = calloc(1, sizeof(JVal)); - if (!v) { - die("out of memory"); - } - v->type = t; - return v; -} - -/* Parse a JSON string literal (cursor sits on the opening quote). */ -static char* jparse_string_raw(JParser* J) { - if (*J->p != '"') { - J->err = true; - return NULL; - } - J->p++; - size_t cap = 16, len = 0; - char* out = malloc(cap); - if (!out) { - die("out of memory"); - } - while (*J->p && *J->p != '"') { - char c = *J->p++; - if (c == '\\') { - char e = *J->p++; - switch (e) { - case '"': c = '"'; break; - case '\\': c = '\\'; break; - case '/': c = '/'; break; - case 'n': c = '\n'; break; - case 't': c = '\t'; break; - case 'r': c = '\r'; break; - case 'b': c = '\b'; break; - case 'f': c = '\f'; break; - case 'u': { - /* Accept but flatten \uXXXX to '?' — the scenarios only - * ever use ASCII identifiers and paths. */ - for (int i = 0; i < 4 && *J->p; ++i) { - J->p++; - } - c = '?'; - break; - } - default: - c = e; - break; - } - } - if (len + 1 >= cap) { - cap *= 2; - out = realloc(out, cap); - if (!out) { - die("out of memory"); - } - } - out[len++] = c; - } - if (*J->p != '"') { - J->err = true; - return NULL; - } - J->p++; - out[len] = '\0'; - return out; -} - -static JVal* jparse_string(JParser* J) { - char* s = jparse_string_raw(J); - if (J->err) { - return NULL; - } - JVal* v = jnew(J_STR); - v->str = s; - return v; -} - -static JVal* jparse_number(JParser* J) { - char* end = NULL; - double d = strtod(J->p, &end); - if (end == J->p) { - J->err = true; - return NULL; - } - J->p = end; - JVal* v = jnew(J_NUM); - v->num = d; - return v; -} - -static JVal* jparse_array(JParser* J) { - J->p++; /* consume '[' */ - JVal* v = jnew(J_ARR); - size_t cap = 4; - v->items = malloc(cap * sizeof(JVal*)); - jskipws(J); - if (*J->p == ']') { - J->p++; - return v; - } - for (;;) { - JVal* item = jparse_value(J); - if (J->err) { - return NULL; - } - if ((size_t)v->nitems >= cap) { - cap *= 2; - v->items = realloc(v->items, cap * sizeof(JVal*)); - } - v->items[v->nitems++] = item; - jskipws(J); - if (*J->p == ',') { - J->p++; - jskipws(J); - continue; - } - if (*J->p == ']') { - J->p++; - break; - } - J->err = true; - return NULL; - } - return v; -} - -static JVal* jparse_object(JParser* J) { - J->p++; /* consume '{' */ - JVal* v = jnew(J_OBJ); - size_t cap = 4; - v->keys = malloc(cap * sizeof(char*)); - v->vals = malloc(cap * sizeof(JVal*)); - jskipws(J); - if (*J->p == '}') { - J->p++; - return v; - } - for (;;) { - jskipws(J); - char* key = jparse_string_raw(J); - if (J->err) { - return NULL; - } - jskipws(J); - if (*J->p != ':') { - J->err = true; - return NULL; - } - J->p++; - JVal* val = jparse_value(J); - if (J->err) { - return NULL; - } - if ((size_t)v->nfields >= cap) { - cap *= 2; - v->keys = realloc(v->keys, cap * sizeof(char*)); - v->vals = realloc(v->vals, cap * sizeof(JVal*)); - } - v->keys[v->nfields] = key; - v->vals[v->nfields] = val; - v->nfields++; - jskipws(J); - if (*J->p == ',') { - J->p++; - continue; - } - if (*J->p == '}') { - J->p++; - break; - } - J->err = true; - return NULL; - } - return v; -} - -static JVal* jparse_value(JParser* J) { - jskipws(J); - switch (*J->p) { - case '{': return jparse_object(J); - case '[': return jparse_array(J); - case '"': return jparse_string(J); - case 't': - if (strncmp(J->p, "true", 4) == 0) { - J->p += 4; - JVal* v = jnew(J_BOOL); - v->num = 1; - return v; - } - break; - case 'f': - if (strncmp(J->p, "false", 5) == 0) { - J->p += 5; - JVal* v = jnew(J_BOOL); - v->num = 0; - return v; - } - break; - case 'n': - if (strncmp(J->p, "null", 4) == 0) { - J->p += 4; - return jnew(J_NULL); - } - break; - default: - if (*J->p == '-' || (*J->p >= '0' && *J->p <= '9')) { - return jparse_number(J); - } - break; - } - J->err = true; - return NULL; -} - -static JVal* jparse(const char* text) { - JParser J = { text, false }; - JVal* v = jparse_value(&J); - if (J.err || !v) { - return NULL; - } - return v; -} - -/* Object field lookup (returns NULL if absent or not an object). */ -static JVal* jget(const JVal* obj, const char* key) { - if (!obj || obj->type != J_OBJ) { - return NULL; - } - for (int i = 0; i < obj->nfields; ++i) { - if (strcmp(obj->keys[i], key) == 0) { - return obj->vals[i]; - } - } - return NULL; -} - -static double jget_num(const JVal* obj, const char* key, double dflt) { - JVal* v = jget(obj, key); - return (v && v->type == J_NUM) ? v->num : dflt; -} - -/* ================================================================== * - * Button name -> GBA key bitmask. - * ================================================================== */ -static uint32_t button_bit(const char* name) { - if (strcmp(name, "A") == 0) return 1u << GBA_KEY_A_BIT; - if (strcmp(name, "B") == 0) return 1u << GBA_KEY_B_BIT; - if (strcmp(name, "SELECT") == 0) return 1u << GBA_KEY_SELECT_BIT; - if (strcmp(name, "START") == 0) return 1u << GBA_KEY_START_BIT; - if (strcmp(name, "RIGHT") == 0) return 1u << GBA_KEY_RIGHT_BIT; - if (strcmp(name, "LEFT") == 0) return 1u << GBA_KEY_LEFT_BIT; - if (strcmp(name, "UP") == 0) return 1u << GBA_KEY_UP_BIT; - if (strcmp(name, "DOWN") == 0) return 1u << GBA_KEY_DOWN_BIT; - if (strcmp(name, "R") == 0) return 1u << GBA_KEY_R_BIT; - if (strcmp(name, "L") == 0) return 1u << GBA_KEY_L_BIT; - die("unknown button: %s", name); - return 0; -} - -/* ================================================================== * - * Recorded reads, printed in the final JSON. - * ================================================================== */ -typedef struct { - char* name; - uint32_t value; -} ReadResult; - -static ReadResult g_reads[256]; -static int g_nreads = 0; - -/* ================================================================== * - * Emulator helpers. - * ================================================================== */ - -/* Advance `frames` frames while holding `keys` (a GBA key bitmask). */ -static void run_frames(struct mCore* core, uint32_t keys, int frames) { - core->setKeys(core, keys); - for (int i = 0; i < frames; ++i) { - core->runFrame(core); - } -} - -/* Read `size` (1/2/4) bytes little-endian from the emulated bus. */ -static uint32_t bus_read(struct mCore* core, uint32_t addr, int size) { - uint32_t value = 0; - for (int i = 0; i < size; ++i) { - uint32_t byte = core->busRead8(core, addr + i) & 0xFF; - value |= byte << (8 * i); - } - return value; -} - -/* Dump the current video buffer (width*height color_t) to a P6 PPM file. - * - * This libmgba build defines color_t as uint32_t with the channel layout - * M_COLOR_RED=0x000000FF, GREEN=0x0000FF00, BLUE=0x00FF0000 - * i.e. bytes are R,G,B,A in memory. We emit R,G,B directly. - * - * A 16-bit fallback (RGB555 / RGB565) is provided for completeness, though - * the installed 0.10.5 Homebrew build uses the 32-bit path above. - */ -static void write_ppm(const char* path, const color_t* buffer, - unsigned width, unsigned height, size_t stride) { - FILE* f = fopen(path, "wb"); - if (!f) { - die("cannot open screenshot path: %s", path); - } - fprintf(f, "P6\n%u %u\n255\n", width, height); - for (unsigned y = 0; y < height; ++y) { - const color_t* row = buffer + (size_t)y * stride; - for (unsigned x = 0; x < width; ++x) { - color_t c = row[x]; - uint8_t rgb[3]; -#if BYTES_PER_PIXEL == 4 - rgb[0] = (uint8_t)(c & 0xFF); /* R */ - rgb[1] = (uint8_t)((c >> 8) & 0xFF); /* G */ - rgb[2] = (uint8_t)((c >> 16) & 0xFF); /* B */ -#else - #ifdef COLOR_5_6_5 - rgb[0] = (uint8_t)(((c >> 11) & 0x1F) * 0x21 >> 2); /* R */ - rgb[1] = (uint8_t)(((c >> 5) & 0x3F) * 0x41 >> 4); /* G */ - rgb[2] = (uint8_t)((c & 0x1F) * 0x21 >> 2); /* B */ - #else /* RGB555 */ - rgb[0] = (uint8_t)((c & 0x1F) * 0x21 >> 2); /* R */ - rgb[1] = (uint8_t)(((c >> 5) & 0x1F) * 0x21 >> 2); /* G */ - rgb[2] = (uint8_t)(((c >> 10) & 0x1F) * 0x21 >> 2); /* B */ - #endif -#endif - if (fwrite(rgb, 1, 3, f) != 3) { - fclose(f); - die("short write to screenshot: %s", path); - } - } - } - fclose(f); -} - -/* ================================================================== * - * Read a whole file into a NUL-terminated buffer. - * ================================================================== */ -static char* slurp(const char* path) { - FILE* f = fopen(path, "rb"); - if (!f) { - die("cannot open scenario: %s", path); - } - fseek(f, 0, SEEK_END); - long n = ftell(f); - if (n < 0) { - fclose(f); - die("cannot size scenario: %s", path); - } - fseek(f, 0, SEEK_SET); - char* buf = malloc((size_t)n + 1); - if (!buf) { - die("out of memory"); - } - size_t got = fread(buf, 1, (size_t)n, f); - fclose(f); - buf[got] = '\0'; - return buf; -} - -/* ================================================================== * - * Main. - * ================================================================== */ -int main(int argc, char** argv) { - if (argc != 3) { - die("usage: mgba_runner "); - } - const char* rom_path = argv[1]; - const char* scenario_path = argv[2]; - - /* --- Parse the scenario up front so bad input fails fast. --- */ - char* json_text = slurp(scenario_path); - JVal* root = jparse(json_text); - if (!root) { - die("failed to parse scenario JSON"); - } - JVal* steps = jget(root, "steps"); - if (!steps || steps->type != J_ARR) { - die("scenario missing \"steps\" array"); - } - - /* --- Create and initialise the GBA core. --- */ - struct mCore* core = GBACoreCreate(); - if (!core) { - die("GBACoreCreate failed"); - } - if (!core->init(core)) { - die("core init failed"); - } - mCoreInitConfig(core, NULL); - - /* --- Allocate and register the video buffer. --- */ - unsigned width = 0, height = 0; - core->desiredVideoDimensions(core, &width, &height); /* GBA: 240x160 */ - color_t* video = malloc((size_t)width * height * BYTES_PER_PIXEL); - if (!video) { - die("cannot allocate video buffer (%ux%u)", width, height); - } - core->setVideoBuffer(core, video, width); /* stride == width pixels */ - - /* --- Load the ROM (core takes ownership of the VFile). --- */ - struct VFile* rom = VFileOpen(rom_path, O_RDONLY); - if (!rom) { - die("cannot open ROM: %s", rom_path); - } - if (!core->loadROM(core, rom)) { - die("loadROM failed: %s", rom_path); - } - - /* --- Boot. --- */ - core->reset(core); - - /* --- Execute the scripted steps in order. --- */ - for (int s = 0; s < steps->nitems; ++s) { - JVal* step = steps->items[s]; - if (step->type != J_OBJ) { - die("step %d is not an object", s); - } - JVal* op_v = jget(step, "op"); - if (!op_v || op_v->type != J_STR) { - die("step %d missing \"op\"", s); - } - const char* op = op_v->str; - - if (strcmp(op, "advance") == 0) { - int frames = (int)jget_num(step, "frames", 1); - run_frames(core, 0, frames); - - } else if (strcmp(op, "press") == 0) { - JVal* buttons = jget(step, "buttons"); - if (!buttons || buttons->type != J_ARR) { - die("step %d (press) missing \"buttons\" array", s); - } - uint32_t mask = 0; - for (int b = 0; b < buttons->nitems; ++b) { - JVal* bn = buttons->items[b]; - if (bn->type != J_STR) { - die("step %d (press) has non-string button", s); - } - mask |= button_bit(bn->str); - } - int frames = (int)jget_num(step, "frames", 1); - int release = (int)jget_num(step, "release", 0); - run_frames(core, mask, frames); /* hold */ - run_frames(core, 0, release); /* then release */ - - } else if (strcmp(op, "read") == 0) { - JVal* name_v = jget(step, "name"); - if (!name_v || name_v->type != J_STR) { - die("step %d (read) missing \"name\"", s); - } - JVal* addr_v = jget(step, "addr"); - if (!addr_v || addr_v->type != J_NUM) { - die("step %d (read) missing \"addr\"", s); - } - int size = (int)jget_num(step, "size", 1); - if (size != 1 && size != 2 && size != 4) { - die("step %d (read) size must be 1, 2, or 4", s); - } - uint32_t addr = (uint32_t)addr_v->num; - uint32_t value = bus_read(core, addr, size); - if (g_nreads >= (int)(sizeof(g_reads) / sizeof(g_reads[0]))) { - die("too many reads"); - } - g_reads[g_nreads].name = name_v->str; - g_reads[g_nreads].value = value; - g_nreads++; - - } else if (strcmp(op, "screenshot") == 0) { - JVal* path_v = jget(step, "path"); - if (!path_v || path_v->type != J_STR) { - die("step %d (screenshot) missing \"path\"", s); - } - write_ppm(path_v->str, video, width, height, width); - - } else { - die("step %d has unknown op: %s", s, op); - } - } - - /* --- Emit the result JSON. --- */ - fputs("{\"reads\": {", stdout); - for (int i = 0; i < g_nreads; ++i) { - if (i) { - fputs(", ", stdout); - } - /* Names are simple identifiers; escape quotes/backslashes anyway. */ - putchar('"'); - for (const char* p = g_reads[i].name; *p; ++p) { - if (*p == '"' || *p == '\\') { - putchar('\\'); - } - putchar(*p); - } - printf("\": %u", g_reads[i].value); - } - fputs("}, \"ok\": true}\n", stdout); - fflush(stdout); - - core->deinit(core); - return 0; -} diff --git a/aot/test/scenarios/boot.json b/aot/test/scenarios/boot.json deleted file mode 100644 index 68748c20..00000000 --- a/aot/test/scenarios/boot.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "steps": [ - { "op": "advance", "frames": 12 }, - { "op": "read", "name": "booted", "addr": 33554451, "size": 1 }, - { "op": "read", "name": "player_x", "addr": 33554436, "size": 2 }, - { "op": "read", "name": "player_y", "addr": 33554438, "size": 2 }, - { "op": "read", "name": "cur_map", "addr": 33554441, "size": 1 }, - { "op": "screenshot", "path": "/tmp/boot.ppm" } - ] -} diff --git a/aot/test/scenarios/collide_turn.json b/aot/test/scenarios/collide_turn.json deleted file mode 100644 index cae01bc2..00000000 --- a/aot/test/scenarios/collide_turn.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "steps": [ - { "op": "advance", "frames": 12 }, - { "op": "press", "buttons": ["RIGHT"], "frames": 8 }, - { "op": "read", "name": "player_x", "addr": 33554436, "size": 2 }, - { "op": "read", "name": "player_dir", "addr": 33554440, "size": 1 } - ] -} diff --git a/aot/test/scenarios/interact.json b/aot/test/scenarios/interact.json deleted file mode 100644 index c0aa5dc7..00000000 --- a/aot/test/scenarios/interact.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "steps": [ - { "op": "advance", "frames": 12 }, - { "op": "press", "buttons": ["RIGHT"], "frames": 6 }, - { "op": "press", "buttons": ["A"], "frames": 1, "release": 4 }, - { "op": "read", "name": "script_active", "addr": 33554443, "size": 1 }, - { "op": "read", "name": "text_active", "addr": 33554442, "size": 1 }, - { "op": "read", "name": "cur_text", "addr": 33554448, "size": 2 }, - { "op": "press", "buttons": ["A"], "frames": 1, "release": 4 }, - { "op": "read", "name": "flags0", "addr": 33554452, "size": 1 }, - { "op": "read", "name": "cur_text2", "addr": 33554448, "size": 2 }, - { "op": "press", "buttons": ["A"], "frames": 1, "release": 4 }, - { "op": "read", "name": "script_active2", "addr": 33554443, "size": 1 }, - { "op": "read", "name": "text_active2", "addr": 33554442, "size": 1 } - ] -} diff --git a/aot/test/scenarios/move_up.json b/aot/test/scenarios/move_up.json deleted file mode 100644 index 2c463605..00000000 --- a/aot/test/scenarios/move_up.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "steps": [ - { "op": "advance", "frames": 12 }, - { "op": "press", "buttons": ["UP"], "frames": 20 }, - { "op": "read", "name": "player_y", "addr": 33554438, "size": 2 }, - { "op": "read", "name": "player_dir", "addr": 33554440, "size": 1 } - ] -} diff --git a/scripts/gba-imagegen.ts b/scripts/gba-imagegen.ts index f0de40bc..3ccb4e08 100644 --- a/scripts/gba-imagegen.ts +++ b/scripts/gba-imagegen.ts @@ -56,7 +56,7 @@ function usageText(): string { " --json Emit machine-readable JSON on stdout", "", "examples:", - " bun imagegen --out aot/demo/imagegen/gba-source.png", + " bun imagegen --out static/games/boardroom/imagegen/source.png", " bun imagegen --out /tmp/gba-sheet.png --theme \"rainy port town\" --json", ].join("\n"); } diff --git a/site/aot.html b/site/aot.html deleted file mode 100644 index b3c6aecd..00000000 --- a/site/aot.html +++ /dev/null @@ -1,100 +0,0 @@ -
-
-
-

PocketJS product line

-

PocketJS AOT

-

- TypeScript and JSX author cartridge RPGs. The build emits GBA-native - tiles, sprites, palettes and bytecode instead of shipping JavaScript. -

- -
- -
-
- AOT-01 - GBA data path -
-
-
- - - - -
-
- -
booting cartridge
-
-
- - -
-
-
- town - 240×160 -
-
-
-
- -
-
-
-

Separate from the framework line

-

AOT is not the PSP UI runtime.

-

- The main PocketJS framework keeps Solid and Vue Vapor running on - QuickJS. AOT instead partially evaluates a cartridge DSL at build time - and ships a fixed native runtime plus compact game data. -

-
-
-
- 01 -

Static declaration zone

-

Maps, layers, NPCs, warps, tilesets and sprites execute in Bun during the build.

-
-
- 02 -

Residual script zone

-

Generator scripts lower to a small stack VM for dialogue, choices, flags and warps.

-
-
- 03 -

Native cartridge output

-

GBA 4bpp tiles, BGR555 palettes and bytecode are packed into PJGB chunks.

-
-
-
-
- -
-
-
-

Build shape

-

Author in TSX. Ship binary game data.

-
-
    -
  1. EvaluateRun the static DSL and collect scene declarations.
  2. -
  3. BakeQuantize tiles, sprites and subpixel dialogue glyphs for GBA.
  4. -
  5. ResidualizeLower supported script control flow into VM bytecode.
  6. -
  7. LinkEmbed the PJGB blob into the fixed C runtime and build a ROM.
  8. -
-
-
- -
-
-
-

Documentation branch

-

AOT has its own docs tree.

-

The framework docs stay focused on PSP-class UI. AOT docs cover the cartridge DSL, compiler stages, PJGB data model and browser demo surface.

-
- Enter AOT docs -
-
diff --git a/site/assets/aot-demo.js b/site/assets/aot-demo.js deleted file mode 100644 index 82410670..00000000 --- a/site/assets/aot-demo.js +++ /dev/null @@ -1,102 +0,0 @@ -const root = document.querySelector("[data-aot-demo]"); -const canvas = document.getElementById("aot-demo-canvas"); - -if (root instanceof HTMLElement && canvas instanceof HTMLCanvasElement) { - const ctx = canvas.getContext("2d"); - const label = root.querySelector("[data-aot-label]"); - const status = root.querySelector("[data-aot-status]"); - const buttons = [...root.querySelectorAll("[data-aot-input]")]; - const scenes = [ - { key: "town", label: "town map", src: "/aot/assets/town.png" }, - { key: "dialogue", label: "dialogue", src: "/aot/assets/dialogue.png" }, - { key: "choice", label: "choice menu", src: "/aot/assets/choice.png" }, - { key: "route", label: "route warp", src: "/aot/assets/route.png" }, - ]; - const images = await Promise.all(scenes.map(loadImage)); - let index = 0; - - const setStatus = (text, ready = true) => { - if (!(status instanceof HTMLElement)) return; - status.textContent = text; - status.dataset.ready = ready ? "true" : "false"; - }; - - const draw = () => { - if (!ctx) return; - const scene = scenes[index]; - const image = images[index]; - ctx.clearRect(0, 0, canvas.width, canvas.height); - ctx.imageSmoothingEnabled = false; - ctx.drawImage(image, 0, 0, canvas.width, canvas.height); - ctx.fillStyle = "rgba(0, 0, 0, 0.16)"; - for (let y = 1; y < canvas.height; y += 3) { - ctx.fillRect(0, y, canvas.width, 1); - } - ctx.fillStyle = "rgba(7, 12, 8, 0.72)"; - ctx.fillRect(6, 6, 72, 13); - ctx.fillStyle = "#cfff9c"; - ctx.font = "7px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace"; - ctx.fillText(scene.label.toUpperCase(), 10, 15); - if (label instanceof HTMLElement) label.textContent = scene.label; - setStatus(scene.key); - }; - - const activate = (input) => { - const button = buttons.find((node) => node instanceof HTMLElement && node.dataset.aotInput === input); - if (!(button instanceof HTMLElement)) return; - button.classList.add("is-active"); - window.setTimeout(() => button.classList.remove("is-active"), 140); - }; - - const go = (next, input) => { - index = (next + scenes.length) % scenes.length; - activate(input); - draw(); - }; - - const inputs = { - a: () => go(index + 1, "a"), - b: () => go(index - 1, "b"), - up: () => go(index - 1, "up"), - down: () => go(index + 1, "down"), - left: () => go(0, "left"), - right: () => go(3, "right"), - }; - - for (const button of buttons) { - if (!(button instanceof HTMLElement)) continue; - button.addEventListener("click", () => inputs[button.dataset.aotInput]?.()); - } - - window.addEventListener("keydown", (event) => { - const input = keyToInput(event.key); - if (!input) return; - event.preventDefault(); - inputs[input]?.(); - }); - - draw(); -} else if (root instanceof HTMLElement) { - const status = root.querySelector("[data-aot-status]"); - if (status instanceof HTMLElement) status.textContent = "canvas unavailable"; -} - -function keyToInput(key) { - if (key === "ArrowUp") return "up"; - if (key === "ArrowDown") return "down"; - if (key === "ArrowLeft") return "left"; - if (key === "ArrowRight") return "right"; - if (key === "Enter" || key.toLowerCase() === "a") return "a"; - if (key === "Escape" || key.toLowerCase() === "b") return "b"; - return null; -} - -function loadImage(scene) { - return new Promise((resolve, reject) => { - const image = new Image(); - image.decoding = "async"; - image.onload = () => resolve(image); - image.onerror = () => reject(new Error(`AOT demo asset failed: ${scene.src}`)); - image.src = scene.src; - }); -} diff --git a/site/assets/static-demo.js b/site/assets/static-demo.js new file mode 100644 index 00000000..8419bbf2 --- /dev/null +++ b/site/assets/static-demo.js @@ -0,0 +1,65 @@ +// Pocket Static product-page demo: a slideshow of E2E captures from the +// BOARDROOM playthrough — one game, three consoles. +const root = document.querySelector("[data-static-demo]"); +const canvas = document.getElementById("static-demo-canvas"); + +function loadImage(scene) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => reject(new Error(`missing ${scene.src}`)); + img.src = scene.src; + }); +} + +if (root instanceof HTMLElement && canvas instanceof HTMLCanvasElement) { + const ctx = canvas.getContext("2d"); + const label = root.querySelector("[data-static-label]"); + const status = root.querySelector("[data-static-status]"); + const buttons = [...root.querySelectorAll("[data-static-input]")]; + const scenes = [ + { key: "boardroom", label: "the boardroom (GBA)", src: "/static/assets/boardroom.png" }, + { key: "vegas", label: "the call (GBA)", src: "/static/assets/vegas.png" }, + { key: "gameboy", label: "same cartridge (Game Boy)", src: "/static/assets/gameboy.png" }, + { key: "nes", label: "same cartridge (NES)", src: "/static/assets/nes.png" }, + ]; + + const setStatus = (text, ready = true) => { + if (!(status instanceof HTMLElement)) return; + status.textContent = text; + status.dataset.ready = ready ? "true" : "false"; + }; + + try { + const images = await Promise.all(scenes.map(loadImage)); + let index = 0; + + const draw = () => { + if (!ctx) return; + const scene = scenes[index]; + const image = images[index]; + ctx.imageSmoothingEnabled = false; + ctx.fillStyle = "#05070d"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const scale = Math.min(canvas.width / image.width, canvas.height / image.height); + const w = Math.floor(image.width * scale); + const h = Math.floor(image.height * scale); + ctx.drawImage(image, (canvas.width - w) >> 1, (canvas.height - h) >> 1, w, h); + if (label instanceof HTMLElement) label.textContent = scene.label; + }; + + const step = (delta) => { + index = (index + delta + scenes.length) % scenes.length; + draw(); + }; + + for (const button of buttons) { + const dir = button.dataset.staticInput; + button.addEventListener("click", () => step(dir === "up" || dir === "left" ? -1 : 1)); + } + draw(); + setStatus("cartridge ready"); + } catch (error) { + setStatus("captures unavailable", false); + } +} diff --git a/site/assets/aot.css b/site/assets/static.css similarity index 75% rename from site/assets/aot.css rename to site/assets/static.css index 789b1f35..6cbfb8a0 100644 --- a/site/assets/aot.css +++ b/site/assets/static.css @@ -1,21 +1,21 @@ -.aot-page { +.static-page { background: linear-gradient(180deg, rgba(10, 18, 16, 0.95) 0%, #080b10 46%, #0d1110 100%); color: #edf7ee; } -.aot-page main { +.static-page main { overflow: hidden; } -.aot-shell { +.static-shell { width: 100%; max-width: 1160px; margin: 0 auto; padding: 0 clamp(1.25rem, 4vw, 2.75rem); } -.aot-hero { +.static-hero { position: relative; padding: clamp(3rem, 7vw, 5.8rem) 0 clamp(3rem, 7vw, 5.5rem); border-bottom: 1px solid rgba(143, 198, 133, 0.2); @@ -27,13 +27,13 @@ background-size: auto, 42px 42px, auto, auto; } -.aot-hero__copy { +.static-hero__copy { max-width: 760px; margin: 0 auto 2.35rem; text-align: center; } -.aot-kicker { +.static-kicker { margin: 0 0 0.9rem; color: #f1c84b; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; @@ -43,20 +43,20 @@ text-transform: uppercase; } -.aot-hero h1, -.aot-section-head h2, -.aot-doc-callout h2 { +.static-hero h1, +.static-section-head h2, +.static-doc-callout h2 { color: #f6ffe8; letter-spacing: 0; } -.aot-hero h1 { +.static-hero h1 { margin: 0; font-size: 6.4rem; line-height: 0.93; } -.aot-hero__lead { +.static-hero__lead { max-width: 690px; margin: 1.2rem auto 0; color: #b8c9ba; @@ -64,7 +64,7 @@ line-height: 1.65; } -.aot-actions { +.static-actions { display: flex; flex-wrap: wrap; justify-content: center; @@ -72,7 +72,7 @@ margin-top: 1.65rem; } -.aot-btn { +.static-btn { display: inline-flex; align-items: center; justify-content: center; @@ -89,23 +89,23 @@ background 0.14s ease; } -.aot-btn:hover { +.static-btn:hover { transform: translateY(-1px); border-color: rgba(241, 200, 75, 0.72); background: rgba(32, 51, 41, 0.96); } -.aot-btn--primary { +.static-btn--primary { border-color: transparent; background: linear-gradient(135deg, #a6d76f 0%, #f1c84b 100%); color: #07120a; } -.aot-btn--primary:hover { +.static-btn--primary:hover { background: linear-gradient(135deg, #b9eb7d 0%, #ffd861 100%); } -.aot-console { +.static-console { width: min(100%, 950px); margin: 0 auto; border: 1px solid rgba(191, 203, 173, 0.26); @@ -119,8 +119,8 @@ inset 0 1px 0 rgba(255, 255, 255, 0.08); } -.aot-console__top, -.aot-console__rail { +.static-console__top, +.static-console__rail { display: flex; justify-content: space-between; gap: 1rem; @@ -132,7 +132,7 @@ text-transform: uppercase; } -.aot-console__body { +.static-console__body { display: grid; grid-template-columns: 130px minmax(0, 1fr) 120px; align-items: center; @@ -140,7 +140,7 @@ margin: 1rem 0; } -.aot-screen { +.static-screen { position: relative; overflow: hidden; border: 10px solid #080b09; @@ -152,7 +152,7 @@ aspect-ratio: 240 / 160; } -.aot-screen::after { +.static-screen::after { content: ""; position: absolute; inset: 0; @@ -163,14 +163,14 @@ mix-blend-mode: screen; } -.aot-screen canvas { +.static-screen canvas { display: block; width: 100%; height: 100%; image-rendering: pixelated; } -.aot-screen__status { +.static-screen__status { position: absolute; left: 0.55rem; bottom: 0.55rem; @@ -185,19 +185,19 @@ text-transform: uppercase; } -.aot-screen__status[data-ready="true"] { +.static-screen__status[data-ready="true"] { border-color: rgba(166, 215, 111, 0.52); color: #cfff9c; } -.aot-dpad { +.static-dpad { position: relative; width: 112px; height: 112px; margin: 0 auto; } -.aot-dpad::before { +.static-dpad::before { content: ""; position: absolute; inset: 39px; @@ -205,8 +205,8 @@ background: #080a09; } -.aot-dpad button, -.aot-face button { +.static-dpad button, +.static-face button { cursor: pointer; color: #f5f7ec; border: 1px solid rgba(255, 255, 255, 0.08); @@ -222,24 +222,24 @@ border-color 0.08s ease; } -.aot-dpad button:hover, -.aot-face button:hover, -.aot-dpad button.is-active, -.aot-face button.is-active { +.static-dpad button:hover, +.static-face button:hover, +.static-dpad button.is-active, +.static-face button.is-active { border-color: rgba(241, 200, 75, 0.6); background: linear-gradient(180deg, rgba(255, 255, 255, 0.12), transparent 34%), #20281d; } -.aot-dpad button:active, -.aot-face button:active, -.aot-dpad button.is-active, -.aot-face button.is-active { +.static-dpad button:active, +.static-face button:active, +.static-dpad button.is-active, +.static-face button.is-active { transform: translateY(1px); } -.aot-dpad button { +.static-dpad button { position: absolute; width: 34px; height: 34px; @@ -247,7 +247,7 @@ font-size: 0; } -.aot-dpad button::before { +.static-dpad button::before { content: ""; display: block; width: 0; @@ -256,53 +256,53 @@ border-style: solid; } -.aot-dpad [data-aot-input="up"] { +.static-dpad [data-aot-input="up"] { top: 0; left: 39px; } -.aot-dpad [data-aot-input="up"]::before { +.static-dpad [data-aot-input="up"]::before { border-width: 0 6px 8px; border-color: transparent transparent #d9e4cf; } -.aot-dpad [data-aot-input="down"] { +.static-dpad [data-aot-input="down"] { bottom: 0; left: 39px; } -.aot-dpad [data-aot-input="down"]::before { +.static-dpad [data-aot-input="down"]::before { border-width: 8px 6px 0; border-color: #d9e4cf transparent transparent; } -.aot-dpad [data-aot-input="left"] { +.static-dpad [data-aot-input="left"] { top: 39px; left: 0; } -.aot-dpad [data-aot-input="left"]::before { +.static-dpad [data-aot-input="left"]::before { border-width: 6px 8px 6px 0; border-color: transparent #d9e4cf transparent transparent; } -.aot-dpad [data-aot-input="right"] { +.static-dpad [data-aot-input="right"] { top: 39px; right: 0; } -.aot-dpad [data-aot-input="right"]::before { +.static-dpad [data-aot-input="right"]::before { border-width: 6px 0 6px 8px; border-color: transparent transparent transparent #d9e4cf; } -.aot-face { +.static-face { display: grid; justify-items: center; gap: 0.8rem; } -.aot-face button { +.static-face button { width: 52px; height: 52px; border-radius: 50%; @@ -310,101 +310,101 @@ font-weight: 800; } -.aot-face [data-aot-input="a"] { +.static-face [data-aot-input="a"] { margin-left: 1.25rem; background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 36%), #7a2730; } -.aot-face [data-aot-input="b"] { +.static-face [data-aot-input="b"] { margin-right: 1.25rem; background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 36%), #274d2d; } -.aot-band, -.aot-flow, -.aot-doc-callout { +.static-band, +.static-flow, +.static-doc-callout { padding: clamp(3.25rem, 7vw, 5.5rem) 0; border-bottom: 1px solid rgba(143, 198, 133, 0.16); } -.aot-band { +.static-band { background: #090d0b; } -.aot-flow { +.static-flow { background: linear-gradient(90deg, rgba(241, 200, 75, 0.09), transparent 44%), #0b1112; } -.aot-doc-callout { +.static-doc-callout { background: #0c100e; } -.aot-section-head { +.static-section-head { max-width: 760px; margin: 0 auto 2rem; text-align: center; } -.aot-section-head h2, -.aot-doc-callout h2 { +.static-section-head h2, +.static-doc-callout h2 { margin: 0; font-size: 3.2rem; line-height: 1.02; } -.aot-section-head p:not(.aot-kicker), -.aot-doc-callout p:not(.aot-kicker) { +.static-section-head p:not(.static-kicker), +.static-doc-callout p:not(.static-kicker) { color: #aebfaf; line-height: 1.7; } -.aot-tiles { +.static-tiles { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1rem; } -.aot-tiles article, -.aot-pipeline li { +.static-tiles article, +.static-pipeline li { border: 1px solid rgba(143, 198, 133, 0.22); border-radius: 8px; background: rgba(18, 29, 24, 0.72); } -.aot-tiles article { +.static-tiles article { padding: 1.2rem; } -.aot-tiles span { +.static-tiles span { color: #f1c84b; font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.78rem; font-weight: 800; } -.aot-tiles h3, -.aot-pipeline strong { +.static-tiles h3, +.static-pipeline strong { color: #f5ffe8; } -.aot-tiles h3 { +.static-tiles h3 { margin: 0.65rem 0 0.45rem; font-size: 1.05rem; } -.aot-tiles p { +.static-tiles p { margin: 0; color: #aebfaf; line-height: 1.6; } -.aot-pipeline { +.static-pipeline { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 0.8rem; @@ -413,107 +413,107 @@ list-style: none; } -.aot-pipeline li { +.static-pipeline li { display: grid; min-height: 132px; padding: 1rem; align-content: space-between; } -.aot-pipeline strong { +.static-pipeline strong { display: block; font-size: 1rem; } -.aot-pipeline span { +.static-pipeline span { display: block; color: #aebfaf; line-height: 1.55; } -.aot-doc-callout__in { +.static-doc-callout__in { display: flex; align-items: center; justify-content: space-between; gap: 2rem; } -.aot-doc-callout__in > div { +.static-doc-callout__in > div { max-width: 720px; } @media (max-width: 860px) { - .aot-hero h1 { + .static-hero h1 { font-size: 4.5rem; } - .aot-hero__lead { + .static-hero__lead { font-size: 1.08rem; } - .aot-section-head h2, - .aot-doc-callout h2 { + .static-section-head h2, + .static-doc-callout h2 { font-size: 2.6rem; } - .aot-console__body { + .static-console__body { grid-template-columns: 1fr; } - .aot-screen { + .static-screen { order: -1; } - .aot-face { + .static-face { grid-template-columns: repeat(2, 52px); justify-content: center; } - .aot-face [data-aot-input="a"], - .aot-face [data-aot-input="b"] { + .static-face [data-aot-input="a"], + .static-face [data-aot-input="b"] { margin: 0; } - .aot-tiles, - .aot-pipeline { + .static-tiles, + .static-pipeline { grid-template-columns: 1fr; } - .aot-doc-callout__in { + .static-doc-callout__in { align-items: flex-start; flex-direction: column; } } @media (max-width: 520px) { - .aot-hero h1 { + .static-hero h1 { font-size: 3.1rem; } - .aot-hero__lead { + .static-hero__lead { font-size: 1rem; } - .aot-section-head h2, - .aot-doc-callout h2 { + .static-section-head h2, + .static-doc-callout h2 { font-size: 2rem; } - .aot-console { + .static-console { border-radius: 18px; } - .aot-console__top, - .aot-console__rail { + .static-console__top, + .static-console__rail { font-size: 0.64rem; } - .aot-screen { + .static-screen { border-width: 7px; border-radius: 12px; } - .aot-screen__status { + .static-screen__status { font-size: 0.6rem; } } diff --git a/site/build.ts b/site/build.ts index 2abde4ba..1715bd20 100644 --- a/site/build.ts +++ b/site/build.ts @@ -29,7 +29,7 @@ import { vdomHelperId, } from "@vue-jsx-vapor/runtime/raw"; import { OG_IMAGE_URL, SITE_DESC, SITE_TITLE, SITE_URL, renderPage } from "./templates.ts"; -import { AOT_DOC_NAV, BLOG_POSTS, DOC_NAV, type DocSection } from "./nav.ts"; +import { BLOG_POSTS, DOC_NAV, STATIC_DOC_NAV, type DocSection } from "./nav.ts"; const ROOT = new URL("..", import.meta.url).pathname; // repo root const SITE = ROOT + "site/"; @@ -302,10 +302,10 @@ function copyDemoAssets(): void { } } -function copyAotAssets(): void { - const docsDir = ROOT + "aot/docs/"; - for (const file of ["town.png", "dialogue.png", "choice.png", "route.png"]) { - copy(docsDir + file, "aot/assets/" + file); +function copyStaticAssets(): void { + const docsDir = ROOT + "static/docs/"; + for (const file of ["boardroom.png", "vegas.png", "gameboy.png", "nes.png"]) { + copy(docsDir + file, "static/assets/" + file); } } @@ -380,12 +380,12 @@ async function main() { copy(SITE + "assets/screen.css", "assets/screen.css"); await bundle("assets/home.js", "assets/home.js"); - // 8. AOT product line. This is intentionally separate from the framework - // playground and docs tree. - write("aot/index.html", renderAotHome()); - copy(SITE + "assets/aot.css", "assets/aot.css"); - copy(SITE + "assets/aot-demo.js", "assets/aot-demo.js"); - copyAotAssets(); + // 8. Pocket Static product line. This is intentionally separate from the + // framework playground and docs tree. + write("static/index.html", renderStaticHome()); + copy(SITE + "assets/static.css", "assets/static.css"); + copy(SITE + "assets/static-demo.js", "assets/static-demo.js"); + copyStaticAssets(); // 9. docs + blog (setupMarkdown installs the shared marked/shiki renderer) const highlight = await setupMarkdown(); @@ -465,17 +465,17 @@ ${body} `; } -const AOT_DESC = "PocketJS AOT turns a TypeScript/JSX cartridge DSL into GBA-native game data and a fixed runtime."; -function renderAotHome(): string { +const STATIC_DESC = "Pocket Static compiles TypeScript generator games into real GBA, Game Boy and NES cartridges."; +function renderStaticHome(): string { return renderPage({ - title: "PocketJS AOT", - active: "aot", - body: readFileSync(SITE + "aot.html", "utf8"), - bodyClass: "aot-page", - head: '', - scripts: [''], - path: "/aot/", - description: AOT_DESC, + title: "Pocket Static", + active: "static", + body: readFileSync(SITE + "static.html", "utf8"), + bodyClass: "static-page", + head: '', + scripts: [''], + path: "/static/", + description: STATIC_DESC, robots: "noindex,nofollow", }); } @@ -657,7 +657,7 @@ async function buildDocs(highlight: Highlight) { head: tree.head, scripts: [], path: hrefFor(slug), - description: tree.active === "aot" ? AOT_DESC : undefined, + description: tree.active === "static" ? STATIC_DESC : undefined, robots: tree.robots, })); } @@ -680,11 +680,11 @@ async function buildDocs(highlight: Highlight) { transformFrameworkCode: true, }); await buildTree({ - active: "aot", - docsDir: SITE + "content/aot-docs/", + active: "static", + docsDir: SITE + "content/static-docs/", head: "", - nav: AOT_DOC_NAV, - outPrefix: "aot/docs", + nav: STATIC_DOC_NAV, + outPrefix: "static/docs", robots: "noindex,nofollow", transformFrameworkCode: false, }); diff --git a/site/content/aot-docs/authoring.md b/site/content/aot-docs/authoring.md deleted file mode 100644 index 828f3b05..00000000 --- a/site/content/aot-docs/authoring.md +++ /dev/null @@ -1,82 +0,0 @@ -# Authoring model - -AOT source files are split into two zones. The static zone runs during the Bun -build and declares the cartridge. The residual zone is limited script code that -can be compiled into the runtime VM. - -The authoring shape is deliberately hybrid: - -- tile layers stay on the typed builder path; -- scene entities are grouped with build-time JSX components; -- scripts are generator bodies that the compiler residualizes into bytecode. - -```tsx -/** @jsxImportSource @pocketjs/aot */ -import { - ascii, - defineMap, - Npc, - PlayerSpawn, - script, - say, - tile, -} from "@pocketjs/aot"; -import { hero, town } from "./assets"; - -const ElderTalk = script(function* () { - yield say("Welcome to the route."); -}); - -function TownEntities() { - return ( - <> - - - - ); -} - -export const Town = defineMap("town") - .tileset(town) - .layer( - ascii` - #### - #..# - #### - `.legend({ - "#": tile("wall"), - ".": tile("grass"), - }), - ) - .entities() - .done(); -``` - -## Static declarations - -The compiler can freely evaluate cartridge declarations. This is where tilesets, -maps, layers, actors, hitboxes, warps, palettes, and asset references are -collected. JSX components are just build-time functions that return scene -nodes; they are expanded before the ROM is built. - -## Typed tile layers - -`defineTileset()` preserves literal tile names, and `.tileset(town).layer(...)` -uses that type to reject legends that reference missing tiles. JSX does not own -tile layers because parent-to-child type propagation is weaker there. - -```tsx -defineMap("typed") - .tileset(town) - .layer(ascii`#.`.legend({ - "#": tile("wall"), - ".": tile("grass"), - })); -``` - -## Residual scripts - -Dialogue scripts preserve only the supported control flow and commands. The -compiler lowers `say`, `choice`, flag checks, and scene transitions into compact -bytecode. Unsupported JavaScript stays a compile-time error so the runtime does -not need to embed a dynamic interpreter. diff --git a/site/content/aot-docs/cartridge.md b/site/content/aot-docs/cartridge.md deleted file mode 100644 index f197c0a8..00000000 --- a/site/content/aot-docs/cartridge.md +++ /dev/null @@ -1,20 +0,0 @@ -# Cartridge format - -PJGB is the packed cartridge data model consumed by the GBA runtime. It is not a -general application bundle; it is a purpose-built layout for tile RPG scenes and -their scripts. - -## Chunk families - -| Chunk | Purpose | -| --- | --- | -| Header | Magic, version, offsets, and cartridge metadata. | -| Palettes | BGR555 background and sprite palette banks. | -| Tiles | 4bpp background tiles and sprite tiles. | -| Maps | Layer tile indices, collision, actors, and warp tables. | -| Scripts | VM bytecode, constants, labels, and string references. | -| Text | Dialogue glyph atlas, advances, and textbox strings. | -| Debug | Optional symbol names and source locations for development builds. | - -The runtime reads offsets directly from the blob. Versioning belongs in the -header so compiler and runtime compatibility is easy to check at boot. diff --git a/site/content/aot-docs/compiler.md b/site/content/aot-docs/compiler.md deleted file mode 100644 index 9662cc97..00000000 --- a/site/content/aot-docs/compiler.md +++ /dev/null @@ -1,22 +0,0 @@ -# Compiler pipeline - -The AOT compiler turns author files into a PJGB cartridge blob and a linked GBA -ROM. Each stage keeps the boundary between build-time JavaScript and runtime -game data explicit. - -## Stages - -1. **Evaluate** the static DSL with Bun and collect maps, actors, assets, and - residual script entry points. -2. **Bake** image assets into 8x8 GBA tiles, 4bpp tile maps, sprite sheets, and - BGR555 palettes. -3. **Bake text** into the subpixel dialogue glyph format used by the textbox - renderer. -4. **Residualize** supported script generators into VM instructions. -5. **Pack** the PJGB chunks with offsets, sizes, and debug metadata. -6. **Link** the blob into the fixed C runtime and emit a `.gba` ROM. - -## Failure model - -The compiler should fail early when author code crosses the supported AOT -surface. That keeps the ROM deterministic and keeps the native runtime small. diff --git a/site/content/aot-docs/getting-started.md b/site/content/aot-docs/getting-started.md deleted file mode 100644 index 49c23a75..00000000 --- a/site/content/aot-docs/getting-started.md +++ /dev/null @@ -1,77 +0,0 @@ -# Getting started - -PocketJS AOT authoring has two build-time surfaces: a typed map builder for -tile layers, and JSX scene prefabs for entities. Keep tilemaps on the builder -path so TypeScript can compare layer tile names against the selected tileset. -Use JSX for NPCs, signs, warps, entrances, and reusable scene groups. - -```tsx -/** @jsxImportSource @pocketjs/aot */ -import { - ascii, - defineGame, - defineMap, - defineTileset, - PlayerSpawn, - Sign, - tile, -} from "@pocketjs/aot"; - -const rows8 = (v: string) => Array.from({ length: 8 }, () => v); - -const town = defineTileset("town", { - palette: [[0, 0, 0]], - tiles: { - grass: { px: rows8("11111111") }, - wall: { solid: true, px: rows8("22222222") }, - }, -}); - -function TownEntities() { - return ( - <> - - - - ); -} - -const Town = defineMap("town") - .tileset(town) - .layer( - ascii` - #### - #..# - #### - `.legend({ - "#": tile("wall"), - ".": tile("grass"), - }), - ) - .entities() - .done(); - -export default defineGame({ - title: "POCKET TOWN", - start: "town:spawn", - maps: [Town], -}); -``` - -## Why this shape - -`defineMap(...).tileset(town).layer(...)` carries the concrete `town.tiles` -type into the layer check. If the legend references `tile("water")` and the -tileset has no `water`, `tsc` fails before the compiler builds a ROM. - -JSX is intentionally used one layer later. Pure JSX components run during the -build, expand into static scene nodes, and never ship to the cartridge. - -## Build - -```bash -bun aot/compiler/cli.ts build aot/demo/game.tsx --out aot/dist/pocket-town.gba -``` - -The build evaluates static declarations, lowers supported `script(function* () { -... })` bodies into bytecode, packs PJGB data, and links the fixed GBA runtime. diff --git a/site/content/aot-docs/overview.md b/site/content/aot-docs/overview.md deleted file mode 100644 index eeb6e467..00000000 --- a/site/content/aot-docs/overview.md +++ /dev/null @@ -1,25 +0,0 @@ -# PocketJS AOT - -PocketJS AOT is a separate product line for GBA-class cartridge games. It uses -TypeScript and JSX as an authoring DSL, but the final program does not ship a -JavaScript engine. The build executes the static declarations, lowers the -supported residual scripts, and links compact game data into a fixed native -runtime. - -The framework docs describe the PSP-oriented UI runtime powered by QuickJS, -Solid, Vue Vapor, and PocketJS host components. AOT is intentionally different: -it targets tile maps, sprites, palettes, dialogue, flags, choices, and warps. - -## Current slice - -- Author typed ASCII tile layers with the map builder and compose entities with build-time JSX prefabs. -- Bake GBA-native 4bpp graphics, BGR555 palettes, and packed glyph data. -- Lower supported generator scripts into bytecode for a small runtime VM. -- Preview captured cartridge states in the independent [web demo](/aot/#demo). - -## Boundaries - -AOT docs live under `/aot/docs/*` so the framework docs can stay focused on -PocketJS UI applications. The AOT browser demo is also separate from the -framework playground: it is a static canvas viewer for generated cartridge -states, not a QuickJS/Solid runtime. diff --git a/site/content/aot-docs/runtime.md b/site/content/aot-docs/runtime.md deleted file mode 100644 index b5ed112e..00000000 --- a/site/content/aot-docs/runtime.md +++ /dev/null @@ -1,18 +0,0 @@ -# GBA runtime - -The AOT runtime is a fixed native program that consumes PJGB data. It is designed -around predictable memory, simple scene stepping, and GBA hardware features. - -## Responsibilities - -- Initialize Mode 0 backgrounds, OBJ sprites, palettes, and VRAM transfers. -- Step the active map, actor state, warps, flags, and script VM. -- Render textbox windows with baked glyph data. -- Decode player input into scene movement and script choices. -- Expose a small debug block for emulator inspection during development. - -## Non-goals - -The runtime does not run Solid, Vue Vapor, QuickJS, or the PocketJS framework -component system. That keeps AOT independent from the PSP UI product line and -lets the compiler own most of the complexity. diff --git a/site/content/aot-docs/web-demo.md b/site/content/aot-docs/web-demo.md deleted file mode 100644 index 9d101dbc..00000000 --- a/site/content/aot-docs/web-demo.md +++ /dev/null @@ -1,19 +0,0 @@ -# Web demo - -The AOT web demo is a static browser preview of generated cartridge states. It -uses the screenshots emitted for the GBA DSL work and draws them into a 240x160 -canvas with pixel scaling. - -It deliberately does not use the PocketJS framework playground. The playground -loads a QuickJS-backed UI runtime and framework examples; the AOT demo presents -the cartridge product line as its own surface. - -## Controls - -- `A` or Enter advances through the captured states. -- `B` or Escape moves backward. -- Arrow left jumps to town; arrow right jumps to route. -- Arrow up and arrow down cycle through the scene list. - -The demo is useful for documentation and product validation. The authoritative -runtime behavior remains the GBA runtime linked with the PJGB cartridge data. diff --git a/site/content/static-docs/boardroom.md b/site/content/static-docs/boardroom.md new file mode 100644 index 00000000..7d22ede8 --- /dev/null +++ b/site/content/static-docs/boardroom.md @@ -0,0 +1,36 @@ +# BOARDROOM, the game + +The launch game adapts the November 2023 OpenAI board crisis into a five-day +RPG: the noon Google Meet in a Las Vegas hotel, the guest badge, three CEOs +in three days, the Microsoft feint, 743 signatures, Ilya's 5 AM reversal, +and a final negotiation battle against THE BOARD. + +`games/boardroom/dossier.md` pins every quoted line and date to the public +record; everything else is original parody, and the game says so. The +finale's moves — TENDER OFFER, HEART EMOJIS, THE LETTER — are a +`rpg/battle.ts` config: the whole battle system is compiler macros, and the +letter move stays gated until the signature drive reaches 743. + +## Why it's a good framework demo + +- **One module, three cartridges.** The same `game.ts` builds `.gba`, `.gb` + and `.nes`, and the CI playthrough clears all 17 checkpoints on each. +- **The story is state.** Chapters are flags; the signature counter is a + var; scene gating is `onEnter` scripts re-hiding actors — everything the + script VM exists to do. +- **Deterministic drama.** The final battle rolls the same dice on every + console, because the story RNG belongs to the script layer, not the + platform. +- **Art as code.** The cast is a deterministic pixel-person generator in the + declaration zone — hair, wardrobe and walk frames as data, no network, no + binary assets in review. + +## Playing it + +```sh +cd static && bun games/boardroom/test/e2e.ts gba # builds dist/boardroom.gba +open dist/boardroom.gba +``` + +D-pad walks, A talks and advances text, up/down move menu cursors. Fired to +rehired in about fifteen minutes. diff --git a/site/content/static-docs/compiler.md b/site/content/static-docs/compiler.md new file mode 100644 index 00000000..59477ae3 --- /dev/null +++ b/site/content/static-docs/compiler.md @@ -0,0 +1,38 @@ +# Pipeline & assets + +``` +evaluate run the declaration zone; freeze script generators as ASTs +scripts generator ASTs -> stack-VM bytecode (per console: text pages differ) +model layouts/legends -> tile grids, collision, actors, warps, budgets +link blobs + tables; symbolic warp/actor operands patched +targets/* native art encodings + data emission + toolchain drive +``` + +`spec/isa.ts` is the single source of truth for the ISA, record layouts, and +the debug block; `spec/gen-c.ts` derives the C header every runtime compiles +against, so TypeScript and C cannot drift. + +## Assets + +Art is authored (or generated) as palette-indexed hex rows — 8x8 tiles and +16x16 sprite frames with three facings (left mirrors right in hardware on +all three consoles). Encoders produce each console's native format: + +- **GBA** — 4bpp packed, 16-color banks in BGR555. +- **Game Boy** — 2bpp interleaved; colors map to 4 shades by luma, with + sprite colors clamped to visible shades. +- **NES** — 2bpp planar; palettes reduce to the console's 64-color master + palette, one background subpalette plus up to four sprite subpalettes. + +## Data placement + +Every blob (scripts, text banks, maps, tile stores) is placed whole. The GBA +keeps a flat address space; the Game Boy (MBC5) and NES (UNROM) assign each +blob to a 16 KB bank and the runtime latches banks per access — on the NES +through a bus-conflict-safe identity table. + +## Budgets + +Budgets are compile errors, not runtime surprises: map sizes per console +(NES maps stop at 32x24 so the textbox owns whole attribute rows), one text +bank entry per wrapped page, 16 KB per blob, 12 sprites, 256 flags, 64 vars. diff --git a/site/content/static-docs/getting-started.md b/site/content/static-docs/getting-started.md new file mode 100644 index 00000000..33fdbe40 --- /dev/null +++ b/site/content/static-docs/getting-started.md @@ -0,0 +1,79 @@ +# Getting started + +## Prerequisites + +- [bun](https://bun.sh) +- `arm-none-eabi-gcc` (GBA), `sdcc` (Game Boy), `cc65` (NES) +- `mgba` and `rgbds` from Homebrew (headless emulator + header fixer) + +```sh +brew install --cask gcc-arm-embedded # or brew install arm-none-eabi-gcc +brew install sdcc cc65 mgba rgbds +cd static && bun install +``` + +## Build the launch game + +```sh +cd static +bun -e ' +import { compileGame } from "./compiler/index.ts"; +import { buildGba } from "./compiler/targets/gba.ts"; +const out = await compileGame("games/boardroom/game.ts", "gba"); +await buildGba(out.linked, "dist/boardroom.gba"); +' +open dist/boardroom.gba # any GBA emulator +``` + +Swap `buildGba`/`"gba"` for `buildGb`/`"gb"` or `buildNes`/`"nes"` and the +same module becomes a Game Boy or NES cartridge. + +## Run the test pyramid + +```sh +bun test . # vm semantics, script compiler, pipeline +bun test/harness/build.ts # build the libmgba runner (once) +bun test/e2e.ts # smoke game, 35 assertions x 3 consoles +bun games/boardroom/test/e2e.ts # the full story, 17 checkpoints x 3 +``` + +The E2E suites read a fixed debug block over each emulator's bus — no +screenshots are asserted, only logical state, which is why one suite can +referee three consoles. + +## A minimal game + +```ts +import { defineGame, defineMap, defineSprite, defineTileset, npc, script } from "@pocketjs/static/rpg"; + +const tiles = defineTileset("town", { palette: [[16,18,22],[92,148,252],[56,56,72]], tiles: { + floor: { px: ["11111111","11111111","11111111","11111111","11111111","11111111","11111111","11111111"] }, + wall: { px: ["22222222","22222222","22222222","22222222","22222222","22222222","22222222","22222222"], solid: true }, +}}); + +const hero = defineSprite("hero", { palette: [[0,0,0],[248,248,248]], facings: { + down: [Array.from({ length: 16 }, () => "1".repeat(16))], + up: [Array.from({ length: 16 }, () => "1".repeat(16))], + right: [Array.from({ length: 16 }, () => "1".repeat(16))], +}}); + +const Hello = script(function* (s, v, f) { + yield* s.say("It works on three consoles."); +}); + +defineGame({ + title: "HELLO", + start: "town:door", + player: hero, + maps: [defineMap("town", { + tileset: tiles, + layout: ` + ######## + #......# + ######## + `, + legend: { "#": "wall", ".": "floor" }, + entrances: { door: { at: [2, 1], dir: "right" } }, + actors: [npc("sign", { sprite: hero, at: [5, 1], talk: Hello })], + })], +}); diff --git a/site/content/static-docs/overview.md b/site/content/static-docs/overview.md new file mode 100644 index 00000000..90453437 --- /dev/null +++ b/site/content/static-docs/overview.md @@ -0,0 +1,43 @@ +# Pocket Static + +Pocket Static is the cartridge product line of the Pocket family. A game is +one TypeScript module; the build compiles it into real ROMs for three +consoles at once — Game Boy Advance, Game Boy, and NES. No JavaScript engine +ships: scripts become bytecode for a small stack VM, and everything the +compiler can decide statically (text wrapping, palettes, maps, branching +structure, battle logic) is decided before the cartridge exists. + +The framework docs describe the PSP-oriented UI runtime powered by QuickJS, +Solid, Vue Vapor, and PocketJS host components. Pocket Static is intentionally +different: it targets tile maps, sprites, dialogue, flags, choices, warps, +and turn-based battles on consoles with 2-16 KB of RAM. + +## The one idea + +A game has two zones: + +- **Declaration zone** — `defineGame`, `defineMap`, `defineSprite`, + `defineTileset`: plain TypeScript, executed at build time. Anything you can + compute in TS is free (BOARDROOM's cast is a tiny procedural pixel-person + generator). +- **Script zone** — `script(function* (s, v, f) { ... })`: generator + functions that are compiled, never executed. `s` is the engine, `v` your + numeric vars, `f` your boolean flags. + +The stack VM is the portability layer: its interpreter is ~600 lines of +portable C, compiled by `arm-none-eabi-gcc`, `sdcc`, and `cc65`. The same +bytecode runs on all three CPUs, and a TypeScript reference interpreter +(`vm/ref.ts`) defines the semantics that every console is tested against. + +## What exists today + +The RPG category: grid movement, solid tiles and actors, talk scripts, +step-on triggers, warps, a typewriter textbox, choice menus, deterministic +RNG, and a battle system that is literally a compiler macro library +(`rpg/battle.ts`). The launch game BOARDROOM plays the same 17-checkpoint +story on all three consoles in CI. + +Categories are a deliberate seam: a category is a syscall table, a DSL, a +model builder, budgets, and one portable runtime module. Visual novels are a +strict subset of the RPG surface; platformers put a physics core behind a +blocking op. See `static/DESIGN.md` in the repo for the contract. diff --git a/site/content/static-docs/scripts.md b/site/content/static-docs/scripts.md new file mode 100644 index 00000000..72da7f41 --- /dev/null +++ b/site/content/static-docs/scripts.md @@ -0,0 +1,54 @@ +# The script compiler + +Script bodies never execute. The compiler walks the generator's TypeScript +AST and emits stack-VM bytecode — so what you write is a real (small) +language, not a statement whitelist. + +## Surface + +```ts +const Fight = script(function* (s, v, f) { + v.hp = 8; // v.* — global i16 vars + let round = 0; // locals get VM slots + while (v.hp > 0 && !f.done) { // &&/|| short-circuit + const move = yield* s.choose(["Hit", "Run"]); + if (move === "Hit") { v.hp -= 2 + (yield* s.rnd(3)); } + else { f.done = true; } // f.* — flags + round += 1; + } + yield* s.say(`Over in ${round} rounds.`); // ${} of runtime values +}); +``` + +Supported: i16 arithmetic with constant folding, comparisons, ternaries, +`if/else`, `while`, classic `for`, `switch` (numeric or choice-string case +labels), `break`/`continue`, compound assignment and `++`/`--`, and +`yield* s.call(OtherScript)` subroutines. + +Engine ops on `s`: `say`, `choose`, `rnd`, `wait`, `lock`/`release`, +`face`, `show`/`hide` (actors), `warp("map:entrance")`, `sfx`. + +## Macros: partial evaluation for gameplay + +A plain generator function used with `yield*` is a **macro**: the compiler +inlines its body at the call site with arguments bound as compile-time +constants. `for...of` over a bound array unrolls; `if` over a static +condition drops the dead branch; `return value` works via a hidden slot. + +`rpg/battle.ts` is a whole turn-based battle system built this way — each +encounter compiles to specialized bytecode, and the runtime needed zero new +code for it. + +## Text + +`say()` bodies are wrapped and paginated per console at compile time (28 +columns on GBA/NES, 18 on Game Boy) — the runtime never measures text. +Runtime `${...}` values compile to scratch-var stores plus an inline format +token the textbox renders as decimal digits. + +## Determinism + +`rnd(n)` advances a seeded xorshift16 that only script calls touch. Same +choices in, same story out — on the reference VM and on all three consoles. +That is what lets the E2E suites use a host-side playthrough as the oracle +for emulator assertions. diff --git a/site/content/static-docs/targets.md b/site/content/static-docs/targets.md new file mode 100644 index 00000000..063d430c --- /dev/null +++ b/site/content/static-docs/targets.md @@ -0,0 +1,50 @@ +# GBA / GB / NES + +One portable engine (`runtime/core/vm.c` + `rpg.c`) owns every gameplay +decision. Each console adds a small HAL — video, input, audio, ROM access — +and nothing else, which is why the logical state is identical by +construction. + +## Game Boy Advance + +Mode 0. BG0 is the map (charblock 0, hardware scroll), BG1 the textbox, +16x16 sprites through a shadow OAM committed at vblank. VRAM is CPU-writable +any time, so the HAL is direct. Cartridge header ships with the logo and +checksum patched — flashcart-friendly. + +## Game Boy + +The textbox is the **window layer**: showing dialogue is a register write, +and closing it never repaints the map. All steady-state VRAM traffic goes +through a write queue drained strictly inside vblank behind a STAT gate — +the DMG silently drops writes outside modes 0/1, which the E2E suite caught +as a single missing letter. OAM goes up via the classic HRAM DMA stub; +blobs bank-switch through MBC5. + +The portable engine avoids u8xu8 multiplies entirely: sdcc 4.6's SM83 port +miscompiles some `__muluchar` frames, so hot arithmetic is accumulation and +shifts. + +## NES + +The NMI owns the PPU: OAM DMA plus a 64-entry VRAM ring (single writes and +fill-8 runs), then a scroll/latch reset. CHR-RAM is uploaded with rendering +off; sprites are 8x16 pairs in pattern table 1; the textbox owns tile rows +24-29 so palette attributes (16px granularity) never bleed into the map. +cc65 code can take more than a video frame per engine tick, so the headless +harness paces scenarios on the debug block's tick counter instead of frames +— scenarios stay tick-accurate at any speed. + +## The contract + +Every runtime mirrors one debug block (magic, player state, script/text +state, RNG, all flags and vars) to a fixed address each frame: + +| console | debug block | emulator | +|---|---|---| +| GBA | `0x02000000` (EWRAM) | libmgba | +| Game Boy | `0xDF00` (WRAM) | libmgba | +| NES | `0x0700` (CPU RAM) | jsnes | + +The cross-target suite drives identical scenarios through all three and +compares every checkpoint against the TypeScript reference VM. diff --git a/site/home.html b/site/home.html index 9bcc0d86..eee178a1 100644 --- a/site/home.html +++ b/site/home.html @@ -406,10 +406,10 @@

macOS

-

Game Boy Advance

+

GBA · Game Boy · NES

Experimental
-

@pocketjs/aot partially evaluates TSX into a pure C runtime — no JavaScript VM on the device at all.

+

@pocketjs/static compiles generator games into real cartridges for three consoles — no JavaScript VM on the device at all.

  • diff --git a/site/nav.ts b/site/nav.ts index 3e83ddc1..36f423d2 100644 --- a/site/nav.ts +++ b/site/nav.ts @@ -120,22 +120,21 @@ export const BLOG_POSTS: BlogPost[] = [ }, ]; -export const AOT_DOC_NAV: DocSection[] = [ +export const STATIC_DOC_NAV: DocSection[] = [ { - title: "Product line", + title: "Pocket Static", items: [ { slug: "overview", title: "Overview" }, { slug: "getting-started", title: "Getting started" }, - { slug: "authoring", title: "Authoring model" }, - { slug: "compiler", title: "Compiler pipeline" }, + { slug: "scripts", title: "The script compiler" }, + { slug: "compiler", title: "Pipeline & assets" }, ], }, { - title: "Runtime", + title: "Consoles", items: [ - { slug: "cartridge", title: "Cartridge format" }, - { slug: "runtime", title: "GBA runtime" }, - { slug: "web-demo", title: "Web demo" }, + { slug: "targets", title: "GBA / GB / NES" }, + { slug: "boardroom", title: "BOARDROOM, the game" }, ], }, ]; diff --git a/site/static.html b/site/static.html new file mode 100644 index 00000000..2eb5d64b --- /dev/null +++ b/site/static.html @@ -0,0 +1,80 @@ +
    +
    +
    +

    Pocket product line

    +

    Pocket Static

    +

    + Write a generator, ship a cartridge. One TypeScript module compiles to + real GBA, Game Boy and NES ROMs — scripts lower to a portable + stack VM, everything else resolves at build time. +

    + +
    + +
    +
    + PS-01 + one source, three consoles +
    +
    +
    + + + + +
    +
    + +
    booting cartridge
    +
    +
    + boardroom + captured from the E2E suites +
    +
    +
    +
    +
    + +
    +
    +
    +

    Scripts that never run

    +

    + script(function* (s, v, f) { ... }) bodies are compiled, + not executed: a real expression compiler lowers locals, control flow, + &&/||, switch, subroutines and macro + expansion into bytecode for a ~600-line portable C interpreter. +

    +
    +
    +

    One engine, three consoles

    +

    + Gameplay lives in portable C compiled by arm-none-eabi-gcc, sdcc and + cc65. Each console adds one small HAL: mode-0 layers on GBA, a + window-layer textbox on Game Boy, an NMI-owned VRAM ring on NES. +

    +
    +
    +

    The reference VM is the referee

    +

    + A TypeScript interpreter is the semantic golden. The E2E suites play + identical scenarios through libmgba and jsnes and hold every console + to the reference answers — same seed, same story, byte-equal + debug state. +

    +
    +
    +

    BOARDROOM

    +

    + The launch game: November 2023 at OpenAI, playable — the Vegas + call, 743 signatures, and a final negotiation battle written entirely + as compiler macros. Quoted lines pinned to the public record in the + research dossier. +

    +
    +
    +
    diff --git a/site/templates.ts b/site/templates.ts index 0754a473..e57c0030 100644 --- a/site/templates.ts +++ b/site/templates.ts @@ -14,7 +14,7 @@ export const OG_IMAGE_URL = `${SITE_URL}/og-image.png`; export interface PageOpts { title: string | null; // null uses the bare wordmark (homepage) - active: string; // "home" | "docs" | "aot" | "playground" | "blog" + active: string; // "home" | "docs" | "static" | "playground" | "blog" body: string; bodyClass?: string; head?: string; diff --git a/skills/pocketjs-gba-imagegen/SKILL.md b/skills/pocketjs-gba-imagegen/SKILL.md index f4853ee0..e8aa0fc0 100644 --- a/skills/pocketjs-gba-imagegen/SKILL.md +++ b/skills/pocketjs-gba-imagegen/SKILL.md @@ -1,6 +1,6 @@ --- name: pocketjs-gba-imagegen -description: Generate original GBA-friendly pixel-art asset source sheets through Codex app-server and ImageGen, then save PNGs for PocketJS AOT asset baking. Use when a local coding agent needs reusable tiles, 16x16 character sprites, RPG item icons, or generic Game Boy Advance-style pixel art through the shared Codex image generation path. +description: Generate original GBA-friendly pixel-art asset source sheets through Codex app-server and ImageGen, then save PNGs for Pocket Static asset baking. Use when a local coding agent needs reusable tiles, 16x16 character sprites, RPG item icons, or generic Game Boy Advance-style pixel art through the shared Codex image generation path. --- # PocketJS GBA ImageGen @@ -22,13 +22,13 @@ git status --short --branch 2. Generate a source sheet with the Bun TypeScript CLI: ```text -bun imagegen --out aot/demo/imagegen/gba-source.png +bun imagegen --out static/games/boardroom/imagegen/source.png ``` 3. Add an art-direction seed only when the user asks for one: ```text -bun imagegen --out aot/demo/imagegen/rainy-port-source.png --theme "rainy port town with stone quays and lanterns" +bun imagegen --out static/games/boardroom/imagegen/rainy-port-source.png --theme "rainy port town with stone quays and lanterns" ``` 4. Use `--json` when another coding agent needs machine-readable output: @@ -37,10 +37,10 @@ bun imagegen --out aot/demo/imagegen/rainy-port-source.png --theme "rainy port t bun imagegen --out /tmp/gba-sheet.png --json ``` -5. If replacing the AOT demo source sheet, rerun the deterministic extractor: +5. If replacing a game's source sheet, rerun that game's deterministic extractor: ```text -bun aot/demo/imagegen/build-assets.ts +bun static/games/boardroom/imagegen/build-assets.ts # (per-game extractor) ``` ## Prompt Profiles @@ -58,4 +58,4 @@ Use `--kind items` for small RPG item icons that remain readable as 16x16 sprite - Keep all command wrappers in Bun TypeScript. Do not add native shell scripts for this workflow. - Generate original assets only; avoid franchise terms and existing character references. - Keep the output as a source sheet on a neutral background with gutters so later scripts can crop or quantize it. -- Treat the generated PNG as source art. The AOT runtime still requires deterministic 4bpp extraction before the image can ship as cartridge data. +- Treat the generated PNG as source art. Pocket Static still requires deterministic palette-indexed extraction before the image can ship as cartridge data. diff --git a/skills/pocketjs-release/SKILL.md b/skills/pocketjs-release/SKILL.md index d21371d9..2b63b6ff 100644 --- a/skills/pocketjs-release/SKILL.md +++ b/skills/pocketjs-release/SKILL.md @@ -11,7 +11,7 @@ A release is one tag push. `.github/workflows/release.yml` publishes `@pocketjs/framework` (repo root) and `@pocketjs/cli` (`cli/`) to npm via **trusted publishing (OIDC)** — no tokens, provenance attached — skipping any version already on the registry (safe to re-run). The site deploys separately -on every `main` push (`deploy.yml`). `@pocketjs/aot` stays private. +on every `main` push (`deploy.yml`). `@pocketjs/static` stays private. ## Standard workflow diff --git a/static/.clangd b/static/.clangd new file mode 100644 index 00000000..6965e0ab --- /dev/null +++ b/static/.clangd @@ -0,0 +1,5 @@ +CompileFlags: + Add: + - -I/Users/evan/.superset/worktrees/pocketjs/zippy-radio/static/runtime/gen + - -I/Users/evan/.superset/worktrees/pocketjs/zippy-radio/static/runtime/core + - -DPS_TARGET_GBA diff --git a/static/.gitignore b/static/.gitignore new file mode 100644 index 00000000..75eb0c47 --- /dev/null +++ b/static/.gitignore @@ -0,0 +1,3 @@ +dist/ +node_modules/ +test/harness/mgba_runner diff --git a/static/01_vegas b/static/01_vegas new file mode 100644 index 00000000..6dff08bf Binary files /dev/null and b/static/01_vegas differ diff --git a/static/02_boardroom b/static/02_boardroom new file mode 100644 index 00000000..804d51a1 Binary files /dev/null and b/static/02_boardroom differ diff --git a/static/03_satya b/static/03_satya new file mode 100644 index 00000000..bce29786 Binary files /dev/null and b/static/03_satya differ diff --git a/static/04_ilya b/static/04_ilya new file mode 100644 index 00000000..07740638 Binary files /dev/null and b/static/04_ilya differ diff --git a/static/05_end b/static/05_end new file mode 100644 index 00000000..d3dd8a83 Binary files /dev/null and b/static/05_end differ diff --git a/static/DESIGN.md b/static/DESIGN.md new file mode 100644 index 00000000..19b99481 --- /dev/null +++ b/static/DESIGN.md @@ -0,0 +1,341 @@ +# Pocket Static — design + +**Pocket Static** (`@pocketjs/static`) is the static game compiler of the Pocket +family: you write a game as TypeScript — declarations that run at build time, +and generator functions that *never* run at all — and the compiler partially +evaluates the whole program down to a real cartridge ROM. One game source, +three consoles: **GBA, Game Boy, NES**. + +It replaces `@pocketjs/aot` (the `aot/` package). "AOT" described a compiler +technique; "Static" describes the product promise: everything dynamic about +your game — layout, text wrapping, palettes, branching story logic — is +resolved statically, and only a tiny fixed interpreter ships on cartridge. +Think static-site generator, but the deploy target is 1989. + +Prior art studied and deliberately not reused (fresh implementation, same +lessons): PR #52 (`aspiring-birch`, multi-target aot), and the +cine → saga → edge GBA DSL lineage. + +## 1. The one idea + +A game is two zones: + +- **Declaration zone** — `defineGame`, `defineMap`, `defineSprite`, + `defineTileset`: plain TypeScript, executed on the host at build time, + filling a registry. Anything you can compute in TS is free. +- **Script zone** — `script(function* (s) { ... })`: generator functions that + are **compiled, never executed**. The compiler lowers each generator body + from its TypeScript AST into bytecode for a small **stack VM** that every + target implements. + +The stack VM is the portability layer. The same bytecode, byte for byte, runs +on an ARM7TDMI, an SM83, and a 6502 — because the interpreter is ~600 lines of +portable C compiled by `arm-none-eabi-gcc`, `sdcc`, and `cc65` respectively. +The E2E suite drives the same scenario through all three consoles and asserts +identical logical state. + +Where PR #52's residualizer accepted only a rigid statement whitelist +(`yield op(...)`, `if (yield pred)`, choose-then-switch), Pocket Static ships +a **real expression compiler** over a typed TS subset (§5). That is the core +"完成度" upgrade: scripts read like normal TypeScript. + +## 2. Categories + +The framework is *category-extensible*. A **category** is a game genre package +that plugs into the shared core. The core owns: the VM (generic opcodes +0x00–0x3F), the script compiler, text encoding/wrapping, the asset encoders, +blob/bank layout, target toolchain drivers, and the debug-block/E2E contract. + +A category contributes: + +1. **Syscall table** (`spec/.ts`) — opcodes 0x40+ with operand layouts. +2. **DSL surface** (`/dsl.ts`) — `define*` builders + typed script ops. +3. **Model builder** (compiler stage) — DSL registry → binary records. +4. **Portable runtime module** (`/runtime/*.c`) — implements the + syscalls + per-frame gameplay against the platform HAL. +5. **Budgets** — per-target limits checked at compile time. + +v1 ships the **RPG** category. Sketches to prove the seams (not built): + +- **Visual Novel** — RPG minus maps/movement: SAY/CHOICE/portrait syscalls + and a backdrop model. Strict subset of RPG's runtime surface. +- **Platformer** — a physics core in the category runtime; the VM scripts + cutscenes/triggers while the category owns per-frame simulation (the + saga/edge `world()`/`action()` blocking-op pattern: the script yields into + an engine mode and resumes with a result). + +## 3. Targets + +| | GBA | GB (DMG) | NES | +|---|---|---|---| +| CPU | ARM7TDMI 16.8MHz | SM83 4.19MHz | 6502 1.79MHz | +| Toolchain | arm-none-eabi-gcc | sdcc (SM83) + sdasgb/sdldgb + rgbfix | cc65/ca65/ld65 | +| Screen (tiles) | 30×20 | 20×18 | 32×30 | +| Tile format | 4bpp, 32B | 2bpp interleaved, 16B | 2bpp planar, 16B | +| BG palette | 16×16 BGR555 | BGP 4 shades | 4 subpal × 3 + backdrop, 16px attr blocks | +| OBJ (16×16 actor) | 1 OBJ, 4bpp, 15c | 2× 8×16 OBJ, 3 shades | 4× 8×8 OBJ, 3c/subpal | +| Cart | flat ROM, header checksum | MBC5, rgbfix header | UNROM (mapper 2) + CHR-RAM, iNES | +| Data access | flat pointers | banked, blob ≤ 16KB/bank | banked, blob ≤ 16KB/bank | +| Scroll | free | free | none in v1 (map = one nametable) | +| Emulator (E2E) | libmgba | libmgba | jsnes | +| Debug block | 0x02000000 (EWRAM) | 0xDD00 (WRAM) | 0x0700 (CPU RAM) | + +Banking rule (GB/NES): the linker assigns each *blob* (a script bank, a text +bank, a map, a tile store) wholly into one 16KB bank; the runtime latches the +bank per access site. No blob may exceed 16KB (compile error). GBA ignores +banking (flat). + +NES color: encoders map authored RGB to nearest entry of the canonical 64-color +NES master palette. GB: authored colors map to 4 shades by luma (per-asset +override allowed). + +## 4. Core VM + +Stack machine. u8 opcode + little-endian operands. All values are **i16**. +State (per running script): operand stack (16), locals (16 slots/frame), call +stack (4 frames). Globals: 256 flags (bitset), 64 vars (i16). One script runs +at a time (RPG v1; the category decides concurrency). + +Core opcodes 0x00–0x3F (`spec/isa.ts` is the single source of truth; the +table below is illustrative): + +``` +END RET NOP +PUSH8 i8 / PUSH16 i16 / POP / DUP +JMP rel16 / JZ rel16 / JNZ rel16 rel measured from AFTER the operand +CALL u16 scriptId no args; locals fresh per frame +LDV u8 / STV u8 global var +LDL u8 / STL u8 local slot +FLAG u8 -> push / SETF u8 / CLRF u8 flags +ADD SUB MUL DIV MOD NEG signed 16-bit; DIV/MOD by 0 -> 0 +EQ NE LT GT LE GE NOT +RND pop n, push 0..n-1 (xorshift16, fixed boot seed) +WAIT pop n, suspend n frames +``` + +`&&`/`||` compile to short-circuit jumps; there are no boolean binary ops in +the ISA. RNG advances **only per RND call** with a constant boot seed: the same +script produces the same story on all three consoles, and E2E can assert +RNG-dependent outcomes cross-target. + +Suspension: SAY/CHOICE/WAIT (and future category ops) suspend the VM with a +reason; the main loop resumes it when the condition clears. The interpreter +runs bounded bursts (≤64 ops) per frame between suspensions. + +RPG syscalls 0x40+: + +``` +SAY u16 textId textbox page; typewriter; suspend until A +CHOICE u8 n, u16 t0..t(n-1) menu; suspend; push picked index +LOCK / RELEASE player input +FACE u8 slot actor faces player (0xFF = talking actor) +AVIS u8 slot, u8 visible actor visibility (people *leave* in this story) +WARP u8 map, u8 x, u8 y, u8 dir +SFX u8 id square-wave blips (confirm/deny/damage/heal/fanfare) +``` + +Items and battles are **not** syscalls: items compile to reserved vars, and +battles are DSL library code (§5) compiled through the same generator +pipeline — menus via CHOICE, HP via vars. A whole battle system with zero +bespoke runtime is the framework's best demo. + +## 5. The script compiler + +Input: `script(function* (s) { ... })` bodies (TS AST). Output: bytecode. +Ops are typed methods on the `s` handle and are always invoked with `yield*`, +which gives exact return typing (`Generator<..., number>` for value ops): + +```ts +const meeting = script(function* (s) { + yield* s.say("ILYA: The board has decided."); + let resolve = 3; + while (resolve > 0) { + const pick = yield* s.choose(["Push back", "Stay calm", "Check phone"]); + if (pick === 0 && !(yield* s.flag("board_softened"))) { + resolve -= 1 + (yield* s.rnd(2)); + yield* s.say(`Resolve: ${resolve}`); + } else if (pick === 2) { yield* s.call(twitterScene); } + } +}); +``` + +Supported statically (compile error otherwise, with file:line): + +- **Expressions**: i16 arithmetic (`+ - * / %`, unary `-`), comparisons, + `! && ||` (short-circuit), parens, numeric/boolean/string literals, + locals, `yield* s.(...)` value ops. Constant subexpressions fold. +- **Locals**: `let/const` with initializers, assignment, `+=` family, `++/--`. +- **Control flow**: `if/else`, `while`, `for(;;)`, `break/continue`, + `switch` over any i16 expression (numeric or choice-string case labels), + plain `return`. +- **Subroutines**: `yield* s.call(otherScript)` → VM CALL (no arguments). +- **Macros (partial evaluation)**: `yield* helper(s, args)` where `helper` is + a plain generator function *inlines* its body at the call site with + parameters bound to compile-time constants (numbers, strings, arrays, + objects). Member access on bound objects folds; `for...of` over bound + arrays unrolls; `if` over a static condition drops the dead branch. This is + how `rpg/battle.ts` specializes a battle per enemy definition. +- **Text interpolation**: template literals; `${...}` of static values fold + into the string, `${...}` of runtime i16 expressions compile to a store + into a scratch var + an inline format token the textbox renders as decimal + digits (≤2 runtime slots per page). + +Text is wrapped and paginated **per target at compile time** (28/18/28 cols × +3/2/3 lines); the runtime never measures. `say()` emits one SAY per page. +Speaker prefixes are plain text ("SAM: ..."), resolved statically. + +Correctness story: `vm/ref.ts` is a TypeScript reference interpreter of the +full ISA. Compiler unit tests execute compiled bytecode against scripted +syscall stubs and assert results — no emulator in the loop. The C VM then only +has to match `ref.ts`, which the cross-target E2E suite enforces. + +## 6. RPG model & assets + +Model records (all layouts in `spec/rpg.ts`, byte-exact across targets; +packaging differs per target — GBA parses a container, GB/NES get residualized +per-bank arrays): + +- **Game**: title, start map/pos/dir, counts, text mode, font/box tile ids. +- **Map**: w×h ≤ 32×30, tile grid (u8 tile ids ≤ 256/target budget), + collision bitmap, actors, warps, step triggers, onEnter script. +- **Actor**: tile pos, sprite id, facing, movement (static/wander), solid, + onTalk script, initial visibility. +- **Sprite**: 16×16, 4 facings × 1–2 walk frames. +- **Text bank**: token streams (ASCII + newline + FMT var slots), deduped. +- **Script bank**: bytecode + u16 offset table. + +Authoring (plain typed builders, no JSX — less machinery than aot's +jsx-runtime and reads just as well): + +```ts +const hq = defineMap("hq", { + tileset: office, + layout: ` + ########## + #....d...# + #..______# + `, + legend: { "#": "wall", ".": "floor", d: "desk", _: "table" }, + actors: [npc("ilya", { sprite: ilya, at: [4, 2], talk: meeting })], + warps: [warp({ at: [9, 1], to: "street:door" })], + triggers: [trigger({ at: [2, 1], run: introScene, once: true })], +}); +``` + +Art pipeline (three lanes, all producing the same neutral form — indexed- +palette hex-row tiles/frames in a generated `assets.generated.ts`): + +1. **Procedural** placeholders (test smoke game; no network, deterministic). +2. **codex imagegen** — `codex exec` generates character/tileset source + sheets (committed PNGs); a deterministic extractor (grid detection + + per-cell quantize to the authored palette) emits the neutral form. +3. **PixelLab** (`api.pixellab.ai/v1`, key in root `.env`) — native-size + pixel sprites with seeds; the SPECS + committed manifest.json discipline + from cine/saga/edge. + +Target encoders consume the neutral form: GBA packs 4bpp + BGR555 banks; +GB maps palette→shades by luma and packs interleaved 2bpp; NES packs planar +2bpp, clusters tile colors into ≤4 BG subpalettes with 16px attribute +consistency (compile error on conflict), and assigns sprites to OBJ subpalettes. +Font: a 95-glyph 8×8 ASCII bitmap font checked in as a neutral asset. + +## 7. Runtimes + +``` +static/vm/core.c portable interpreter (gcc/sdcc/cc65) +static/rpg/runtime/rpg.c portable gameplay: grid movement (8px grid, + 2px/frame), collision, actor update, interact, + warps/triggers, textbox/choice state machines, + syscall handlers — against the HAL below +static/runtime//* the HAL: video (tiles/map/OBJ/scroll), input, + frame wait, textbox blit, SFX, debug mirror, + bank latch, crt0 + linker script / header +``` + +HAL is a small header of functions (`hal.h`): `hal_poll_input`, +`hal_vsync`, `hal_map_load`, `hal_tile_write`, `hal_obj_set`, +`hal_text_cell`, `hal_sfx`, `hal_data_ptr(blobId)` (bank latch), +`hal_debug_commit`. Per-target quirks live behind it: + +- **GBA**: mode 0, BG0 map + BG1 textbox, shadow OAM DMA at vblank, + crt0 ends `msr cpsr_c, #0x5F` (IRQ-enabled — the aot 0xDF hang lesson), + header logo+checksum patched for real hardware. +- **GB**: BG + window textbox, STAT-safe VRAM writes (only during + vblank/hblank budget), OAM via DMA routine in HRAM, MBC5 bank latch, + rgbfix -v -m 0x19. +- **NES**: NMI owns PPU: OAM DMA + a bounded VRAM update queue drained in + vblank; CHR-RAM glyph/tile upload at map load (rendering off); textbox is + a nametable overlay with save/restore; UNROM bank latch (bus-conflict-safe + table write); iNES header emitted by the compiler. + +All gameplay decisions (movement, collision, script effects) happen in +portable code ⇒ logical state is cycle-exact identical across targets by +construction; only presentation differs. + +## 8. Debug block & E2E + +Every runtime mirrors the same 176-byte block to a fixed address each frame +(`spec/isa.ts` DBG; magic `PSDB`): booted, frame u32, player x/y/dir/map, +script id/active, text id/active, choice cursor, wait counter, rng state, +flags[32], vars[64×i16]. Harnesses read it over the emulated bus: + +- `test/harness/mgba_runner.c` (libmgba via Homebrew; auto-detects GBA/GB + core) — scenario JSON in: `advance/press/read/screenshot`; JSON out. +- `test/harness/nes_runner.ts` (jsnes, pure TS) — same protocol. + +Suites: `test/vm.test.ts` (compiler ↔ ref VM, no emulator), +`test/e2e.ts` (smoke game × 3 targets — boot, walk, block, talk, choice, +flags, vars, battle-loop, warp, trigger, RNG determinism), +`games/boardroom/test/e2e.ts` (full-story playthrough × 3 targets). +Screenshots land as PNG in `dist/shots/` (harness PPM → PNG in Bun). + +## 9. The launch game — BOARDROOM + +`static/games/boardroom/` — *BOARDROOM: five days in November*. The +November 2023 OpenAI board crisis as a satirical-but-factual RPG (public +figures, public record, original dialogue; research dossier with sources in +`games/boardroom/dossier.md`, kept accurate on dates/quotes). + +- **Ch.1 THE CALL** — Fri Nov 17. HQ office map. The Google Meet trigger: + "not consistently candid." Play as Sam; talk to Mira, Greg (who quits: + gains item RESIGNATION LETTER ×1). +- **Ch.2 THE WEEKEND** — negotiation maps (HQ boardroom, investors' calls), + Emmett Shear arrives; wander NPC journalists. +- **Ch.3 THE LETTER** — employee floor; collect signatures (vars) to 743; + Ilya flips ("I deeply regret..."), joins your side (AVIS + flag). +- **Ch.4 THE RETURN** — final "negotiation battle" vs THE BOARD (DSL battle + library: RESOLVE as HP; moves TENDER OFFER / HEART EMOJI / EMPLOYEE + LETTER), then the epilogue map + new board. + +Art: codex-generated sheets (Sam, Greg, Ilya, Mira, Adam, Helen, Emmett, +Satya walkers + office/boardroom/street tilesets), PixelLab fallback, +committed with manifest. + +## 10. Replacing aot/ + +- `git rm -r aot/`; `static/` supersedes it (PR notes "supersedes #52"). +- `tsconfig.json`: drop `@pocketjs/aot*` aliases + `"aot"` include; add + `@pocketjs/static*` + `"static"`. +- Site: `/aot/` product page + `site/content/aot-docs/*` → + `/static/` page + fresh docs (overview, getting started, scripts, + categories, targets, testing); `site/build.ts` screenshot copies point at + `static/docs/*.png`; `site/home.html` copy updated. +- Root `scripts/gba-imagegen.ts` + `skills/pocketjs-gba-imagegen` → default + paths point at `static/games/*/imagegen/`. +- `skills/pocketjs-release/SKILL.md`: "@pocketjs/aot stays private" → + `@pocketjs/static`. + +## 11. Budgets (compile-time errors, per target) + +Maps ≤ 32 (≤32×30 tiles each); BG tiles ≤ 208 (GB/NES; 95 font + box + blank +reserved above that), ≤ 448 (GBA); sprites ≤ 12 × (4 dir × 2 frames); actors +≤ 16/map; flags ≤ 256; vars ≤ 64 (upper 16 reserved: items/scratch); +scripts ≤ 256, script blob ≤ 16KB/bank; texts ≤ 512; total ROM: GBA ≤ 4MB, +GB ≤ 512KB, NES ≤ 256KB PRG. + +## 12. Out of scope v1 (explicit) + +CJK text (cjk16 is proven in #52; the token format reserves the escape), +music (edge's DirectSound is GBA-only; square-wave SFX only), saves, NES +scrolling, metatiles, real-hardware verification (headers are +flashcart-correct; only emulators are in CI). diff --git a/static/README.md b/static/README.md new file mode 100644 index 00000000..58702838 --- /dev/null +++ b/static/README.md @@ -0,0 +1,94 @@ +# Pocket Static + +**Write a generator. Ship a cartridge.** + +Pocket Static is the static game compiler of the Pocket family: a game is one +TypeScript module — declarations that run at build time, and generator +functions that *never run at all* — and the compiler partially evaluates the +whole thing down to real console ROMs. One source, three cartridges: +**Game Boy Advance, Game Boy, and NES.** + +```ts +const GuideTalk = script(function* (s, v, f) { + yield* s.say("GUIDE: New build! Want to spar?"); + if ((yield* s.choose(["Spar", "Later"])) === "Spar") { + v.hp = 8; v.foe = 6; + while (v.foe > 0 && v.hp > 0) { + if ((yield* s.choose(["Strike", "Guard"])) === "Strike") { + v.foe -= 2 + (yield* s.rnd(2)); + } else { v.hp += 1; } + if (v.foe > 0) v.hp -= 1; + } + if (v.hp > 0) { f.beat_guide = true; yield* s.say(`You win with ${v.hp} HP left.`); } + } +}); +``` + +That script body is never executed. The compiler lowers it from its AST into +bytecode for a small stack VM whose interpreter is ~600 lines of portable C — +compiled by `arm-none-eabi-gcc`, `sdcc`, and `cc65` respectively. Locals, +arithmetic, `if/while/for/switch`, short-circuit `&&`/`||`, subroutine calls, +compile-time macro expansion, and `${...}` text interpolation all work the +way the TypeScript reads. Everything static — text wrapping, palettes, map +data, branching structure — is resolved at build time; only a fixed +interpreter ships. + +The launch game is **BOARDROOM** (`games/boardroom/`): the November 2023 +OpenAI board crisis as a five-day RPG, with a research dossier pinning every +quoted line to the public record. The finale's turn-based battle against THE +BOARD is `rpg/battle.ts` — a complete battle system written as compiler +macros, zero bespoke runtime. + +## Determinism is the product + +- The story RNG is a seeded xorshift16 advanced only by script `rnd()` calls: + the same playthrough produces the same story on every console. +- `vm/ref.ts` is a host-side reference interpreter — the semantic golden. + Compiler tests run against it with no emulator in the loop. +- Every runtime mirrors one debug block (same layout, fixed address) each + frame. The E2E suites drive headless emulators (libmgba, jsnes) through + identical scenarios and hold all three consoles to the reference VM's + answers: the smoke suite is 35 assertions × 3 consoles, the BOARDROOM + playthrough 17 checkpoints × 3. + +## Layout + +``` +spec/ the contract: ISA, records, debug block, targets (gen-c.ts -> C) +vm/ reference interpreter + bytecode assembler/disassembler +compiler/ evaluate -> script compiler -> model -> link -> targets/{gba,gb,nes} +rpg/ the RPG category: DSL, battle macros, portable C engine +runtime/ core/ (portable vm.c + rpg.c) + one small HAL per console +test/ vm + compiler + pipeline unit tests, cross-target E2E, harnesses +games/ boardroom/ (+ test/smoke/ contract game) +``` + +## Build & test + +Prereqs: `bun`, `arm-none-eabi-gcc`, `sdcc`, `cc65`, `mgba` (Homebrew), and +`rgbds` for header fixing. `jsnes` comes from npm. + +```sh +bun install && bun spec/gen-c.ts +bun test/harness/build.ts # mgba runner (once) +bun test . # unit tests (vm, compiler, pipeline) +bun test/e2e.ts # smoke game on gba+gb+nes +bun games/boardroom/test/e2e.ts # the full BOARDROOM playthrough x3 +``` + +ROMs land in `dist/` — `.gba`, `.gb` (MBC5, rgbfix'd), `.nes` (UNROM, +CHR-RAM). Headers are flashcart-correct; only emulators are in CI. + +## Categories + +RPG is the first category; the seams for more are deliberate. A category is +a syscall table (opcodes 0x40+), a DSL, a model builder, budgets, and a +portable runtime module — the core VM, script compiler, text pipeline, and +target toolchains are shared. Visual novels are a strict subset of RPG's +surface; platformers put a physics core behind a blocking `action()` op, the +way saga/edge did on GBA. See `DESIGN.md` for the full contract. + +Supersedes `@pocketjs/aot` (and the multi-target prototype in PR #52) with a +fresh implementation: a real expression compiler instead of a statement +whitelist, one portable engine instead of three, and the reference-VM oracle +holding every console to the same story. diff --git a/static/bun.lock b/static/bun.lock new file mode 100644 index 00000000..4feafaaa --- /dev/null +++ b/static/bun.lock @@ -0,0 +1,18 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@pocketjs/static", + "devDependencies": { + "jsnes": "^1.2.1", + "typescript": "^5", + }, + }, + }, + "packages": { + "jsnes": ["jsnes@1.2.1", "", {}, "sha512-Gn+lnv5B5c/TVwFSc8vzo7amiSogRyfMI9UL7VjYgKDrlTLf7GKuK2WD+t0gEcLbzh/NfMpkIlopzN/3cq760A=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + } +} diff --git a/static/compiler/assets.ts b/static/compiler/assets.ts new file mode 100644 index 00000000..a9b297fd --- /dev/null +++ b/static/compiler/assets.ts @@ -0,0 +1,237 @@ +// static/compiler/assets.ts — neutral art -> native tile encodings. +// +// Neutral form everywhere upstream: 8x8 tiles / 16x16 sprite frames as rows +// of hex nibbles indexing a <=16-color RGB palette (index 0 = backdrop/ +// transparent). This file owns the three native encodings: +// +// GBA 4bpp packed: 32 B/tile, low nibble = LEFT pixel of the pair +// GB 2bpp interleaved: 16 B/tile, per row lo-bitplane byte then hi byte, +// MSB = leftmost pixel +// NES 2bpp planar: 16 B/tile, 8 bytes plane 0 then 8 bytes plane 1 +// +// Color reduction (GB shades / NES subpalettes) happens here too: GB maps +// palette entries to 4 shades by luma; NES picks the nearest entries of the +// canonical 64-color master palette. + +import { rgb555 } from "../spec/isa.ts"; +import type { Rgb } from "../rpg/dsl.ts"; + +export type TilePx = Uint8Array; // 64 palette indices, row-major + +export function tileFromHexRows(rows: readonly string[]): TilePx { + if (rows.length !== 8) throw new Error(`tile needs 8 rows (got ${rows.length})`); + const px = new Uint8Array(64); + rows.forEach((row, y) => { + if (row.length !== 8) throw new Error(`tile row ${y} needs 8 nibbles (got "${row}")`); + for (let x = 0; x < 8; x++) { + const v = parseInt(row[x], 16); + if (Number.isNaN(v)) throw new Error(`bad hex nibble "${row[x]}" in tile row "${row}"`); + px[y * 8 + x] = v; + } + }); + return px; +} + +/** 16x16 frame (16 rows x 16 nibbles) -> 4 tiles in TL,TR,BL,BR order. */ +export function frameToTiles(rows: readonly string[]): TilePx[] { + if (rows.length !== 16 || rows.some((r) => r.length !== 16)) { + throw new Error("sprite frames are 16 rows x 16 hex nibbles"); + } + const quad = (ox: number, oy: number): TilePx => { + const px = new Uint8Array(64); + for (let y = 0; y < 8; y++) { + for (let x = 0; x < 8; x++) { + px[y * 8 + x] = parseInt(rows[oy + y][ox + x], 16); + } + } + return px; + }; + return [quad(0, 0), quad(8, 0), quad(0, 8), quad(8, 8)]; +} + +export function hflipTile(px: TilePx): TilePx { + const out = new Uint8Array(64); + for (let y = 0; y < 8; y++) for (let x = 0; x < 8; x++) out[y * 8 + x] = px[y * 8 + (7 - x)]; + return out; +} + +/** Solid fill tile. */ +export const fillTile = (index: number): TilePx => new Uint8Array(64).fill(index); + +/** 1-bit glyph bitmap (8 bytes, MSB left) -> TilePx with ink/bg indices. */ +export function glyphTile(bitmap: readonly number[], ink: number, bg: number): TilePx { + const px = new Uint8Array(64); + for (let y = 0; y < 8; y++) { + for (let x = 0; x < 8; x++) { + px[y * 8 + x] = bitmap[y] & (0x80 >> x) ? ink : bg; + } + } + return px; +} + +// --------------------------------------------------------------------------- +// Native encodings +// --------------------------------------------------------------------------- +export function encodeTileGba(px: TilePx): Uint8Array { + const out = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + const left = px[i * 2] & 0xf; + const right = px[i * 2 + 1] & 0xf; + out[i] = left | (right << 4); + } + return out; +} + +/** 2-bit-depth encoders share a per-pixel value map (palette idx -> 0..3). */ +export function encodeTileGb(px: TilePx, valueOf: (idx: number) => number): Uint8Array { + const out = new Uint8Array(16); + for (let y = 0; y < 8; y++) { + let lo = 0; + let hi = 0; + for (let x = 0; x < 8; x++) { + const v = valueOf(px[y * 8 + x]) & 3; + if (v & 1) lo |= 0x80 >> x; + if (v & 2) hi |= 0x80 >> x; + } + out[y * 2] = lo; + out[y * 2 + 1] = hi; + } + return out; +} + +export function encodeTileNes(px: TilePx, valueOf: (idx: number) => number): Uint8Array { + const out = new Uint8Array(16); + for (let y = 0; y < 8; y++) { + let p0 = 0; + let p1 = 0; + for (let x = 0; x < 8; x++) { + const v = valueOf(px[y * 8 + x]) & 3; + if (v & 1) p0 |= 0x80 >> x; + if (v & 2) p1 |= 0x80 >> x; + } + out[y] = p0; + out[8 + y] = p1; + } + return out; +} + +// --------------------------------------------------------------------------- +// Color reduction +// --------------------------------------------------------------------------- +export const luma = ([r, g, b]: Rgb): number => 0.299 * r + 0.587 * g + 0.114 * b; + +/** + * GB (DMG): palette index -> shade 0..3 (0 = lightest under the standard + * BGP). Index 0 maps to 0 (backdrop/transparent). Others rank by luma: + * brighter -> lower shade value. + */ +export function gbShadeLut(palette: readonly Rgb[]): (idx: number) => number { + const lut = new Uint8Array(16); + for (let i = 1; i < palette.length; i++) { + const l = luma(palette[i]); + lut[i] = l >= 200 ? 0 : l >= 130 ? 1 : l >= 60 ? 2 : 3; + } + // Backdrop stays 0; but a non-transparent tile pixel that DELIBERATELY uses + // index 0 also renders as shade 0 — acceptable: index 0 is "background". + return (idx) => lut[idx & 0xf]; +} + +/** GBA: neutral palette -> BGR555 bank (16 entries, index 0 kept as-is). */ +export function gbaPaletteBank(palette: readonly Rgb[]): Uint16Array { + const bank = new Uint16Array(16); + palette.forEach(([r, g, b], i) => { + bank[i] = rgb555(r, g, b); + }); + return bank; +} + +// The canonical 64-color NES master palette (2C02, the ubiquitous table). +// prettier-ignore +export const NES_MASTER: Rgb[] = [ + [84,84,84],[0,30,116],[8,16,144],[48,0,136],[68,0,100],[92,0,48],[84,4,0],[60,24,0], + [32,42,0],[8,58,0],[0,64,0],[0,60,0],[0,50,60],[0,0,0],[0,0,0],[0,0,0], + [152,150,152],[8,76,196],[48,50,236],[92,30,228],[136,20,176],[160,20,100],[152,34,32],[120,60,0], + [84,90,0],[40,114,0],[8,124,0],[0,118,40],[0,102,120],[0,0,0],[0,0,0],[0,0,0], + [236,238,236],[76,154,236],[120,124,236],[176,98,236],[228,84,236],[236,88,180],[236,106,100],[212,136,32], + [160,170,0],[116,196,0],[76,208,32],[56,204,108],[56,180,204],[60,60,60],[0,0,0],[0,0,0], + [236,238,236],[168,204,236],[188,188,236],[212,178,236],[236,174,236],[236,174,212],[236,180,176],[228,196,144], + [204,210,120],[180,222,120],[168,226,144],[152,226,180],[160,214,228],[160,162,160],[0,0,0],[0,0,0], +]; + +export function nearestNesColor([r, g, b]: Rgb): number { + let best = 0; + let bestD = Infinity; + for (let i = 0; i < NES_MASTER.length; i++) { + if ((i & 0x0f) >= 0x0e) continue; // skip the mirrored blacks + const [nr, ng, nb] = NES_MASTER[i]; + const d = 3 * (r - nr) ** 2 + 6 * (g - ng) ** 2 + (b - nb) ** 2; + if (d < bestD) { + bestD = d; + best = i; + } + } + return best; +} + +/** + * NES: reduce a <=16-color neutral palette to ONE 4-entry subpalette + * (v1 keeps one BG subpalette for art + one for the textbox, one OBJ + * subpalette per sprite). Entry 0 is the shared backdrop. Returns the + * subpalette (NES master indices) + the pixel value LUT. + */ +export function nesReduce(palette: readonly Rgb[], backdrop: number): { + subpal: [number, number, number, number]; + valueOf: (idx: number) => number; +} { + // Pick the 3 most distinct colors by a simple max-min-distance greedy. + const entries = palette.slice(1).map((rgb, i) => ({ rgb, idx: i + 1 })); + const chosen: { rgb: Rgb; idx: number }[] = []; + const dist = (a: Rgb, b: Rgb) => 3 * (a[0] - b[0]) ** 2 + 6 * (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2; + while (chosen.length < 3 && entries.length > 0) { + let pick = 0; + if (chosen.length === 0) { + // start with the most saturated/extreme color (farthest from grey) + let bestScore = -1; + entries.forEach((e, i) => { + const l = luma(e.rgb); + const score = dist(e.rgb, [l, l, l] as unknown as Rgb) + l; + if (score > bestScore) { + bestScore = score; + pick = i; + } + }); + } else { + let bestScore = -1; + entries.forEach((e, i) => { + const score = Math.min(...chosen.map((c) => dist(e.rgb, c.rgb))); + if (score > bestScore) { + bestScore = score; + pick = i; + } + }); + } + chosen.push(entries.splice(pick, 1)[0]); + } + while (chosen.length < 3) chosen.push({ rgb: [0, 0, 0], idx: 0 }); + + const subpal: [number, number, number, number] = [ + backdrop, + nearestNesColor(chosen[0].rgb), + nearestNesColor(chosen[1].rgb), + nearestNesColor(chosen[2].rgb), + ]; + const lut = new Uint8Array(16); + for (let i = 1; i < palette.length; i++) { + let best = 1; + let bestD = Infinity; + for (let c = 0; c < 3; c++) { + const d = dist(palette[i], chosen[c].rgb); + if (d < bestD) { + bestD = d; + best = c + 1; + } + } + lut[i] = best; + } + return { subpal, valueOf: (idx) => lut[idx & 0xf] }; +} diff --git a/static/compiler/bake-font.ts b/static/compiler/bake-font.ts new file mode 100644 index 00000000..ff7c47fd --- /dev/null +++ b/static/compiler/bake-font.ts @@ -0,0 +1,51 @@ +#!/usr/bin/env bun +// static/compiler/bake-font.ts — one-shot: convert the public-domain font8x8 +// bitmap font (Daniel Hepper / Marcel Sondaar / IBM VGA, public domain) into +// compiler/font.gen.ts. Checked-in output; rerun only to change the font. +// +// bun static/compiler/bake-font.ts + +const src = await Bun.file(process.argv[2]).text(); + +// font8x8_basic covers U+0000..U+007F; each glyph is 8 bytes, LSB = leftmost +// pixel. We keep 0x20..0x7E (the 95 FONT_GLYPHS) and re-pack as MSB-left so +// downstream encoders read bits naturally. +const m = src.match(/font8x8_basic\[128\]\[8\]\s*=\s*\{([\s\S]*?)\};/); +if (!m) throw new Error("font8x8_basic table not found"); +const rows = [...m[1].matchAll(/\{([^}]*)\}/g)].map((g) => + g[1] + .split(",") + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => Number(s)), +); +if (rows.length !== 128) throw new Error(`expected 128 glyphs, got ${rows.length}`); + +const flip = (b: number): number => { + let r = 0; + for (let i = 0; i < 8; i++) if (b & (1 << i)) r |= 0x80 >> i; + return r; +}; + +const glyphs: number[][] = []; +for (let c = 0x20; c <= 0x7e; c++) { + glyphs.push(rows[c].map(flip)); +} + +const lines: string[] = []; +lines.push("// static/compiler/font.gen.ts — GENERATED by bake-font.ts. DO NOT EDIT."); +lines.push("//"); +lines.push("// 95 ASCII glyphs (0x20..0x7E), 8 bytes each, MSB = leftmost pixel."); +lines.push("// Source: font8x8 by Daniel Hepper (public domain), based on"); +lines.push("// Marcel Sondaar / IBM public-domain VGA fonts."); +lines.push("export const FONT8: readonly (readonly number[])[] = ["); +for (let i = 0; i < glyphs.length; i++) { + const ch = String.fromCharCode(0x20 + i); + const bytes = glyphs[i].map((b) => "0x" + b.toString(16).padStart(2, "0")).join(", "); + lines.push(` [${bytes}], // ${JSON.stringify(ch)}`); +} +lines.push("];"); + +const out = new URL("./font.gen.ts", import.meta.url).pathname; +await Bun.write(out, lines.join("\n") + "\n"); +console.log(`wrote ${out} (${glyphs.length} glyphs)`); diff --git a/static/compiler/compile-scripts.ts b/static/compiler/compile-scripts.ts new file mode 100644 index 00000000..5734f07d --- /dev/null +++ b/static/compiler/compile-scripts.ts @@ -0,0 +1,26 @@ +// static/compiler/compile-scripts.ts — front-door for turning a game module's +// residual zone into runnable bytecode: parse, collect sites, compile all +// scripts. Used by the full game pipeline and directly by unit tests / +// the story simulator (which run the result on vm/ref.ts). + +import type { TargetName } from "../spec/isa.ts"; +import { Ctx } from "./context.ts"; +import { collectSites, parseSource, type Sites } from "./sites.ts"; +import { compileScripts, type CompiledScripts } from "./script.ts"; + +export interface ScriptCompileResult extends CompiledScripts { + ctx: Ctx; + sites: Sites; +} + +export function compileScriptSource( + source: string, + target: TargetName = "gba", + fileName = "game.ts", +): ScriptCompileResult { + const file = parseSource(source, fileName); + const sites = collectSites(file); + const ctx = new Ctx(target); + const compiled = compileScripts(sites, ctx); + return { ...compiled, ctx, sites }; +} diff --git a/static/compiler/context.ts b/static/compiler/context.ts new file mode 100644 index 00000000..188412f8 --- /dev/null +++ b/static/compiler/context.ts @@ -0,0 +1,103 @@ +// static/compiler/context.ts — the per-target compile context: interners for +// texts/vars/flags, script directory, and cross-stage fixups. One Ctx per +// (game, target) pair — text pagination is target-shaped, so bytecode is too. + +import { TARGETS, VAR_USER_MAX, VM_FLAGS, type TargetName, type TargetSpec } from "../spec/isa.ts"; +import { RPG_BUDGET } from "../spec/rpg.ts"; +import { encodeOption, encodePage, richToDebugString, wrapPages, type RichText } from "./text.ts"; + +export interface TextRecord { + /** Token stream (already page-shaped). */ + tokens: Uint8Array; + /** Debug representation for e2e lookup ("PAGE:..." / "OPT:..."). */ + debug: string; +} + +export interface WarpFixup { + /** Offset of the 4 operand bytes (map,x,y,dir) in the script blob. */ + at: number; + dest: string; // "map:entrance" + where: string; // error context +} + +export interface ActorFixup { + /** Offset of the slot operand byte in the script blob. */ + at: number; + actorId: string; + where: string; +} + +export class Ctx { + readonly target: TargetSpec; + readonly texts: TextRecord[] = []; + private textIndex = new Map(); + private varIds = new Map(); + private flagIds = new Map(); + /** Filled by the script stage; offsets patched by the link stage. */ + warpFixups: WarpFixup[] = []; + actorFixups: ActorFixup[] = []; + + constructor(target: TargetName) { + this.target = TARGETS[target]; + } + + // --- texts --------------------------------------------------------------- + private internTokens(tokens: Uint8Array, debug: string): number { + const key = Buffer.from(tokens).toString("latin1"); + const hit = this.textIndex.get(key); + if (hit !== undefined) return hit; + if (this.texts.length >= RPG_BUDGET.MAX_TEXTS) { + throw new Error(`text budget exceeded (${RPG_BUDGET.MAX_TEXTS})`); + } + const id = this.texts.length; + this.texts.push({ tokens, debug }); + this.textIndex.set(key, id); + return id; + } + + /** Intern a say() body; returns one text id per page. */ + internPages(rt: RichText, where: string): number[] { + const pages = wrapPages(rt, this.target, where); + return pages.map((page) => { + const tokens = encodePage(page); + const debug = page.map(richToDebugString).join("\n"); + return this.internTokens(tokens, debug); + }); + } + + /** Intern a single-line choice option. */ + internOption(rt: RichText, where: string): number { + const tokens = encodeOption(rt, this.target.textCols - 2, where); // 2 cells for the cursor + return this.internTokens(tokens, richToDebugString(rt)); + } + + // --- vars / flags ---------------------------------------------------------- + varId(name: string, where: string): number { + const hit = this.varIds.get(name); + if (hit !== undefined) return hit; + const id = this.varIds.size; + if (id >= VAR_USER_MAX) throw new Error(`${where}: var budget exceeded (${VAR_USER_MAX} user vars)`); + this.varIds.set(name, id); + return id; + } + + flagId(name: string, where: string): number { + const hit = this.flagIds.get(name); + if (hit !== undefined) return hit; + const id = this.flagIds.size; + if (id >= VM_FLAGS) throw new Error(`${where}: flag budget exceeded (${VM_FLAGS})`); + this.flagIds.set(name, id); + return id; + } + + get varNames(): Record { + return Object.fromEntries(this.varIds); + } + get flagNames(): Record { + return Object.fromEntries(this.flagIds); + } + /** Text debug strings by id (e2e page lookup). */ + get textDebug(): string[] { + return this.texts.map((t) => t.debug); + } +} diff --git a/static/compiler/evaluate.ts b/static/compiler/evaluate.ts new file mode 100644 index 00000000..dfcf64c1 --- /dev/null +++ b/static/compiler/evaluate.ts @@ -0,0 +1,90 @@ +// static/compiler/evaluate.ts — run the declaration zone, freeze the +// residual zone. +// +// The trick (proven across aot/cine/saga/edge): parse the entry module, note +// every `script()` call site (source order = registration order), +// REWRITE each generator argument to its numeric id, redirect the +// "@pocketjs/static" import to this repo's dsl module, transpile, write a +// temp .mjs BESIDE the entry (so its relative imports still resolve), and +// dynamic-import it. The executed module fills REGISTRY with declarations +// while the compiler keeps the generator ASTs for lowering. + +import { unlink } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import ts from "typescript"; +import { REGISTRY, resetRegistry, type GameDecl, type Registry } from "../rpg/dsl.ts"; +import { collectSites, type Sites } from "./sites.ts"; + +export interface Evaluated { + sites: Sites; + game: GameDecl; + registry: Registry; +} + +const DSL_PATH = resolve(import.meta.dir, "..", "rpg", "dsl.ts"); +let evalCounter = 0; + +export async function evaluateGame(entry: string): Promise { + const entryPath = resolve(entry); + const source = await Bun.file(entryPath).text(); + const file = ts.createSourceFile(entryPath, source, ts.ScriptTarget.ES2022, true); + const sites = collectSites(file); + + // Rewrites, applied back-to-front so spans stay valid. + const edits: { start: number; end: number; text: string }[] = []; + for (const site of sites.scripts) { + edits.push({ start: site.fn.getStart(file), end: site.fn.getEnd(), text: String(site.id) }); + } + const visitImports = (node: ts.Node): void => { + if ( + (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && + node.moduleSpecifier && + ts.isStringLiteral(node.moduleSpecifier) + ) { + const spec = node.moduleSpecifier.text; + if (spec === "@pocketjs/static" || spec === "@pocketjs/static/rpg") { + edits.push({ + start: node.moduleSpecifier.getStart(file), + end: node.moduleSpecifier.getEnd(), + text: JSON.stringify(DSL_PATH), + }); + } + } + ts.forEachChild(node, visitImports); + }; + visitImports(file); + + let rewritten = source; + edits.sort((a, b) => b.start - a.start); + for (const e of edits) rewritten = rewritten.slice(0, e.start) + e.text + rewritten.slice(e.end); + + const js = ts.transpileModule(rewritten, { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.ESNext, + verbatimModuleSyntax: false, + }, + fileName: entryPath, + }).outputText; + + const temp = join(dirname(entryPath), `.__static.${process.pid}.${evalCounter++}.mjs`); + await Bun.write(temp, js); + resetRegistry(); + try { + await import(temp); + } finally { + await unlink(temp).catch(() => {}); + } + + if (!REGISTRY.game) throw new Error(`${entryPath}: the module never called defineGame()`); + if (REGISTRY.scriptCount !== sites.scripts.length) { + throw new Error( + `${entryPath}: ${sites.scripts.length} script(...) sites in source but ${REGISTRY.scriptCount} registered at run time — scripts must be created unconditionally at module top level`, + ); + } + return { + sites, + game: REGISTRY.game, + registry: { ...REGISTRY, tilesets: [...REGISTRY.tilesets], sprites: [...REGISTRY.sprites] }, + }; +} diff --git a/static/compiler/font.gen.ts b/static/compiler/font.gen.ts new file mode 100644 index 00000000..ef1e1309 --- /dev/null +++ b/static/compiler/font.gen.ts @@ -0,0 +1,102 @@ +// static/compiler/font.gen.ts — GENERATED by bake-font.ts. DO NOT EDIT. +// +// 95 ASCII glyphs (0x20..0x7E), 8 bytes each, MSB = leftmost pixel. +// Source: font8x8 by Daniel Hepper (public domain), based on +// Marcel Sondaar / IBM public-domain VGA fonts. +export const FONT8: readonly (readonly number[])[] = [ + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // " " + [0x18, 0x3c, 0x3c, 0x18, 0x18, 0x00, 0x18, 0x00], // "!" + [0x6c, 0x6c, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // "\"" + [0x6c, 0x6c, 0xfe, 0x6c, 0xfe, 0x6c, 0x6c, 0x00], // "#" + [0x30, 0x7c, 0xc0, 0x78, 0x0c, 0xf8, 0x30, 0x00], // "$" + [0x00, 0xc6, 0xcc, 0x18, 0x30, 0x66, 0xc6, 0x00], // "%" + [0x38, 0x6c, 0x38, 0x76, 0xdc, 0xcc, 0x76, 0x00], // "&" + [0x60, 0x60, 0xc0, 0x00, 0x00, 0x00, 0x00, 0x00], // "'" + [0x18, 0x30, 0x60, 0x60, 0x60, 0x30, 0x18, 0x00], // "(" + [0x60, 0x30, 0x18, 0x18, 0x18, 0x30, 0x60, 0x00], // ")" + [0x00, 0x66, 0x3c, 0xff, 0x3c, 0x66, 0x00, 0x00], // "*" + [0x00, 0x30, 0x30, 0xfc, 0x30, 0x30, 0x00, 0x00], // "+" + [0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x60], // "," + [0x00, 0x00, 0x00, 0xfc, 0x00, 0x00, 0x00, 0x00], // "-" + [0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x30, 0x00], // "." + [0x06, 0x0c, 0x18, 0x30, 0x60, 0xc0, 0x80, 0x00], // "/" + [0x7c, 0xc6, 0xce, 0xde, 0xf6, 0xe6, 0x7c, 0x00], // "0" + [0x30, 0x70, 0x30, 0x30, 0x30, 0x30, 0xfc, 0x00], // "1" + [0x78, 0xcc, 0x0c, 0x38, 0x60, 0xcc, 0xfc, 0x00], // "2" + [0x78, 0xcc, 0x0c, 0x38, 0x0c, 0xcc, 0x78, 0x00], // "3" + [0x1c, 0x3c, 0x6c, 0xcc, 0xfe, 0x0c, 0x1e, 0x00], // "4" + [0xfc, 0xc0, 0xf8, 0x0c, 0x0c, 0xcc, 0x78, 0x00], // "5" + [0x38, 0x60, 0xc0, 0xf8, 0xcc, 0xcc, 0x78, 0x00], // "6" + [0xfc, 0xcc, 0x0c, 0x18, 0x30, 0x30, 0x30, 0x00], // "7" + [0x78, 0xcc, 0xcc, 0x78, 0xcc, 0xcc, 0x78, 0x00], // "8" + [0x78, 0xcc, 0xcc, 0x7c, 0x0c, 0x18, 0x70, 0x00], // "9" + [0x00, 0x30, 0x30, 0x00, 0x00, 0x30, 0x30, 0x00], // ":" + [0x00, 0x30, 0x30, 0x00, 0x00, 0x30, 0x30, 0x60], // ";" + [0x18, 0x30, 0x60, 0xc0, 0x60, 0x30, 0x18, 0x00], // "<" + [0x00, 0x00, 0xfc, 0x00, 0x00, 0xfc, 0x00, 0x00], // "=" + [0x60, 0x30, 0x18, 0x0c, 0x18, 0x30, 0x60, 0x00], // ">" + [0x78, 0xcc, 0x0c, 0x18, 0x30, 0x00, 0x30, 0x00], // "?" + [0x7c, 0xc6, 0xde, 0xde, 0xde, 0xc0, 0x78, 0x00], // "@" + [0x30, 0x78, 0xcc, 0xcc, 0xfc, 0xcc, 0xcc, 0x00], // "A" + [0xfc, 0x66, 0x66, 0x7c, 0x66, 0x66, 0xfc, 0x00], // "B" + [0x3c, 0x66, 0xc0, 0xc0, 0xc0, 0x66, 0x3c, 0x00], // "C" + [0xf8, 0x6c, 0x66, 0x66, 0x66, 0x6c, 0xf8, 0x00], // "D" + [0xfe, 0x62, 0x68, 0x78, 0x68, 0x62, 0xfe, 0x00], // "E" + [0xfe, 0x62, 0x68, 0x78, 0x68, 0x60, 0xf0, 0x00], // "F" + [0x3c, 0x66, 0xc0, 0xc0, 0xce, 0x66, 0x3e, 0x00], // "G" + [0xcc, 0xcc, 0xcc, 0xfc, 0xcc, 0xcc, 0xcc, 0x00], // "H" + [0x78, 0x30, 0x30, 0x30, 0x30, 0x30, 0x78, 0x00], // "I" + [0x1e, 0x0c, 0x0c, 0x0c, 0xcc, 0xcc, 0x78, 0x00], // "J" + [0xe6, 0x66, 0x6c, 0x78, 0x6c, 0x66, 0xe6, 0x00], // "K" + [0xf0, 0x60, 0x60, 0x60, 0x62, 0x66, 0xfe, 0x00], // "L" + [0xc6, 0xee, 0xfe, 0xfe, 0xd6, 0xc6, 0xc6, 0x00], // "M" + [0xc6, 0xe6, 0xf6, 0xde, 0xce, 0xc6, 0xc6, 0x00], // "N" + [0x38, 0x6c, 0xc6, 0xc6, 0xc6, 0x6c, 0x38, 0x00], // "O" + [0xfc, 0x66, 0x66, 0x7c, 0x60, 0x60, 0xf0, 0x00], // "P" + [0x78, 0xcc, 0xcc, 0xcc, 0xdc, 0x78, 0x1c, 0x00], // "Q" + [0xfc, 0x66, 0x66, 0x7c, 0x6c, 0x66, 0xe6, 0x00], // "R" + [0x78, 0xcc, 0xe0, 0x70, 0x1c, 0xcc, 0x78, 0x00], // "S" + [0xfc, 0xb4, 0x30, 0x30, 0x30, 0x30, 0x78, 0x00], // "T" + [0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0xfc, 0x00], // "U" + [0xcc, 0xcc, 0xcc, 0xcc, 0xcc, 0x78, 0x30, 0x00], // "V" + [0xc6, 0xc6, 0xc6, 0xd6, 0xfe, 0xee, 0xc6, 0x00], // "W" + [0xc6, 0xc6, 0x6c, 0x38, 0x38, 0x6c, 0xc6, 0x00], // "X" + [0xcc, 0xcc, 0xcc, 0x78, 0x30, 0x30, 0x78, 0x00], // "Y" + [0xfe, 0xc6, 0x8c, 0x18, 0x32, 0x66, 0xfe, 0x00], // "Z" + [0x78, 0x60, 0x60, 0x60, 0x60, 0x60, 0x78, 0x00], // "[" + [0xc0, 0x60, 0x30, 0x18, 0x0c, 0x06, 0x02, 0x00], // "\\" + [0x78, 0x18, 0x18, 0x18, 0x18, 0x18, 0x78, 0x00], // "]" + [0x10, 0x38, 0x6c, 0xc6, 0x00, 0x00, 0x00, 0x00], // "^" + [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff], // "_" + [0x30, 0x30, 0x18, 0x00, 0x00, 0x00, 0x00, 0x00], // "`" + [0x00, 0x00, 0x78, 0x0c, 0x7c, 0xcc, 0x76, 0x00], // "a" + [0xe0, 0x60, 0x60, 0x7c, 0x66, 0x66, 0xdc, 0x00], // "b" + [0x00, 0x00, 0x78, 0xcc, 0xc0, 0xcc, 0x78, 0x00], // "c" + [0x1c, 0x0c, 0x0c, 0x7c, 0xcc, 0xcc, 0x76, 0x00], // "d" + [0x00, 0x00, 0x78, 0xcc, 0xfc, 0xc0, 0x78, 0x00], // "e" + [0x38, 0x6c, 0x60, 0xf0, 0x60, 0x60, 0xf0, 0x00], // "f" + [0x00, 0x00, 0x76, 0xcc, 0xcc, 0x7c, 0x0c, 0xf8], // "g" + [0xe0, 0x60, 0x6c, 0x76, 0x66, 0x66, 0xe6, 0x00], // "h" + [0x30, 0x00, 0x70, 0x30, 0x30, 0x30, 0x78, 0x00], // "i" + [0x0c, 0x00, 0x0c, 0x0c, 0x0c, 0xcc, 0xcc, 0x78], // "j" + [0xe0, 0x60, 0x66, 0x6c, 0x78, 0x6c, 0xe6, 0x00], // "k" + [0x70, 0x30, 0x30, 0x30, 0x30, 0x30, 0x78, 0x00], // "l" + [0x00, 0x00, 0xcc, 0xfe, 0xfe, 0xd6, 0xc6, 0x00], // "m" + [0x00, 0x00, 0xf8, 0xcc, 0xcc, 0xcc, 0xcc, 0x00], // "n" + [0x00, 0x00, 0x78, 0xcc, 0xcc, 0xcc, 0x78, 0x00], // "o" + [0x00, 0x00, 0xdc, 0x66, 0x66, 0x7c, 0x60, 0xf0], // "p" + [0x00, 0x00, 0x76, 0xcc, 0xcc, 0x7c, 0x0c, 0x1e], // "q" + [0x00, 0x00, 0xdc, 0x76, 0x66, 0x60, 0xf0, 0x00], // "r" + [0x00, 0x00, 0x7c, 0xc0, 0x78, 0x0c, 0xf8, 0x00], // "s" + [0x10, 0x30, 0x7c, 0x30, 0x30, 0x34, 0x18, 0x00], // "t" + [0x00, 0x00, 0xcc, 0xcc, 0xcc, 0xcc, 0x76, 0x00], // "u" + [0x00, 0x00, 0xcc, 0xcc, 0xcc, 0x78, 0x30, 0x00], // "v" + [0x00, 0x00, 0xc6, 0xd6, 0xfe, 0xfe, 0x6c, 0x00], // "w" + [0x00, 0x00, 0xc6, 0x6c, 0x38, 0x6c, 0xc6, 0x00], // "x" + [0x00, 0x00, 0xcc, 0xcc, 0xcc, 0x7c, 0x0c, 0xf8], // "y" + [0x00, 0x00, 0xfc, 0x98, 0x30, 0x64, 0xfc, 0x00], // "z" + [0x1c, 0x30, 0x30, 0xe0, 0x30, 0x30, 0x1c, 0x00], // "{" + [0x00, 0x18, 0x18, 0x00, 0x18, 0x18, 0x18, 0x00], // "|" + [0xe0, 0x30, 0x30, 0x1c, 0x30, 0x30, 0xe0, 0x00], // "}" + [0x76, 0xdc, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // "~" +]; diff --git a/static/compiler/index.ts b/static/compiler/index.ts new file mode 100644 index 00000000..80e6f0f5 --- /dev/null +++ b/static/compiler/index.ts @@ -0,0 +1,56 @@ +// static/compiler/index.ts — the Pocket Static compile pipeline. +// +// evaluate (declaration zone runs, residual zone frozen as ASTs) +// -> compile scripts (generator ASTs -> bytecode, per-target text pages) +// -> build model (layouts, actors, warps, budgets) +// -> patch fixups (warp/actor symbols -> operands) +// -> link (blobs + tables) +// -> target packager (native art, data emission, toolchain) [targets/*] + +import type { TargetName } from "../spec/isa.ts"; +import { TARGETS } from "../spec/isa.ts"; +import { Ctx } from "./context.ts"; +import { evaluateGame } from "./evaluate.ts"; +import { linkGame, type LinkedGame } from "./link.ts"; +import { buildModel, patchFixups } from "./model.ts"; +import { compileScripts } from "./script.ts"; +import { resolveHelperImports, type Sites } from "./sites.ts"; + +export interface DebugInfo { + target: TargetName; + vars: Record; + flags: Record; + /** Text id -> page text ("\n" line breaks, "{vNN}" fmt slots). */ + texts: string[]; + maps: Record; + actors: Record; + scripts: Record; +} + +export interface CompileOutput { + target: TargetName; + linked: LinkedGame; + sites: Sites; + debug: DebugInfo; +} + +export async function compileGame(entryPath: string, target: TargetName): Promise { + const { sites, game, registry } = await evaluateGame(entryPath); + await resolveHelperImports(sites, entryPath); + const ctx = new Ctx(target); + const scripts = compileScripts(sites, ctx); + const model = buildModel(game, registry, ctx, TARGETS[target]); + patchFixups(scripts.blob, ctx, model); + const linked = linkGame(model, ctx, scripts.blob, scripts.table); + + const debug: DebugInfo = { + target, + vars: ctx.varNames, + flags: ctx.flagNames, + texts: ctx.textDebug, + maps: model.mapIndex, + actors: model.actorSlots, + scripts: Object.fromEntries(sites.scriptIds), + }; + return { target, linked, sites, debug }; +} diff --git a/static/compiler/link.ts b/static/compiler/link.ts new file mode 100644 index 00000000..10115a60 --- /dev/null +++ b/static/compiler/link.ts @@ -0,0 +1,151 @@ +// static/compiler/link.ts — serialize the model into the target-independent +// blobs (game header, script blob, text blobs, map blobs) and the fixed- +// region tables. Native art blobs are appended per target (targets/*), which +// then package everything for their toolchain. + +import { BANK_SIZE, BLOB_KIND, ByteWriter, TEXT_ENTRY_SIZE } from "../spec/isa.ts"; +import { + ACTOR_SIZE, + GAME_HEADER_SIZE, + GAME_TITLE_LEN, + MAP_HEADER_SIZE, + RPG_BUDGET, + TRIGGER_SIZE, + WARP_SIZE, +} from "../spec/rpg.ts"; +import type { Ctx } from "./context.ts"; +import type { GameModel } from "./model.ts"; + +export interface Blob { + kind: number; // BLOB_KIND.* + /** For MAP blobs: the map id. For TEXT: the text-blob ordinal. */ + id: number; + bytes: Uint8Array; +} + +export interface LinkedGame { + model: GameModel; + ctx: Ctx; + header: Uint8Array; + /** Blob index space shared with the targets (art appended after these). */ + blobs: Blob[]; + scriptBlobIndex: number; + /** Per script id: byte offset in the script blob. */ + scriptTable: number[]; + /** Per text id: (blob index, offset). */ + textTable: { blob: number; off: number }[]; + /** Per map id: blob index. */ + mapBlobIndex: number[]; +} + +export function linkGame(model: GameModel, ctx: Ctx, scriptBlob: Uint8Array, scriptTable: number[]): LinkedGame { + if (scriptBlob.length > RPG_BUDGET.MAX_SCRIPT_BLOB) { + throw new Error(`script bytecode is ${scriptBlob.length} B — exceeds one bank (${RPG_BUDGET.MAX_SCRIPT_BLOB})`); + } + + const blobs: Blob[] = []; + const scriptBlobIndex = blobs.length; + blobs.push({ kind: BLOB_KIND.SCRIPTS, id: 0, bytes: scriptBlob }); + + // Texts: first-fit into <=16 KB blobs, each stream whole. + const textTable: { blob: number; off: number }[] = []; + let tw: ByteWriter | null = null; + let twIndex = -1; + let textBlobOrdinal = 0; + const flushText = (): void => { + if (tw && tw.length > 0) blobs[twIndex].bytes = tw.toUint8Array(); + tw = null; + }; + for (const t of ctx.texts) { + if (t.tokens.length > RPG_BUDGET.MAX_TEXT_BLOB) throw new Error("single text exceeds a bank — split it"); + if (!tw || tw.length + t.tokens.length > RPG_BUDGET.MAX_TEXT_BLOB) { + flushText(); + tw = new ByteWriter(); + twIndex = blobs.length; + blobs.push({ kind: BLOB_KIND.TEXT, id: textBlobOrdinal++, bytes: new Uint8Array(0) }); + } + textTable.push({ blob: twIndex, off: tw.length }); + tw.bytes(t.tokens); + } + flushText(); + + // Maps. + const mapBlobIndex: number[] = []; + model.maps.forEach((m, mi) => { + const w = new ByteWriter(); + const cells = m.width * m.height; + const collisionBytes = Math.ceil(cells / 8); + const tilesOff = MAP_HEADER_SIZE; + const collisionOff = tilesOff + cells; + const actorsOff = collisionOff + collisionBytes; + const warpsOff = actorsOff + m.actors.length * ACTOR_SIZE; + const triggersOff = warpsOff + m.warps.length * WARP_SIZE; + + w.u8(m.width) + .u8(m.height) + .u8(m.actors.length) + .u8(m.warps.length) + .u8(m.triggers.length) + .u8(0) + .u16(m.onEnter) + .u16(tilesOff) + .u16(collisionOff) + .u16(actorsOff) + .u16(warpsOff) + .u16(triggersOff) + .u16(0); + if (w.length !== MAP_HEADER_SIZE) throw new Error("map header layout drifted"); + + for (const t of m.tiles) w.u8(t); + const bits = new Uint8Array(collisionBytes); + m.solid.forEach((s, i) => { + if (s) bits[i >> 3] |= 1 << (i & 7); + }); + // Actors are solid obstacles too, but dynamically (they move/hide) — the + // runtime checks them separately; the bitset is static geometry only. + w.bytes(bits); + for (const a of m.actors) { + w.u8(a.x).u8(a.y).u8(a.spriteId).u8(a.facing).u8(a.move).u8(a.flags).u16(a.talk); + } + for (const wp of m.warps) { + w.u8(wp.x).u8(wp.y).u8(wp.destMap).u8(wp.destX).u8(wp.destY).u8(wp.destDir); + } + for (const tr of m.triggers) { + w.u8(tr.x).u8(tr.y).u16(tr.script).u8(tr.flags).u8(tr.onceFlag); + } + + const bytes = w.toUint8Array(); + if (bytes.length > BANK_SIZE) throw new Error(`map ${m.name}: ${bytes.length} B exceeds a bank`); + mapBlobIndex.push(blobs.length); + blobs.push({ kind: BLOB_KIND.MAP, id: mi, bytes }); + }); + + // Game header. + const h = new ByteWriter(); + h.ascii(model.title, GAME_TITLE_LEN) + .u8(model.start.map) + .u8(model.start.x) + .u8(model.start.y) + .u8(model.start.dir) + .u8(model.maps.length) + .u8(model.sprites.length) + .u16(ctx.texts.length) + .u16(scriptTable.length) + .u8(model.playerSpriteId) + .u8(0); + if (h.length !== GAME_HEADER_SIZE) throw new Error("game header layout drifted"); + + if (textTable.length !== ctx.texts.length) throw new Error("text table drifted"); + void TEXT_ENTRY_SIZE; + + return { + model, + ctx, + header: h.toUint8Array(), + blobs, + scriptBlobIndex, + scriptTable, + textTable, + mapBlobIndex, + }; +} diff --git a/static/compiler/model.ts b/static/compiler/model.ts new file mode 100644 index 00000000..7ead63d4 --- /dev/null +++ b/static/compiler/model.ts @@ -0,0 +1,241 @@ +// static/compiler/model.ts — declarations -> the concrete game model: +// numeric ids everywhere, tile grids + collision from layout/legend, actors, +// warps, triggers, entrances resolved, budgets enforced. Target-independent +// (pixel encoding happens in targets/*; text pagination already happened in +// the script stage's Ctx). + +import { type TargetSpec } from "../spec/isa.ts"; +import { + ACTOR_F, + DIR_BY_NAME, + MAX_ACTORS_PER_MAP, + MOVE, + RPG_BUDGET, + SCRIPT_NONE, + type DirName, +} from "../spec/rpg.ts"; +import type { ActorDecl, GameDecl, Registry, SpriteDecl, TilesetDecl } from "../rpg/dsl.ts"; +import type { Ctx } from "./context.ts"; + +export interface ActorModel { + id: string; + x: number; + y: number; + spriteId: number; + facing: number; + move: number; + flags: number; + talk: number; // script id or SCRIPT_NONE +} + +export interface MapModel { + name: string; + width: number; + height: number; + tiles: number[]; // row-major tile indices (0 = blank) + solid: boolean[]; + actors: ActorModel[]; + warps: { x: number; y: number; destMap: number; destX: number; destY: number; destDir: number }[]; + triggers: { x: number; y: number; script: number; flags: number; onceFlag: number }[]; + onEnter: number; + entrances: Record; +} + +export interface GameModel { + title: string; + start: { map: number; x: number; y: number; dir: number }; + playerSpriteId: number; + tileset: TilesetDecl; + /** Tile index -> tileset tile name ("" for blank at 0). */ + tileNames: string[]; + sprites: SpriteDecl[]; + maps: MapModel[]; + mapIndex: Record; + /** Script-referenced actor ids -> location (for AVIS/FACE fixups). */ + actorSlots: Record; +} + +const parseLayout = (layout: string, mapName: string): string[] => { + const rawLines = layout.split("\n"); + while (rawLines.length && rawLines[0].trim() === "") rawLines.shift(); + while (rawLines.length && rawLines[rawLines.length - 1].trim() === "") rawLines.pop(); + if (rawLines.length === 0) throw new Error(`map ${mapName}: empty layout`); + const indents = rawLines.filter((l) => l.trim() !== "").map((l) => l.length - l.trimStart().length); + const strip = Math.min(...indents); + const lines = rawLines.map((l) => l.slice(strip).replace(/\s+$/, "")); + const width = Math.max(...lines.map((l) => l.length)); + return lines.map((l) => l.padEnd(width, " ")); +}; + +export function buildModel(game: GameDecl, registry: Registry, ctx: Ctx, target: TargetSpec): GameModel { + // v1: one tileset per game — every map must share it. + const tilesets = new Set(game.maps.map((m) => m.tileset)); + if (tilesets.size !== 1) { + throw new Error(`v1 supports exactly one tileset per game (got ${[...tilesets].map((t) => t.name).join(", ")})`); + } + const tileset = game.maps[0].tileset; + + const tileIndex = new Map(); + const tileNames = [""]; + Object.keys(tileset.tiles).forEach((name) => { + tileIndex.set(name, tileNames.length); + tileNames.push(name); + }); + if (tileNames.length - 1 > Math.min(RPG_BUDGET.MAX_TILESET_TILES, target.bgArtTiles)) { + throw new Error( + `tileset ${tileset.name}: ${tileNames.length - 1} tiles exceeds the ${target.name} art budget (${Math.min(RPG_BUDGET.MAX_TILESET_TILES, target.bgArtTiles)})`, + ); + } + + // Sprites: the player plus every actor sprite, in REGISTRY order. + const spriteIds = new Map(); + registry.sprites.forEach((sp, i) => spriteIds.set(sp, i)); + if (registry.sprites.length > RPG_BUDGET.MAX_SPRITES) { + throw new Error(`${registry.sprites.length} sprites exceeds budget ${RPG_BUDGET.MAX_SPRITES}`); + } + const spriteIdOf = (sp: SpriteDecl, whom: string): number => { + const id = spriteIds.get(sp); + if (id === undefined) throw new Error(`${whom}: sprite was not created with defineSprite()`); + return id; + }; + + if (game.maps.length > RPG_BUDGET.MAX_MAPS) throw new Error(`too many maps (${game.maps.length})`); + const mapIndex: Record = {}; + game.maps.forEach((m, i) => { + if (mapIndex[m.name] !== undefined) throw new Error(`duplicate map name "${m.name}"`); + mapIndex[m.name] = i; + }); + + const actorSlots: Record = {}; + const dirOf = (d: DirName | undefined, fallback = 0): number => (d ? DIR_BY_NAME[d] : fallback); + + const maps: MapModel[] = game.maps.map((m, mi) => { + const lines = parseLayout(m.layout, m.name); + const width = lines[0].length; + const height = lines.length; + if (width > target.maxMapW || height > target.maxMapH) { + throw new Error(`map ${m.name}: ${width}x${height} exceeds ${target.name} max ${target.maxMapW}x${target.maxMapH}`); + } + const tiles: number[] = []; + const solid: boolean[] = []; + for (const line of lines) { + for (const ch of line) { + if (ch === " " && !(m.legend[" "] !== undefined)) { + tiles.push(0); + solid.push(false); + continue; + } + const tileName = m.legend[ch]; + if (tileName === undefined) throw new Error(`map ${m.name}: layout char "${ch}" missing from legend`); + const ti = tileIndex.get(tileName); + if (ti === undefined) throw new Error(`map ${m.name}: legend "${ch}" -> unknown tile "${tileName}"`); + tiles.push(ti); + solid.push(tileset.tiles[tileName].solid === true); + } + } + + const inBounds = (x: number, y: number, what: string): void => { + if (x < 0 || y < 0 || x >= width || y >= height) { + throw new Error(`map ${m.name}: ${what} at (${x},${y}) is outside ${width}x${height}`); + } + }; + + const actors = (m.actors ?? []).map((a: ActorDecl, slot: number) => { + inBounds(a.at[0], a.at[1], `actor "${a.id}"`); + if (actorSlots[a.id]) throw new Error(`actor id "${a.id}" is used on two maps — ids are global`); + actorSlots[a.id] = { map: mi, slot }; + return { + id: a.id, + x: a.at[0], + y: a.at[1], + spriteId: spriteIdOf(a.sprite, `actor ${a.id}`), + facing: dirOf(a.facing), + move: a.move === "wander" ? MOVE.WANDER : MOVE.STATIC, + flags: (a.solid === false ? 0 : ACTOR_F.SOLID) | (a.hidden ? ACTOR_F.HIDDEN : 0), + talk: a.talk ? a.talk.__script : SCRIPT_NONE, + }; + }); + if (actors.length > MAX_ACTORS_PER_MAP) { + throw new Error(`map ${m.name}: ${actors.length} actors exceeds ${MAX_ACTORS_PER_MAP}`); + } + + const entrances: MapModel["entrances"] = {}; + for (const [name, e] of Object.entries(m.entrances ?? {})) { + inBounds(e.at[0], e.at[1], `entrance "${name}"`); + entrances[name] = { x: e.at[0], y: e.at[1], dir: dirOf(e.dir) }; + } + + const triggers = (m.triggers ?? []).map((t) => { + inBounds(t.at[0], t.at[1], "trigger"); + const onceFlag = t.once ? ctx.flagId(`__trig_${m.name}_${t.at[0]}_${t.at[1]}`, `map ${m.name}`) : 0; + return { x: t.at[0], y: t.at[1], script: t.run.__script, flags: t.once ? 1 : 0, onceFlag }; + }); + + return { + name: m.name, + width, + height, + tiles, + solid, + actors, + warps: [], // filled below once all maps/entrances exist + triggers, + onEnter: m.onEnter ? m.onEnter.__script : SCRIPT_NONE, + entrances, + }; + }); + + // Second pass: warps (need every map's entrances). + const resolveDest = (dest: string, whom: string): { map: number; x: number; y: number; dir: number } => { + const [mapName, entName] = dest.split(":"); + const di = mapIndex[mapName]; + if (di === undefined) throw new Error(`${whom}: unknown map "${mapName}" in "${dest}"`); + const ent = maps[di].entrances[entName]; + if (!ent) throw new Error(`${whom}: map "${mapName}" has no entrance "${entName}"`); + return { map: di, x: ent.x, y: ent.y, dir: ent.dir }; + }; + + game.maps.forEach((m, mi) => { + maps[mi].warps = (m.warps ?? []).map((w) => { + const d = resolveDest(w.to, `map ${m.name} warp`); + if (w.at[0] < 0 || w.at[1] < 0 || w.at[0] >= maps[mi].width || w.at[1] >= maps[mi].height) { + throw new Error(`map ${m.name}: warp at (${w.at[0]},${w.at[1]}) out of bounds`); + } + return { x: w.at[0], y: w.at[1], destMap: d.map, destX: d.x, destY: d.y, destDir: d.dir }; + }); + }); + + const start = resolveDest(game.start, "game start"); + + return { + title: game.title, + start: { map: start.map, x: start.x, y: start.y, dir: start.dir }, + playerSpriteId: spriteIdOf(game.player, "player"), + tileset, + tileNames, + sprites: registry.sprites, + maps, + mapIndex, + actorSlots, + }; +} + +/** Resolve the script stage's symbolic fixups against the built model. */ +export function patchFixups(blob: Uint8Array, ctx: Ctx, model: GameModel): void { + for (const w of ctx.warpFixups) { + const [mapName, entName] = w.dest.split(":"); + const mi = model.mapIndex[mapName]; + if (mi === undefined) throw new Error(`${w.where}: unknown map "${mapName}"`); + const ent = model.maps[mi].entrances[entName]; + if (!ent) throw new Error(`${w.where}: map "${mapName}" has no entrance "${entName}"`); + blob[w.at] = mi; + blob[w.at + 1] = ent.x; + blob[w.at + 2] = ent.y; + blob[w.at + 3] = ent.dir; + } + for (const a of ctx.actorFixups) { + const loc = model.actorSlots[a.actorId]; + if (!loc) throw new Error(`${a.where}: unknown actor id "${a.actorId}"`); + blob[a.at] = loc.slot; + } +} diff --git a/static/compiler/play.ts b/static/compiler/play.ts new file mode 100644 index 00000000..9d14e2fe --- /dev/null +++ b/static/compiler/play.ts @@ -0,0 +1,113 @@ +#!/usr/bin/env bun +// static/compiler/play.ts — build a game and play it. +// +// bun run play [game] [target] +// game boardroom | smoke | path/to/game.ts (default: boardroom) +// target gba | gb | nes (default: gba) +// +// gba/gb open in the windowed mGBA.app (Homebrew). nes serves a local jsnes +// player (the same core the E2E harness uses) and opens your browser — +// no extra emulator install needed. +// +// Controls: d-pad walks, A talks/advances (mGBA: X key; browser: X), +// B = mGBA Z / browser Z, up/down move menu cursors. + +import { $ } from "bun"; +import { existsSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { TARGETS, type TargetName } from "../spec/isa.ts"; +import { compileGame } from "./index.ts"; +import { buildGba } from "./targets/gba.ts"; +import { buildGb } from "./targets/gb.ts"; +import { buildNes } from "./targets/nes.ts"; + +const ROOT = join(import.meta.dir, ".."); +const GAMES: Record = { + boardroom: join(ROOT, "games", "boardroom", "game.ts"), + smoke: join(ROOT, "test", "smoke", "game.ts"), +}; + +const [gameArg = "boardroom", targetArg = "gba"] = process.argv.slice(2); +const target = targetArg as TargetName; +if (!TARGETS[target]) { + console.error(`unknown target "${targetArg}" (gba | gb | nes)`); + process.exit(2); +} +const entry = GAMES[gameArg] ?? resolve(gameArg); +if (!existsSync(entry)) { + console.error(`no such game "${gameArg}" (boardroom | smoke | path/to/game.ts)`); + process.exit(2); +} +const name = gameArg in GAMES ? gameArg : "game"; +const rom = join(ROOT, "dist", `${name}${TARGETS[target].ext}`); + +console.log(`building ${name} for ${target}...`); +const out = await compileGame(entry, target); +if (target === "gba") await buildGba(out.linked, rom); +else if (target === "gb") await buildGb(out.linked, rom); +else await buildNes(out.linked, rom); +console.log(` ${rom}`); + +if (target === "gba" || target === "gb") { + const prefix = (await $`brew --prefix mgba`.text()).trim(); + const apps = [...new Bun.Glob("**/mGBA.app").scanSync({ cwd: prefix, onlyFiles: false })]; + if (apps.length === 0) { + console.error("mGBA.app not found — brew install mgba, or open the ROM in any emulator:"); + console.error(` ${rom}`); + process.exit(1); + } + await $`open -n ${join(prefix, apps[0])} --args ${rom}`; + console.log("mGBA launched. Keys: arrows = d-pad, X = A, Z = B."); +} else { + const jsnesPath = join(ROOT, "node_modules", "jsnes", "dist", "jsnes.min.js"); + const romBytes = await Bun.file(rom).arrayBuffer(); + const page = `${name}.nes — Pocket Static + + +

    arrows = d-pad   X = A   Z = B   enter = start   shift = select

    + +`; + const server = Bun.serve({ + hostname: "127.0.0.1", + port: 0, + fetch(req) { + const path = new URL(req.url).pathname; + if (path === "/") return new Response(page, { headers: { "content-type": "text/html" } }); + if (path === "/jsnes.min.js") return new Response(Bun.file(jsnesPath)); + if (path === "/rom") return new Response(romBytes); + return new Response("not found", { status: 404 }); + }, + }); + const url = `http://127.0.0.1:${server.port}/`; + console.log(`jsnes player at ${url} (ctrl-c to stop)`); + if (!process.env.PLAY_NO_OPEN) await $`open ${url}`; +} diff --git a/static/compiler/script.ts b/static/compiler/script.ts new file mode 100644 index 00000000..f8d8fa26 --- /dev/null +++ b/static/compiler/script.ts @@ -0,0 +1,1084 @@ +// static/compiler/script.ts — compile script generator bodies to stack-VM +// bytecode. This is a real (small) compiler over a typed TS subset, not a +// statement-pattern matcher: expressions, locals, control flow, short-circuit +// logic, switch, subroutine CALLs and compile-time macro inlining all work +// the way the TypeScript reads. +// +// The residual contract: script bodies NEVER execute on the host. Everything +// they may reference is either (a) the three role parameters +// `function* (s, v, f)` — engine ops, global vars proxy, flags proxy — +// (b) local `let/const`, (c) compile-time constants, or (d) helper generator +// functions (inlined macros with statically-bound arguments). + +import ts from "typescript"; +import { OP, VAR_SCRATCH_BASE, VM_LOCALS, VM_VARS } from "../spec/isa.ts"; +import { DIR_BY_NAME, FACE_SELF, RPG_OP, SFX, type DirName } from "../spec/rpg.ts"; +import type { Ctx } from "./context.ts"; +import { richFromString, type RichText } from "./text.ts"; +import type { ScriptSite, Sites } from "./sites.ts"; + +// --------------------------------------------------------------------------- +// Static values (compile-time constants) +// --------------------------------------------------------------------------- +export type StaticValue = number | string | boolean | StaticValue[] | { [k: string]: StaticValue }; + +type Binding = + | { kind: "role"; role: "ops" | "vars" | "flags" } + | { kind: "local"; slot: number; choiceOptions?: string[] } + | { kind: "const"; value: StaticValue }; + +class Scope { + private byName = new Map(); + constructor(readonly parent?: Scope) {} + lookup(name: string): Binding | undefined { + return this.byName.get(name) ?? this.parent?.lookup(name); + } + declare(name: string, b: Binding): void { + this.byName.set(name, b); + } +} + +export class ScriptError extends Error { + constructor(node: ts.Node, fallback: ts.SourceFile, msg: string) { + const sf = node.getSourceFile() ?? fallback; + const { line, character } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + super( + `${sf.fileName}:${line + 1}:${character + 1}: ${msg}\n near: ${node.getText(sf).slice(0, 90)}`, + ); + } +} + +interface LoopCtx { + breaks: number[]; // JMP operand offsets to patch to loop end + continues: number[]; // JMP operand offsets to patch to continue target + continueTarget?: number; // known immediately for while; patched late for for +} + +interface MacroCtx { + endJumps: number[]; // `return` sites + resultSlot?: number; // set when the macro is used in value position +} + +const SCRATCH_SLOTS = VM_VARS - VAR_SCRATCH_BASE; + +// --------------------------------------------------------------------------- +export class ScriptCompiler { + code: number[] = []; + private scopes: Scope; + private nextSlot = 0; + private loops: LoopCtx[] = []; + private macros: MacroCtx[] = []; + private expansion: string[] = []; // macro names, for recursion detection + /** Set by compileExpr when the value came straight from s.choose(). */ + private lastChoiceOptions?: string[]; + + constructor( + private site: ScriptSite, + private sites: Sites, + private ctx: Ctx, + ) { + this.scopes = new Scope(); + const params = site.fn.parameters; + const roles = ["ops", "vars", "flags"] as const; + params.forEach((p, i) => { + if (!ts.isIdentifier(p.name)) throw this.err(p, "script parameters must be plain identifiers"); + if (i >= roles.length) throw this.err(p, "scripts take at most (s, v, f)"); + this.scopes.declare(p.name.text, { kind: "role", role: roles[i] }); + }); + } + + private get sf(): ts.SourceFile { + return this.sites.file; + } + private err(node: ts.Node, msg: string): ScriptError { + return new ScriptError(node, this.sf, msg); + } + private where(node: ts.Node): string { + const sf = node.getSourceFile() ?? this.sf; + const { line } = sf.getLineAndCharacterOfPosition(node.getStart(sf)); + return `${sf.fileName}:${line + 1}`; + } + + // --- emission helpers ------------------------------------------------------ + private u8(v: number): void { + this.code.push(v & 0xff); + } + private u16(v: number): void { + this.code.push(v & 0xff, (v >> 8) & 0xff); + } + private emitPush(v: number, node: ts.Node): void { + const x = Math.trunc(v); + if (x < -32768 || x > 32767) throw this.err(node, `constant ${x} out of i16 range`); + if (x >= -128 && x <= 127) { + this.u8(OP.PUSH8); + this.u8(x & 0xff); + } else { + this.u8(OP.PUSH16); + this.u16(x & 0xffff); + } + } + /** Emit a jump; returns the operand offset for patching. */ + private jump(op: number): number { + this.u8(op); + const at = this.code.length; + this.u16(0); + return at; + } + private patch(at: number, target = this.code.length): void { + const rel = target - (at + 2); + this.code[at] = rel & 0xff; + this.code[at + 1] = (rel >> 8) & 0xff; + } + private allocSlot(node: ts.Node): number { + if (this.nextSlot >= VM_LOCALS) { + throw this.err(node, `too many locals (max ${VM_LOCALS} per script, macros included)`); + } + return this.nextSlot++; + } + + // --- static evaluation ------------------------------------------------------ + tryStatic(node: ts.Expression): { ok: true; value: StaticValue } | { ok: false } { + const no = { ok: false } as const; + const yes = (value: StaticValue) => ({ ok: true, value }) as const; + if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)) return this.tryStatic(node.expression); + if (ts.isNumericLiteral(node)) return yes(Number(node.text)); + if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return yes(node.text); + if (node.kind === ts.SyntaxKind.TrueKeyword) return yes(true); + if (node.kind === ts.SyntaxKind.FalseKeyword) return yes(false); + if (ts.isIdentifier(node)) { + const b = this.scopes.lookup(node.text); + if (b?.kind === "const") return yes(b.value); + return no; + } + if (ts.isPrefixUnaryExpression(node)) { + const inner = this.tryStatic(node.operand); + if (!inner.ok) return no; + if (node.operator === ts.SyntaxKind.MinusToken && typeof inner.value === "number") return yes(-inner.value); + if (node.operator === ts.SyntaxKind.ExclamationToken) return yes(!inner.value); + return no; + } + if (ts.isArrayLiteralExpression(node)) { + const out: StaticValue[] = []; + for (const e of node.elements) { + const r = this.tryStatic(e); + if (!r.ok) return no; + out.push(r.value); + } + return yes(out); + } + if (ts.isObjectLiteralExpression(node)) { + const out: { [k: string]: StaticValue } = {}; + for (const p of node.properties) { + if (!ts.isPropertyAssignment(p) || (!ts.isIdentifier(p.name) && !ts.isStringLiteral(p.name))) return no; + const r = this.tryStatic(p.initializer); + if (!r.ok) return no; + out[p.name.text] = r.value; + } + return yes(out); + } + if (ts.isPropertyAccessExpression(node)) { + const obj = this.tryStatic(node.expression); + if (!obj.ok || typeof obj.value !== "object" || obj.value === null || Array.isArray(obj.value)) { + if (obj.ok && Array.isArray(obj.value) && node.name.text === "length") return yes(obj.value.length); + return no; + } + const v = (obj.value as { [k: string]: StaticValue })[node.name.text]; + return v === undefined ? no : yes(v); + } + if (ts.isElementAccessExpression(node)) { + const obj = this.tryStatic(node.expression); + const idx = this.tryStatic(node.argumentExpression); + if (!obj.ok || !idx.ok) return no; + if (Array.isArray(obj.value) && typeof idx.value === "number") { + const v = obj.value[idx.value]; + return v === undefined ? no : yes(v); + } + if (typeof obj.value === "object" && typeof idx.value === "string") { + const v = (obj.value as { [k: string]: StaticValue })[idx.value]; + return v === undefined ? no : yes(v); + } + return no; + } + if (ts.isTemplateExpression(node)) { + let s = node.head.text; + for (const span of node.templateSpans) { + const r = this.tryStatic(span.expression); + if (!r.ok) return no; + s += String(r.value) + span.literal.text; + } + return yes(s); + } + if (ts.isBinaryExpression(node)) { + const a = this.tryStatic(node.left); + const b = this.tryStatic(node.right); + if (!a.ok || !b.ok) return no; + const x = a.value as number | string | boolean; + const y = b.value as number | string | boolean; + switch (node.operatorToken.kind) { + case ts.SyntaxKind.PlusToken: + return yes((x as never) + (y as never)); + case ts.SyntaxKind.MinusToken: + return yes((x as number) - (y as number)); + case ts.SyntaxKind.AsteriskToken: + return yes((x as number) * (y as number)); + case ts.SyntaxKind.SlashToken: + return yes(Math.trunc((x as number) / (y as number))); + case ts.SyntaxKind.PercentToken: + return yes((x as number) % (y as number)); + case ts.SyntaxKind.EqualsEqualsEqualsToken: + return yes(x === y); + case ts.SyntaxKind.ExclamationEqualsEqualsToken: + return yes(x !== y); + case ts.SyntaxKind.LessThanToken: + return yes(x < y); + case ts.SyntaxKind.GreaterThanToken: + return yes(x > y); + case ts.SyntaxKind.LessThanEqualsToken: + return yes(x <= y); + case ts.SyntaxKind.GreaterThanEqualsToken: + return yes(x >= y); + case ts.SyntaxKind.AmpersandAmpersandToken: + return yes(x && y); + case ts.SyntaxKind.BarBarToken: + return yes(x || y); + default: + return no; + } + } + return no; + } + + private staticOrThrow(node: ts.Expression, what: string): StaticValue { + const r = this.tryStatic(node); + if (!r.ok) throw this.err(node, `${what} must be a compile-time constant`); + return r.value; + } + + // --- role recognition -------------------------------------------------------- + private roleOf(node: ts.Expression): "ops" | "vars" | "flags" | undefined { + if (!ts.isIdentifier(node)) return undefined; + const b = this.scopes.lookup(node.text); + return b?.kind === "role" ? b.role : undefined; + } + + /** v.name / f.name (or v["name"]). Returns the interned id. */ + private globalRef(node: ts.Expression): { kind: "var" | "flag"; id: number } | undefined { + let objExpr: ts.Expression | undefined; + let name: string | undefined; + if (ts.isPropertyAccessExpression(node)) { + objExpr = node.expression; + name = node.name.text; + } else if (ts.isElementAccessExpression(node)) { + objExpr = node.expression; + const idx = this.tryStatic(node.argumentExpression); + if (idx.ok && typeof idx.value === "string") name = idx.value; + } + if (!objExpr || name === undefined) return undefined; + const role = this.roleOf(objExpr); + if (role === "vars") return { kind: "var", id: this.ctx.varId(name, this.where(node)) }; + if (role === "flags") return { kind: "flag", id: this.ctx.flagId(name, this.where(node)) }; + return undefined; + } + + /** s.(...) call, unwrapped from yield*. */ + private opsCall(node: ts.Expression): { op: string; args: readonly ts.Expression[]; call: ts.CallExpression } | undefined { + let e = node; + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e)) e = e.expression; + if (!ts.isCallExpression(e) || !ts.isPropertyAccessExpression(e.expression)) return undefined; + if (this.roleOf(e.expression.expression) !== "ops") return undefined; + return { op: e.expression.name.text, args: e.arguments, call: e }; + } + + /** yield* unwrap. */ + private yieldStar(node: ts.Expression): ts.Expression | undefined { + let e = node; + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e)) e = e.expression; + if (ts.isYieldExpression(e) && e.asteriskToken && e.expression) return e.expression; + return undefined; + } + + // --- rich text (say/choice payloads) ----------------------------------------- + /** + * Build a RichText from a string-ish expression. Runtime `${...}` spans + * compile to scratch-var stores (emitted NOW, before the SAY) + FMT atoms. + */ + private richText(node: ts.Expression, fmtUsed: { n: number }): RichText { + const st = this.tryStatic(node); + if (st.ok) { + if (typeof st.value !== "string") throw this.err(node, "expected text"); + return richFromString(st.value); + } + let e = node; + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e)) e = e.expression; + if (ts.isTemplateExpression(e)) { + const rt: RichText = [...richFromString(e.head.text)]; + for (const span of e.templateSpans) { + const r = this.tryStatic(span.expression); + if (r.ok) { + rt.push(...richFromString(String(r.value))); + } else { + if (fmtUsed.n >= SCRATCH_SLOTS) { + throw this.err(span.expression, `too many runtime \${...} slots (max ${SCRATCH_SLOTS} per statement)`); + } + const scratch = VAR_SCRATCH_BASE + fmtUsed.n++; + this.compileExpr(span.expression); + this.u8(OP.STV); + this.u8(scratch); + rt.push({ fmtVar: scratch }); + } + rt.push(...richFromString(span.literal.text)); + } + return rt; + } + throw this.err(node, "text must be a string literal, a template literal, or a compile-time constant"); + } + + // --- expressions --------------------------------------------------------------- + /** Compile an expression; leaves exactly one i16 on the VM stack. */ + compileExpr(node: ts.Expression): void { + this.lastChoiceOptions = undefined; + + const st = this.tryStatic(node); + if (st.ok) { + const v = st.value; + if (typeof v === "number") return this.emitPush(v, node); + if (typeof v === "boolean") return this.emitPush(v ? 1 : 0, node); + throw this.err(node, `a ${Array.isArray(v) ? "array" : typeof v} constant cannot be a runtime value`); + } + + if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node)) return this.compileExpr(node.expression); + + // yield* — value ops and value macros + const inner = this.yieldStar(node); + if (inner) return this.compileYieldValue(node, inner); + + if (ts.isIdentifier(node)) { + const b = this.scopes.lookup(node.text); + if (!b) throw this.err(node, `unknown identifier "${node.text}" (locals, v.*, f.* and constants only)`); + if (b.kind === "local") { + this.u8(OP.LDL); + this.u8(b.slot); + this.lastChoiceOptions = b.choiceOptions; + return; + } + throw this.err(node, `"${node.text}" cannot be used as a value here`); + } + + const g = this.globalRef(node); + if (g) { + this.u8(g.kind === "var" ? OP.LDV : OP.FLAG); + this.u8(g.id); + return; + } + + if (ts.isPrefixUnaryExpression(node)) { + switch (node.operator) { + case ts.SyntaxKind.ExclamationToken: + this.compileExpr(node.operand); + this.u8(OP.NOT); + return; + case ts.SyntaxKind.MinusToken: + this.compileExpr(node.operand); + this.u8(OP.NEG); + return; + default: + throw this.err(node, "unsupported unary operator"); + } + } + + if (ts.isBinaryExpression(node)) return this.compileBinary(node); + + if (ts.isConditionalExpression(node)) { + this.compileExpr(node.condition); + const toElse = this.jump(OP.JZ); + this.compileExpr(node.whenTrue); + const toEnd = this.jump(OP.JMP); + this.patch(toElse); + this.compileExpr(node.whenFalse); + this.patch(toEnd); + return; + } + + throw this.err(node, "unsupported expression in a script"); + } + + private compileBinary(node: ts.BinaryExpression): void { + const k = node.operatorToken.kind; + // Assignment used as an expression is not supported (statement only). + if ( + k === ts.SyntaxKind.EqualsToken || + k === ts.SyntaxKind.PlusEqualsToken || + k === ts.SyntaxKind.MinusEqualsToken + ) { + throw this.err(node, "assignments are statements, not expressions, in scripts"); + } + if (k === ts.SyntaxKind.AmpersandAmpersandToken) { + // JS semantics: a && b -> a if falsy else b + this.compileExpr(node.left); + this.u8(OP.DUP); + const short = this.jump(OP.JZ); + this.u8(OP.POP); + this.compileExpr(node.right); + this.patch(short); + return; + } + if (k === ts.SyntaxKind.BarBarToken) { + this.compileExpr(node.left); + this.u8(OP.DUP); + const short = this.jump(OP.JNZ); + this.u8(OP.POP); + this.compileExpr(node.right); + this.patch(short); + return; + } + + // choice-string comparisons: `pick === "Battle"` + const strCmp = this.tryChoiceStringCompare(node); + if (strCmp) return; + + const table: Partial> = { + [ts.SyntaxKind.PlusToken]: OP.ADD, + [ts.SyntaxKind.MinusToken]: OP.SUB, + [ts.SyntaxKind.AsteriskToken]: OP.MUL, + [ts.SyntaxKind.SlashToken]: OP.DIV, + [ts.SyntaxKind.PercentToken]: OP.MOD, + [ts.SyntaxKind.EqualsEqualsEqualsToken]: OP.EQ, + [ts.SyntaxKind.ExclamationEqualsEqualsToken]: OP.NE, + [ts.SyntaxKind.LessThanToken]: OP.LT, + [ts.SyntaxKind.GreaterThanToken]: OP.GT, + [ts.SyntaxKind.LessThanEqualsToken]: OP.LE, + [ts.SyntaxKind.GreaterThanEqualsToken]: OP.GE, + }; + if (k === ts.SyntaxKind.EqualsEqualsToken || k === ts.SyntaxKind.ExclamationEqualsToken) { + throw this.err(node, "use === / !== in scripts"); + } + const op = table[k]; + if (op === undefined) throw this.err(node, "unsupported binary operator"); + this.compileExpr(node.left); + const leftChoice = this.lastChoiceOptions; + this.compileExpr(node.right); + this.lastChoiceOptions = leftChoice; // (only meaningful for ===/!== path) + this.u8(op); + this.lastChoiceOptions = undefined; + } + + /** `pick === "Battle"` where pick carries choice metadata. */ + private tryChoiceStringCompare(node: ts.BinaryExpression): boolean { + const k = node.operatorToken.kind; + if (k !== ts.SyntaxKind.EqualsEqualsEqualsToken && k !== ts.SyntaxKind.ExclamationEqualsEqualsToken) return false; + const sides = [ + { val: node.left, str: node.right }, + { val: node.right, str: node.left }, + ]; + for (const { val, str } of sides) { + const s = this.tryStatic(str); + if (!s.ok || typeof s.value !== "string") continue; + if (!ts.isIdentifier(val)) continue; + const b = this.scopes.lookup(val.text); + if (b?.kind !== "local" || !b.choiceOptions) continue; + const idx = b.choiceOptions.indexOf(s.value); + if (idx < 0) { + throw this.err(str, `"${s.value}" is not one of the choices [${b.choiceOptions.join(", ")}]`); + } + this.u8(OP.LDL); + this.u8(b.slot); + this.emitPush(idx, node); + this.u8(k === ts.SyntaxKind.EqualsEqualsEqualsToken ? OP.EQ : OP.NE); + return true; + } + return false; + } + + // --- yield* value position --------------------------------------------------- + private compileYieldValue(node: ts.Expression, inner: ts.Expression): void { + const call = this.opsCall(inner); + if (call) { + switch (call.op) { + case "choose": { + const options = this.chooseOptions(call); + this.emitChoice(options, call.call); + this.lastChoiceOptions = options; + return; + } + case "rnd": { + if (call.args.length !== 1) throw this.err(call.call, "s.rnd(n) takes one argument"); + this.compileExpr(call.args[0]); + this.u8(OP.RND); + return; + } + default: + throw this.err(call.call, `s.${call.op}() does not produce a value`); + } + } + // value macro + if (ts.isCallExpression(inner) && ts.isIdentifier(inner.expression)) { + const helper = this.sites.helpers.get(inner.expression.text); + if (helper) { + this.inlineMacro(inner, helper, /*wantValue*/ true); + return; + } + } + throw this.err(node, "yield* here must be a value op (s.choose/s.rnd) or a helper generator"); + } + + private chooseOptions(call: { args: readonly ts.Expression[]; call: ts.CallExpression }): string[] { + if (call.args.length !== 1) throw this.err(call.call, "s.choose([...options]) takes one array argument"); + const v = this.staticOrThrow(call.args[0], "choice options"); + if (!Array.isArray(v) || v.some((o) => typeof o !== "string")) { + throw this.err(call.call, "choice options must be an array of strings"); + } + return v as string[]; + } + + private emitChoice(options: string[], node: ts.Node): void { + const max = this.ctx.target.maxChoices; + if (options.length < 1 || options.length > max) { + throw this.err(node, `choose() needs 1..${max} options on ${this.ctx.target.name} (got ${options.length})`); + } + this.u8(RPG_OP.CHOICE); + this.u8(options.length); + for (const o of options) this.u16(this.ctx.internOption(richFromString(o), this.where(node))); + } + + // --- statements ----------------------------------------------------------------- + compileBody(): void { + const body = this.site.fn.body; + this.compileBlockStatements(body.statements); + this.u8(OP.RET); + } + + private compileBlockStatements(stmts: readonly ts.Statement[]): void { + // Locals die with their scope, so their slots are reclaimable: restore + // the allocator on scope exit (the VM has no closures — nothing outlives + // its block). + const savedSlots = this.nextSlot; + this.scopes = new Scope(this.scopes); + for (const s of stmts) this.compileStatement(s); + this.scopes = this.scopes.parent!; + this.nextSlot = savedSlots; + } + + private compileStatement(s: ts.Statement): void { + if (ts.isVariableStatement(s)) return this.compileVarDecl(s); + if (ts.isExpressionStatement(s)) return this.compileExprStatement(s.expression); + if (ts.isIfStatement(s)) return this.compileIf(s); + if (ts.isWhileStatement(s)) return this.compileWhile(s); + if (ts.isForStatement(s)) return this.compileFor(s); + if (ts.isForOfStatement(s)) return this.compileForOf(s); + if (ts.isSwitchStatement(s)) return this.compileSwitch(s); + if (ts.isBreakStatement(s)) { + const loop = this.loops[this.loops.length - 1]; + if (!loop) throw this.err(s, "break outside a loop (switch breaks are handled inline)"); + loop.breaks.push(this.jump(OP.JMP)); + return; + } + if (ts.isContinueStatement(s)) { + const loop = this.loops[this.loops.length - 1]; + if (!loop) throw this.err(s, "continue outside a loop"); + if (loop.continueTarget !== undefined) this.patch(this.jump(OP.JMP), loop.continueTarget); + else loop.continues.push(this.jump(OP.JMP)); + return; + } + if (ts.isReturnStatement(s)) return this.compileReturn(s); + if (ts.isBlock(s)) return this.compileBlockStatements(s.statements); + if (s.kind === ts.SyntaxKind.EmptyStatement) return; + throw this.err(s, "unsupported statement in a script"); + } + + private compileVarDecl(s: ts.VariableStatement): void { + const isConst = (s.declarationList.flags & ts.NodeFlags.Const) !== 0; + for (const d of s.declarationList.declarations) { + if (!ts.isIdentifier(d.name)) throw this.err(d, "destructuring is not supported in scripts"); + if (!d.initializer) throw this.err(d, "script locals need an initializer"); + const st = this.tryStatic(d.initializer); + if (isConst && st.ok) { + this.scopes.declare(d.name.text, { kind: "const", value: st.value }); + continue; + } + const slot = this.allocSlot(d); + this.compileExpr(d.initializer); + const choiceOptions = this.lastChoiceOptions; + this.u8(OP.STL); + this.u8(slot); + this.scopes.declare(d.name.text, { kind: "local", slot, choiceOptions }); + } + } + + private compileExprStatement(e: ts.Expression): void { + // assignment / compound assignment / ++ / -- + if (ts.isBinaryExpression(e)) { + const k = e.operatorToken.kind; + const compound: Partial> = { + [ts.SyntaxKind.PlusEqualsToken]: OP.ADD, + [ts.SyntaxKind.MinusEqualsToken]: OP.SUB, + [ts.SyntaxKind.AsteriskEqualsToken]: OP.MUL, + [ts.SyntaxKind.SlashEqualsToken]: OP.DIV, + [ts.SyntaxKind.PercentEqualsToken]: OP.MOD, + }; + if (k === ts.SyntaxKind.EqualsToken) return this.compileAssign(e.left, e.right, undefined, e); + const op = compound[k]; + if (op !== undefined) return this.compileAssign(e.left, e.right, op, e); + } + if (ts.isPrefixUnaryExpression(e) || ts.isPostfixUnaryExpression(e)) { + const k = e.operator; + if (k === ts.SyntaxKind.PlusPlusToken || k === ts.SyntaxKind.MinusMinusToken) { + const one = ts.factory.createNumericLiteral("1"); + // ++x as statement == x += 1 (no value use) + return this.compileAssign( + e.operand, + one, + k === ts.SyntaxKind.PlusPlusToken ? OP.ADD : OP.SUB, + e, + /*syntheticRight*/ true, + ); + } + } + // yield* op / macro in statement position + const inner = this.yieldStar(e); + if (inner) return this.compileYieldStatement(e, inner); + throw this.err(e, "unsupported expression statement (did you forget yield* ?)"); + } + + private compileAssign( + target: ts.Expression, + rhs: ts.Expression, + compoundOp: number | undefined, + node: ts.Node, + syntheticRight = false, + ): void { + // local + if (ts.isIdentifier(target)) { + const b = this.scopes.lookup(target.text); + if (!b) throw this.err(target, `unknown identifier "${target.text}"`); + if (b.kind !== "local") throw this.err(target, `cannot assign to "${target.text}"`); + if (compoundOp !== undefined) { + this.u8(OP.LDL); + this.u8(b.slot); + } + if (syntheticRight) this.emitPush(1, node); + else this.compileExpr(rhs); + if (compoundOp !== undefined) this.u8(compoundOp); + this.u8(OP.STL); + this.u8(b.slot); + return; + } + // v.name / f.name + const g = this.globalRef(target); + if (g?.kind === "var") { + if (compoundOp !== undefined) { + this.u8(OP.LDV); + this.u8(g.id); + } + if (syntheticRight) this.emitPush(1, node); + else this.compileExpr(rhs); + if (compoundOp !== undefined) this.u8(compoundOp); + this.u8(OP.STV); + this.u8(g.id); + return; + } + if (g?.kind === "flag") { + if (compoundOp !== undefined) throw this.err(node, "flags only support plain assignment"); + const st = this.tryStatic(rhs); + if (st.ok && typeof st.value === "boolean") { + this.u8(st.value ? OP.SETF : OP.CLRF); + this.u8(g.id); + return; + } + this.compileExpr(rhs); + this.u8(OP.STF); + this.u8(g.id); + return; + } + throw this.err(target, "assignment target must be a local, v., or f."); + } + + private compileYieldStatement(node: ts.Expression, inner: ts.Expression): void { + const call = this.opsCall(inner); + if (call) return this.compileOpStatement(call); + if (ts.isCallExpression(inner) && ts.isIdentifier(inner.expression)) { + const helper = this.sites.helpers.get(inner.expression.text); + if (helper) return this.inlineMacro(inner, helper, /*wantValue*/ false); + throw this.err(inner, `"${inner.expression.text}" is not a helper generator in this file`); + } + throw this.err(node, "yield* must call an s.* op or a helper generator"); + } + + private compileOpStatement(call: { op: string; args: readonly ts.Expression[]; call: ts.CallExpression }): void { + const { op, args, call: node } = call; + const where = this.where(node); + switch (op) { + case "say": { + if (args.length !== 1) throw this.err(node, "s.say(text) takes one argument"); + const fmtUsed = { n: 0 }; + const rt = this.richText(args[0], fmtUsed); + const pages = this.ctx.internPages(rt, where); + for (const id of pages) { + this.u8(RPG_OP.SAY); + this.u16(id); + } + return; + } + case "choose": { + // choose in statement position: emit + drop the result + const options = this.chooseOptions({ args, call: node }); + this.emitChoice(options, node); + this.u8(OP.POP); + return; + } + case "rnd": + throw this.err(node, "s.rnd() result is unused — assign it or use it in an expression"); + case "wait": { + if (args.length !== 1) throw this.err(node, "s.wait(frames) takes one argument"); + this.compileExpr(args[0]); + this.u8(OP.WAIT); + return; + } + case "lock": + this.u8(RPG_OP.LOCK); + return; + case "release": + this.u8(RPG_OP.RELEASE); + return; + case "face": { + if (args.length === 0) { + this.u8(RPG_OP.FACE); + this.u8(FACE_SELF); + return; + } + const id = this.staticOrThrow(args[0], "actor id"); + if (typeof id !== "string") throw this.err(node, "s.face(actorId) takes an actor id string"); + this.u8(RPG_OP.FACE); + this.ctx.actorFixups.push({ at: this.code.length + this.fixupBase(), actorId: id, where }); + this.u8(0); + return; + } + case "show": + case "hide": { + if (args.length !== 1) throw this.err(node, `s.${op}(actorId) takes one argument`); + const id = this.staticOrThrow(args[0], "actor id"); + if (typeof id !== "string") throw this.err(node, "actor id must be a string"); + this.u8(RPG_OP.AVIS); + this.ctx.actorFixups.push({ at: this.code.length + this.fixupBase(), actorId: id, where }); + this.u8(0); + this.u8(op === "show" ? 1 : 0); + return; + } + case "warp": { + if (args.length !== 1) throw this.err(node, 's.warp("map:entrance") takes one argument'); + const dest = this.staticOrThrow(args[0], "warp destination"); + if (typeof dest !== "string" || !dest.includes(":")) { + throw this.err(node, 'warp destination must be "map:entrance"'); + } + this.u8(RPG_OP.WARP); + this.ctx.warpFixups.push({ at: this.code.length + this.fixupBase(), dest, where }); + this.u8(0); + this.u8(0); + this.u8(0); + this.u8(0); + return; + } + case "sfx": { + if (args.length !== 1) throw this.err(node, "s.sfx(name) takes one argument"); + const name = this.staticOrThrow(args[0], "sfx name"); + const key = String(name).toUpperCase() as keyof typeof SFX; + if (!(key in SFX)) throw this.err(node, `unknown sfx "${name}" (${Object.keys(SFX).join(", ").toLowerCase()})`); + this.u8(RPG_OP.SFX); + this.u8(SFX[key]); + return; + } + case "faceDir": { + // authoring aid used by cutscenes: turn the player. Encoded as a + // warp-in-place? No — v1 keeps player facing implicit; reject. + throw this.err(node, "s.faceDir is not part of the v1 surface"); + } + case "call": { + if (args.length !== 1 || !ts.isIdentifier(args[0])) { + throw this.err(node, "s.call(ScriptName) takes a top-level script const"); + } + const id = this.sites.scriptIds.get(args[0].text); + if (id === undefined) throw this.err(args[0], `"${args[0].text}" is not a top-level script(...) const`); + this.u8(OP.CALL); + this.u16(id); + return; + } + default: + throw this.err(node, `unknown script op s.${op}()`); + } + } + + /** Fixup offsets are recorded blob-relative by the link stage; during a + * single script compile they are code-relative (base 0) and shifted later. */ + private fixupBase(): number { + return 0; + } + + private compileIf(s: ts.IfStatement): void { + // Statically-decidable conditions drop the dead branch (macro staples). + const st = this.tryStatic(s.expression); + if (st.ok) { + const taken = !!st.value; + if (taken) this.compileStatement(s.thenStatement); + else if (s.elseStatement) this.compileStatement(s.elseStatement); + return; + } + this.compileExpr(s.expression); + const toElse = this.jump(OP.JZ); + this.compileStatement(s.thenStatement); + if (s.elseStatement) { + const toEnd = this.jump(OP.JMP); + this.patch(toElse); + this.compileStatement(s.elseStatement); + this.patch(toEnd); + } else { + this.patch(toElse); + } + } + + private compileWhile(s: ts.WhileStatement): void { + const loopStart = this.code.length; + const st = this.tryStatic(s.expression); + let toEnd: number | undefined; + if (st.ok) { + if (!st.value) return; // while(false) — dead + // while(true): no test + } else { + this.compileExpr(s.expression); + toEnd = this.jump(OP.JZ); + } + this.loops.push({ breaks: [], continues: [], continueTarget: loopStart }); + this.compileStatement(s.statement); + this.patch(this.jump(OP.JMP), loopStart); + if (toEnd !== undefined) this.patch(toEnd); + const loop = this.loops.pop()!; + for (const b of loop.breaks) this.patch(b); + } + + private compileFor(s: ts.ForStatement): void { + const savedSlots = this.nextSlot; + this.scopes = new Scope(this.scopes); + if (s.initializer) { + if (ts.isVariableDeclarationList(s.initializer)) { + this.compileVarDecl( + ts.factory.createVariableStatement(undefined, s.initializer) as ts.VariableStatement & { + declarationList: ts.VariableDeclarationList; + }, + ); + } else { + this.compileExprStatement(s.initializer); + } + } + const loopStart = this.code.length; + let toEnd: number | undefined; + if (s.condition) { + this.compileExpr(s.condition); + toEnd = this.jump(OP.JZ); + } + this.loops.push({ breaks: [], continues: [] }); + this.compileStatement(s.statement); + const loop = this.loops.pop()!; + const continueTarget = this.code.length; + for (const c of loop.continues) this.patch(c, continueTarget); + if (s.incrementor) this.compileExprStatement(s.incrementor); + this.patch(this.jump(OP.JMP), loopStart); + if (toEnd !== undefined) this.patch(toEnd); + for (const b of loop.breaks) this.patch(b); + this.scopes = this.scopes.parent!; + this.nextSlot = savedSlots; + } + + private compileForOf(s: ts.ForOfStatement): void { + // Unrolled iteration over a compile-time array (macro workhorse). + if (!ts.isVariableDeclarationList(s.initializer) || s.initializer.declarations.length !== 1) { + throw this.err(s, "for...of needs `for (const x of )`"); + } + const decl = s.initializer.declarations[0]; + if (!ts.isIdentifier(decl.name)) throw this.err(decl, "for...of variable must be an identifier"); + const arr = this.staticOrThrow(s.expression, "for...of iterable"); + if (!Array.isArray(arr)) throw this.err(s.expression, "for...of iterates a compile-time array"); + for (const item of arr) { + this.scopes = new Scope(this.scopes); + this.scopes.declare(decl.name.text, { kind: "const", value: item }); + this.compileStatement(s.statement); + this.scopes = this.scopes.parent!; + } + } + + private compileSwitch(s: ts.SwitchStatement): void { + this.compileExpr(s.expression); + const choiceOptions = this.lastChoiceOptions ?? this.switchDiscriminantChoices(s.expression); + const slot = this.allocSlot(s); // hidden discriminant temp + this.u8(OP.STL); + this.u8(slot); + + const clauses = s.caseBlock.clauses; + const bodyJumps = new Map(); + let defaultIdx = -1; + clauses.forEach((clause, i) => { + if (ts.isDefaultClause(clause)) { + if (defaultIdx >= 0) throw this.err(clause, "duplicate default"); + defaultIdx = i; + return; + } + const label = this.staticOrThrow(clause.expression, "case label"); + let val: number; + if (typeof label === "number") { + val = label; + } else if (typeof label === "string") { + if (!choiceOptions) throw this.err(clause, "string case labels need a choice-valued switch"); + val = choiceOptions.indexOf(label); + if (val < 0) throw this.err(clause, `"${label}" is not one of [${choiceOptions.join(", ")}]`); + } else { + throw this.err(clause, "case label must be a number or a choice string"); + } + this.u8(OP.LDL); + this.u8(slot); + this.emitPush(val, clause); + this.u8(OP.EQ); + bodyJumps.set(i, this.jump(OP.JNZ)); + }); + const fall = this.jump(OP.JMP); + + // switch participates in break like a loop (break exits the switch) + this.loops.push({ breaks: [], continues: [], continueTarget: undefined }); + const bodyOffsets: number[] = []; + clauses.forEach((clause, i) => { + bodyOffsets[i] = this.code.length; + const j = bodyJumps.get(i); + if (j !== undefined) this.patch(j, bodyOffsets[i]); + if (i === defaultIdx) this.patch(fall, bodyOffsets[i]); + this.compileBlockStatements(clause.statements); + }); + const loop = this.loops.pop()!; + if (loop.continues.length) throw this.err(s, "continue inside switch is not supported"); + const end = this.code.length; + if (defaultIdx < 0) this.patch(fall, end); + for (const b of loop.breaks) this.patch(b, end); + this.nextSlot = slot; // the hidden discriminant temp is dead now + } + + /** switch (yield* s.choose([...])) — metadata via direct inspection. */ + private switchDiscriminantChoices(e: ts.Expression): string[] | undefined { + const inner = this.yieldStar(e); + if (!inner) return undefined; + const call = this.opsCall(inner); + if (call?.op === "choose") return this.chooseOptions(call); + return undefined; + } + + private compileReturn(s: ts.ReturnStatement): void { + const macro = this.macros[this.macros.length - 1]; + if (macro) { + if (s.expression) { + if (macro.resultSlot === undefined) { + throw this.err(s, "this helper is used as a statement — `return ` has nowhere to go"); + } + this.compileExpr(s.expression); + this.u8(OP.STL); + this.u8(macro.resultSlot); + } + macro.endJumps.push(this.jump(OP.JMP)); + return; + } + if (s.expression) throw this.err(s, "scripts cannot return a value; use plain `return;`"); + this.u8(OP.RET); + } + + // --- macro inlining ------------------------------------------------------------- + private inlineMacro( + call: ts.CallExpression, + helper: ts.FunctionExpression | ts.FunctionDeclaration, + wantValue: boolean, + ): void { + const name = ts.isIdentifier(call.expression) ? call.expression.text : ""; + if (this.expansion.includes(name)) { + throw this.err(call, `recursive macro expansion: ${[...this.expansion, name].join(" -> ")}`); + } + if (this.expansion.length >= 8) throw this.err(call, "macro expansion too deep (8)"); + if (!helper.body) throw this.err(call, `helper "${name}" has no body`); + + // Bind parameters: role identifiers pass through; everything else must be + // a compile-time constant. + const outer = this.scopes; + const macroScope = new Scope(); // macros do NOT see the caller's locals + helper.parameters.forEach((p, i) => { + if (!ts.isIdentifier(p.name)) throw this.err(p, "helper parameters must be identifiers"); + const arg = call.arguments[i]; + if (arg === undefined) { + if (p.initializer) { + const dv = this.tryStatic(p.initializer); + if (!dv.ok) throw this.err(p, "helper default values must be compile-time constants"); + macroScope.declare(p.name.text, { kind: "const", value: dv.value }); + return; + } + throw this.err(call, `helper "${name}" expects argument #${i + 1} (${p.name.text})`); + } + const role = this.roleOf(arg); + if (role) { + macroScope.declare(p.name.text, { kind: "role", role }); + return; + } + const st = this.tryStatic(arg); + if (!st.ok) { + throw this.err( + arg, + `macro argument "${p.name.text}" must be a compile-time constant (or the s/v/f handles)`, + ); + } + macroScope.declare(p.name.text, { kind: "const", value: st.value }); + }); + + const macro: MacroCtx = { endJumps: [], resultSlot: wantValue ? this.allocSlot(call) : undefined }; + if (macro.resultSlot !== undefined) { + // default result 0 on fallthrough + this.emitPush(0, call); + this.u8(OP.STL); + this.u8(macro.resultSlot); + } + this.macros.push(macro); + this.expansion.push(name); + this.scopes = macroScope; + this.compileBlockStatements(helper.body.statements); + this.scopes = outer; + this.expansion.pop(); + this.macros.pop(); + const end = this.code.length; + for (const j of macro.endJumps) this.patch(j, end); + if (macro.resultSlot !== undefined) { + this.u8(OP.LDL); + this.u8(macro.resultSlot); + } + } +} + +// --------------------------------------------------------------------------- +// Whole-module compile: every script site -> one blob + id table. +// --------------------------------------------------------------------------- +export interface CompiledScripts { + blob: Uint8Array; + /** Byte offset of each script id in the blob. */ + table: number[]; +} + +export function compileScripts(sites: Sites, ctx: Ctx): CompiledScripts { + const chunks: number[][] = []; + const table: number[] = []; + let base = 0; + for (const site of sites.scripts) { + const before = { warp: ctx.warpFixups.length, actor: ctx.actorFixups.length }; + const c = new ScriptCompiler(site, sites, ctx); + c.compileBody(); + // shift this script's fixups to blob-relative offsets + for (let i = before.warp; i < ctx.warpFixups.length; i++) ctx.warpFixups[i].at += base; + for (let i = before.actor; i < ctx.actorFixups.length; i++) ctx.actorFixups[i].at += base; + table.push(base); + chunks.push(c.code); + base += c.code.length; + } + const blob = new Uint8Array(base); + let at = 0; + for (const c of chunks) { + blob.set(c, at); + at += c.length; + } + return { blob, table }; +} diff --git a/static/compiler/sites.ts b/static/compiler/sites.ts new file mode 100644 index 00000000..44adfda4 --- /dev/null +++ b/static/compiler/sites.ts @@ -0,0 +1,125 @@ +// static/compiler/sites.ts — find the residual zone in a game module: every +// `script(function* (...) {...})` call site (in source order — the same order +// the executed declaration zone registers them, since both follow top-level +// statement order), plus the helper generator functions macros expand from. + +import ts from "typescript"; + +export interface ScriptSite { + id: number; + fn: ts.FunctionExpression; + /** The const name it is bound to, if any (enables s.call(Name)). */ + name?: string; +} + +export interface Sites { + file: ts.SourceFile; + scripts: ScriptSite[]; + /** const/function name -> generator AST usable as an inline macro. */ + helpers: Map; + /** const name -> script id (for s.call resolution). */ + scriptIds: Map; +} + +export function parseSource(sourceText: string, fileName = "game.ts"): ts.SourceFile { + return ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.ES2022, true); +} + +/** + * Macros may live in imported modules (e.g. @pocketjs/static/rpg/battle): + * parse each imported helper module and merge its top-level generator + * functions into sites.helpers. AST-only — helper modules never execute. + */ +export async function resolveHelperImports(sites: Sites, entryPath: string): Promise { + const { dirname, join, resolve } = await import("node:path"); + const seen = new Set(); + const scan = async (file: ts.SourceFile, filePath: string): Promise => { + const imports: string[] = []; + ts.forEachChild(file, (node) => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + imports.push(node.moduleSpecifier.text); + } + }); + for (const spec of imports) { + let target: string | undefined; + if (spec === "@pocketjs/static/rpg/battle") { + target = resolve(import.meta.dir, "..", "rpg", "battle.ts"); + } else if (spec.startsWith("./") || spec.startsWith("../")) { + target = resolve(dirname(filePath), spec.endsWith(".ts") ? spec : `${spec}.ts`); + } + if (!target || seen.has(target)) continue; + seen.add(target); + const text = await Bun.file(target).text().catch(() => null); + if (text === null) continue; + const mod = parseSource(text, target); + const visit = (node: ts.Node): void => { + if (ts.isFunctionDeclaration(node) && node.asteriskToken && node.name) { + if (!sites.helpers.has(node.name.text)) sites.helpers.set(node.name.text, node); + return; + } + if (ts.isVariableStatement(node)) { + for (const d of node.declarationList.declarations) { + if ( + ts.isIdentifier(d.name) && + d.initializer && + ts.isFunctionExpression(d.initializer) && + d.initializer.asteriskToken && + !sites.helpers.has(d.name.text) + ) { + sites.helpers.set(d.name.text, d.initializer); + } + } + return; + } + }; + ts.forEachChild(mod, visit); + await scan(mod, target); + } + }; + await scan(sites.file, entryPath); + void join; +} + +const isScriptCall = (node: ts.Node): node is ts.CallExpression => + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === "script" && + node.arguments.length === 1 && + ts.isFunctionExpression(node.arguments[0]) && + node.arguments[0].asteriskToken !== undefined; + +export function collectSites(file: ts.SourceFile): Sites { + const scripts: ScriptSite[] = []; + const helpers = new Map(); + const scriptIds = new Map(); + + const visit = (node: ts.Node, constName?: string): void => { + if (isScriptCall(node)) { + const id = scripts.length; + scripts.push({ id, fn: node.arguments[0] as ts.FunctionExpression, name: constName }); + if (constName !== undefined) scriptIds.set(constName, id); + return; // do not descend into script bodies looking for more sites + } + if (ts.isVariableStatement(node)) { + for (const d of node.declarationList.declarations) { + if (!ts.isIdentifier(d.name) || !d.initializer) continue; + if (isScriptCall(d.initializer)) { + visit(d.initializer, d.name.text); + } else if (ts.isFunctionExpression(d.initializer) && d.initializer.asteriskToken) { + helpers.set(d.name.text, d.initializer); + } else { + ts.forEachChild(d.initializer, (c) => visit(c)); + } + } + return; + } + if (ts.isFunctionDeclaration(node) && node.asteriskToken && node.name) { + helpers.set(node.name.text, node); + return; + } + ts.forEachChild(node, (c) => visit(c)); + }; + ts.forEachChild(file, (c) => visit(c)); + + return { file, scripts, helpers, scriptIds }; +} diff --git a/static/compiler/targets/gb.ts b/static/compiler/targets/gb.ts new file mode 100644 index 00000000..6d3d2db4 --- /dev/null +++ b/static/compiler/targets/gb.ts @@ -0,0 +1,201 @@ +// static/compiler/targets/gb.ts — the Game Boy packager: DMG 2bpp art, +// MBC5 bank assignment (each blob whole in one 16 KB bank, accessed through +// the 0x4000 window), bank-0 C tables + banked assembly data, and the +// sdcc/sdasgb/sdldgb/makebin/rgbfix toolchain drive. + +import { $ } from "bun"; +import { dirname, join } from "node:path"; +import { BANK_SIZE, FONT_GLYPHS } from "../../spec/isa.ts"; +import { SPRITE_ENTRY_SIZE } from "../../spec/rpg.ts"; +import { encodeTileGb, frameToTiles, gbShadeLut, glyphTile, luma, tileFromHexRows } from "../assets.ts"; +import { FONT8 } from "../font.gen.ts"; +import type { LinkedGame } from "../link.ts"; + +const RUNTIME = join(import.meta.dir, "..", "..", "runtime"); + +interface GbBlob { + bytes: Uint8Array; +} + +export function encodeGbArt(linked: LinkedGame): { art: Uint8Array; font: Uint8Array; obj: Uint8Array } { + const { model } = linked; + const bgLut = gbShadeLut(model.tileset.palette); + + const art: number[] = []; + for (let i = 1; i < model.tileNames.length; i++) { + const tile = model.tileset.tiles[model.tileNames[i]]; + art.push(...encodeTileGb(tileFromHexRows(tile.px), bgLut)); + } + + const font: number[] = []; + for (let g = 0; g < FONT_GLYPHS; g++) { + // ink = 3 (black), paper = 0 (white) under the identity BGP ramp + font.push(...encodeTileGb(glyphTile(FONT8[g], 3, 0), (v) => v)); + } + + // OBJ: pixel 0 transparent; visible colors clamp to shades 1..3 so nothing + // vanishes (DMG OBJ color 0 is always transparent). + const obj: number[] = []; + for (const sp of model.sprites) { + const shade = gbShadeLut(sp.palette); + const lut = (idx: number): number => { + if (idx === 0) return 0; + const s = shade(idx); + return s === 0 ? 1 : s; + }; + const framesPerDir = sp.facings.down.length; + void framesPerDir; + for (const facing of ["down", "up", "right"] as const) { + for (const frame of sp.facings[facing]) { + const [tl, tr, bl, br] = frameToTiles(frame); + // 8x16 pairs: left column (top,bottom), then right column + for (const t of [tl, bl, tr, br]) obj.push(...encodeTileGb(t, lut)); + } + } + } + void luma; + return { art: Uint8Array.from(art), font: Uint8Array.from(font), obj: Uint8Array.from(obj) }; +} + +export function emitGbSources(linked: LinkedGame): { genC: string; bankAsm: Map; bankCount: number } { + const { model } = linked; + const { art, font, obj } = encodeGbArt(linked); + + // Full blob list: linked blobs + art/font/obj. + const blobs: GbBlob[] = linked.blobs.map((b) => ({ bytes: b.bytes })); + const artBlob = blobs.length; + blobs.push({ bytes: art }); + const fontBlob = blobs.length; + blobs.push({ bytes: font }); + const objBlob = blobs.length; + blobs.push({ bytes: obj }); + + // First-fit bank assignment (banks 1..). + const bankOf: number[] = []; + const bankUsed: number[] = []; // per bank index (0 = bank 1) + for (let i = 0; i < blobs.length; i++) { + const size = blobs[i].bytes.length; + if (size > BANK_SIZE) throw new Error(`gb: blob ${i} is ${size} B > one bank`); + let placed = -1; + for (let b = 0; b < bankUsed.length; b++) { + if (bankUsed[b] + size <= BANK_SIZE) { + placed = b; + break; + } + } + if (placed < 0) { + placed = bankUsed.length; + bankUsed.push(0); + } + bankOf[i] = placed + 1; + bankUsed[placed] += size; + } + + // Banked data: one .s per bank. + const bankAsm = new Map(); + for (let b = 1; b <= bankUsed.length; b++) { + const lines: string[] = [ + `; gen_bank${b}.s — GENERATED by targets/gb.ts. DO NOT EDIT.`, + `\t.module gen_bank${b}`, + `\t.area _CODE_${b}`, + ]; + blobs.forEach((blob, i) => { + if (bankOf[i] !== b) return; + lines.push(`_ps_b${i}::`); + const bytes = blob.bytes; + for (let at = 0; at < bytes.length; at += 16) { + const row = Array.from(bytes.subarray(at, at + 16)).join(","); + lines.push(`\t.db ${row}`); + } + if (bytes.length === 0) lines.push("\t.db 0"); + }); + bankAsm.set(b, lines.join("\n") + "\n"); + } + + // Bank 0 tables. + const lines: string[] = []; + const push = (s: string) => lines.push(s); + const cArray = (name: string, bytes: ArrayLike, type = "unsigned char"): void => { + const vals: string[] = []; + for (let i = 0; i < bytes.length; i++) vals.push(String(bytes[i])); + push(`const ${type} ${name}[] = { ${vals.join(",")} };`); + }; + push("/* gen_data.c — GENERATED by targets/gb.ts. DO NOT EDIT. */"); + push('#include "spec_gen.h"'); + push("typedef unsigned char u8; typedef unsigned short u16;"); + cArray("ps_game_header", linked.header, "u8"); + push(`const u16 ps_script_table[] = { ${linked.scriptTable.join(",")} };`); + { + const b: number[] = []; + for (const e of linked.textTable) b.push(e.blob, e.off & 0xff, (e.off >> 8) & 0xff); + cArray("ps_text_table", b, "u8"); + } + cArray("ps_map_blob", linked.mapBlobIndex, "u8"); + // Sprite table: tile_base in 8x8 OBJ tiles, 4 per frame, [L pair][R pair]. + { + const table: number[] = []; + let tileBase = 0; + for (const sp of model.sprites) { + const framesPerDir = sp.facings.down.length; + table.push(tileBase & 0xff, (tileBase >> 8) & 0xff, framesPerDir, 0); + tileBase += 3 * framesPerDir * 4; + } + if (table.length !== model.sprites.length * SPRITE_ENTRY_SIZE) throw new Error("sprite table drifted"); + cArray("ps_sprite_table", table, "u8"); + } + blobs.forEach((_, i) => push(`extern const u8 ps_b${i}[];`)); + cArray("ps_blob_bank", bankOf, "u8"); + push(`const u8 *const ps_blob_addr[] = { ${blobs.map((_, i) => `ps_b${i}`).join(",")} };`); + push(`const u8 ps_art_blob = ${artBlob};`); + push(`const u8 ps_font_blob = ${fontBlob};`); + push(`const u8 ps_obj_blob = ${objBlob};`); + push(`const u8 ps_bg_tile_count = ${model.tileNames.length - 1};`); + push(`const u16 ps_obj_tile_bytes = ${obj.length};`); + + return { genC: lines.join("\n") + "\n", bankAsm, bankCount: bankUsed.length }; +} + +export async function buildGb(linked: LinkedGame, outRom: string): Promise { + const outDir = dirname(outRom); + const genDir = join(outDir, "gen-gb"); + await $`mkdir -p ${genDir}`.quiet(); + + const { genC, bankAsm, bankCount } = emitGbSources(linked); + await Bun.write(join(genDir, "gen_data.c"), genC); + for (const [b, asm] of bankAsm) await Bun.write(join(genDir, `gen_bank${b}.s`), asm); + + const gbDir = join(RUNTIME, "gb"); + const coreDir = join(RUNTIME, "core"); + const genInc = join(RUNTIME, "gen"); + const cflags = ["-msm83", "--opt-code-speed", `-DPS_TARGET_GB`, `-I${genInc}`, `-I${coreDir}`, `-I${gbDir}`]; + + // compile C + for (const [src, rel] of [ + [join(coreDir, "vm.c"), "vm.rel"], + [join(coreDir, "rpg.c"), "rpg.rel"], + [join(gbDir, "hal_gb.c"), "hal_gb.rel"], + [join(genDir, "gen_data.c"), "gen_data.rel"], + ] as const) { + await $`sdcc ${cflags} -c ${src} -o ${join(genDir, rel)}`.quiet(); + } + // assemble crt0 + banks + await $`sdasgb -plosgff -o ${join(genDir, "crt0.rel")} ${join(gbDir, "crt0.s")}`.quiet(); + const bankRels: string[] = []; + for (const b of bankAsm.keys()) { + const rel = join(genDir, `gen_bank${b}.rel`); + await $`sdasgb -plosgff -o ${rel} ${join(genDir, `gen_bank${b}.s`)}`.quiet(); + bankRels.push(rel); + } + + // link (banked areas at 0x4000 window) + const ihx = join(genDir, "game.ihx"); + const bankFlags = [...bankAsm.keys()].map((b) => `-Wl-b_CODE_${b}=0x${(b * 0x10000 + 0x4000).toString(16)}`); + await $`sdcc -msm83 --no-std-crt0 --code-loc 0x0200 --data-loc 0xc0a0 ${bankFlags} ${join(genDir, "crt0.rel")} ${join(genDir, "vm.rel")} ${join(genDir, "rpg.rel")} ${join(genDir, "hal_gb.rel")} ${join(genDir, "gen_data.rel")} ${bankRels} -o ${ihx}`.quiet(); + + // ROM image: round banks up to a power of two (MBC5 minimum 2 x 16K) + let romBanks = 2; + while (romBanks < bankCount + 1) romBanks *= 2; + await $`makebin -Z -yo ${romBanks} ${ihx} ${outRom}`.quiet(); + const title = linked.model.title.toUpperCase().replace(/[^A-Z0-9 ]/g, "").slice(0, 11); + await $`rgbfix -v -m 0x19 -p 0 -t ${title} ${outRom}`.quiet(); +} diff --git a/static/compiler/targets/gba.ts b/static/compiler/targets/gba.ts new file mode 100644 index 00000000..77b14b03 --- /dev/null +++ b/static/compiler/targets/gba.ts @@ -0,0 +1,158 @@ +// static/compiler/targets/gba.ts — the GBA packager: native 4bpp art, one +// generated gen_data.c (flat ROM keeps every blob addressable), and the +// arm-none-eabi toolchain drive + cartridge header patch. + +import { $ } from "bun"; +import { dirname, join } from "node:path"; +import { FONT_GLYPHS, TARGETS, bgLayout } from "../../spec/isa.ts"; +import { SPRITE_ENTRY_SIZE } from "../../spec/rpg.ts"; +import { + encodeTileGba, + frameToTiles, + gbaPaletteBank, + glyphTile, + tileFromHexRows, +} from "../assets.ts"; +import { FONT8 } from "../font.gen.ts"; +import type { LinkedGame } from "../link.ts"; + +const RUNTIME = join(import.meta.dir, "..", "..", "runtime"); + +// GBA text ink/paper pixel values inside font tiles (palette bank 15). +const INK = 1; +const PAPER = 2; + +export interface GbaArtifacts { + genC: string; +} + +export function emitGbaData(linked: LinkedGame): string { + const { model } = linked; + const t = TARGETS.gba; + const L = bgLayout(t); + const lines: string[] = []; + const push = (s: string) => lines.push(s); + + const cArray = (name: string, bytes: ArrayLike, type = "unsigned char"): void => { + const vals: string[] = []; + for (let i = 0; i < bytes.length; i++) vals.push(String(bytes[i])); + push(`const ${type} ${name}[] = { ${vals.join(",")} };`); + }; + + push("/* gen_data.c — GENERATED by targets/gba.ts. DO NOT EDIT. */"); + push('#include "spec_gen.h"'); + push("typedef unsigned char u8; typedef unsigned short u16;"); + + // Fixed-region tables. + cArray("ps_game_header", linked.header, "u8"); + { + const vals = linked.scriptTable.map(String).join(","); + push(`const u16 ps_script_table[] = { ${vals} };`); + } + { + const b: number[] = []; + for (const e of linked.textTable) b.push(e.blob, e.off & 0xff, (e.off >> 8) & 0xff); + cArray("ps_text_table", b, "u8"); + } + cArray("ps_map_blob", linked.mapBlobIndex, "u8"); + + // Blobs. + linked.blobs.forEach((b, i) => cArray(`ps_blob_${i}`, b.bytes, "u8")); + push(`const u8 *const ps_blobs[] = { ${linked.blobs.map((_, i) => `ps_blob_${i}`).join(",")} };`); + + // BG art tiles (index 1..N; blank is index 0 and stays zero-filled). + const art: number[] = []; + for (let i = 1; i < model.tileNames.length; i++) { + const tile = model.tileset.tiles[model.tileNames[i]]; + art.push(...encodeTileGba(tileFromHexRows(tile.px))); + } + cArray("ps_bg_art", art, "u8"); + push(`const u16 ps_bg_art_tiles = ${model.tileNames.length - 1};`); + + // Font tiles (95 glyphs at the fixed base). + const font: number[] = []; + for (let g = 0; g < FONT_GLYPHS; g++) { + font.push(...encodeTileGba(glyphTile(FONT8[g], INK, PAPER))); + } + cArray("ps_font", font, "u8"); + + // OBJ tiles + sprite table. GBA 1D mapping: frame block = TL,TR,BL,BR. + const objBytes: number[] = []; + const spriteTable: number[] = []; + let tileBase = 0; + model.sprites.forEach((sp, si) => { + const framesPerDir = sp.facings.down.length; + spriteTable.push(tileBase & 0xff, (tileBase >> 8) & 0xff, framesPerDir, si); + for (const facing of ["down", "up", "right"] as const) { + for (const frame of sp.facings[facing]) { + for (const tile of frameToTiles(frame)) objBytes.push(...encodeTileGba(tile)); + tileBase += 4; + } + } + }); + if (spriteTable.length !== model.sprites.length * SPRITE_ENTRY_SIZE) { + throw new Error("sprite table drifted"); + } + cArray("ps_obj_tiles", objBytes, "u8"); + push(`const u16 ps_obj_tile_count = ${tileBase};`); + cArray("ps_sprite_table", spriteTable, "u8"); + push(`const u8 ps_sprite_count = ${model.sprites.length};`); + + // Palettes: BG bank 0 = tileset, BG bank 15 = textbox; OBJ bank i = sprite i. + cArray("ps_bg_pal0", Array.from(gbaPaletteBank(model.tileset.palette)), "u16"); + const textPal = new Uint16Array(16); + textPal[INK] = 0x0000; // black ink + textPal[PAPER] = 0x7fff; // white paper + cArray("ps_text_pal", Array.from(textPal), "u16"); + { + const objPal: number[] = []; + for (const sp of model.sprites) objPal.push(...gbaPaletteBank(sp.palette)); + cArray("ps_obj_pal", objPal, "u16"); + } + push(`const u16 ps_bg_font_base = ${L.fontBase};`); + + return lines.join("\n") + "\n"; +} + +// The GBA BIOS validates these 156 bytes at boot on real hardware; every +// homebrew toolchain (gbafix, devkitARM, tonc) ships the same table. +// prettier-ignore +const NINTENDO_LOGO = [ + 0x24,0xFF,0xAE,0x51,0x69,0x9A,0xA2,0x21,0x3D,0x84,0x82,0x0A,0x84,0xE4,0x09,0xAD, + 0x11,0x24,0x8B,0x98,0xC0,0x81,0x7F,0x21,0xA3,0x52,0xBE,0x19,0x93,0x09,0xCE,0x20, + 0x10,0x46,0x4A,0x4A,0xF8,0x27,0x31,0xEC,0x58,0xC7,0xE8,0x33,0x82,0xE3,0xCE,0xBF, + 0x85,0xF4,0xDF,0x94,0xCE,0x4B,0x09,0xC1,0x94,0x56,0x8A,0xC0,0x13,0x72,0xA7,0xFC, + 0x9F,0x84,0x4D,0x73,0xA3,0xCA,0x9A,0x61,0x58,0x97,0xA3,0x27,0xFC,0x03,0x98,0x76, + 0x23,0x1D,0xC7,0x61,0x03,0x04,0xAE,0x56,0xBF,0x38,0x84,0x00,0x40,0xA7,0x0E,0xFD, + 0xFF,0x52,0xFE,0x03,0x6F,0x95,0x30,0xF1,0x97,0xFB,0xC0,0x85,0x60,0xD6,0x80,0x25, + 0xA9,0x63,0xBE,0x03,0x01,0x4E,0x38,0xE2,0xF9,0xA2,0x34,0xFF,0xBB,0x3E,0x03,0x44, + 0x78,0x00,0x90,0xCB,0x88,0x11,0x3A,0x94,0x65,0xC0,0x7C,0x63,0x87,0xF0,0x3C,0xAF, + 0xD6,0x25,0xE4,0x8B,0x38,0x0A,0xAC,0x72,0x21,0xD4,0xF8,0x07, +]; + +export async function buildGba(linked: LinkedGame, outRom: string): Promise { + const outDir = dirname(outRom); + const genDir = join(outDir, "gen-gba"); + await $`mkdir -p ${genDir}`.quiet(); + await Bun.write(join(genDir, "gen_data.c"), emitGbaData(linked)); + + const gbaDir = join(RUNTIME, "gba"); + const coreDir = join(RUNTIME, "core"); + const genInc = join(RUNTIME, "gen"); + const elf = join(genDir, "game.elf"); + + await $`arm-none-eabi-gcc -mcpu=arm7tdmi -mthumb-interwork -marm -ffreestanding -nostdlib -O2 -fno-strict-aliasing -Wall -Werror=implicit-function-declaration -DPS_TARGET_GBA -I${genInc} -I${coreDir} -I${gbaDir} -T${gbaDir}/gba.ld ${gbaDir}/crt0.s ${coreDir}/vm.c ${coreDir}/rpg.c ${gbaDir}/hal_gba.c ${join(genDir, "gen_data.c")} -lgcc -o ${elf}`; + await $`arm-none-eabi-objcopy -O binary ${elf} ${outRom}`; + + // Cartridge header: logo + title + checksum (flashcart-friendly). + const rom = new Uint8Array(await Bun.file(outRom).arrayBuffer()); + rom.set(NINTENDO_LOGO, 0x04); + const title = linked.model.title.toUpperCase().replace(/[^A-Z0-9 ]/g, "").slice(0, 12); + for (let i = 0; i < 12; i++) rom[0xa0 + i] = i < title.length ? title.charCodeAt(i) : 0; + rom.set([0x50, 0x53, 0x54, 0x43], 0xac); // game code "PSTC" + rom[0xb2] = 0x96; // fixed value + let sum = 0; + for (let i = 0xa0; i <= 0xbc; i++) sum = (sum + rom[i]) & 0xff; + rom[0xbd] = (-(0x19 + sum)) & 0xff; + await Bun.write(outRom, rom); +} diff --git a/static/compiler/targets/nes.ts b/static/compiler/targets/nes.ts new file mode 100644 index 00000000..32131ebd --- /dev/null +++ b/static/compiler/targets/nes.ts @@ -0,0 +1,249 @@ +// static/compiler/targets/nes.ts — the NES packager: planar 2bpp CHR with +// master-palette reduction, UNROM (mapper 2) bank assignment (data banks at +// the $8000 window, code fixed at $C000), generated ld65 config + iNES +// header, and the cc65/ca65/ld65 toolchain drive. + +import { $ } from "bun"; +import { dirname, join } from "node:path"; +import { BANK_SIZE, FONT_GLYPHS } from "../../spec/isa.ts"; +import { encodeTileNes, frameToTiles, glyphTile, nearestNesColor, nesReduce, tileFromHexRows } from "../assets.ts"; +import { FONT8 } from "../font.gen.ts"; +import type { LinkedGame } from "../link.ts"; + +const RUNTIME = join(import.meta.dir, "..", "..", "runtime"); +const CC65_LIB = "/opt/homebrew/share/cc65/lib/none.lib"; + +export function encodeNesArt(linked: LinkedGame): { + art: Uint8Array; + font: Uint8Array; + obj: Uint8Array; + bgPal: number[]; + objPal: number[]; + spriteTable: number[]; +} { + const { model } = linked; + const backdrop = nearestNesColor(model.tileset.palette[0]); + + const bg = nesReduce(model.tileset.palette, backdrop); + const art: number[] = []; + for (let i = 1; i < model.tileNames.length; i++) { + const tile = model.tileset.tiles[model.tileNames[i]]; + art.push(...encodeTileNes(tileFromHexRows(tile.px), bg.valueOf)); + } + + const font: number[] = []; + for (let g = 0; g < FONT_GLYPHS; g++) { + font.push(...encodeTileNes(glyphTile(FONT8[g], 3, 1), (v) => v)); // ink black(3), paper white(1) + } + + // OBJ: per-sprite subpalette, deduped into the 4 OBJ slots. + const obj: number[] = []; + const objSubpals: string[] = []; + const objPal: number[] = []; + const spriteTable: number[] = []; + let tileBase = 0; + for (const sp of model.sprites) { + const red = nesReduce(sp.palette, backdrop); + const key = red.subpal.join(","); + let slot = objSubpals.indexOf(key); + if (slot < 0 && objSubpals.length < 4) { + slot = objSubpals.length; + objSubpals.push(key); + objPal.push(...red.subpal); + } else if (slot < 0) { + // 4 hardware OBJ subpalettes: extra sprites adopt the nearest one. + let bestD = Infinity; + for (let i = 0; i < objSubpals.length; i++) { + const pal = objSubpals[i].split(",").map(Number); + let d = 0; + for (let c = 1; c < 4; c++) d += Math.abs(pal[c] - red.subpal[c]); + if (d < bestD) { + bestD = d; + slot = i; + } + } + } + const framesPerDir = sp.facings.down.length; + spriteTable.push(tileBase & 0xff, (tileBase >> 8) & 0xff, framesPerDir, slot); + for (const facing of ["down", "up", "right"] as const) { + for (const frame of sp.facings[facing]) { + const [tl, tr, bl, br] = frameToTiles(frame); + for (const t of [tl, bl, tr, br]) obj.push(...encodeTileNes(t, red.valueOf)); + tileBase += 4; + } + } + } + while (objPal.length < 16) objPal.push(0x0f); + if (tileBase > 256) throw new Error(`nes: ${tileBase} OBJ tiles exceeds pattern table 1 (256)`); + + return { + art: Uint8Array.from(art), + font: Uint8Array.from(font), + obj: Uint8Array.from(obj), + bgPal: [backdrop, ...bg.subpal.slice(1)], + objPal, + spriteTable, + }; +} + +export async function buildNes(linked: LinkedGame, outRom: string): Promise { + const { model } = linked; + const outDir = dirname(outRom); + const genDir = join(outDir, "gen-nes"); + await $`mkdir -p ${genDir}`.quiet(); + + const { art, font, obj, bgPal, objPal, spriteTable } = encodeNesArt(linked); + + // Blob list + first-fit into switchable banks. + const blobs = [...linked.blobs.map((b) => b.bytes), art, font, obj]; + const artBlob = linked.blobs.length; + const fontBlob = artBlob + 1; + const objBlob = artBlob + 2; + const bankOf: number[] = []; + const bankUsed: number[] = []; + blobs.forEach((bytes, i) => { + if (bytes.length > BANK_SIZE) throw new Error(`nes: blob ${i} exceeds one bank`); + let placed = -1; + for (let b = 0; b < bankUsed.length; b++) { + if (bankUsed[b] + bytes.length <= BANK_SIZE) { + placed = b; + break; + } + } + if (placed < 0) { + placed = bankUsed.length; + bankUsed.push(0); + } + bankOf[i] = placed; + bankUsed[placed] += bytes.length; + }); + const dataBanks = bankUsed.length; + const prgCount = dataBanks + 1; // + fixed bank + + // gen_data.c (fixed bank tables) + { + const lines: string[] = []; + const push = (s: string) => lines.push(s); + const cArray = (name: string, bytes: ArrayLike, type = "unsigned char"): void => { + const vals: string[] = []; + for (let i = 0; i < bytes.length; i++) vals.push(String(bytes[i])); + push(`const ${type} ${name}[] = { ${vals.join(",")} };`); + }; + push("/* gen_data.c — GENERATED by targets/nes.ts. DO NOT EDIT. */"); + push('#include "spec_gen.h"'); + push("typedef unsigned char u8; typedef unsigned short u16;"); + cArray("ps_game_header", linked.header, "u8"); + push(`const u16 ps_script_table[] = { ${linked.scriptTable.join(",")} };`); + { + const b: number[] = []; + for (const e of linked.textTable) b.push(e.blob, e.off & 0xff, (e.off >> 8) & 0xff); + cArray("ps_text_table", b, "u8"); + } + cArray("ps_map_blob", linked.mapBlobIndex, "u8"); + cArray("ps_sprite_table", spriteTable, "u8"); + blobs.forEach((_, i) => push(`extern const u8 ps_b${i}[];`)); + cArray("ps_blob_bank", bankOf, "u8"); + push(`const u8 *const ps_blob_addr[] = { ${blobs.map((_, i) => `ps_b${i}`).join(",")} };`); + push(`const u8 ps_art_blob = ${artBlob};`); + push(`const u8 ps_font_blob = ${fontBlob};`); + push(`const u8 ps_obj_blob = ${objBlob};`); + push(`const u8 ps_bg_tile_count = ${model.tileNames.length - 1};`); + push(`const u16 ps_obj_tile_bytes = ${obj.length};`); + cArray("ps_bg_pal", bgPal, "u8"); + cArray("ps_obj_pal", objPal, "u8"); + await Bun.write(join(genDir, "gen_data.c"), lines.join("\n") + "\n"); + } + + // iNES header + per-bank data .s + { + const h = [ + `; gen_header.s — GENERATED. iNES: mapper 2 (UNROM), CHR-RAM.`, + `.segment "HEADER"`, + `.byte $4e, $45, $53, $1a`, + `.byte ${prgCount}`, + `.byte 0`, + `.byte $20`, + `.byte 0, 0, 0, 0, 0, 0, 0, 0, 0`, + ]; + await Bun.write(join(genDir, "gen_header.s"), h.join("\n") + "\n"); + } + for (let b = 0; b < dataBanks; b++) { + const lines = [`; gen_bank${b}.s — GENERATED by targets/nes.ts.`, `.segment "BANK${b}"`]; + blobs.forEach((bytes, i) => { + if (bankOf[i] !== b) return; + lines.push(`.export _ps_b${i}`); + lines.push(`_ps_b${i}:`); + for (let at = 0; at < bytes.length; at += 16) { + lines.push(`.byte ${Array.from(bytes.subarray(at, at + 16)).join(",")}`); + } + if (bytes.length === 0) lines.push(".byte 0"); + }); + await Bun.write(join(genDir, `gen_bank${b}.s`), lines.join("\n") + "\n"); + } + + // ld65 config + { + const mem: string[] = [` HDR: start = $0000, size = $0010, type = ro, file = %O, fill = yes;`]; + const seg: string[] = [` HEADER: load = HDR, type = ro;`]; + for (let b = 0; b < dataBanks; b++) { + mem.push(` PRG${b}: start = $8000, size = $4000, type = ro, file = %O, fill = yes, fillval = $ff;`); + seg.push(` BANK${b}: load = PRG${b}, type = ro;`); + } + mem.push(` PRGFIX: start = $c000, size = $4000, type = ro, file = %O, fill = yes, fillval = $ff;`); + mem.push(` ZP: start = $0002, size = $00fa, type = rw;`); + mem.push(` RAM: start = $0300, size = $0400, type = rw;`); + seg.push(` CODE: load = PRGFIX, type = ro;`); + seg.push(` STARTUP: load = PRGFIX, type = ro, optional = yes;`); + seg.push(` ONCE: load = PRGFIX, type = ro, optional = yes;`); + seg.push(` RODATA: load = PRGFIX, type = ro;`); + seg.push(` DATA: load = PRGFIX, run = RAM, type = rw, define = yes;`); + seg.push(` VECTORS: load = PRGFIX, type = ro, start = $fffa;`); + seg.push(` ZEROPAGE: load = ZP, type = zp;`); + seg.push(` BSS: load = RAM, type = bss, define = yes;`); + const cfg = [ + "SYMBOLS {", + " __STACKSTART__: type = weak, value = $0700;", + " __STACKSIZE__: type = weak, value = $0100;", + "}", + `MEMORY {\n${mem.join("\n")}\n}`, + `SEGMENTS {\n${seg.join("\n")}\n}`, + "FEATURES {", + " CONDES: type = constructor, label = __CONSTRUCTOR_TABLE__, count = __CONSTRUCTOR_COUNT__, segment = RODATA;", + " CONDES: type = destructor, label = __DESTRUCTOR_TABLE__, count = __DESTRUCTOR_COUNT__, segment = RODATA;", + "}", + "", + ].join("\n"); + await Bun.write(join(genDir, "nes.cfg"), cfg); + } + + const nesDir = join(RUNTIME, "nes"); + const coreDir = join(RUNTIME, "core"); + const genInc = join(RUNTIME, "gen"); + const cc = ["-t", "none", "-O", "-DPS_TARGET_NES", `-I${genInc}`, `-I${coreDir}`, `-I${nesDir}`]; + + const objs: string[] = []; + for (const [src, name] of [ + [join(coreDir, "vm.c"), "vm"], + [join(coreDir, "rpg.c"), "rpg"], + [join(nesDir, "hal_nes.c"), "hal_nes"], + [join(genDir, "gen_data.c"), "gen_data"], + ] as const) { + const s = join(genDir, `${name}.cc65.s`); + await $`cc65 ${cc} -o ${s} ${src}`.quiet(); + const o = join(genDir, `${name}.o`); + await $`ca65 -o ${o} ${s}`.quiet(); + objs.push(o); + } + for (const asm of ["gen_header", ...Array.from({ length: dataBanks }, (_, b) => `gen_bank${b}`)]) { + const o = join(genDir, `${asm}.o`); + await $`ca65 -o ${o} ${join(genDir, `${asm}.s`)}`.quiet(); + objs.push(o); + } + { + const o = join(genDir, "crt0.o"); + await $`ca65 -o ${o} ${join(nesDir, "crt0.s")}`.quiet(); + objs.push(o); + } + + await $`ld65 -C ${join(genDir, "nes.cfg")} -o ${outRom} ${objs} ${CC65_LIB}`.quiet(); +} diff --git a/static/compiler/text.ts b/static/compiler/text.ts new file mode 100644 index 00000000..2d149343 --- /dev/null +++ b/static/compiler/text.ts @@ -0,0 +1,140 @@ +// static/compiler/text.ts — compile-time text layout. The runtime never +// measures: every SAY page and CHOICE option arrives pre-wrapped for the +// target, as a token stream (spec/isa.ts TOK_*). +// +// A page "atom" is either an ASCII char or a FMT slot (which reserves +// FMT_CELLS columns). Wrapping is greedy word-wrap on spaces; explicit \n +// forces a line break; a word longer than a line hard-breaks. + +import { FMT_CELLS, TOK_ASCII_MAX, TOK_ASCII_MIN, TOK_END, TOK_FMT, TOK_NEWLINE, type TargetSpec } from "../spec/isa.ts"; + +export type TextAtom = { ch: string } | { fmtVar: number }; + +/** A logical text: literal runs + runtime format slots. */ +export type RichText = TextAtom[]; + +export function richFromString(s: string): RichText { + const out: RichText = []; + for (const ch of s) out.push({ ch }); + return out; +} + +export function richToDebugString(rt: RichText): string { + return rt.map((a) => ("ch" in a ? a.ch : `{v${a.fmtVar}}`)).join(""); +} + +const atomCells = (a: TextAtom): number => ("ch" in a ? 1 : FMT_CELLS); + +function checkAscii(a: TextAtom, context: string): void { + if ("ch" in a && a.ch !== "\n") { + const c = a.ch.charCodeAt(0); + if (c < TOK_ASCII_MIN || c > TOK_ASCII_MAX) { + throw new Error(`non-ASCII character ${JSON.stringify(a.ch)} in ${context} — v1 text is ASCII-only`); + } + } +} + +/** Split a RichText into lines of at most `cols` cells (no pagination). */ +export function wrapLines(rt: RichText, cols: number, context = "text"): RichText[] { + // Tokenize into words (runs of non-space atoms) and explicit breaks. + type Word = { atoms: RichText; cells: number }; + const lines: RichText[] = []; + let line: RichText = []; + let lineCells = 0; + let word: Word = { atoms: [], cells: 0 }; + + const flushWord = (): void => { + if (word.atoms.length === 0) return; + if (word.cells > cols) { + // Hard-break an over-long word cell by cell. + for (const a of word.atoms) { + if (lineCells + atomCells(a) > cols) { + lines.push(line); + line = []; + lineCells = 0; + } + line.push(a); + lineCells += atomCells(a); + } + word = { atoms: [], cells: 0 }; + return; + } + const sep = line.length > 0 ? 1 : 0; + if (lineCells + sep + word.cells > cols) { + lines.push(line); + line = []; + lineCells = 0; + } else if (sep) { + line.push({ ch: " " }); + lineCells += 1; + } + line.push(...word.atoms); + lineCells += word.cells; + word = { atoms: [], cells: 0 }; + }; + + for (const a of rt) { + checkAscii(a, context); + if ("ch" in a && a.ch === "\n") { + flushWord(); + lines.push(line); + line = []; + lineCells = 0; + continue; + } + if ("ch" in a && a.ch === " ") { + flushWord(); + continue; + } + word.atoms.push(a); + word.cells += atomCells(a); + } + flushWord(); + lines.push(line); + // Drop a single trailing empty line produced by a trailing \n, keep + // intentional blank lines elsewhere. + while (lines.length > 1 && lines[lines.length - 1].length === 0) lines.pop(); + return lines; +} + +/** Wrap + paginate for a target's textbox. Returns pages of lines. */ +export function wrapPages(rt: RichText, t: TargetSpec, context = "text"): RichText[][] { + const lines = wrapLines(rt, t.textCols, context); + const pages: RichText[][] = []; + for (let i = 0; i < lines.length; i += t.textLines) { + pages.push(lines.slice(i, i + t.textLines)); + } + return pages; +} + +/** Encode one page (lines joined by TOK_NEWLINE) as a token stream. */ +export function encodePage(lines: RichText[]): Uint8Array { + const bytes: number[] = []; + lines.forEach((line, i) => { + if (i > 0) bytes.push(TOK_NEWLINE); + for (const a of line) { + if ("ch" in a) bytes.push(a.ch.charCodeAt(0)); + else bytes.push(TOK_FMT, a.fmtVar); + } + }); + bytes.push(TOK_END); + return Uint8Array.from(bytes); +} + +/** A single-line text (choice options): must fit `cols`, no newlines. */ +export function encodeOption(rt: RichText, cols: number, context: string): Uint8Array { + let cells = 0; + for (const a of rt) { + checkAscii(a, context); + if ("ch" in a && a.ch === "\n") throw new Error(`${context}: choice options are single-line`); + cells += atomCells(a); + } + if (cells > cols) throw new Error(`${context}: option is ${cells} cells, max ${cols}`); + const bytes: number[] = []; + for (const a of rt) { + if ("ch" in a) bytes.push(a.ch.charCodeAt(0)); + else bytes.push(TOK_FMT, a.fmtVar); + } + bytes.push(TOK_END); + return Uint8Array.from(bytes); +} diff --git a/static/docs/boardroom.png b/static/docs/boardroom.png new file mode 100644 index 00000000..e3b6bff8 Binary files /dev/null and b/static/docs/boardroom.png differ diff --git a/static/docs/gameboy.png b/static/docs/gameboy.png new file mode 100644 index 00000000..450890ee Binary files /dev/null and b/static/docs/gameboy.png differ diff --git a/static/docs/nes.png b/static/docs/nes.png new file mode 100644 index 00000000..ff597e1e Binary files /dev/null and b/static/docs/nes.png differ diff --git a/static/docs/vegas.png b/static/docs/vegas.png new file mode 100644 index 00000000..aa18e17f Binary files /dev/null and b/static/docs/vegas.png differ diff --git a/static/games/boardroom/assets.ts b/static/games/boardroom/assets.ts new file mode 100644 index 00000000..cb41d886 --- /dev/null +++ b/static/games/boardroom/assets.ts @@ -0,0 +1,210 @@ +// static/games/boardroom/assets.ts — BOARDROOM art. +// +// Declaration-zone TypeScript: a tiny deterministic pixel-person generator +// (16x16 walkers, 3 facings, 2 frames) plus a hand-tiled office tileset. +// Palette indices are authored, so every target encoder gets clean input. +// If a generated imagegen sheet lands later, it replaces THIS module while +// keeping tile/sprite names stable. + +import { defineSprite, defineTileset, type Rgb, type SpriteDecl } from "@pocketjs/static/rpg"; + +// --------------------------------------------------------------------------- +// Office tileset. Palette (16): institutional SF-office grey-blue + accents. +// --------------------------------------------------------------------------- +const P = { + bg: [18, 20, 26] as Rgb, // 0 backdrop + floor: [196, 198, 206] as Rgb, // 1 light office floor + floorShade: [168, 170, 180] as Rgb, // 2 + wall: [72, 76, 92] as Rgb, // 3 + wallDark: [48, 52, 64] as Rgb, // 4 + wood: [148, 110, 70] as Rgb, // 5 desk wood + woodDark: [104, 76, 48] as Rgb, // 6 + screen: [120, 200, 255] as Rgb, // 7 laptop glow + green: [72, 148, 92] as Rgb, // 8 plant + greenDark: [44, 96, 60] as Rgb, // 9 + door: [232, 220, 200] as Rgb, // 10 + carpet: [92, 112, 152] as Rgb, // 11 boardroom carpet + carpetDark: [72, 88, 124] as Rgb, // 12 + glass: [156, 212, 232] as Rgb, // 13 window + accent: [214, 120, 60] as Rgb, // 14 warm accent (chair) + ink: [28, 30, 38] as Rgb, // 15 outline +}; +const PALETTE: Rgb[] = Object.values(P); + +// tile helper: 8 strings of 8 chars using the nibble alphabet below +const T = (...rows: string[]) => ({ px: rows }); + +export const office = defineTileset("office", { + palette: PALETTE, + tiles: { + floor: T("11111111", "11111112", "11111111", "11111111", "11121111", "11111111", "11111111", "21111111"), + carpet: T("bbbbbbbb", "bbbcbbbb", "bbbbbbbb", "bbbbbcbb", "bbbbbbbb", "bcbbbbbb", "bbbbbbbb", "bbbbbbcb"), + wall: { + px: ["33333333", "34444443", "34444443", "33333333", "44444444", "43333334", "43333334", "44444444"], + solid: true, + }, + wallTop: { px: ["ffffffff", "33333333", "33333333", "34444443", "34444443", "33333333", "33333333", "ffffffff"], solid: true }, + window: { + px: ["33333333", "3dddddd3", "3dddddd3", "3dd11dd3", "3dddddd3", "3dddddd3", "33333333", "44444444"], + solid: true, + }, + desk: { + px: ["ffffffff", "f555555f", "f565655f", "f555555f", "f566665f", "f555555f", "ffffffff", "11111111"], + solid: true, + }, + laptop: { + px: ["ffffffff", "f555555f", "f577775f", "f577775f", "f555555f", "f556555f", "ffffffff", "11111111"], + solid: true, + }, + table: { + px: ["ffffffff", "f555555f", "f555555f", "f556555f", "f555555f", "f555655f", "f555555f", "ffffffff"], + solid: true, + }, + chair: T("bbbbbbbb", "bfeeeefb", "bfeeeefb", "bfeeeefb", "bffffffb", "bbfbbfbb", "bbbbbbbb", "bbbbbbbb"), + door: T("aaaaaaaa", "aaaaaaaa", "aaaaaaaa", "aaaaafaa", "aaaaaaaa", "aaaaaaaa", "aaaaaaaa", "aaaaaaaa"), + plant: { + px: ["11111111", "11898111", "18999811", "11898911", "11989111", "111f1111", "11fff111", "11111111"], + solid: true, + }, + server: { + px: ["ffffffff", "f444444f", "f477774f", "f444444f", "f477774f", "f444444f", "f477774f", "ffffffff"], + solid: true, + }, + }, +}); + +// --------------------------------------------------------------------------- +// Pixel people. Shared skin/shadow slots + per-person hair & clothes. +// Sprite palette: 0 transparent, 1 skin, 2 hair, 3 shirt, 4 shirt shade, +// 5 legs, 6 ink, 7 skin shade. +// NES budget: 4 distinct OBJ palettes — people are grouped into wardrobe +// palettes (see WARDROBE below) so the reducer dedupes cleanly. +// --------------------------------------------------------------------------- +interface Person { + name: string; + skin: Rgb; + hair: Rgb; + shirt: Rgb; + shirtShade: Rgb; + legs: Rgb; + /** bald-ish hairline (ilya, satya) */ + bald?: boolean; +} + +const row16 = (s: string): string => { + if (s.length !== 16) throw new Error(`row16: ${s.length}`); + return s; +}; + +/** 16x16 walker frames from a compact template. Facings: down/up/right. */ +function personFrames(p: Person): SpriteDecl["facings"] { + const hairTop = p.bald ? "0000011111100000" : "0000112222110000"; + const hairTopUp = "0000112222110000"; // back of head always has hair color + const hairRow = p.bald ? "0001111111111000".replace(/1/g, "1") : "0001222222211000"; + const hairRowUp = "0001222222221000"; + const faceRow = "0001211111121000"; + const eyeRow = "0001161111611000"; + const mouthRow = "0001111771111000".replace(/7/g, "1"); + const chinRow = "0000111111110000"; + const bodyTop = "0000333333330000"; + const bodyMid = "0003333443333000"; + const bodyArms = "0013333443333100"; + const bodyLow = "0003334433433000"; + const hips = "0000555555550000"; + const legsA = "0000550000550000"; + const legsB = "0000055005500000"; + const feetA = "0000660000660000"; + const feetB = "0000066006600000"; + + const down = (legs: string, feet: string) => [ + row16("0000000000000000"), + row16(hairTop), + row16(hairRow), + row16(faceRow), + row16(eyeRow), + row16(mouthRow), + row16(chinRow), + row16(bodyTop), + row16(bodyMid), + row16(bodyArms), + row16(bodyLow), + row16(hips), + row16(legs), + row16(legs), + row16(feet), + row16("0000000000000000"), + ]; + const up = (legs: string, feet: string) => [ + row16("0000000000000000"), + row16(hairTopUp), + row16(hairRowUp), + row16(hairRowUp), + row16("0001222222221000"), + row16("0001122222211000"), + row16(chinRow), + row16(bodyTop), + row16(bodyMid), + row16(bodyArms), + row16(bodyLow), + row16(hips), + row16(legs), + row16(legs), + row16(feet), + row16("0000000000000000"), + ]; + const right = (legs: string, feet: string) => [ + row16("0000000000000000"), + row16(p.bald ? "0000011111000000" : "0000112221100000"), + row16(p.bald ? "0000111111100000" : "0001222221100000"), + row16("0000112111600000"), + row16("0000111111100000"), + row16("0000011111100000"), + row16("0000011110000000"), + row16("0000333333000000"), + row16("0000333334300000"), + row16("0000133333100000"), + row16("0000333433000000"), + row16("0000055550000000"), + row16(legs), + row16(legs), + row16(feet), + row16("0000000000000000"), + ]; + return { + down: [down(legsA, feetA), down(legsB, feetB)], + up: [up(legsA, feetA), up(legsB, feetB)], + right: [right("0000055500000000", "0000066000000000"), right("0000550550000000", "0000660066000000")], + }; +} + +const person = (p: Person): SpriteDecl => + defineSprite(p.name, { + palette: [ + [0, 0, 0], + p.skin, + p.hair, + p.shirt, + p.shirtShade, + p.legs, + [24, 24, 28], + [200, 160, 130], + ], + facings: personFrames(p), + }); + +// Wardrobe palettes (shared per NES OBJ-palette group) +const SKIN: Rgb = [236, 188, 152]; +const SKIN2: Rgb = [188, 136, 100]; +const GREY = { shirt: [150, 150, 160] as Rgb, shirtShade: [110, 110, 120] as Rgb, legs: [70, 74, 90] as Rgb }; +const DARK = { shirt: [64, 68, 84] as Rgb, shirtShade: [44, 46, 58] as Rgb, legs: [38, 40, 50] as Rgb }; +const WARM = { shirt: [186, 88, 66] as Rgb, shirtShade: [140, 62, 46] as Rgb, legs: [60, 56, 70] as Rgb }; +const BLUE = { shirt: [66, 112, 186] as Rgb, shirtShade: [46, 80, 140] as Rgb, legs: [40, 48, 72] as Rgb }; + +export const sam = person({ name: "sam", skin: SKIN, hair: [140, 104, 60], ...GREY }); +export const employee = person({ name: "employee", skin: SKIN, hair: [80, 60, 40], ...GREY }); +export const ilya = person({ name: "ilya", skin: SKIN, hair: [90, 80, 70], bald: true, ...DARK }); +export const board = person({ name: "board", skin: SKIN, hair: [50, 44, 40], ...DARK }); +export const greg = person({ name: "greg", skin: SKIN, hair: [104, 78, 46], ...WARM }); +export const mira = person({ name: "mira", skin: SKIN, hair: [58, 44, 36], ...WARM }); +export const satya = person({ name: "satya", skin: SKIN2, hair: [40, 36, 34], bald: true, ...BLUE }); +export const emmett = person({ name: "emmett", skin: SKIN, hair: [150, 110, 60], ...BLUE }); diff --git a/static/games/boardroom/dossier.md b/static/games/boardroom/dossier.md new file mode 100644 index 00000000..657f2424 --- /dev/null +++ b/static/games/boardroom/dossier.md @@ -0,0 +1,42 @@ +# BOARDROOM — research dossier + +The game adapts the November 2023 OpenAI board crisis. Dates, sequence, and +every quoted line below are from the public record; the rest of the game's +dialogue is original parody. Corrections welcome — the game treats this file +as its source of truth. + +## Timeline the game follows + +| Beat in game | Fact | Source | +|---|---|---| +| The Meet call | Fri Nov 17, ~noon PT: Sam Altman, in a Las Vegas hotel for the F1 Grand Prix weekend, is fired on a Google Meet by the board (Sutskever, D'Angelo, Toner, McCauley); he later says he was "confused beyond belief" | WSJ/Yahoo; Brockman's timeline thread | +| "not consistently candid" | The OpenAI announcement: he "was not consistently candid in his communications with the board" | OpenAI blog, Nov 17 | +| Greg's exit | Brockman stripped of the chair at 12:23 PM ("keeping his role"), quits that evening: "based on today's news, i quit"; three senior researchers follow | Brockman on X; TechCrunch | +| One minute of warning | Microsoft was told ~1 minute before publication | Axios timeline | +| Mira interim | Murati, told the night before, becomes interim CEO | Brockman thread | +| Guest badge | Sun Nov 19: Altman visits HQ — "first and last time i ever wear one of these" | sama on X / CNN | +| Lightcap memo | Firing was "not malfeasance" but "a breakdown in communication" | Axios, Nov 18 | +| Emmett Shear | Appointed interim CEO near midnight Sunday — third CEO in three days; his statement: the board "did NOT remove Sam over any specific disagreement on safety", 30-day plan incl. an independent investigator | eshear on X; CNBC | +| Microsoft offer | Nadella announces Sam + Greg joining "a new advanced AI research team"; "we remain committed to our partnership with OpenAI"; Sam replies "one team, one mission" | satyanadella on X; CNN | +| The letter | Employee letter demanding the board resign: 505 signatures at release, ~743 of ~770 eventually; "unable to work for... people that lack competence, judgement and care"; Microsoft guaranteed jobs for all | CNN; Axios | +| Heart emojis | Murati: "OpenAI is nothing without its people"; mass reposts + heart emojis as a loyalty census | miramurati on X; Fortune | +| Ilya flips | Nov 20, ~5 AM: "I deeply regret my participation in the board's actions. I never intended to harm OpenAI." — after a tearful in-office plea by Anna Brockman, whose wedding Ilya had officiated | ilyasut on X; WSJ | +| Tender offer stakes | Thrive-led tender at ~$86B valuation hung over the weekend; back on track Nov 30 | CNBC | +| The agreement | Nov 21, ~10 PM: "agreement in principle" — Altman returns as CEO, new initial board Bret Taylor (chair), Larry Summers, Adam D'Angelo; Toner/McCauley/Sutskever leave the board | OpenAI; SiliconANGLE | +| Epilogue | Nov 29 official; Microsoft gets a non-voting observer; Mar 2024 WilmerHale review: a breakdown of trust, not safety or products; total ~106 hours | TechCrunch; CNN | + +## Deliberately omitted + +- Q* rumors (Reuters Nov 22): disputed, later largely decoupled from the + firing — the game does not use them. +- Toner's fuller 2024 account (the ChatGPT-launch-on-Twitter line, the FTC + argument): post-window; Helen's single in-game line sticks to the + documented Oct 2023 paper dispute. +- The Anthropic merger feeler: confirmed only later via deposition; skipped. + +## Fairness notes + +Public figures, newsworthy events, short verbatim quotes with in-game +attribution; everything else is invented comedy, and the game says so on the +title card. No real conversation is reconstructed as if verbatim; the battle +is a menu, not a deposition. diff --git a/static/games/boardroom/game.ts b/static/games/boardroom/game.ts new file mode 100644 index 00000000..c6887218 --- /dev/null +++ b/static/games/boardroom/game.ts @@ -0,0 +1,458 @@ +// static/games/boardroom/game.ts — BOARDROOM +// +// Five days in November 2023, playable. You are Sam Altman; the speedrun +// category is "fired to rehired". A satirical adaptation of the OpenAI board +// crisis — events, dates and quoted lines follow the public record +// (see dossier.md for sources); all other dialogue is original parody. +// +// One TypeScript module in, three cartridges out: GBA, Game Boy, NES. + +import { defineGame, defineMap, npc, script, trigger, warp, type Flags, type Ops, type Vars } from "@pocketjs/static/rpg"; +import { battle } from "@pocketjs/static/rpg/battle"; +import { board, emmett, employee, greg, ilya, mira, office, sam, satya } from "./assets.ts"; + +// --------------------------------------------------------------------------- +// Chapter 1 — THE CALL (Fri Nov 17, a Las Vegas hotel room) +// --------------------------------------------------------------------------- +const TheCall = script(function* (s, v, f) { + yield* s.lock(); + yield* s.sfx("deny"); + yield* s.say("FRIDAY, NOV 17. LAS VEGAS. F1 weekend. You are SAM ALTMAN, and your laptop is ringing at noon."); + yield* s.say("A Google Meet. The whole board, minus Greg. ILYA does the talking."); + yield* s.say("ILYA: Sam, the board has reviewed. You were, quote, not consistently candid in your communications."); + yield* s.say("ILYA: You are fired. The blog post is already up."); + const pick = yield* s.choose(["Ask why", "Stay calm", "Refresh X"]); + if (pick === "Ask why") { + yield* s.say("SAM: Candid about WHAT, exactly?"); + yield* s.say("The call has ended."); + } else if (pick === "Stay calm") { + yield* s.say("You are confused beyond belief, but your face does inbox-zero."); + yield* s.say("The call has ended."); + } else { + yield* s.say("Too late. Everyone else already knows. Microsoft got one minute of warning."); + } + yield* s.say("Your phone melts: 4 percent battery, 611 unread. MIRA is interim CEO. She found out last night."); + yield* s.wait(30); + yield* s.sfx("confirm"); + yield* s.say("GREG (12:23 PM): removed as chairman, quote, keeping my role."); + yield* s.say("GREG (evening): based on today's news, i quit."); + yield* s.say("Three senior researchers follow him out the door before midnight."); + yield* s.say("You type: i loved my time at openai. You add the salute. o7"); + f.fired = true; + yield* s.release(); +}); + +const HotelDoor = script(function* (s, v, f) { + if (f.fired) { + yield* s.say("Vegas can wait. San Francisco cannot."); + yield* s.say("SUNDAY, NOV 19. OPENAI HQ. You are holding a GUEST badge."); + yield* s.say("SAM: first and last time i ever wear one of these."); + f.badge = true; + yield* s.warp("hq:lobby"); + } else { + yield* s.say("The laptop is still ringing. Better answer it first."); + } +}); + +// --------------------------------------------------------------------------- +// HQ cast +// --------------------------------------------------------------------------- +const MiraTalk = script(function* (s, v, f) { + yield* s.lock(); + yield* s.face(); + if (f.won) { + yield* s.say("MIRA: Back to CTO, thank goodness. Ship something."); + } else if (f.letter_active) { + if (f.mira_boost) { + yield* s.say("MIRA: The letter is out. Go collect the rest of us."); + } else { + yield* s.say("MIRA: I posted five words this morning."); + yield* s.say("MIRA: OpenAI is nothing without its people."); + yield* s.say("The reposts stack up like a DDoS. Hearts everywhere. <3 <3 <3"); + v.sigs += 200; + f.mira_boost = true; + yield* s.sfx("fanfare"); + yield* s.say(`Signatures: ${v.sigs} of 770.`); + } + } else if (f.ms_open) { + yield* s.say("MIRA: Go see Satya. Then come back for the part where we save this place."); + } else if (f.shear_met) { + yield* s.say("MIRA: Your phone. Redmond is calling, and it is not about Teams."); + yield* s.say("SATYA (on the phone): We remain committed to our partnership with OpenAI..."); + yield* s.say("SATYA: ...and Sam and Greg will be joining Microsoft to lead a new advanced AI research team."); + yield* s.say("SAM (replying): one team, one mission."); + f.ms_open = true; + yield* s.sfx("confirm"); + yield* s.say("Microsoft's office is now open, across town. (West door.)"); + } else { + yield* s.say("MIRA: They told me the night before. Only me."); + yield* s.say("MIRA: The memo says it was NOT malfeasance. Just, quote, a breakdown in communication."); + yield* s.say("MIRA: The board is holed up in the BOARDROOM, east. They picked an interim CEO who is not me. Again."); + } + yield* s.release(); +}); + +function* employeeLine(s: Ops, v: Vars, f: Flags, line: string) { + yield* s.lock(); + yield* s.face(); + if (f.letter_active && !f.letter_first) { + yield* s.say(line); + yield* s.say("EMP: quote, We are unable to work for people who lack competence, judgement and care. unquote."); + v.sigs += 505; + f.letter_first = true; + yield* s.sfx("fanfare"); + yield* s.say(`Signatures: ${v.sigs} of 770.`); + } else if (f.letter_active) { + yield* s.say(line); + yield* s.say(`EMP: Counter says ${v.sigs}. Ilya has not signed. Yet.`); + } else if (f.fired) { + yield* s.say(line); + } else { + yield* s.say("EMP: Heads down. Shipping."); + } + yield* s.release(); +} + +const Emp1Talk = script(function* (s, v, f) { + yield* employeeLine(s, v, f, "EMP: The Slack is just heart emojis now. It means we walk if you do."); +}); +const Emp2Talk = script(function* (s, v, f) { + yield* employeeLine(s, v, f, "EMP: Three CEOs in three days. I stopped updating the org chart."); +}); +const Emp3Talk = script(function* (s, v, f) { + yield* employeeLine(s, v, f, "EMP: The tender offer was at 86 billion. WAS."); +}); + +const HqEnter = script(function* (s, v, f) { + if (f.fired && !f.hq_seen) { + f.hq_seen = true; + yield* s.lock(); + yield* s.say("OPENAI HQ. The espresso machine is the only thing still operating normally."); + yield* s.say("MIRA waits by the desks. The BOARDROOM is east."); + yield* s.release(); + } +}); + +const HqWestDoor = script(function* (s, v, f) { + if (f.ms_open) { + yield* s.warp("msoffice:door"); + } else { + yield* s.say("Across town: Microsoft. No reason to go. Yet."); + } +}); + +const HqEastDoor = script(function* (s, v, f) { + yield* s.warp("boardroom:door"); +}); + +// --------------------------------------------------------------------------- +// Boardroom cast +// --------------------------------------------------------------------------- +const AdamTalk = script(function* (s, v, f) { + yield* s.lock(); + yield* s.face(); + if (f.won) { + yield* s.say("ADAM: For the record, I never left the board. Continuity matters."); + } else if (f.letter_full) { + yield* s.say("ADAM: You brought a letter to a governance fight?"); + const go = yield* s.choose(["Negotiate", "Not yet"]); + if (go === "Negotiate") { + yield* s.say("TUESDAY, NOV 21. 10 PM. Final session. The BOARD digs in."); + yield* battle(s, v, f, { + foe: "THE BOARD", + foeHp: 14, + myName: "CRED", + myHp: 10, + foeDmg: 1, + foeBonus: 2, + foeQuip: "THE BOARD cites governance structure.", + winFlag: "won", + labels: ["TENDER OFFER", "HEART EMOJIS", "THE LETTER"], + moves: [ + { + i: 0, + label: "TENDER OFFER", + dmg: 2, + bonus: 3, + heal: 0, + gate: "", + fizzle: "", + quip: "You mention the 86B tender offer, gently.", + }, + { + i: 1, + label: "HEART EMOJIS", + dmg: 1, + bonus: 0, + heal: 2, + gate: "", + fizzle: "", + quip: "The timeline floods with hearts.", + }, + { + i: 2, + label: "THE LETTER", + dmg: 4, + bonus: 3, + heal: 0, + gate: "letter_full", + fizzle: "The letter is missing signatures. It reads as a group chat.", + quip: "743 of 770 names. Ilya's is on it.", + }, + ], + }); + if (f.won) { + yield* s.call(Ending); + } else { + yield* s.say("THE BOARD holds. Rest, then talk to Adam again."); + } + } else { + yield* s.say("ADAM: The offer expires when the news cycle does."); + } + } else if (f.shear_met) { + yield* s.say("ADAM: An agreement needs leverage, Sam. Bring some."); + } else { + yield* s.say("ADAM: The board has full confidence in its process."); + yield* s.say("HELEN: The process was the deliberative review."); + yield* s.say("TASHA: The review was the process."); + } + yield* s.release(); +}); + +const BoardMember1 = script(function* (s, v, f) { + yield* s.lock(); + yield* s.face(); + if (f.won) { + yield* s.say("HELEN: I am leaving the board. My paper stands."); + } else { + yield* s.say("HELEN: My CSET paper praised a competitor's safety posture. You tried to remove me for it."); + } + yield* s.release(); +}); + +const BoardMember2 = script(function* (s, v, f) { + yield* s.lock(); + yield* s.face(); + if (f.won) { + yield* s.say("TASHA: Governance is a marathon. We sprinted."); + } else { + yield* s.say("TASHA: This is fine."); + } + yield* s.release(); +}); + +const EmmettTalk = script(function* (s, v, f) { + yield* s.lock(); + yield* s.face(); + if (f.won) { + yield* s.say("EMMETT: Shortest CEO stint of my life, and I co-founded a streaming site."); + } else if (!f.shear_met) { + yield* s.say("Sunday, near midnight: the board appoints EMMETT SHEAR, of Twitch, interim CEO. The third CEO in three days."); + yield* s.say("EMMETT: Before I took this job, I checked. The board did NOT remove Sam over any specific safety disagreement."); + yield* s.say("EMMETT: My 30-day plan has one bullet in bold: hire an independent investigator."); + f.shear_met = true; + yield* s.say("EMMETT: Between us? Go make my job unnecessary. Mira has your phone."); + } else { + yield* s.say("EMMETT: I am not a caretaker CEO without evidence. Bring me an ending."); + } + yield* s.release(); +}); + +const IlyaTalk = script(function* (s, v, f) { + yield* s.lock(); + yield* s.face(); + if (f.ilya_flipped) { + yield* s.say("ILYA: The board seat mattered less than the lab. Go finish it."); + } else if (f.letter_active) { + yield* s.say("ILYA stares at the table for a long time."); + yield* s.say("Anna Brockman stood right here yesterday, in tears. Ilya officiated her wedding at this office."); + yield* s.say("ILYA: I deeply regret my participation in the board's actions. I never intended to harm OpenAI."); + yield* s.say("He signs the letter. Number 738 through 743 sign with him."); + v.sigs += 38; + f.ilya_flipped = true; + f.letter_full = true; + yield* s.sfx("fanfare"); + yield* s.say(`Signatures: ${v.sigs} of 770. The letter is ready. ADAM is waiting.`); + } else { + yield* s.say("ILYA: The board acted with... deliberation."); + yield* s.say("He does not look deliberate. He looks miserable."); + } + yield* s.release(); +}); + +const BoardroomDoor = script(function* (s, v, f) { + yield* s.warp("hq:eastdoor"); +}); + +// --------------------------------------------------------------------------- +// Microsoft office +// --------------------------------------------------------------------------- +const SatyaTalk = script(function* (s, v, f) { + yield* s.lock(); + yield* s.face(); + if (f.won) { + yield* s.say("SATYA: The partnership endures. Also we get a board observer seat now."); + } else if (!f.ms_done) { + yield* s.say("SATYA: Welcome. Badges are printing. A new advanced AI research team, funded on day one."); + const pick = yield* s.choose(["Join MSFT", "Stall politely"]); + if (pick === "Join MSFT") { + yield* s.say("SATYA: Excellent. Although, between us, I would rather you fix OpenAI. That is where the GPUs already are."); + } else { + yield* s.say("SATYA: Good instinct. This offer works best as leverage anyway."); + } + f.ms_done = true; + f.letter_active = true; + yield* s.say("GREG: The letter is circulating at HQ right now. Everyone is waiting for you."); + } else { + yield* s.say("SATYA: Every OpenAI employee has a guaranteed seat here. All seven hundred. We remain committed."); + } + yield* s.release(); +}); + +const GregTalk = script(function* (s, v, f) { + yield* s.lock(); + yield* s.face(); + if (f.won) { + yield* s.say("GREG: un-quit. one commit, force pushed."); + } else if (f.letter_active) { + yield* s.say("GREG: Anna went to see Ilya at HQ. I have never seen her that determined."); + } else { + yield* s.say("GREG: i quit at 12:19 and had a new org chart by 12:23. efficiency."); + } + yield* s.release(); +}); + +const MsDoor = script(function* (s, v, f) { + yield* s.warp("hq:westdoor"); +}); + +// --------------------------------------------------------------------------- +// Ending +// --------------------------------------------------------------------------- +const Ending = script(function* (s, v, f) { + yield* s.say("10:02 PM: We have reached an agreement in principle for Sam Altman to return to OpenAI as CEO."); + yield* s.say("New initial board: Bret Taylor, chair. Larry Summers. Adam D'Angelo."); + yield* s.say("Helen Toner and Tasha McCauley depart. Ilya keeps the lab, not the seat."); + yield* s.say("WEDNESDAY, NOV 29: it is official. Mira is CTO again. The tender offer is back on."); + yield* s.say("Months later, the independent review lands: a breakdown of trust. Not safety. Not the products."); + yield* s.say("Total time fired: 106 hours. CEOs consumed: 3. Signatures: 743 of 770."); + yield* s.say("Slack, forever after: <3 <3 <3 <3 <3"); + yield* s.sfx("fanfare"); + yield* s.say("BOARDROOM - fin. Built with Pocket Static: one TypeScript file, three consoles."); +}); + +// --------------------------------------------------------------------------- +// Maps +// --------------------------------------------------------------------------- +const hotel = defineMap("hotel", { + tileset: office, + layout: ` + ############ + #..........# + #.n........# + #..........# + #....c.....# + #..........# + ######d##### + `, + legend: { "#": "wall", ".": "carpet", n: "laptop", c: "chair", d: "door" }, + entrances: { + start: { at: [3, 3], dir: "up" }, + }, + triggers: [ + trigger({ at: [2, 3], run: TheCall, once: true }), + trigger({ at: [6, 6], run: HotelDoor }), + ], +}); + +const hq = defineMap("hq", { + tileset: office, + layout: ` + ####w####w####w####w + #..................# + #.~n..~n......~n...# + #..................# + #.p..............p.# + #..................# + d..................D + #..................# + #...~n....~n.......# + #..................# + #################### + `, + legend: { "#": "wall", w: "window", ".": "floor", "~": "desk", n: "laptop", p: "plant", d: "door", D: "door" }, + entrances: { + lobby: { at: [10, 6], dir: "up" }, + westdoor: { at: [1, 6], dir: "right" }, + eastdoor: { at: [18, 6], dir: "left" }, + }, + actors: [ + npc("mira", { sprite: mira, at: [9, 4], facing: "down", talk: MiraTalk }), + npc("emp1", { sprite: employee, at: [2, 3], facing: "down", talk: Emp1Talk }), + npc("emp2", { sprite: employee, at: [7, 3], facing: "down", talk: Emp2Talk }), + npc("emp3", { sprite: employee, at: [11, 9], facing: "up", talk: Emp3Talk }), + ], + triggers: [ + trigger({ at: [0, 6], run: HqWestDoor }), + trigger({ at: [19, 6], run: HqEastDoor }), + ], + onEnter: HqEnter, +}); + +const boardroom = defineMap("boardroom", { + tileset: office, + layout: ` + ######w##w###### + #..............# + #..............# + #...ttttttt....# + #..............# + #..............# + #..............# + d..............# + ################ + `, + legend: { "#": "wall", w: "window", ".": "carpet", t: "table", d: "door" }, + entrances: { + door: { at: [1, 7], dir: "right" }, + }, + actors: [ + npc("adam", { sprite: board, at: [5, 2], facing: "down", talk: AdamTalk }), + npc("helen", { sprite: board, at: [7, 2], facing: "down", talk: BoardMember1 }), + npc("tasha", { sprite: board, at: [9, 2], facing: "down", talk: BoardMember2 }), + npc("ilya", { sprite: ilya, at: [5, 5], facing: "down", talk: IlyaTalk }), + npc("emmett", { sprite: emmett, at: [11, 5], facing: "left", talk: EmmettTalk }), + ], + triggers: [trigger({ at: [0, 7], run: BoardroomDoor })], +}); + +const msoffice = defineMap("msoffice", { + tileset: office, + layout: ` + ####ww####ww#### + #.ss.......gg..# + #..............# + #..............# + #...tt.........# + #..............# + #..............# + d..............# + ################ + `, + legend: { "#": "wall", w: "window", ".": "floor", t: "table", s: "server", g: "plant", d: "door" }, + entrances: { + door: { at: [1, 7], dir: "right" }, + }, + actors: [ + npc("satya", { sprite: satya, at: [5, 3], facing: "down", talk: SatyaTalk }), + npc("greg", { sprite: greg, at: [10, 5], facing: "down", talk: GregTalk }), + ], + triggers: [trigger({ at: [0, 7], run: MsDoor })], +}); + +defineGame({ + title: "BOARDROOM", + start: "hotel:start", + player: sam, + maps: [hotel, hq, boardroom, msoffice], +}); diff --git a/static/games/boardroom/test/e2e.ts b/static/games/boardroom/test/e2e.ts new file mode 100644 index 00000000..b3bac2a9 --- /dev/null +++ b/static/games/boardroom/test/e2e.ts @@ -0,0 +1,253 @@ +#!/usr/bin/env bun +// static/games/boardroom/test/e2e.ts — the full BOARDROOM playthrough on +// every console. +// +// The reference VM is the story oracle: one persistent RefVM plays the whole +// beat list (scripts in encounter order, flags/vars/RNG carried across), and +// its event log becomes the console input script — one A per say page, +// cursor moves + A per choice, advances for waits. If a console's debug +// block disagrees with the oracle at the checkpoints, that console is wrong. +// +// bun static/games/boardroom/test/e2e.ts # all targets +// bun static/games/boardroom/test/e2e.ts gba nes # subset + +import { $ } from "bun"; +import { join } from "node:path"; +import { compileGame, type CompileOutput } from "../../../compiler/index.ts"; +import { buildGba } from "../../../compiler/targets/gba.ts"; +import { buildGb } from "../../../compiler/targets/gb.ts"; +import { buildNes } from "../../../compiler/targets/nes.ts"; +import { DBG, KEYS, TARGETS, dbgFlagAddr, dbgVarAddr, type KeyName, type TargetName } from "../../../spec/isa.ts"; +import { RefVM } from "../../../vm/ref.ts"; +import { AutoRpgHost, type RpgEvent } from "../../../vm/rpg-host.ts"; + +const HERE = import.meta.dir; +const ROOT = join(HERE, "..", "..", ".."); +const DIST = join(ROOT, "dist"); +const SHOTS = join(DIST, "shots"); +const ENTRY = join(HERE, "..", "game.ts"); +const RUNNER = join(ROOT, "test", "harness", "mgba_runner"); +const NES_RUNNER = join(ROOT, "test", "harness", "nes_runner.ts"); + +let passed = 0; +let failed = 0; +function check(name: string, got: unknown, want: unknown): void { + const ok = got === want; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: ${got}${ok ? "" : ` (want ${want})`}`); + ok ? passed++ : failed++; +} + +// --------------------------------------------------------------------------- +// Scenario builder +// --------------------------------------------------------------------------- +const REVEAL = 80; + +class Scenario { + lines: string[] = []; + advance(frames: number): this { + this.lines.push(`A ${frames}`); + return this; + } + press(keys: KeyName[], hold = 2, release = 6): this { + const mask = keys.reduce((m, k) => m | KEYS[k], 0); + this.lines.push(`P ${mask.toString(16)} ${hold} ${release}`); + return this; + } + read(name: string, addr: number, size: 1 | 2 | 4): this { + this.lines.push(`R ${name} 0x${addr.toString(16)} ${size}`); + return this; + } + shot(path: string): this { + this.lines.push(`S ${path}`); + return this; + } + /** One grid step is 5 engine ticks; exact holds prevent overshoot. */ + walk(dir: KeyName, steps: number): this { + return this.press([dir], steps * 5, 6); + } + /** Face + talk to an adjacent solid actor. */ + talk(dir: KeyName): this { + this.press([dir], 3, 4); // blocked step just turns + return this.press(["A"], 2, 8); + } + /** Replay an oracle beat's events as input. */ + beat(events: RpgEvent[]): this { + for (const e of events) { + if (e.kind === "say") this.advance(REVEAL).press(["A"], 2, 6); + else if (e.kind === "choice") { + this.advance(40); + for (let i = 0; i < e.picked; i++) this.press(["DOWN"], 2, 4); + this.press(["A"], 2, 8); + } else if (e.kind === "wait") this.advance(e.frames + 8); + } + return this; + } +} + +async function run(target: TargetName, rom: string, sc: Scenario): Promise> { + const file = join(DIST, `story-${target}.txt`); + await Bun.write(file, sc.lines.join("\n") + "\n"); + const out = + target === "nes" ? await $`bun ${NES_RUNNER} ${rom} ${file}`.text() : await $`${RUNNER} ${rom} ${file}`.text(); + const line = out.trim().split("\n").reverse().find((l) => l.startsWith("{")); + if (!line) throw new Error(`runner produced no JSON:\n${out}`); + const parsed = JSON.parse(line); + if (!parsed.ok) throw new Error(`runner error: ${line}`); + return parsed.reads ?? {}; +} + +// --------------------------------------------------------------------------- +// The oracle: play the whole story beat list on one reference VM. +// --------------------------------------------------------------------------- +interface Beat { + script: string; + picks: number[]; + events: RpgEvent[]; +} + +function playOracle(out: CompileOutput): { beats: Record; sigs: number; won: number } { + const code = out.linked.blobs[out.linked.scriptBlobIndex].bytes; + const S = out.debug.scripts; + // Beat order mirrors the playthrough below. Choice picks are the story's + // "canonical run": Ask why, Join MSFT, Negotiate, THE LETTER repeated. + const plan: [string, number[]][] = [ + ["TheCall", [0]], + ["HotelDoor", []], + ["HqEnter", []], + ["MiraTalk", []], + ["AdamTalk", []], + ["EmmettTalk", []], + ["HqEastDoor", []], + ["BoardroomDoor", []], + ["HqEnter", []], + ["MiraTalk", []], + ["HqWestDoor", []], + ["SatyaTalk", [0]], + ["GregTalk", []], + ["MsDoor", []], + ["HqEnter", []], + ["Emp1Talk", []], + ["MiraTalk", []], + ["HqEastDoor", []], + ["IlyaTalk", []], + ["AdamTalk", Array(40).fill(2).map((v, i) => (i === 0 ? 0 : 2))], + ]; + const beats: Record = {}; + let vm: RefVM | null = null; + for (const [name, picks] of plan) { + const host = new AutoRpgHost(picks); + const next = new RefVM(code, out.linked.scriptTable, host); + if (vm) { + next.vars.set(vm.vars); + next.flags.set(vm.flags); + next.rng = vm.rng; + } + host.play(next, S[name]); + (beats[name] ??= []).push(host.events); + vm = next; + } + return { + beats, + sigs: vm!.getVar(out.debug.vars.sigs), + won: vm!.getFlag(out.debug.flags.won), + }; +} + +// --------------------------------------------------------------------------- +// Per-target playthrough +// --------------------------------------------------------------------------- +async function testTarget(target: TargetName): Promise { + console.log(`\n=== ${target.toUpperCase()} ===`); + const out = await compileGame(ENTRY, target); + const rom = join(DIST, `boardroom${TARGETS[target].ext}`); + if (target === "gba") await buildGba(out.linked, rom); + else if (target === "gb") await buildGb(out.linked, rom); + else await buildNes(out.linked, rom); + await $`mkdir -p ${SHOTS}`.quiet(); + + const oracle = playOracle(out); + const nextBeat: Record = {}; + const beat = (name: string): RpgEvent[] => { + const runs = oracle.beats[name]; + const i = nextBeat[name] ?? 0; + nextBeat[name] = i + 1; + if (!runs || !runs[i]) throw new Error(`oracle has no run ${i} for beat ${name}`); + return runs[i]; + }; + + const A = (f: keyof typeof DBG) => TARGETS[target].debugAddr + DBG[f]; + const F = (name: string) => dbgFlagAddr(target, out.debug.flags[name]); + const { maps, vars } = out.debug; + const shot = (n: string) => join(SHOTS, `${target}_br_${n}.ppm`); + + const sc = new Scenario(); + // --- Ch.1: the call ------------------------------------------------------- + sc.advance(30) + .read("bootMap", A("CUR_MAP"), 1) + .walk("LEFT", 1) // onto the laptop trigger + .beat(beat("TheCall")) + .advance(10) + .read("fired", F("fired").addr, 1) + .shot(shot("01_vegas")); + // to the hotel door: right 4, down 3 -> trigger -> badge scene -> hq lobby + sc.walk("RIGHT", 4).walk("DOWN", 3).beat(beat("HotelDoor")).beat(beat("HqEnter")).advance(10); + sc.read("hqMap", A("CUR_MAP"), 1).read("hqX", A("PLAYER_X"), 2).read("hqY", A("PLAYER_Y"), 2); + // --- Ch.2: Mira, the board, Emmett --------------------------------------- + sc.walk("LEFT", 1).walk("UP", 1).talk("UP").beat(beat("MiraTalk")); // Mira #1 + sc.walk("DOWN", 1).walk("RIGHT", 10).beat(beat("HqEastDoor")).advance(10); // east door -> boardroom + sc.read("brMap", A("CUR_MAP"), 1).shot(shot("02_boardroom")); + sc.walk("UP", 5).walk("RIGHT", 3).talk("RIGHT").beat(beat("AdamTalk")); // Adam (stonewall) + // around the table, along row 4, to Emmett (Ilya blocks row 5) + sc.walk("LEFT", 1).walk("DOWN", 2).walk("RIGHT", 7).walk("DOWN", 1).talk("RIGHT").beat(beat("EmmettTalk")); + sc.walk("DOWN", 2).walk("LEFT", 9).walk("LEFT", 1).beat(beat("BoardroomDoor")).beat(beat("HqEnter")).advance(10); + sc.walk("LEFT", 9).walk("UP", 1).talk("UP").beat(beat("MiraTalk")); // Mira: the Satya call + sc.read("msOpen", F("ms_open").addr, 1); + // --- Ch.3: Microsoft + the letter ----------------------------------------- + sc.walk("DOWN", 1).walk("LEFT", 9).beat(beat("HqWestDoor")).advance(10); // west door + sc.read("msMap", A("CUR_MAP"), 1); + sc.walk("UP", 4).walk("RIGHT", 3).talk("RIGHT").beat(beat("SatyaTalk")).shot(shot("03_satya")); // Satya + // Satya's desk blocks row 4 — go over the top to Greg, exit down the right + sc.walk("UP", 1).walk("RIGHT", 6).walk("DOWN", 2).talk("DOWN").beat(beat("GregTalk")); // Greg + sc.walk("RIGHT", 1).walk("DOWN", 3).walk("LEFT", 10).walk("LEFT", 1).beat(beat("MsDoor")).beat(beat("HqEnter")).advance(10); + sc.walk("UP", 3).talk("RIGHT").beat(beat("Emp1Talk")); // employee signatures + sc.read("sigs1", dbgVarAddr(target, vars.sigs), 2); + sc.walk("DOWN", 2).walk("RIGHT", 8).talk("UP").beat(beat("MiraTalk")); // Mira boost + sc.read("sigs2", dbgVarAddr(target, vars.sigs), 2); + sc.walk("DOWN", 1).walk("RIGHT", 10).beat(beat("HqEastDoor")).advance(10); // boardroom again + sc.walk("UP", 2).walk("RIGHT", 3).talk("RIGHT").beat(beat("IlyaTalk")).shot(shot("04_ilya")); // Ilya flips + sc.read("sigs3", dbgVarAddr(target, vars.sigs), 2).read("full", F("letter_full").addr, 1); + // --- Ch.4: the negotiation ------------------------------------------------ + sc.walk("LEFT", 1).walk("UP", 3).walk("RIGHT", 1).talk("RIGHT").beat(beat("AdamTalk")).shot(shot("05_end")); + sc.advance(20) + .read("won", F("won").addr, 1) + .read("sigsEnd", dbgVarAddr(target, vars.sigs), 2) + .read("endMap", A("CUR_MAP"), 1) + .read("scriptEnd", A("SCRIPT_ACTIVE"), 1) + .read("textEnd", A("TEXT_ACTIVE"), 1); + + const r = await run(target, rom, sc); + + check("boots in the hotel", r.bootMap, maps.hotel); + check("ch1: fired", (r.fired >> F("fired").bit) & 1, 1); + check("badge warp to hq", r.hqMap, maps.hq); + check("hq lobby x", r.hqX, 10); + check("hq lobby y", r.hqY, 6); + check("east door to boardroom", r.brMap, maps.boardroom); + check("mira takes the satya call", (r.msOpen >> F("ms_open").bit) & 1, 1); + check("west door to microsoft", r.msMap, maps.msoffice); + check("letter first batch (505)", (r.sigs1 << 16) >> 16, 505); + check("mira boost (705)", (r.sigs2 << 16) >> 16, 705); + check("ilya flips (743)", (r.sigs3 << 16) >> 16, oracle.sigs); + check("letter full", (r.full >> F("letter_full").bit) & 1, 1); + check("the board folds", (r.won >> F("won").bit) & 1, oracle.won); + check("signatures final", (r.sigsEnd << 16) >> 16, oracle.sigs); + check("ends in the boardroom", r.endMap, maps.boardroom); + check("no script running", r.scriptEnd, 0); + check("no textbox", r.textEnd, 0); +} + +const requested = process.argv.slice(2) as TargetName[]; +const targets: TargetName[] = requested.length ? requested : (["gba", "gb", "nes"] as TargetName[]); +for (const t of targets) await testTarget(t); +console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); +process.exit(failed === 0 ? 0 : 1); diff --git a/static/package.json b/static/package.json new file mode 100644 index 00000000..af7f087e --- /dev/null +++ b/static/package.json @@ -0,0 +1,29 @@ +{ + "name": "@pocketjs/static", + "version": "0.1.0", + "private": true, + "description": "Pocket Static — the static game compiler: TypeScript in, GBA/GB/NES cartridges out.", + "type": "module", + "exports": { + ".": "./rpg/dsl.ts", + "./rpg": "./rpg/dsl.ts", + "./rpg/battle": "./rpg/battle.ts", + "./spec": "./spec/isa.ts", + "./compiler": "./compiler/index.ts" + }, + "bin": { + "pocket-static": "./compiler/cli.ts" + }, + "scripts": { + "gen": "bun spec/gen-c.ts", + "test": "bun test . && bun test/e2e.ts", + "test:vm": "bun test vm", + "test:e2e": "bun test/e2e.ts", + "build:boardroom": "bun games/boardroom/imagegen/build-assets.ts && bun compiler/cli.ts build games/boardroom/game.ts --out dist/boardroom --target gba,gb,nes", + "play": "bun compiler/play.ts" + }, + "devDependencies": { + "jsnes": "^1.2.1", + "typescript": "^5" + } +} diff --git a/static/rpg/battle.ts b/static/rpg/battle.ts new file mode 100644 index 00000000..c5f221b8 --- /dev/null +++ b/static/rpg/battle.ts @@ -0,0 +1,92 @@ +// static/rpg/battle.ts — a whole turn-based battle system with zero runtime +// support: plain generator helpers that the Pocket Static compiler inlines +// and specializes per encounter (macro expansion + static unrolling). Menus +// are CHOICEs, HP are vars, damage is the deterministic story RNG — so the +// same fight plays out identically on GBA, GB, NES and the reference VM. +// +// Battle vars: v.bt_foe / v.bt_me. Result: f[cfg.winFlag]. + +import type { Flags, Ops, Vars } from "./dsl.ts"; + +export interface BattleMove { + /** Choice-list index (list order must match). */ + i: number; + label: string; + /** Base damage + rnd(bonus) extra (bonus 0 = fixed). */ + dmg: number; + bonus: number; + /** Self-heal applied before damage. */ + heal: number; + /** Required flag ("" = always available); missing -> fizzle text. */ + gate: string; + fizzle: string; + /** Line shown when the move lands. */ + quip: string; +} + +export interface BattleCfg { + foe: string; + foeHp: number; + myName: string; + myHp: number; + /** Foe counterattack: dmg + rnd(bonus). */ + foeDmg: number; + foeBonus: number; + foeQuip: string; + winFlag: string; + moves: BattleMove[]; + labels: string[]; +} + +function* applyMove(s: Ops, v: Vars, m: BattleMove) { + if (m.heal > 0) { + v.bt_me += m.heal; + yield* s.sfx("heal"); + } + if (m.bonus > 0) { + v.bt_dmg = m.dmg + (yield* s.rnd(m.bonus)); + } else { + v.bt_dmg = m.dmg; + } + v.bt_foe -= v.bt_dmg; + yield* s.sfx("damage"); + yield* s.say(`${m.quip} (${v.bt_dmg} dmg.)`); +} + +export function* battle(s: Ops, v: Vars, f: Flags, cfg: BattleCfg) { + v.bt_foe = cfg.foeHp; + v.bt_me = cfg.myHp; + yield* s.say(`${cfg.foe} stands firm. RESOLVE ${cfg.foeHp}. Your ${cfg.myName}: ${cfg.myHp}.`); + while (v.bt_foe > 0 && v.bt_me > 0) { + const move = yield* s.choose(cfg.labels); + for (const m of cfg.moves) { + if (move === m.i) { + if (m.gate !== "") { + if (f[m.gate]) { + yield* applyMove(s, v, m); + } else { + yield* s.sfx("deny"); + yield* s.say(m.fizzle); + } + } else { + yield* applyMove(s, v, m); + } + } + } + if (v.bt_foe > 0) { + v.bt_dmg = cfg.foeDmg + (yield* s.rnd(cfg.foeBonus)); + v.bt_me -= v.bt_dmg; + yield* s.sfx("damage"); + yield* s.say(`${cfg.foeQuip} (${v.bt_dmg} dmg.)`); + if (v.bt_me > 0) { + yield* s.say(`${cfg.foe}: ${v.bt_foe}. ${cfg.myName}: ${v.bt_me}.`); + } + } + } + if (v.bt_me > 0) { + f[cfg.winFlag] = true; + yield* s.sfx("fanfare"); + } else { + f[cfg.winFlag] = false; + } +} diff --git a/static/rpg/dsl.ts b/static/rpg/dsl.ts new file mode 100644 index 00000000..7db16b93 --- /dev/null +++ b/static/rpg/dsl.ts @@ -0,0 +1,262 @@ +// static/rpg/dsl.ts — the @pocketjs/static RPG authoring surface. +// +// Two zones (see static/DESIGN.md §1): +// - Declaration zone: defineTileset / defineSprite / defineMap / defineGame +// and the npc/warp/trigger builders. Plain TypeScript, EXECUTED at build +// time on the host; fills the module-level REGISTRY. +// - Residual zone: script(function* (s, v, f) { ... }). NEVER executed — +// the compiler rewrites each script() to script() before +// evaluation and lowers the generator body from its AST +// (static/compiler/script.ts). +// +// The `s` ops below therefore have no host implementation: their types are +// the API; their bodies throw if anything ever actually calls them. + +// --------------------------------------------------------------------------- +// Residual script surface (types only) +// --------------------------------------------------------------------------- +export type Vars = Record; +export type Flags = Record; + +/** Engine ops available inside scripts. All are used with `yield*`. */ +export interface Ops { + /** Show textbox pages (auto-wrapped per console). `${}` of runtime values allowed. */ + say(text: string): Generator; + /** Menu; resolves to the picked index. Options are compile-time strings. */ + choose(options: readonly string[]): Generator; + /** Uniform 0..n-1 from the deterministic story RNG. */ + rnd(n: number): Generator; + /** Suspend for n frames. */ + wait(frames: number): Generator; + /** Freeze / unfreeze player movement. */ + lock(): Generator; + release(): Generator; + /** Actor faces the player. No argument = the actor that started this script. */ + face(actorId?: string): Generator; + /** Show/hide an actor on the current map (persist via flags + onEnter). */ + show(actorId: string): Generator; + hide(actorId: string): Generator; + /** Move the player to "map:entrance". */ + warp(dest: string): Generator; + /** Square-wave blip: confirm/deny/damage/heal/fanfare. */ + sfx(name: "confirm" | "deny" | "damage" | "heal" | "fanfare"): Generator; + /** Run another top-level script as a subroutine. */ + call(target: ScriptRef): Generator; +} + +export type ScriptBody = (s: Ops, v: Vars, f: Flags) => Generator; + +export interface ScriptRef { + readonly __script: number; +} + +const residual = (name: string) => { + throw new Error(`${name}() is residual-only: script bodies never execute (the compiler lowers them)`); +}; + +// --------------------------------------------------------------------------- +// Declarations +// --------------------------------------------------------------------------- +export type Rgb = readonly [number, number, number]; +export type DirName = "down" | "up" | "left" | "right"; +export type MoveName = "static" | "wander"; + +export interface TileDecl { + /** 8 rows of 8 hex nibbles (palette indices, 0 = backdrop). */ + px: readonly string[]; + solid?: boolean; +} + +export interface TilesetDecl = Record> { + name: string; + /** Up to 16 colors; index 0 = backdrop. */ + palette: readonly Rgb[]; + tiles: Tiles; +} + +export interface SpriteDecl { + name: string; + /** Up to 16 colors; index 0 = transparent. */ + palette: readonly Rgb[]; + /** + * 16x16 frames as 16 rows of 16 hex nibbles. Facings down/up/right with + * 1..2 frames each (left renders as mirrored right). Frame 0 stands, + * frame 1 (optional) is the walk alternate. + */ + facings: { + down: readonly (readonly string[])[]; + up: readonly (readonly string[])[]; + right: readonly (readonly string[])[]; + }; +} + +export interface ActorDecl { + id: string; + sprite: SpriteDecl; + at: readonly [number, number]; + facing?: DirName; + move?: MoveName; + /** Solid defaults to true (actors block the player). */ + solid?: boolean; + talk?: ScriptRef; + /** Start hidden (reveal with s.show). */ + hidden?: boolean; +} + +export interface WarpDecl { + at: readonly [number, number]; + to: string; // "map:entrance" +} + +export interface TriggerDecl { + at: readonly [number, number]; + run: ScriptRef; + /** Run once: the runtime arms an auto-allocated flag afterwards. */ + once?: boolean; +} + +export interface EntranceDecl { + at: readonly [number, number]; + dir?: DirName; +} + +export interface MapDecl { + name: string; + tileset: TilesetDecl; + /** Rows of legend characters; uniform width; blank lines ignored. */ + layout: string; + /** char -> tile name in the tileset. " " is always the blank tile. */ + legend: Record; + entrances?: Record; + actors?: ActorDecl[]; + warps?: WarpDecl[]; + triggers?: TriggerDecl[]; + onEnter?: ScriptRef; +} + +export interface GameDecl { + /** Cartridge title (ASCII, <= 16 chars). */ + title: string; + /** "map:entrance" the player spawns at. */ + start: string; + player: SpriteDecl; + maps: MapDecl[]; +} + +// --------------------------------------------------------------------------- +// Registry — module-level, shared between the executed game module and the +// compiler (both import this exact module instance). +// --------------------------------------------------------------------------- +export interface Registry { + tilesets: TilesetDecl[]; + sprites: SpriteDecl[]; + scriptCount: number; + game: GameDecl | null; +} + +export const REGISTRY: Registry = { tilesets: [], sprites: [], scriptCount: 0, game: null }; + +/** + * Reset per-compile state. Tilesets/sprites deliberately SURVIVE: asset + * modules are cached by the JS runtime and only execute once, while the + * (rewritten) game module re-executes per compile — so declarations register + * idempotently by name and the game/script state resets every time. + */ +export function resetRegistry(): void { + REGISTRY.scriptCount = 0; + REGISTRY.game = null; +} + +function upsert(list: T[], item: T): void { + const at = list.findIndex((x) => x.name === item.name); + if (at >= 0) list[at] = item; + else list.push(item); +} + +// --------------------------------------------------------------------------- +// Builders +// --------------------------------------------------------------------------- +export function defineTileset>( + name: string, + decl: { palette: readonly Rgb[]; tiles: Tiles }, +): TilesetDecl { + if (decl.palette.length > 16) throw new Error(`tileset ${name}: palette > 16 colors`); + for (const [tn, t] of Object.entries(decl.tiles)) { + if (t.px.length !== 8 || t.px.some((r) => r.length !== 8)) { + throw new Error(`tileset ${name}: tile "${tn}" must be 8 rows x 8 hex nibbles`); + } + } + const ts: TilesetDecl = { name, ...decl }; + upsert(REGISTRY.tilesets, ts); + return ts; +} + +export function defineSprite(name: string, decl: Omit): SpriteDecl { + const sp: SpriteDecl = { name, ...decl }; + for (const key of ["down", "up", "right"] as const) { + const frames = sp.facings[key]; + if (!frames || frames.length < 1 || frames.length > 2) { + throw new Error(`sprite ${name}: facing "${key}" needs 1..2 frames`); + } + for (const f of frames) { + if (f.length !== 16 || f.some((r) => r.length !== 16)) { + throw new Error(`sprite ${name}: frames are 16 rows x 16 hex nibbles`); + } + } + } + if (sp.palette.length > 16) throw new Error(`sprite ${name}: palette > 16 colors`); + upsert(REGISTRY.sprites, sp); + return sp; +} + +export function defineMap(name: string, decl: Omit): MapDecl { + return { name, ...decl }; +} + +export function defineGame(decl: GameDecl): GameDecl { + if (REGISTRY.game) throw new Error("defineGame called twice"); + if (!/^[\x20-\x7e]{1,16}$/.test(decl.title)) { + throw new Error(`game title must be 1..16 ASCII chars (got ${JSON.stringify(decl.title)})`); + } + REGISTRY.game = decl; + return decl; +} + +export const npc = (id: string, decl: Omit): ActorDecl => ({ id, ...decl }); +export const warp = (decl: WarpDecl): WarpDecl => decl; +export const trigger = (decl: TriggerDecl): TriggerDecl => decl; + +/** + * Residual script. At compile time the argument is a generator function; the + * compiler rewrites the call to `script()` before the module executes, + * so at build-run time we only see numbers and hand out stable refs. + */ +export function script(body: ScriptBody | number): ScriptRef { + if (typeof body !== "number") { + throw new Error( + "script(fn) reached the host un-rewritten — build games with the Pocket Static compiler (bun static/compiler/cli.ts)", + ); + } + if (body !== REGISTRY.scriptCount) { + throw new Error(`script id ${body} registered out of order (expected ${REGISTRY.scriptCount})`); + } + REGISTRY.scriptCount++; + return { __script: body }; +} + +// Residual op namespace values (never called; here so `s` has a runtime +// identity if anyone pokes it). +export const __residualOps: Ops = { + say: () => residual("s.say"), + choose: () => residual("s.choose"), + rnd: () => residual("s.rnd"), + wait: () => residual("s.wait"), + lock: () => residual("s.lock"), + release: () => residual("s.release"), + face: () => residual("s.face"), + show: () => residual("s.show"), + hide: () => residual("s.hide"), + warp: () => residual("s.warp"), + sfx: () => residual("s.sfx"), + call: () => residual("s.call"), +} as unknown as Ops; diff --git a/static/runtime/core/hal.h b/static/runtime/core/hal.h new file mode 100644 index 00000000..63ba038e --- /dev/null +++ b/static/runtime/core/hal.h @@ -0,0 +1,72 @@ +/* static/runtime/core/hal.h — the platform seam of Pocket Static. + * + * Everything above this line of the runtime (vm.c, rpg.c) is PORTABLE C — + * compiled unchanged by arm-none-eabi-gcc (GBA), sdcc (GB) and cc65 (NES). + * Everything below it is one small HAL per console. Gameplay decisions all + * happen in portable code, which is what makes the logical state identical + * across targets by construction. + * + * Portable code rules (the intersection of three compilers): + * - C89 declarations (locals at block top), no VLAs, no floats. + * - Explicit-width typedefs below; int is 16-bit on two targets. + * - No pointer caching across HAL calls that may re-latch ROM banks: + * re-fetch hal_blob() after any call that could touch another blob. + * - No standard library. + */ +#ifndef PS_HAL_H +#define PS_HAL_H + +#include "spec_gen.h" + +typedef unsigned char u8; +typedef signed char s8; +typedef unsigned short u16; +typedef signed short s16; +typedef unsigned long u32; + +/* ---- ROM data ------------------------------------------------------------- + * Blob index space is the compiler's (link.ts): scripts, texts, maps in + * order, then target-appended art. hal_blob() returns a readable pointer, + * latching the bank on banked targets — the pointer is valid until the next + * hal_blob() call. + */ +const u8 *hal_blob(u8 blob); + +/* Fixed-region tables (generated as native arrays by the target packagers): */ +extern const u8 ps_game_header[]; /* GAME_HEADER_SIZE bytes */ +extern const u16 ps_script_table[]; /* per script: offset in SCRIPTS blob */ +extern const u8 ps_text_table[]; /* per text: u8 blob, u16 offset */ +extern const u8 ps_map_blob[]; /* per map: blob index */ +extern const u8 ps_sprite_table[]; /* per sprite: SPRITE_ENTRY_SIZE */ + +/* ---- frame / input --------------------------------------------------------*/ +void hal_init(void); +/* Wait for vblank, commit queued video work + OAM, then return. */ +void hal_frame(void); +/* Held keys as the normalized PS_KEY_* mask. */ +u8 hal_keys(void); + +/* ---- video ---------------------------------------------------------------*/ +/* Redraw the whole BG from the current map blob (rendering may be blanked). */ +void hal_map_draw(u8 mapBlob, u8 w, u8 h); +/* Camera top-left in pixels (ignored on non-scrolling targets). */ +void hal_scroll(u16 px, u16 py); +/* Position one 16x16 actor object. slot 0 = player. hidden -> off-screen. */ +void hal_obj(u8 slot, s16 px, s16 py, u8 spriteId, u8 dir, u8 frame, u8 hidden); + +/* Textbox (fixed PS_TEXT_COLS-wide region, `rows` text lines tall). + * open MUST present a clean box (all cells cleared to the box fill); + * close restores the map view. Both may be gradual on queued targets. */ +void hal_text_open(u8 rows); +void hal_text_close(void); +/* Write one glyph cell (glyph 0..FONT_GLYPHS-1) at textbox col/row. */ +void hal_text_glyph(u8 col, u8 row, u8 glyph); + +/* ---- audio ----------------------------------------------------------------*/ +void hal_sfx(u8 id); + +/* ---- engine entry points (portable side, called by each main.c) ----------*/ +void rpg_boot(void); +void rpg_tick(void); + +#endif /* PS_HAL_H */ diff --git a/static/runtime/core/rpg.c b/static/runtime/core/rpg.c new file mode 100644 index 00000000..69570745 --- /dev/null +++ b/static/runtime/core/rpg.c @@ -0,0 +1,604 @@ +/* static/runtime/core/rpg.c — the portable RPG engine. + * + * Owns every gameplay decision: grid movement + collision, actors, talk/ + * trigger/warp dispatch, the textbox + choice state machines (typewriter, + * FMT decimal slots), the RPG syscalls, and the debug block. The HAL only + * moves pixels and beeps. + * + * Determinism: all state transitions happen here in fixed order, driven by + * hal_keys() once per tick — the cross-target E2E suite holds every console + * to the same trace. + */ +#include "vm.h" + +#define DBGB ((volatile u8 *)PS_DEBUG_ADDR) +#define DBG8(off) (*(volatile u8 *)(PS_DEBUG_ADDR + (off))) +#define DBG16(off) (*(volatile u16 *)(PS_DEBUG_ADDR + (off))) +#define DBG32(off) (*(volatile u32 *)(PS_DEBUG_ADDR + (off))) + +#define MAX_ACTORS 16 + +typedef struct { + u8 x, y; /* tile (committed at step end) */ + u16 px, py; /* pixels */ + u8 spriteId; + u8 dir; + u8 move; + u8 flags; /* ACTOR_F_* */ + u16 talk; + u8 stepLeft; /* pixels remaining in the current step */ + u8 anim; + u8 phase; /* wander cadence offset (slot * WANDER_PHASE, precomputed) */ + u16 lcg; +} ActorRt; +/* NOTE: 8-bit-safe arithmetic only in this file — sdcc 4.6 (SM83) miscompiles + * some u8*u8 multiply frames (__muluchar), so anything that would lower to it + * is written as accumulation or shifts instead. */ + +static ActorRt actors[MAX_ACTORS]; +static u8 actor_count; + +static u8 cur_map; +static u8 map_w, map_h; +static u8 mblob; /* blob index of the current map */ +static u16 tiles_off, coll_off, actors_off, warps_off, trig_off; +static u8 warp_count, trig_count; +static u16 pending_enter; /* onEnter deferred while a script is running */ + +/* player */ +static u8 ptx, pty, pdir; +static u16 ppx, ppy; +static u8 pstep, panim, plock; + +/* input */ +static u8 keys_now, keys_prev; +#define PRESSED(k) ((keys_now & (k)) && !(keys_prev & (k))) + +/* textbox */ +#define TB_NONE 0 +#define TB_SAY 1 +#define TB_CHOICE 2 +static u8 tb_mode; +static u16 tb_text; +static u8 tb_blob; +static u16 tb_off; +static u8 tb_col, tb_row; +static u8 tb_done; +static u8 ch_n, ch_cursor, ch_drawn; +static u16 ch_texts[PS_MAX_CHOICES]; +static u8 interact_slot; /* actor that started the current script, 0xFF none */ + +static u32 frame_no; + +static const s8 DIRDX[4] = { 0, 0, -1, 1 }; +static const s8 DIRDY[4] = { 1, -1, 0, 0 }; + +/* ---- data helpers ---------------------------------------------------------*/ +static u16 rd16(const u8 *p) { return (u16)(p[0] | ((u16)p[1] << 8)); } + +static void map_view_load(void) { + const u8 *m; + mblob = ps_map_blob[cur_map]; + m = hal_blob(mblob); + map_w = m[0]; + map_h = m[1]; + actor_count = m[2]; + warp_count = m[3]; + trig_count = m[4]; + tiles_off = rd16(m + 8); + coll_off = rd16(m + 10); + actors_off = rd16(m + 12); + warps_off = rd16(m + 14); + trig_off = rd16(m + 16); +} + +static u8 tile_solid(u8 x, u8 y) { + const u8 *m; + u16 i; + if (x >= map_w || y >= map_h) return 1; + i = (u16)y * map_w + x; + m = hal_blob(mblob); + return (m[coll_off + (i >> 3)] >> (i & 7)) & 1; +} + +static u8 actor_at(u8 x, u8 y) { + u8 i; + for (i = 0; i < actor_count; i++) { + if (actors[i].flags & ACTOR_F_HIDDEN) continue; + if (actors[i].x == x && actors[i].y == y) return i; + } + return 0xff; +} + +static u8 blocked(u8 x, u8 y) { + u8 a; + if (tile_solid(x, y)) return 1; + a = actor_at(x, y); + if (a != 0xff && (actors[a].flags & ACTOR_F_SOLID)) return 1; + return 0; +} + +/* ---- map load --------------------------------------------------------------*/ +static void map_load(u8 map, u8 x, u8 y, u8 dir) { + const u8 *m; + const u8 *a; + u8 i, phase; + u16 lcg, enter; + + cur_map = map; + map_view_load(); + m = hal_blob(mblob); + enter = rd16(m + 6); + + a = m + actors_off; + phase = 0; + lcg = 0x1234; + for (i = 0; i < actor_count; i++) { + actors[i].x = a[0]; + actors[i].y = a[1]; + actors[i].px = (u16)a[0] << 3; + actors[i].py = (u16)a[1] << 3; + actors[i].spriteId = a[2]; + actors[i].dir = a[3]; + actors[i].move = a[4]; + actors[i].flags = a[5]; + actors[i].talk = rd16(a + 6); + actors[i].stepLeft = 0; + actors[i].anim = 0; + actors[i].phase = phase; + actors[i].lcg = lcg; + a += ACTOR_SIZE; + phase = (u8)(phase + WANDER_PHASE); + lcg = (u16)(lcg + 977); + } + + ptx = x; + pty = y; + pdir = dir; + ppx = (u16)x << 3; + ppy = (u16)y << 3; + pstep = 0; + interact_slot = 0xff; + + hal_map_draw(mblob, map_w, map_h); + + if (enter != SCRIPT_NONE) { + if (vm.active) pending_enter = enter; + else vm_start(enter); + } +} + +/* ---- textbox ---------------------------------------------------------------*/ +static void text_locate(u16 id) { + const u8 *e = ps_text_table + (u16)id * TEXT_ENTRY_SIZE; + tb_blob = e[0]; + tb_off = rd16(e + 1); +} + +static void say_open(u16 id) { + tb_mode = TB_SAY; + tb_text = id; + text_locate(id); + tb_col = 0; + tb_row = 0; + tb_done = 0; + hal_text_open(PS_TEXT_LINES); +} + +static void choice_open(void) { + tb_mode = TB_CHOICE; + tb_text = ch_texts[0]; + ch_cursor = 0; + ch_drawn = 0; + tb_done = 0; + hal_text_open(ch_n); +} + +static void tb_close(void) { + tb_mode = TB_NONE; + tb_text = TEXT_NONE; + hal_text_close(); +} + +/* Render a signed decimal at the current cell; returns cells written. */ +static void tb_put(u8 glyph) { + if (tb_col < PS_TEXT_COLS) { + hal_text_glyph(tb_col, tb_row, glyph); + tb_col++; + } +} + +static void tb_number(s16 v) { + u8 buf[6]; + u8 n = 0; + u16 mag; + if (v < 0) { + tb_put((u8)('-' - 0x20)); + mag = (u16)(-v); + } else { + mag = (u16)v; + } + do { + buf[n++] = (u8)(mag % 10); + mag /= 10; + } while (mag && n < 6); + while (n) { + n--; + tb_put((u8)('0' - 0x20 + buf[n])); + } +} + +/* Advance the typewriter by one token; returns 0 when the page is done. */ +static u8 tb_step(void) { + const u8 *t = hal_blob(tb_blob); + u8 tok = t[tb_off]; + if (tok == TOK_END) { + tb_done = 1; + return 0; + } + tb_off++; + if (tok == TOK_NEWLINE) { + tb_row++; + tb_col = 0; + return 1; + } + if (tok == TOK_FMT) { + u8 var = t[tb_off]; + tb_off++; + tb_number(vm_get_var(var)); + return 1; + } + tb_put((u8)(tok - TOK_ASCII_MIN)); + return 1; +} + +static void say_tick(void) { + u8 i; + if (!tb_done) { + for (i = 0; i < 2; i++) { + if (!tb_step()) break; + } + if (PRESSED(PS_KEY_A)) { + while (tb_step()) {} + } + return; + } + if (PRESSED(PS_KEY_A)) { + tb_close(); + vm_resume(); + /* eat the edge: the dismissing press must not double as an interact */ + keys_prev |= PS_KEY_A; + } +} + +static void choice_row(u8 row, u16 textId, u8 selected) { + u16 save_off; + u8 save_blob, save_col, save_row; + tb_col = 0; + tb_row = row; + tb_put(selected ? (u8)('>' - 0x20) : 0); + tb_put(0); + save_blob = tb_blob; + save_off = tb_off; + save_col = tb_col; + save_row = tb_row; + text_locate(textId); + { + const u8 *t; + u8 tok; + for (;;) { + t = hal_blob(tb_blob); + tok = t[tb_off]; + if (tok == TOK_END) break; + tb_off++; + if (tok == TOK_FMT) { + u8 var = t[tb_off]; + tb_off++; + tb_number(vm_get_var(var)); + continue; + } + tb_put((u8)(tok - TOK_ASCII_MIN)); + } + } + tb_blob = save_blob; + tb_off = save_off; + tb_col = save_col; + tb_row = save_row; +} + +static void choice_tick(void) { + u8 i; + if (!ch_drawn) { + for (i = 0; i < ch_n; i++) choice_row(i, ch_texts[i], i == ch_cursor); + ch_drawn = 1; + return; + } + if (PRESSED(PS_KEY_UP) && ch_cursor > 0) { + tb_col = 0; + tb_row = ch_cursor; + tb_put(0); + ch_cursor--; + tb_col = 0; + tb_row = ch_cursor; + tb_put((u8)('>' - 0x20)); + } + if (PRESSED(PS_KEY_DOWN) && ch_cursor < ch_n - 1) { + tb_col = 0; + tb_row = ch_cursor; + tb_put(0); + ch_cursor++; + tb_col = 0; + tb_row = ch_cursor; + tb_put((u8)('>' - 0x20)); + } + if (PRESSED(PS_KEY_A)) { + hal_sfx(SFX_CONFIRM); + tb_close(); + vm_resume_value(ch_cursor); + keys_prev |= PS_KEY_A; + } +} + +/* ---- warps / triggers -------------------------------------------------------*/ +static void check_warp_trigger(void) { + const u8 *m = hal_blob(mblob); + const u8 *w; + const u8 *t; + u8 i; + w = m + warps_off; + for (i = 0; i < warp_count; i++, w += WARP_SIZE) { + if (w[0] == ptx && w[1] == pty) { + map_load(w[2], w[3], w[4], w[5]); + return; + } + } + t = m + trig_off; + for (i = 0; i < trig_count; i++, t += TRIGGER_SIZE) { + if (t[0] == ptx && t[1] == pty) { + u16 script = rd16(t + 2); + if ((t[4] & TRIGGER_F_ONCE) && vm_get_flag(t[5])) continue; + if (t[4] & TRIGGER_F_ONCE) vm_set_flag(t[5], 1); + if (script != SCRIPT_NONE && !vm.active) { + interact_slot = 0xff; + vm_start(script); + } + return; + } + } +} + +/* ---- player ------------------------------------------------------------------*/ +static u8 face_from_keys(void) { + if (keys_now & PS_KEY_DOWN) return DIR_DOWN; + if (keys_now & PS_KEY_UP) return DIR_UP; + if (keys_now & PS_KEY_LEFT) return DIR_LEFT; + if (keys_now & PS_KEY_RIGHT) return DIR_RIGHT; + return 0xff; +} + +static void player_tick(void) { + u8 want; + if (pstep) { + ppx = (u16)(ppx + STEP_PX * DIRDX[pdir]); + ppy = (u16)(ppy + STEP_PX * DIRDY[pdir]); + pstep -= STEP_PX; + if ((pstep & 3) == 0) panim ^= 1; + if (pstep == 0) { + ptx = (u8)(ppx >> 3); + pty = (u8)(ppy >> 3); + check_warp_trigger(); + } + return; + } + if (plock || vm.active || tb_mode != TB_NONE) return; + + want = face_from_keys(); + if (want != 0xff) { + u8 nx, ny; + pdir = want; + nx = (u8)(ptx + DIRDX[pdir]); + ny = (u8)(pty + DIRDY[pdir]); + if (!blocked(nx, ny)) pstep = 8; + } + + if (PRESSED(PS_KEY_A)) { + u8 fx = (u8)(ptx + DIRDX[pdir]); + u8 fy = (u8)(pty + DIRDY[pdir]); + u8 a = actor_at(fx, fy); + if (a != 0xff && actors[a].talk != SCRIPT_NONE) { + /* the classic: the NPC turns to face you */ + actors[a].dir = (u8)(pdir ^ 1); /* down<->up, left<->right */ + interact_slot = a; + vm_start(actors[a].talk); + } + } +} + +/* ---- actors --------------------------------------------------------------------*/ +static void actors_tick(void) { + u8 i; + for (i = 0; i < actor_count; i++) { + ActorRt *a = &actors[i]; + if (a->flags & ACTOR_F_HIDDEN) continue; + if (a->stepLeft) { + a->px = (u16)(a->px + STEP_PX * DIRDX[a->dir]); + a->py = (u16)(a->py + STEP_PX * DIRDY[a->dir]); + a->stepLeft -= STEP_PX; + if ((a->stepLeft & 3) == 0) a->anim ^= 1; + continue; + } + if (a->move != MOVE_WANDER) continue; + if (vm.active || tb_mode != TB_NONE) continue; + if (((u16)((u16)frame_no + a->phase) % WANDER_PERIOD) != 0) continue; + a->lcg = (u16)(a->lcg * 25173u + 13849u); + { + u8 d = (u8)((a->lcg >> 8) & 3); + u8 nx = (u8)(a->x + DIRDX[d]); + u8 ny = (u8)(a->y + DIRDY[d]); + a->dir = d; + if (!blocked(nx, ny) && !(nx == ptx && ny == pty)) { + a->x = nx; + a->y = ny; + a->stepLeft = 8; + } + } + } +} + +/* ---- camera / objects ------------------------------------------------------------*/ +static u16 cam_x, cam_y; + +static void camera_tick(void) { + u16 vieww = (u16)PS_SCREEN_TILES_W << 3; + u16 viewh = (u16)PS_SCREEN_TILES_H << 3; + u16 mapw = (u16)map_w << 3; + u16 maph = (u16)map_h << 3; + s16 cx = (s16)(ppx + 4 - (s16)(vieww >> 1)); + s16 cy = (s16)(ppy + 4 - (s16)(viewh >> 1)); + if (cx < 0) cx = 0; + if (cy < 0) cy = 0; + if (mapw > vieww && cx > (s16)(mapw - vieww)) cx = (s16)(mapw - vieww); + if (mapw <= vieww) cx = 0; + if (maph > viewh && cy > (s16)(maph - viewh)) cy = (s16)(maph - viewh); + if (maph <= viewh) cy = 0; + cam_x = (u16)cx; + cam_y = (u16)cy; + hal_scroll(cam_x, cam_y); +} + +static void objects_tick(void) { + u8 i; + hal_obj(0, (s16)(ppx - cam_x - 4), (s16)(ppy - cam_y - 8), ps_game_header[26], pdir, + (u8)(pstep ? panim : 0), 0); + for (i = 0; i < actor_count; i++) { + ActorRt *a = &actors[i]; + hal_obj((u8)(i + 1), (s16)(a->px - cam_x - 4), (s16)(a->py - cam_y - 8), a->spriteId, a->dir, + (u8)(a->stepLeft ? a->anim : 0), (u8)(a->flags & ACTOR_F_HIDDEN ? 1 : 0)); + } +} + +/* ---- syscalls -----------------------------------------------------------------*/ +void rpg_syscall(u8 op) { + switch (op) { + case OP_SAY: { + u16 id = vm_fetch16(); + say_open(id); + vm.waiting = WAITING_TEXT; + break; + } + case OP_CHOICE: { + u8 i; + ch_n = vm_fetch8(); + for (i = 0; i < ch_n && i < PS_MAX_CHOICES; i++) ch_texts[i] = vm_fetch16(); + choice_open(); + vm.waiting = WAITING_CHOICE; + break; + } + case OP_LOCK: + plock = 1; + break; + case OP_RELEASE: + plock = 0; + break; + case OP_FACE: { + u8 slot = vm_fetch8(); + if (slot == FACE_SELF) slot = interact_slot; + if (slot < actor_count) actors[slot].dir = (u8)(pdir ^ 1); + break; + } + case OP_AVIS: { + u8 slot = vm_fetch8(); + u8 on = vm_fetch8(); + if (slot < actor_count) { + if (on) actors[slot].flags &= (u8)~ACTOR_F_HIDDEN; + else actors[slot].flags |= ACTOR_F_HIDDEN; + } + break; + } + case OP_WARP: { + u8 map = vm_fetch8(); + u8 x = vm_fetch8(); + u8 y = vm_fetch8(); + u8 dir = vm_fetch8(); + map_load(map, x, y, dir); + break; + } + case OP_SFX: + hal_sfx(vm_fetch8()); + break; + default: + /* unknown syscall: stop the script, keep the game alive */ + vm.active = 0; + break; + } +} + +/* ---- debug block -----------------------------------------------------------------*/ +static void debug_tick(void) { + DBG8(DBGO_PLAYER_DIR) = pdir; + DBG8(DBGO_CUR_MAP) = cur_map; + DBG8(DBGO_TEXT_ACTIVE) = tb_mode != TB_NONE; + DBG8(DBGO_SCRIPT_ACTIVE) = vm.active; + DBG8(DBGO_CHOICE_CURSOR) = ch_cursor; + DBG8(DBGO_WAITING) = vm.waiting; + DBG16(DBGO_PLAYER_X) = ptx; + DBG16(DBGO_PLAYER_Y) = pty; + DBG16(DBGO_CUR_TEXT) = tb_mode != TB_NONE ? tb_text : TEXT_NONE; + DBG16(DBGO_CUR_SCRIPT) = vm.active ? vm.script : SCRIPT_NONE; + DBG32(DBGO_FRAME) = frame_no; + DBG16(DBGO_RNG) = vm.rng; +} + +/* ---- entry points -------------------------------------------------------------------*/ +void rpg_boot(void) { + u8 i; + /* flags/vars storage is the debug block: zero it before use */ + for (i = 0; i < DEBUG_BLOCK_SIZE; i++) DBGB[i] = 0; + DBGB[0] = DEBUG_MAGIC_0; + DBGB[1] = DEBUG_MAGIC_1; + DBGB[2] = DEBUG_MAGIC_2; + DBGB[3] = DEBUG_MAGIC_3; + + vm.rng = PS_RNG_SEED; + vm.active = 0; + vm.waiting = WAITING_NONE; + pending_enter = SCRIPT_NONE; + tb_mode = TB_NONE; + tb_text = TEXT_NONE; + interact_slot = 0xff; + frame_no = 0; + plock = 0; + + map_load(ps_game_header[16], ps_game_header[17], ps_game_header[18], ps_game_header[19]); + DBG8(DBGO_BOOTED) = 1; +} + +void rpg_tick(void) { + keys_prev = keys_now; + keys_now = hal_keys(); + + if (tb_mode == TB_SAY) say_tick(); + else if (tb_mode == TB_CHOICE) choice_tick(); + + if (vm.active && vm.waiting == WAITING_FRAMES) { + if (vm.wait_frames) vm.wait_frames--; + if (vm.wait_frames == 0) vm_resume(); + } + + if (vm.active && vm.waiting == WAITING_NONE && tb_mode == TB_NONE) vm_run(); + + if (!vm.active && pending_enter != SCRIPT_NONE) { + u16 e = pending_enter; + pending_enter = SCRIPT_NONE; + vm_start(e); + vm_run(); + } + + player_tick(); + actors_tick(); + camera_tick(); + objects_tick(); + + frame_no++; + debug_tick(); +} diff --git a/static/runtime/core/vm.c b/static/runtime/core/vm.c new file mode 100644 index 00000000..58c7826f --- /dev/null +++ b/static/runtime/core/vm.c @@ -0,0 +1,253 @@ +/* static/runtime/core/vm.c — the Pocket Static stack VM, portable C. + * Semantics are pinned by static/vm/ref.ts + static/test/vm.test.ts; if this + * file and ref.ts disagree, this file is wrong. + * + * The cartridge build is forgiving where the host reference is strict: + * out-of-range access clamps/no-ops instead of trapping (there is no one to + * read a panic on a handheld). The compiler guarantees well-formed bytecode; + * clamping is belt-and-braces. + */ +#include "vm.h" + +Vm vm; + +/* vars/flags live INSIDE the debug block: the mirror is the storage. */ +#define DBGB ((volatile u8 *)PS_DEBUG_ADDR) +#define VARS ((volatile s16 *)(PS_DEBUG_ADDR + DBGO_VARS)) +#define FLAGS (DBGB + DBGO_FLAGS) + +static const u8 *code; + +s16 vm_get_var(u8 id) { return VARS[id & (VM_VARS - 1)]; } +void vm_set_var(u8 id, s16 v) { VARS[id & (VM_VARS - 1)] = v; } +u8 vm_get_flag(u8 id) { return (FLAGS[id >> 3] >> (id & 7)) & 1; } +void vm_set_flag(u8 id, u8 on) { + u8 m = (u8)(1 << (id & 7)); + if (on) FLAGS[id >> 3] |= m; + else FLAGS[id >> 3] &= (u8)~m; +} + +static void push(s16 v) { + if (vm.sp < VM_STACK) vm.stack[vm.sp++] = v; +} +static s16 pop(void) { + if (vm.sp == 0) return 0; + return vm.stack[--vm.sp]; +} + +u8 vm_fetch8(void) { return code[vm.ip++]; } +u16 vm_fetch16(void) { + u16 v = code[vm.ip]; + v |= ((u16)code[vm.ip + 1]) << 8; + vm.ip += 2; + return v; +} + +void vm_push(s16 v) { push(v); } +s16 vm_pop(void) { return pop(); } + +void vm_start(u16 scriptId) { + vm.script = scriptId; + vm.ip = ps_script_table[scriptId]; + vm.sp = 0; + vm.frame = 0; + vm.waiting = WAITING_NONE; + vm.active = 1; +} + +void vm_resume(void) { vm.waiting = WAITING_NONE; } + +void vm_resume_value(s16 v) { + push(v); + vm.waiting = WAITING_NONE; +} + +static void vm_end(void) { + vm.active = 0; + vm.waiting = WAITING_NONE; +} + +u16 vm_rng_next(void) { + u16 x = vm.rng; + x ^= (u16)(x << 7); + x ^= (u16)(x >> 9); + x ^= (u16)(x << 8); + vm.rng = x; + return x; +} + +void vm_run(void) { + u8 budget = VM_BURST; + s16 a, b; + u8 op, o8; + u16 o16; + + if (!vm.active || vm.waiting != WAITING_NONE) return; + code = hal_blob(0); /* blob 0 is always SCRIPTS (link.ts) */ + + while (budget--) { + op = code[vm.ip++]; + if (op >= PS_SYSCALL_BASE) { + rpg_syscall(op); + if (!vm.active || vm.waiting != WAITING_NONE) return; + code = hal_blob(0); /* a syscall may have latched another blob */ + continue; + } + switch (op) { + case OP_END: + vm_end(); + return; + case OP_NOP: + break; + case OP_PUSH8: + push((s8)vm_fetch8()); + break; + case OP_PUSH16: + push((s16)vm_fetch16()); + break; + case OP_POP: + pop(); + break; + case OP_DUP: + a = pop(); + push(a); + push(a); + break; + case OP_JMP: + o16 = vm_fetch16(); + vm.ip = (u16)(vm.ip + (s16)o16); + break; + case OP_JZ: + o16 = vm_fetch16(); + if (pop() == 0) vm.ip = (u16)(vm.ip + (s16)o16); + break; + case OP_JNZ: + o16 = vm_fetch16(); + if (pop() != 0) vm.ip = (u16)(vm.ip + (s16)o16); + break; + case OP_CALL: + o16 = vm_fetch16(); + if (vm.frame < VM_FRAMES - 1) { + u8 i; + s16 *fl; + vm.ret[vm.frame++] = vm.ip; + fl = &vm.locals[(u16)vm.frame * VM_LOCALS]; + for (i = 0; i < VM_LOCALS; i++) fl[i] = 0; + vm.ip = ps_script_table[o16]; + } + break; + case OP_RET: + if (vm.frame == 0) { + vm_end(); + return; + } + vm.ip = vm.ret[--vm.frame]; + break; + case OP_LDV: + push(vm_get_var(vm_fetch8())); + break; + case OP_STV: + o8 = vm_fetch8(); + vm_set_var(o8, pop()); + break; + case OP_LDL: + o8 = vm_fetch8(); + push(vm.locals[(u16)vm.frame * VM_LOCALS + (o8 & (VM_LOCALS - 1))]); + break; + case OP_STL: + o8 = vm_fetch8(); + vm.locals[(u16)vm.frame * VM_LOCALS + (o8 & (VM_LOCALS - 1))] = pop(); + break; + case OP_FLAG: + push(vm_get_flag(vm_fetch8())); + break; + case OP_SETF: + vm_set_flag(vm_fetch8(), 1); + break; + case OP_CLRF: + vm_set_flag(vm_fetch8(), 0); + break; + case OP_STF: + o8 = vm_fetch8(); + vm_set_flag(o8, pop() != 0); + break; + case OP_ADD: + b = pop(); + a = pop(); + push((s16)(a + b)); + break; + case OP_SUB: + b = pop(); + a = pop(); + push((s16)(a - b)); + break; + case OP_MUL: + b = pop(); + a = pop(); + push((s16)(a * b)); + break; + case OP_DIV: + b = pop(); + a = pop(); + push(b == 0 ? 0 : (s16)(a / b)); + break; + case OP_MOD: + b = pop(); + a = pop(); + push(b == 0 ? 0 : (s16)(a % b)); + break; + case OP_NEG: + push((s16)-pop()); + break; + case OP_EQ: + b = pop(); + a = pop(); + push(a == b); + break; + case OP_NE: + b = pop(); + a = pop(); + push(a != b); + break; + case OP_LT: + b = pop(); + a = pop(); + push(a < b); + break; + case OP_GT: + b = pop(); + a = pop(); + push(a > b); + break; + case OP_LE: + b = pop(); + a = pop(); + push(a <= b); + break; + case OP_GE: + b = pop(); + a = pop(); + push(a >= b); + break; + case OP_NOT: + push(pop() == 0); + break; + case OP_RND: + a = pop(); + if (a <= 0) push(0); + else push((s16)(vm_rng_next() % (u16)a)); + break; + case OP_WAIT: + a = pop(); + if (a > 0) { + vm.wait_frames = (u16)a; + vm.waiting = WAITING_FRAMES; + return; + } + break; + default: + vm_end(); /* illegal opcode: stop the script, keep the game alive */ + return; + } + } +} diff --git a/static/runtime/core/vm.h b/static/runtime/core/vm.h new file mode 100644 index 00000000..f9779717 --- /dev/null +++ b/static/runtime/core/vm.h @@ -0,0 +1,43 @@ +/* static/runtime/core/vm.h — VM state + the seam to the category runtime. */ +#ifndef PS_VM_H +#define PS_VM_H + +#include "hal.h" + +typedef struct { + u16 ip; + u16 script; /* running script id */ + u16 rng; /* xorshift16 state (PS_RNG_SEED at boot) */ + u16 wait_frames; + u8 sp; + u8 frame; /* call depth, 0 = top */ + u8 waiting; /* WAITING_* */ + u8 active; + s16 stack[VM_STACK]; + u16 ret[VM_FRAMES]; + s16 locals[VM_FRAMES * VM_LOCALS]; +} Vm; + +extern Vm vm; + +void vm_start(u16 scriptId); +void vm_run(void); +void vm_resume(void); +void vm_resume_value(s16 v); + +/* Operand access for the category syscall handler. */ +u8 vm_fetch8(void); +u16 vm_fetch16(void); +void vm_push(s16 v); +s16 vm_pop(void); + +s16 vm_get_var(u8 id); +void vm_set_var(u8 id, s16 v); +u8 vm_get_flag(u8 id); +void vm_set_flag(u8 id, u8 on); +u16 vm_rng_next(void); + +/* Implemented by the category runtime (rpg.c): ops >= PS_SYSCALL_BASE. */ +void rpg_syscall(u8 op); + +#endif diff --git a/static/runtime/gb/crt0.s b/static/runtime/gb/crt0.s new file mode 100644 index 00000000..3399d3de --- /dev/null +++ b/static/runtime/gb/crt0.s @@ -0,0 +1,65 @@ +; static/runtime/gb/crt0.s — Game Boy startup for Pocket Static (sdasgb). +; Header logo/checksums are patched by rgbfix; makebin sizes the ROM. +; No GBDK: this is the whole bare-metal bring-up. + + .module crt0 + .globl _main + + .area _HEADER (ABS) + .org 0x0000 + ; rst vectors + unused irq vectors: point everything harmless + ret + .org 0x0040 ; vblank irq (unused; IME stays off) + reti + .org 0x0048 + reti + .org 0x0050 + reti + .org 0x0058 + reti + .org 0x0060 + reti + + .org 0x0100 + nop + jp init + ; 0x0104-0x0133 Nintendo logo (rgbfix -v writes it) + .ds 48 + ; 0x0134-0x0143 title (rgbfix -t writes it) + .ds 16 + .db 0x00, 0x00 ; new licensee + .db 0x00 ; sgb + .db 0x19 ; cart type MBC5 + .db 0x00 ; rom size (rgbfix/makebin fix) + .db 0x00 ; ram size + .db 0x01 ; dest + .db 0x33 ; old licensee + .db 0x00 ; version + .db 0x00 ; header checksum (rgbfix) + .db 0x00, 0x00 ; global checksum (rgbfix) + +init: + di + ld sp, #0xDF00 ; debug block owns 0xDF00-0xDFBB; stack below it + + ; zero WRAM 0xC000-0xDEFF (bss + queue + shadow OAM) + ld hl, #0xC000 + ld bc, #0x1F00 +clr: + xor a + ld (hl+), a + dec bc + ld a, b + or c + jr nz, clr + + call gsinit + jp _main + + ; sdcc initialized-data copy: GSINIT fragments run, GSFINAL returns. + .area _GSINIT +gsinit: + .area _GSFINAL + ret + + .area _CODE diff --git a/static/runtime/gb/hal_gb.c b/static/runtime/gb/hal_gb.c new file mode 100644 index 00000000..b785b456 --- /dev/null +++ b/static/runtime/gb/hal_gb.c @@ -0,0 +1,319 @@ +/* static/runtime/gb/hal_gb.c — the Game Boy (DMG) platform layer. + * + * BG = map (0x9800, SCX/SCY scroll), Window = textbox (0x9C00 — showing the + * box is a register write, no map restore ever). BG tiles use signed + * addressing (0x8800-0x97FF) so OBJ own 0x8000-0x87FF; OBJ are 8x16 pairs. + * All VRAM traffic outside map loads goes through a small write queue + * drained during vblank; map loads switch the LCD off (inside vblank) and + * blast directly. OAM goes up via the classic HRAM DMA stub. + */ +#include "hal.h" + +#define REG8(a) (*(volatile u8 *)(a)) +#define rP1 REG8(0xFF00) +#define rDIV REG8(0xFF04) +#define rLCDC REG8(0xFF40) +#define rSTAT REG8(0xFF41) +#define rSCY REG8(0xFF42) +#define rSCX REG8(0xFF43) +#define rLY REG8(0xFF44) +#define rBGP REG8(0xFF47) +#define rOBP0 REG8(0xFF48) +#define rOBP1 REG8(0xFF49) +#define rWY REG8(0xFF4A) +#define rWX REG8(0xFF4B) +#define rDMA REG8(0xFF46) +#define rNR10 REG8(0xFF10) +#define rNR11 REG8(0xFF11) +#define rNR12 REG8(0xFF12) +#define rNR13 REG8(0xFF13) +#define rNR14 REG8(0xFF14) +#define rNR50 REG8(0xFF24) +#define rNR51 REG8(0xFF25) +#define rNR52 REG8(0xFF26) + +#define MBC5_BANK (*(volatile u8 *)0x2000) + +#define VRAM8(a) ((volatile u8 *)(a)) +#define BG_MAP 0x9800 +#define WIN_MAP 0x9C00 + +/* generated (gen data, bank 0). Art lives in banked blobs: latch + copy at + * init (LCD off), so bank 0 stays code + tables only. */ +extern const u8 ps_blob_bank[]; +extern const u8 *const ps_blob_addr[]; +extern const u8 ps_art_blob; /* blob of 2bpp art tiles (ids 1..) */ +extern const u8 ps_bg_tile_count; +extern const u8 ps_font_blob; /* blob of 95 2bpp glyphs */ +extern const u8 ps_obj_blob; /* blob of OBJ tiles */ +extern const u16 ps_obj_tile_bytes; + +/* shadow OAM: DMA source must be 0xXX00-aligned; crt0 zeroed WRAM. + * Linker keeps _DATA at 0xC0A0+, so 0xC000 is ours. */ +#define OAM_SHADOW ((volatile u8 *)0xC000) + +/* write queue: [q_head, q_n) pending. Drained strictly inside vblank — + * the DMG drops VRAM writes outside modes 0/1, so the drain loop watches + * STAT and stops the moment vblank ends (the tail waits a frame). */ +#define QCAP 192 +static u8 q_hi[QCAP], q_lo[QCAP], q_val[QCAP]; +static u8 q_head, q_n; +static u8 text_rows, text_top; +static u8 scroll_x, scroll_y; +static u8 dma_stub_ready; + +const u8 *hal_blob(u8 blob) { + MBC5_BANK = ps_blob_bank[blob]; + return ps_blob_addr[blob]; +} + +static void qpush(u16 addr, u8 val) { + if (q_n >= QCAP) { + u8 i, len; + if (q_head == 0) return; /* truly full: drop (budgets keep us below QCAP) */ + len = (u8)(q_n - q_head); + for (i = 0; i < len; i++) { + q_hi[i] = q_hi[i + q_head]; + q_lo[i] = q_lo[i + q_head]; + q_val[i] = q_val[i + q_head]; + } + q_head = 0; + q_n = len; + } + q_hi[q_n] = (u8)(addr >> 8); + q_lo[q_n] = (u8)addr; + q_val[q_n] = val; + q_n++; +} + +static void wait_vblank(void) { + while (rLY >= 144) {} + while (rLY < 144) {} +} + +/* OAM DMA stub in HRAM (0xFF80): ldh (rDMA),a; loop 40; ret */ +static void install_dma_stub(void) { + static const u8 stub[] = { 0xe0, 0x46, 0x3e, 0x28, 0x3d, 0x20, 0xfd, 0xc9 }; + u8 i; + for (i = 0; i < sizeof stub; i++) *(volatile u8 *)(0xFF80 + i) = stub[i]; + dma_stub_ready = 1; +} + +static void oam_dma(void) { + __asm__("ld a, #0xC0"); + __asm__("call 0xFF80"); +} + +/* ---- init ---------------------------------------------------------------------*/ +static void upload_bg_tile(u8 id, const u8 *src) { + /* signed addressing: ids 0..127 -> 0x9000, 128..255 -> 0x8800 */ + u16 base = id < 128 ? (u16)(0x9000 + (u16)id * 16) : (u16)(0x8800 + (u16)(id - 128) * 16); + volatile u8 *d = VRAM8(base); + u8 i; + for (i = 0; i < 16; i++) d[i] = src[i]; +} + +void hal_init(void) { + u16 i; + u8 t; + volatile u8 *d; + + wait_vblank(); + rLCDC = 0x00; /* LCD off: free VRAM access */ + + /* palettes: BGP dark-descending; OBP0 same ramp, color 0 transparent */ + rBGP = 0xE4; /* 3,2,1,0 */ + rOBP0 = 0xE4; + rOBP1 = 0xE4; + + /* art tiles (id 1..) + blank (id 0) */ + { + static const u8 zero[16] = { 0 }; + upload_bg_tile(0, zero); + } + { + const u8 *src = hal_blob(ps_art_blob); + for (t = 0; t < ps_bg_tile_count; t++) upload_bg_tile((u8)(t + 1), src + (u16)t * 16); + } + /* font at the fixed base */ + { + const u8 *src = hal_blob(ps_font_blob); + for (t = 0; t < FONT_GLYPHS; t++) upload_bg_tile((u8)(PS_BG_FONT_BASE + t), src + (u16)t * 16); + } + + /* OBJ tiles at 0x8000 */ + { + const u8 *src = hal_blob(ps_obj_blob); + d = VRAM8(0x8000); + for (i = 0; i < ps_obj_tile_bytes; i++) d[i] = src[i]; + } + + /* clear both maps to blank; window rows prefill with the space glyph */ + d = VRAM8(BG_MAP); + for (i = 0; i < 32 * 32; i++) d[i] = 0; + d = VRAM8(WIN_MAP); + for (i = 0; i < 32 * 32; i++) d[i] = PS_BG_FONT_BASE; + + install_dma_stub(); + + /* sound on, both terminals, square 1 ready */ + rNR52 = 0x80; + rNR50 = 0x77; + rNR51 = 0x11; + + rWX = 7; + rWY = 144; /* window parked off-screen */ + /* LCD on: BG on, OBJ on 8x16, window on (map 0x9C00), signed BG tiles */ + rLCDC = 0x80 | 0x01 | 0x02 | 0x04 | 0x20 | 0x40; +} + +/* ---- frame ---------------------------------------------------------------------*/ +void hal_frame(void) { + wait_vblank(); + /* OAM + scroll first (cheap, must not miss the frame), then drain the + * queue for as long as vblank lasts — STAT mode 1 is the gate. */ + oam_dma(); + rSCX = scroll_x; + rSCY = scroll_y; + { + u8 budget = 12; + while (q_head < q_n && budget) { + if ((rSTAT & 3) != 1 || rLY > 150) break; /* vblank over: next frame */ + *(volatile u8 *)(((u16)q_hi[q_head] << 8) | q_lo[q_head]) = q_val[q_head]; + q_head++; + budget--; + } + } + if (q_head == q_n) { + q_head = 0; + q_n = 0; + } +} + +/* ---- input ----------------------------------------------------------------------*/ +u8 hal_keys(void) { + u8 pad, btn; + rP1 = 0x20; /* select dpad */ + pad = rP1; + pad = rP1; + pad = (u8)(~pad & 0x0f); /* right,left,up,down (bits 0-3) */ + rP1 = 0x10; /* select buttons */ + btn = rP1; + btn = rP1; + btn = rP1; + btn = (u8)(~btn & 0x0f); /* a,b,select,start */ + rP1 = 0x30; + /* normalize: PS mask = a,b,select,start,right,left,up,down */ + return (u8)(btn | (pad << 4)); +} + +/* ---- video -----------------------------------------------------------------------*/ +void hal_map_draw(u8 mapBlob, u8 w, u8 h) { + const u8 *row; + volatile u8 *d = VRAM8(BG_MAP); + volatile u8 *out; + u8 x, y; + + wait_vblank(); + rLCDC &= 0x7f; /* LCD off inside vblank: safe on hardware */ + { + const u8 *m = hal_blob(mapBlob); + row = m + (u16)(m[8] | ((u16)m[9] << 8)); + } + out = d; + for (y = 0; y < 32; y++) { + for (x = 0; x < 32; x++) { + u8 e = 0; + if (x < w && y < h) e = row[x]; + out[x] = e; + } + if (y < h) row += w; + out += 32; + } + q_head = 0; + q_n = 0; /* stale textbox writes are void after a map change */ + rLCDC |= 0x80; +} + +void hal_scroll(u16 px, u16 py) { + scroll_x = (u8)px; + scroll_y = (u8)py; +} + +void hal_obj(u8 slot, s16 px, s16 py, u8 spriteId, u8 dir, u8 frame, u8 hidden) { + volatile u8 *o = OAM_SHADOW + (u16)slot * 8; /* 2 hw sprites = 8 bytes */ + const u8 *sp = ps_sprite_table + (u16)spriteId * SPRITE_ENTRY_SIZE; + u16 tile_base = (u16)(sp[0] | ((u16)sp[1] << 8)); + u8 frames = sp[2]; + u8 flip = 0; + u8 dblock = dir; + u8 t; + + if (hidden || px <= -16 || py <= -16 || px >= 160 || py >= 144) { + o[0] = 0; + o[4] = 0; + return; + } + if (dir == DIR_LEFT) { + dblock = DIR_RIGHT; + flip = 0x20; + } + if (frame >= frames) frame = 0; + /* frame block = 4 tiles as [leftTop,leftBottom,rightTop,rightBottom]; + * 8x16 OBJ tile index ignores bit 0. frames is 1 or 2 — shift, don't + * multiply (see the __muluchar note in rpg.c). */ + t = frames == 2 ? (u8)(dblock << 1) : dblock; + t = (u8)(tile_base + ((u16)(t + frame) << 2)); + /* left column */ + o[0] = (u8)(py + 16); + o[1] = (u8)(px + 8 + (flip ? 8 : 0)); + o[2] = t; + o[3] = flip; + /* right column */ + o[4] = (u8)(py + 16); + o[5] = (u8)(px + 8 + (flip ? 0 : 8)); + o[6] = (u8)(t + 2); + o[7] = flip; +} + +/* ---- textbox ---------------------------------------------------------------------*/ +void hal_text_open(u8 rows) { + u8 x, y; + text_rows = rows; + text_top = 0; + /* clear the window's used rows back to paper */ + for (y = 0; y < (u8)(rows + 2); y++) { + for (x = 0; x < 20; x++) qpush((u16)(WIN_MAP + (u16)y * 32 + x), PS_BG_FONT_BASE); + } + rWY = (u8)(144 - ((u8)(rows + 2)) * 8); +} + +void hal_text_close(void) { + rWY = 144; +} + +void hal_text_glyph(u8 col, u8 row, u8 glyph) { + qpush((u16)(WIN_MAP + (u16)(row + 1) * 32 + 1 + col), (u8)(PS_BG_FONT_BASE + glyph)); +} + +/* ---- sfx --------------------------------------------------------------------------*/ +void hal_sfx(u8 id) { + static const u8 duty[5] = { 0x80, 0x40, 0x80, 0x80, 0xc0 }; + static const u8 hi[5] = { 0xc6, 0xc4, 0xc3, 0xc6, 0xc7 }; + if (id > 4) return; + rNR10 = 0x00; + rNR11 = duty[id]; + rNR12 = 0xa3; + rNR13 = 0x00; + rNR14 = hi[id]; +} + +/* ---- entry --------------------------------------------------------------------------*/ +void main(void) { + hal_init(); + rpg_boot(); + for (;;) { + rpg_tick(); + hal_frame(); + } +} diff --git a/static/runtime/gba/crt0.s b/static/runtime/gba/crt0.s new file mode 100644 index 00000000..0d540250 --- /dev/null +++ b/static/runtime/gba/crt0.s @@ -0,0 +1,48 @@ +@ static/runtime/gba/crt0.s — cartridge header + startup for Pocket Static GBA. +@ The header's Nintendo logo bytes and checksum are patched into the final +@ .gba by targets/gba.ts (buildGba); the zeros here are placeholders. + + .section .crt0, "ax" + .arm + .global _start +_start: + b rom_start + + .space 156 @ Nintendo logo (patched post-link) + .space 12 @ game title (patched) + .space 4 @ game code (patched) + .byte 0x30, 0x31 @ maker "01" + .byte 0x96 @ fixed + .byte 0x00 @ main unit + .byte 0x00 @ device type + .space 7 @ reserved + .byte 0x00 @ software version + .byte 0x00 @ complement check (patched) + .space 2 @ reserved + +rom_start: + @ system mode, IRQs architecturally enabled (REG_IME stays 0) + mov r0, #0x5f + msr cpsr_c, r0 + ldr sp, =0x03007f00 + + @ copy .data ROM -> IWRAM + ldr r0, =__data_lma + ldr r1, =__data_start + ldr r2, =__data_end +1: cmp r1, r2 + ldrlo r3, [r0], #4 + strlo r3, [r1], #4 + blo 1b + + @ zero .bss + ldr r1, =__bss_start + ldr r2, =__bss_end + mov r3, #0 +2: cmp r1, r2 + strlo r3, [r1], #4 + blo 2b + + ldr r0, =main + bx r0 +3: b 3b diff --git a/static/runtime/gba/gba.h b/static/runtime/gba/gba.h new file mode 100644 index 00000000..d570f94c --- /dev/null +++ b/static/runtime/gba/gba.h @@ -0,0 +1,38 @@ +/* static/runtime/gba/gba.h — the handful of GBA registers this runtime uses. */ +#ifndef PS_GBA_H +#define PS_GBA_H + +#include "hal.h" + +#define REG(addr) (*(volatile u16 *)(addr)) +#define REG32(addr) (*(volatile u32 *)(addr)) + +#define REG_DISPCNT REG(0x04000000) +#define REG_VCOUNT REG(0x04000006) +#define REG_BG0CNT REG(0x04000008) +#define REG_BG1CNT REG(0x0400000a) +#define REG_BG0HOFS REG(0x04000010) +#define REG_BG0VOFS REG(0x04000012) +#define REG_BG1HOFS REG(0x04000014) +#define REG_BG1VOFS REG(0x04000016) +#define REG_KEYINPUT REG(0x04000130) + +#define REG_SOUNDCNT_L REG(0x04000080) +#define REG_SOUNDCNT_H REG(0x04000082) +#define REG_SOUNDCNT_X REG(0x04000084) +#define REG_SOUND1CNT_L REG(0x04000060) +#define REG_SOUND1CNT_H REG(0x04000062) +#define REG_SOUND1CNT_X REG(0x04000064) + +#define PAL_BG ((volatile u16 *)0x05000000) +#define PAL_OBJ ((volatile u16 *)0x05000200) +#define VRAM ((volatile u16 *)0x06000000) +#define VRAM_OBJ ((volatile u16 *)0x06010000) +#define OAM ((volatile u16 *)0x07000000) + +/* charblock 0 = BG tiles; screenblock 8 = map, 9 = textbox layer */ +#define SB_MAP 8 +#define SB_TEXT 9 +#define SCREENBLOCK(n) ((volatile u16 *)(0x06000000 + (n) * 0x800)) + +#endif diff --git a/static/runtime/gba/gba.ld b/static/runtime/gba/gba.ld new file mode 100644 index 00000000..bd609351 --- /dev/null +++ b/static/runtime/gba/gba.ld @@ -0,0 +1,34 @@ +/* static/runtime/gba/gba.ld — ROM + IWRAM layout. EWRAM (0x02000000) is + * deliberately left out of every section: the debug block owns its base. */ +OUTPUT_ARCH(arm) +ENTRY(_start) + +MEMORY { + rom (rx) : ORIGIN = 0x08000000, LENGTH = 32M + iwram (rwx) : ORIGIN = 0x03000000, LENGTH = 32K +} + +SECTIONS { + .text : { + KEEP(*(.crt0)) + *(.text*) + *(.rodata*) + . = ALIGN(4); + } > rom + + __data_lma = .; + .data : AT(__data_lma) { + __data_start = .; + *(.data*) + . = ALIGN(4); + __data_end = .; + } > iwram + + .bss : { + __bss_start = .; + *(.bss*) + *(COMMON) + . = ALIGN(4); + __bss_end = .; + } > iwram +} diff --git a/static/runtime/gba/hal_gba.c b/static/runtime/gba/hal_gba.c new file mode 100644 index 00000000..8a9f4221 --- /dev/null +++ b/static/runtime/gba/hal_gba.c @@ -0,0 +1,180 @@ +/* static/runtime/gba/hal_gba.c — the whole GBA platform layer. + * + * Mode 0. BG0 = map (charblock 0, screenblock 8, scrolls), BG1 = textbox + * (screenblock 9, priority 0, never scrolls), OBJ 1D mapping with a shadow + * OAM committed each vblank. VRAM on the GBA is CPU-writable at any time, so + * there is no update queue — the HAL is embarrassingly direct. + */ +#include "gba.h" + +/* generated data (gen_data.c) */ +extern const u8 *const ps_blobs[]; +extern const u8 ps_bg_art[]; +extern const u16 ps_bg_art_tiles; +extern const u8 ps_font[]; +extern const u8 ps_obj_tiles[]; +extern const u16 ps_obj_tile_count; +extern const u16 ps_bg_pal0[]; +extern const u16 ps_text_pal[]; +extern const u16 ps_obj_pal[]; +extern const u8 ps_sprite_count; + +#define TEXT_PALBANK 15 + +static u16 oam_shadow[128 * 4]; +static u8 text_rows, text_top; + +const u8 *hal_blob(u8 blob) { return ps_blobs[blob]; } + +/* ---- boot -------------------------------------------------------------------*/ +static void upload_tiles(void) { + const u16 *src; + volatile u16 *dst; + u16 i, n; + + /* art at tile 1 (tile 0 = blank, VRAM already zero) */ + src = (const u16 *)ps_bg_art; + dst = VRAM + 16; /* one 4bpp tile = 32 bytes = 16 u16 */ + n = (u16)(ps_bg_art_tiles * 16); + for (i = 0; i < n; i++) dst[i] = src[i]; + + /* font at the fixed base */ + src = (const u16 *)ps_font; + dst = VRAM + (u32)PS_BG_FONT_BASE * 16; + n = FONT_GLYPHS * 16; + for (i = 0; i < n; i++) dst[i] = src[i]; + + /* OBJ tiles */ + src = (const u16 *)ps_obj_tiles; + dst = VRAM_OBJ; + n = (u16)(ps_obj_tile_count * 16); + for (i = 0; i < n; i++) dst[i] = src[i]; +} + +void hal_init(void) { + u16 i; + for (i = 0; i < 16; i++) PAL_BG[i] = ps_bg_pal0[i]; + for (i = 0; i < 16; i++) PAL_BG[TEXT_PALBANK * 16 + i] = ps_text_pal[i]; + for (i = 0; i < (u16)(ps_sprite_count * 16); i++) PAL_OBJ[i] = ps_obj_pal[i]; + upload_tiles(); + + for (i = 0; i < 128; i++) oam_shadow[i * 4] = 0x0200; /* disabled */ + + REG_BG0CNT = (SB_MAP << 8) | 1; /* 4bpp, charblock 0, sb 8, prio 1 */ + REG_BG1CNT = (SB_TEXT << 8) | 0; /* 4bpp, charblock 0, sb 9, prio 0 */ + REG_BG1HOFS = 0; + REG_BG1VOFS = 0; + + /* PSG on */ + REG_SOUNDCNT_X = 0x0080; + REG_SOUNDCNT_L = 0xff77; + REG_SOUNDCNT_H = 0x0002; + + REG_DISPCNT = 0x0040 /* OBJ 1D */ | 0x1000 /* OBJ on */ | 0x0100 /* BG0 */ | 0x0200 /* BG1 */; +} + +/* ---- frame ------------------------------------------------------------------*/ +static void oam_commit(void) { + u16 i; + for (i = 0; i < 128 * 4; i++) OAM[i] = oam_shadow[i]; +} + +void hal_frame(void) { + while (REG_VCOUNT >= 160) {} + while (REG_VCOUNT < 160) {} + oam_commit(); +} + +u8 hal_keys(void) { return (u8)(~REG_KEYINPUT & 0xff); } + +/* ---- video --------------------------------------------------------------------*/ +void hal_map_draw(u8 mapBlob, u8 w, u8 h) { + const u8 *m = hal_blob(mapBlob); + u16 tiles_off = (u16)(m[8] | ((u16)m[9] << 8)); + volatile u16 *sb = SCREENBLOCK(SB_MAP); + u8 x, y; + for (y = 0; y < 32; y++) { + for (x = 0; x < 32; x++) { + u16 e = 0; + if (x < w && y < h) e = m[tiles_off + (u16)y * w + x]; + sb[(u16)y * 32 + x] = e; + } + } +} + +void hal_scroll(u16 px, u16 py) { + REG_BG0HOFS = px; + REG_BG0VOFS = py; +} + +void hal_obj(u8 slot, s16 px, s16 py, u8 spriteId, u8 dir, u8 frame, u8 hidden) { + u16 *o = &oam_shadow[(u16)slot * 4]; + const u8 *sp = ps_sprite_table + (u16)spriteId * SPRITE_ENTRY_SIZE; + u16 tile_base = (u16)(sp[0] | ((u16)sp[1] << 8)); + u8 frames = sp[2]; + u8 pal = sp[3]; + u8 flip = 0; + u8 dblock = dir; + + if (hidden || px <= -16 || py <= -16 || px >= 240 || py >= 160) { + o[0] = 0x0200; /* disable */ + return; + } + if (dir == DIR_LEFT) { + dblock = DIR_RIGHT; + flip = 1; + } + if (frame >= frames) frame = 0; + o[0] = (u16)((py & 0xff) | 0x0000); /* square, 4bpp */ + o[1] = (u16)((px & 0x1ff) | (flip << 12) | 0x4000); /* size 16x16 */ + o[2] = (u16)((tile_base + ((u16)dblock * frames + frame) * 4) | ((u16)pal << 12)); +} + +/* ---- textbox ---------------------------------------------------------------------*/ +void hal_text_open(u8 rows) { + volatile u16 *sb = SCREENBLOCK(SB_TEXT); + u8 x, y; + text_rows = rows; + text_top = (u8)(PS_SCREEN_TILES_H - rows - 2); + for (y = text_top; y < PS_SCREEN_TILES_H; y++) { + for (x = 0; x < PS_SCREEN_TILES_W; x++) { + /* space glyph = opaque paper */ + sb[(u16)y * 32 + x] = (u16)(PS_BG_FONT_BASE | (TEXT_PALBANK << 12)); + } + } +} + +void hal_text_close(void) { + volatile u16 *sb = SCREENBLOCK(SB_TEXT); + u8 x, y; + for (y = text_top; y < PS_SCREEN_TILES_H; y++) { + for (x = 0; x < PS_SCREEN_TILES_W; x++) sb[(u16)y * 32 + x] = 0; + } +} + +void hal_text_glyph(u8 col, u8 row, u8 glyph) { + volatile u16 *sb = SCREENBLOCK(SB_TEXT); + u16 cell = (u16)(text_top + 1 + row) * 32 + 1 + col; + sb[cell] = (u16)((PS_BG_FONT_BASE + glyph) | (TEXT_PALBANK << 12)); +} + +/* ---- sfx --------------------------------------------------------------------------*/ +void hal_sfx(u8 id) { + /* square channel 1 presets: (duty/envelope, frequency) */ + static const u16 env[5] = { 0xa1c0, 0x81c0, 0xa1c0, 0xa1c0, 0xa2c0 }; + static const u16 freq[5] = { 1750, 1200, 900, 1650, 1900 }; + if (id > 4) return; + REG_SOUND1CNT_L = 0x0008; + REG_SOUND1CNT_H = env[id]; + REG_SOUND1CNT_X = (u16)(0x8000 | freq[id]); +} + +/* ---- entry -------------------------------------------------------------------------*/ +int main(void) { + hal_init(); + rpg_boot(); + for (;;) { + rpg_tick(); + hal_frame(); + } +} diff --git a/static/runtime/gen/spec_gen.h b/static/runtime/gen/spec_gen.h new file mode 100644 index 00000000..0654b569 --- /dev/null +++ b/static/runtime/gen/spec_gen.h @@ -0,0 +1,188 @@ +/* spec_gen.h — GENERATED by static/spec/gen-c.ts. DO NOT EDIT. */ +#ifndef PS_SPEC_GEN_H +#define PS_SPEC_GEN_H + +/* ---- core VM ---- */ +#define OP_END 0x0 +#define OP_NOP 0x1 +#define OP_PUSH8 0x2 +#define OP_PUSH16 0x3 +#define OP_POP 0x4 +#define OP_DUP 0x5 +#define OP_JMP 0x6 +#define OP_JZ 0x7 +#define OP_JNZ 0x8 +#define OP_CALL 0x9 +#define OP_RET 0xA +#define OP_LDV 0xB +#define OP_STV 0xC +#define OP_LDL 0xD +#define OP_STL 0xE +#define OP_FLAG 0xF +#define OP_SETF 0x10 +#define OP_CLRF 0x11 +#define OP_STF 0x12 +#define OP_ADD 0x13 +#define OP_SUB 0x14 +#define OP_MUL 0x15 +#define OP_DIV 0x16 +#define OP_MOD 0x17 +#define OP_NEG 0x18 +#define OP_EQ 0x19 +#define OP_NE 0x1A +#define OP_LT 0x1B +#define OP_GT 0x1C +#define OP_LE 0x1D +#define OP_GE 0x1E +#define OP_NOT 0x1F +#define OP_RND 0x20 +#define OP_WAIT 0x21 +#define PS_SYSCALL_BASE 0x40 +#define VM_STACK 16 +#define VM_FRAMES 4 +#define VM_LOCALS 16 +#define VM_VARS 64 +#define VM_FLAGS 256 +#define VM_BURST 64 +#define VAR_SCRATCH_BASE 60 +#define PS_RNG_SEED 0x2A17 + +/* ---- keys (normalized pad bitmask) ---- */ +#define PS_KEY_A 0x1 +#define PS_KEY_B 0x2 +#define PS_KEY_SELECT 0x4 +#define PS_KEY_START 0x8 +#define PS_KEY_RIGHT 0x10 +#define PS_KEY_LEFT 0x20 +#define PS_KEY_UP 0x40 +#define PS_KEY_DOWN 0x80 + +/* ---- waiting states ---- */ +#define WAITING_NONE 0 +#define WAITING_TEXT 1 +#define WAITING_CHOICE 2 +#define WAITING_FRAMES 3 + +/* ---- rpg syscalls ---- */ +#define OP_SAY 0x40 +#define OP_CHOICE 0x41 +#define OP_LOCK 0x42 +#define OP_RELEASE 0x43 +#define OP_FACE 0x44 +#define OP_AVIS 0x45 +#define OP_WARP 0x46 +#define OP_SFX 0x47 +#define FACE_SELF 0xFF +#define SFX_CONFIRM 0 +#define SFX_DENY 1 +#define SFX_DAMAGE 2 +#define SFX_HEAL 3 +#define SFX_FANFARE 4 + +/* ---- directions / movement ---- */ +#define DIR_DOWN 0 +#define DIR_UP 1 +#define DIR_LEFT 2 +#define DIR_RIGHT 3 +#define MOVE_STATIC 0 +#define MOVE_WANDER 1 +#define WANDER_PERIOD 96 +#define WANDER_PHASE 17 +#define STEP_PX 2 + +/* ---- text tokens ---- */ +#define TOK_END 0x0 +#define TOK_FMT 0x1 +#define TOK_NEWLINE 0xA +#define TOK_ASCII_MIN 0x20 +#define TOK_ASCII_MAX 0x7E +#define FONT_GLYPHS 95 +#define FMT_CELLS 5 +#define TEXT_ENTRY_SIZE 3 + +/* ---- record layouts ---- */ +#define GAME_TITLE_LEN 16 +#define GAME_HEADER_SIZE 28 +#define MAP_HEADER_SIZE 20 +#define ACTOR_SIZE 8 +#define WARP_SIZE 6 +#define TRIGGER_SIZE 6 +#define SPRITE_ENTRY_SIZE 4 +#define SPRITE_FACINGS 3 +#define SPRITE_TILES_PER_FRAME 4 +#define ACTOR_F_SOLID 1 +#define ACTOR_F_HIDDEN 2 +#define TRIGGER_F_ONCE 1 +#define SCRIPT_NONE 0xFFFF +#define TEXT_NONE 0xFFFF + +/* ---- debug block ---- */ +#define DBGO_MAGIC 0x0 +#define DBGO_BOOTED 0x4 +#define DBGO_PLAYER_DIR 0x5 +#define DBGO_CUR_MAP 0x6 +#define DBGO_TEXT_ACTIVE 0x7 +#define DBGO_SCRIPT_ACTIVE 0x8 +#define DBGO_CHOICE_CURSOR 0x9 +#define DBGO_WAITING 0xA +#define DBGO_RESERVED0 0xB +#define DBGO_PLAYER_X 0xC +#define DBGO_PLAYER_Y 0xE +#define DBGO_CUR_TEXT 0x10 +#define DBGO_CUR_SCRIPT 0x12 +#define DBGO_FRAME 0x14 +#define DBGO_RNG 0x18 +#define DBGO_RESERVED1 0x1A +#define DBGO_FLAGS 0x1C +#define DBGO_VARS 0x3C +#define DEBUG_BLOCK_SIZE 0xBC +#define DEBUG_MAGIC_0 0x50 +#define DEBUG_MAGIC_1 0x53 +#define DEBUG_MAGIC_2 0x44 +#define DEBUG_MAGIC_3 0x42 + +/* ---- per-target ---- */ +#if defined(PS_TARGET_GBA) +#define PS_DEBUG_ADDR 0x2000000 +#define PS_TEXT_COLS 28 +#define PS_TEXT_LINES 3 +#define PS_MAX_CHOICES 4 +#define PS_SCREEN_TILES_W 30 +#define PS_SCREEN_TILES_H 20 +#define PS_TILE_BYTES 32 +#define PS_SCROLLS 1 +#define PS_BG_TOTAL_TILES 512 +#define PS_BG_CURSOR_TILE 415 +#define PS_BG_BOX_TILE 416 +#define PS_BG_FONT_BASE 417 +#elif defined(PS_TARGET_GB) +#define PS_DEBUG_ADDR 0xDF00 +#define PS_TEXT_COLS 18 +#define PS_TEXT_LINES 3 +#define PS_MAX_CHOICES 4 +#define PS_SCREEN_TILES_W 20 +#define PS_SCREEN_TILES_H 18 +#define PS_TILE_BYTES 16 +#define PS_SCROLLS 1 +#define PS_BG_TOTAL_TILES 256 +#define PS_BG_CURSOR_TILE 159 +#define PS_BG_BOX_TILE 160 +#define PS_BG_FONT_BASE 161 +#elif defined(PS_TARGET_NES) +#define PS_DEBUG_ADDR 0x700 +#define PS_TEXT_COLS 28 +#define PS_TEXT_LINES 3 +#define PS_MAX_CHOICES 4 +#define PS_SCREEN_TILES_W 32 +#define PS_SCREEN_TILES_H 30 +#define PS_TILE_BYTES 16 +#define PS_SCROLLS 0 +#define PS_BG_TOTAL_TILES 256 +#define PS_BG_CURSOR_TILE 159 +#define PS_BG_BOX_TILE 160 +#define PS_BG_FONT_BASE 161 +#else +#error "define exactly one of PS_TARGET_GBA / PS_TARGET_GB / PS_TARGET_NES" +#endif + +#endif /* PS_SPEC_GEN_H */ diff --git a/static/runtime/nes/crt0.s b/static/runtime/nes/crt0.s new file mode 100644 index 00000000..7634bcd7 --- /dev/null +++ b/static/runtime/nes/crt0.s @@ -0,0 +1,139 @@ +; static/runtime/nes/crt0.s — NES startup + NMI for Pocket Static (ca65). +; +; The NMI owns the PPU: OAM DMA from $0200, then drains the shared VRAM +; write ring (single 3-byte entries; hi-bit entries mean "fill 8 bytes"), +; then resets the scroll/address latch. The C side only ever touches the +; PPU directly during init and map loads (rendering + NMI off). + +.import _main +.importzp sp +.import _q_hi, _q_lo, _q_val, _q_head, _q_tail, _ppuctrl, _nmi_count +.export _ps_banktable + +.segment "CODE" + +reset: + sei + cld + ldx #$40 + stx $4017 ; APU frame IRQ off + ldx #$ff + txs + inx ; x = 0 + stx $2000 ; NMI off + stx $2001 ; rendering off + stx $4010 ; DMC off + + bit $2002 +@vb1: + bit $2002 + bpl @vb1 + + ; clear RAM $0000-$07FF + lda #0 + tax +@clr: + sta $0000,x + sta $0100,x + sta $0200,x + sta $0300,x + sta $0400,x + sta $0500,x + sta $0600,x + sta $0700,x + inx + bne @clr + +@vb2: + bit $2002 + bpl @vb2 + + ; cc65 C stack at $06FF, growing down + lda #$ff + sta sp + lda #$06 + sta sp+1 + + jsr _main +@halt: + jmp @halt + +; --------------------------------------------------------------------------- +nmi: + pha + txa + pha + tya + pha + + ; OAM DMA from $0200 + lda #$00 + sta $2003 + lda #$02 + sta $4014 + + ; drain the ring: budget 32 units (single = 1 unit, fill-8 = 8) + ldy #32 +@loop: + lda _q_head + cmp _q_tail + beq @done + and #$3f + tax + lda _q_hi,x + bpl @single + ; fill-8 entry + and #$3f + sta $2006 + lda _q_lo,x + sta $2006 + lda _q_val,x + ldx #8 +@fill: + sta $2007 + dex + bne @fill + inc _q_head + tya + sec + sbc #8 + tay + beq @done + bpl @loop + bmi @done +@single: + sta $2006 + lda _q_lo,x + sta $2006 + lda _q_val,x + sta $2007 + inc _q_head + dey + bne @loop +@done: + ; reset address latch + scroll, restore control + bit $2002 + lda #0 + sta $2005 + sta $2005 + lda _ppuctrl + sta $2000 + + inc _nmi_count + + pla + tay + pla + tax + pla +irq: + rti + +; UNROM bus-conflict-safe bank latch table (write index to matching byte). +_ps_banktable: + .byte 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 + +.segment "VECTORS" + .word nmi + .word reset + .word irq diff --git a/static/runtime/nes/hal_nes.c b/static/runtime/nes/hal_nes.c new file mode 100644 index 00000000..1549d293 --- /dev/null +++ b/static/runtime/nes/hal_nes.c @@ -0,0 +1,274 @@ +/* static/runtime/nes/hal_nes.c — the NES platform layer. + * + * cc65, mapper 2 (UNROM) with CHR-RAM. The NMI (crt0.s) owns all steady- + * state PPU traffic: OAM DMA + a 64-entry VRAM write ring (single writes and + * fill-8 runs). C touches the PPU directly only with rendering + NMI off + * (init, map loads). No scrolling: maps are one nametable, and the textbox + * permanently owns tile rows 24-29 (whole attribute rows). + */ +#include "hal.h" + +#define PPU_CTRL (*(volatile u8 *)0x2000) +#define PPU_MASK (*(volatile u8 *)0x2001) +#define PPU_STATUS (*(volatile u8 *)0x2002) +#define PPU_ADDR (*(volatile u8 *)0x2006) +#define PPU_DATA (*(volatile u8 *)0x2007) +#define APU_STATUS (*(volatile u8 *)0x4015) +#define APU_P1_ENV (*(volatile u8 *)0x4000) +#define APU_P1_SWEEP (*(volatile u8 *)0x4001) +#define APU_P1_LO (*(volatile u8 *)0x4002) +#define APU_P1_HI (*(volatile u8 *)0x4003) +#define JOY1 (*(volatile u8 *)0x4016) + +#define OAM_SHADOW ((volatile u8 *)0x0200) +#define NT0 0x2000 +#define ATTR0 0x23c0 + +/* PPUCTRL shadow: NMI on, 8x16 sprites, BG table 0, inc +1, NT 0. */ +#define CTRL_ON 0xa0 + +/* generated (fixed bank) */ +extern const u8 ps_blob_bank[]; +extern const u8 *const ps_blob_addr[]; +extern const u8 ps_art_blob; +extern const u8 ps_bg_tile_count; +extern const u8 ps_font_blob; +extern const u8 ps_obj_blob; +extern const u16 ps_obj_tile_bytes; +extern const u8 ps_bg_pal[4]; /* backdrop + art subpal */ +extern const u8 ps_obj_pal[16]; /* 4 OBJ subpals */ +extern u8 ps_banktable[]; /* crt0: bus-conflict-safe latch */ + +/* shared with crt0's NMI (volatile: the NMI mutates head/count) */ +u8 q_hi[64], q_lo[64], q_val[64]; +volatile u8 q_head; +volatile u8 q_tail; +u8 ppuctrl; +volatile u8 nmi_count; + +static u8 text_rows; +static u8 restore_rows; /* box rows pending blank-restore after close */ + +const u8 *hal_blob(u8 blob) { + u8 b = ps_blob_bank[blob]; + ps_banktable[b] = b; + return ps_blob_addr[blob]; +} + +/* ring producer (NMI consumes) */ +static void qpush(u8 hi, u8 lo, u8 val) { + while ((u8)(q_tail - q_head) >= 64) {} /* ring full: the NMI will drain */ + { + u8 i = q_tail & 63; + q_hi[i] = hi; + q_lo[i] = lo; + q_val[i] = val; + } + ++q_tail; +} +#define QFILL8 0x80 + +static void nmi_wait(void) { + u8 start = nmi_count; + while (nmi_count == start) {} +} + +/* ---- init -------------------------------------------------------------------*/ +static void ppu_addr(u16 a) { + (void)PPU_STATUS; + PPU_ADDR = (u8)(a >> 8); + PPU_ADDR = (u8)a; +} + +static void upload_chr(u16 base, const u8 *src, u16 bytes) { + u16 i; + ppu_addr(base); + for (i = 0; i < bytes; i++) PPU_DATA = src[i]; +} + +void hal_init(void) { + u8 i; + const u8 *src; + + /* CHR-RAM: BG table 0 = blank + art + font(fixed base); table 1 = OBJ */ + src = hal_blob(ps_art_blob); + upload_chr(16, src, (u16)ps_bg_tile_count << 4); + src = hal_blob(ps_font_blob); + upload_chr((u16)PS_BG_FONT_BASE << 4, src, FONT_GLYPHS * 16); + src = hal_blob(ps_obj_blob); + upload_chr(0x1000, src, ps_obj_tile_bytes); + + /* palettes */ + ppu_addr(0x3f00); + for (i = 0; i < 4; i++) PPU_DATA = ps_bg_pal[i]; + for (i = 0; i < 8; i++) PPU_DATA = 0x0f; /* subpal 1,2 unused */ + PPU_DATA = ps_bg_pal[0]; + PPU_DATA = 0x30; /* white */ + PPU_DATA = 0x2d; /* grey */ + PPU_DATA = 0x0f; /* black — subpal 3 = textbox */ + ppu_addr(0x3f10); + for (i = 0; i < 16; i++) PPU_DATA = ps_obj_pal[i]; + + /* OAM shadow off-screen */ + for (i = 0; i < 64; i++) OAM_SHADOW[(u8)(i << 2)] = 0xff; + + APU_STATUS = 0x01; /* pulse 1 on */ + + ppuctrl = CTRL_ON; + PPU_CTRL = CTRL_ON; + PPU_MASK = 0x1e; /* BG + sprites on, left column shown */ +} + +/* ---- frame -------------------------------------------------------------------*/ +void hal_frame(void) { + nmi_wait(); +} + +u8 hal_keys(void) { + u8 i, v, out = 0; + /* standard shift order: A,B,Select,Start,Up,Down,Left,Right */ + static const u8 MAP[8] = { 0x01, 0x02, 0x04, 0x08, 0x40, 0x80, 0x20, 0x10 }; + JOY1 = 1; + JOY1 = 0; + for (i = 0; i < 8; i++) { + v = JOY1; + if (v & 1) out |= MAP[i]; + } + return out; +} + +/* ---- video ---------------------------------------------------------------------*/ +void hal_map_draw(u8 mapBlob, u8 w, u8 h) { + const u8 *m; + const u8 *row; + u8 x, y; + + nmi_wait(); + PPU_CTRL = 0; /* NMI off */ + PPU_MASK = 0; /* rendering off */ + + m = hal_blob(mapBlob); + row = m + (u16)(m[8] | ((u16)m[9] << 8)); + ppu_addr(NT0); + for (y = 0; y < 30; y++) { + for (x = 0; x < 32; x++) { + u8 e = 0; + if (x < w && y < h) e = row[x]; + PPU_DATA = e; + } + if (y < h) row += w; + } + /* attributes: everything subpal 0 */ + ppu_addr(ATTR0); + for (x = 0; x < 64; x++) PPU_DATA = 0; + + q_head = 0; + q_tail = 0; + restore_rows = 0; + + ppu_addr(0x2000); /* leave the address away from palette space */ + (void)PPU_STATUS; + PPU_CTRL = ppuctrl; + PPU_MASK = 0x1e; +} + +void hal_scroll(u16 px, u16 py) { + (void)px; + (void)py; +} + +void hal_obj(u8 slot, s16 px, s16 py, u8 spriteId, u8 dir, u8 frame, u8 hidden) { + volatile u8 *o = OAM_SHADOW + ((u8)(slot << 3)); /* 2 hw sprites */ + const u8 *sp = ps_sprite_table + ((u16)spriteId << 2); + u16 tile_base = (u16)(sp[0] | ((u16)sp[1] << 8)); + u8 frames = sp[2]; + u8 pal = sp[3]; + u8 flip = 0; + u8 dblock = dir; + u8 pair; + + if (hidden || px <= -16 || py <= -16 || px >= 256 || py >= 240) { + o[0] = 0xff; + o[4] = 0xff; + return; + } + if (dir == DIR_LEFT) { + dblock = DIR_RIGHT; + flip = 0x40; + } + if (frame >= frames) frame = 0; + /* frame block = 4 tiles [Ltop,Lbot,Rtop,Rbot] in pattern table 1. + * 8x16 OAM tile byte: bit0 selects the table (1), bits 1-7 pick the even + * tile of the pair — so left column = t|1, right column = (t+2)|1. */ + pair = frames == 2 ? (u8)(dblock << 1) : dblock; /* dblock*frames, no mul */ + { + u8 t = (u8)((u8)tile_base + ((u8)(pair + frame) << 2)); + /* NES renders sprites one line late; py 0 clamps (255 would hide it) */ + u8 oy = py > 0 ? (u8)(py - 1) : 0; + o[0] = oy; + o[1] = (u8)(t | 1); + o[2] = (u8)(pal | flip); + o[3] = flip ? (u8)(px + 8) : (u8)px; + o[4] = oy; + o[5] = (u8)((t + 2) | 1); + o[6] = (u8)(pal | flip); + o[7] = flip ? (u8)px : (u8)(px + 8); + } +} + +/* ---- textbox ----------------------------------------------------------------------- + * Box = tile rows 24..29 (attr rows 6-7). Text row r -> tile row 25+r, + * cols 2..29. Open paints paper fills + attr; close restores blank fills. + */ +static void box_fills(u8 tile, u8 attr) { + u8 y, q; + for (y = 24; y < 30; y++) { + u16 base = NT0 + ((u16)y << 5); + for (q = 0; q < 4; q++) { + qpush((u8)((base >> 8) | QFILL8), (u8)(base & 0xff), tile); + base += 8; + } + } + { + u16 a = ATTR0 + 48; + for (q = 0; q < 2; q++) { + qpush((u8)((a >> 8) | QFILL8), (u8)(a & 0xff), attr); + a += 8; + } + } +} + +void hal_text_open(u8 rows) { + text_rows = rows; + box_fills(PS_BG_FONT_BASE, 0xff); /* paper + subpal 3 */ +} + +void hal_text_close(void) { + box_fills(0, 0x00); /* blank + subpal 0 (maps never reach these rows) */ +} + +void hal_text_glyph(u8 col, u8 row, u8 glyph) { + u16 a = NT0 + ((u16)(25 + row) << 5) + 2 + col; + qpush((u8)(a >> 8), (u8)a, (u8)(PS_BG_FONT_BASE + glyph)); +} + +/* ---- sfx ------------------------------------------------------------------------------*/ +void hal_sfx(u8 id) { + static const u8 env[5] = { 0x86, 0x84, 0x83, 0x86, 0x87 }; + static const u8 lo[5] = { 0x60, 0xa0, 0xf0, 0x70, 0x50 }; + if (id > 4) return; + APU_P1_ENV = env[id]; + APU_P1_SWEEP = 0x08; + APU_P1_LO = lo[id]; + APU_P1_HI = 0x08; +} + +/* ---- entry ----------------------------------------------------------------------------*/ +void main(void) { + hal_init(); + rpg_boot(); + for (;;) { + rpg_tick(); + hal_frame(); + } +} diff --git a/static/spec/gen-c.ts b/static/spec/gen-c.ts new file mode 100644 index 00000000..9f1eaad6 --- /dev/null +++ b/static/spec/gen-c.ts @@ -0,0 +1,159 @@ +#!/usr/bin/env bun +// static/spec/gen-c.ts — derive runtime/gen/spec_gen.h from the TS spec. +// +// bun static/spec/gen-c.ts +// +// One header serves all targets; target-specific values sit behind +// PS_TARGET_GBA / PS_TARGET_GB / PS_TARGET_NES (exactly one must be defined +// by the build). C code must use ONLY these macros for anything the compiler +// also encodes — that is what keeps the two sides from drifting. + +import { join } from "node:path"; +import { + DBG, + DEBUG_BLOCK_SIZE, + DEBUG_MAGIC_BYTES, + FMT_CELLS, + FONT_GLYPHS, + KEYS, + OP, + RNG_SEED, + SYSCALL_BASE, + TARGETS, + TEXT_ENTRY_SIZE, + TOK_ASCII_MAX, + TOK_ASCII_MIN, + TOK_END, + TOK_FMT, + TOK_NEWLINE, + VAR_SCRATCH_BASE, + VM_BURST, + VM_FLAGS, + VM_FRAMES, + VM_LOCALS, + VM_STACK, + VM_VARS, + WAITING, +} from "./isa.ts"; +import { + ACTOR_F, + ACTOR_SIZE, + DIR, + FACE_SELF, + GAME_HEADER_SIZE, + GAME_TITLE_LEN, + MAP_HEADER_SIZE, + MOVE, + RPG_OP, + SCRIPT_NONE, + SFX, + SPRITE_ENTRY_SIZE, + SPRITE_FACINGS, + SPRITE_TILES_PER_FRAME, + STEP_PX, + TEXT_NONE, + TRIGGER_F, + TRIGGER_SIZE, + WANDER_PERIOD, + WANDER_PHASE, + WARP_SIZE, +} from "./rpg.ts"; + +const lines: string[] = []; +const put = (s = "") => lines.push(s); +const def = (name: string, v: number | string) => put(`#define ${name} ${typeof v === "number" ? v : v}`); +const hex = (v: number) => `0x${v.toString(16).toUpperCase()}`; + +put("/* spec_gen.h — GENERATED by static/spec/gen-c.ts. DO NOT EDIT. */"); +put("#ifndef PS_SPEC_GEN_H"); +put("#define PS_SPEC_GEN_H"); +put(); +put("/* ---- core VM ---- */"); +for (const [name, code] of Object.entries(OP)) def(`OP_${name}`, hex(code)); +def("PS_SYSCALL_BASE", hex(SYSCALL_BASE)); +def("VM_STACK", VM_STACK); +def("VM_FRAMES", VM_FRAMES); +def("VM_LOCALS", VM_LOCALS); +def("VM_VARS", VM_VARS); +def("VM_FLAGS", VM_FLAGS); +def("VM_BURST", VM_BURST); +def("VAR_SCRATCH_BASE", VAR_SCRATCH_BASE); +def("PS_RNG_SEED", hex(RNG_SEED)); +put(); +put("/* ---- keys (normalized pad bitmask) ---- */"); +for (const [name, v] of Object.entries(KEYS)) def(`PS_KEY_${name}`, hex(v)); +put(); +put("/* ---- waiting states ---- */"); +for (const [name, v] of Object.entries(WAITING)) def(`WAITING_${name}`, v); +put(); +put("/* ---- rpg syscalls ---- */"); +for (const [name, code] of Object.entries(RPG_OP)) def(`OP_${name}`, hex(code)); +def("FACE_SELF", hex(FACE_SELF)); +for (const [name, v] of Object.entries(SFX)) def(`SFX_${name}`, v); +put(); +put("/* ---- directions / movement ---- */"); +for (const [name, v] of Object.entries(DIR)) def(`DIR_${name}`, v); +for (const [name, v] of Object.entries(MOVE)) def(`MOVE_${name}`, v); +def("WANDER_PERIOD", WANDER_PERIOD); +def("WANDER_PHASE", WANDER_PHASE); +def("STEP_PX", STEP_PX); +put(); +put("/* ---- text tokens ---- */"); +def("TOK_END", hex(TOK_END)); +def("TOK_FMT", hex(TOK_FMT)); +def("TOK_NEWLINE", hex(TOK_NEWLINE)); +def("TOK_ASCII_MIN", hex(TOK_ASCII_MIN)); +def("TOK_ASCII_MAX", hex(TOK_ASCII_MAX)); +def("FONT_GLYPHS", FONT_GLYPHS); +def("FMT_CELLS", FMT_CELLS); +def("TEXT_ENTRY_SIZE", TEXT_ENTRY_SIZE); +put(); +put("/* ---- record layouts ---- */"); +def("GAME_TITLE_LEN", GAME_TITLE_LEN); +def("GAME_HEADER_SIZE", GAME_HEADER_SIZE); +def("MAP_HEADER_SIZE", MAP_HEADER_SIZE); +def("ACTOR_SIZE", ACTOR_SIZE); +def("WARP_SIZE", WARP_SIZE); +def("TRIGGER_SIZE", TRIGGER_SIZE); +def("SPRITE_ENTRY_SIZE", SPRITE_ENTRY_SIZE); +def("SPRITE_FACINGS", SPRITE_FACINGS); +def("SPRITE_TILES_PER_FRAME", SPRITE_TILES_PER_FRAME); +for (const [name, v] of Object.entries(ACTOR_F)) def(`ACTOR_F_${name}`, v); +for (const [name, v] of Object.entries(TRIGGER_F)) def(`TRIGGER_F_${name}`, v); +def("SCRIPT_NONE", hex(SCRIPT_NONE)); +def("TEXT_NONE", hex(TEXT_NONE)); +put(); +put("/* ---- debug block ---- */"); +for (const [name, off] of Object.entries(DBG)) def(`DBGO_${name}`, hex(off)); +def("DEBUG_BLOCK_SIZE", hex(DEBUG_BLOCK_SIZE)); +DEBUG_MAGIC_BYTES.forEach((b, idx) => def(`DEBUG_MAGIC_${idx}`, hex(b))); +put(); +put("/* ---- per-target ---- */"); +const guards = { gba: "PS_TARGET_GBA", gb: "PS_TARGET_GB", nes: "PS_TARGET_NES" } as const; +let first = true; +for (const t of ["gba", "gb", "nes"] as const) { + const spec = TARGETS[t]; + put(`#${first ? "if" : "elif"} defined(${guards[t]})`); + first = false; + def("PS_DEBUG_ADDR", hex(spec.debugAddr)); + def("PS_TEXT_COLS", spec.textCols); + def("PS_TEXT_LINES", spec.textLines); + def("PS_MAX_CHOICES", spec.maxChoices); + def("PS_SCREEN_TILES_W", spec.screenTilesW); + def("PS_SCREEN_TILES_H", spec.screenTilesH); + def("PS_TILE_BYTES", spec.tileBytes); + def("PS_SCROLLS", spec.scrolls ? 1 : 0); + def("PS_BG_TOTAL_TILES", spec.bgTotalTiles); + def("PS_BG_CURSOR_TILE", spec.bgTotalTiles - 97); + def("PS_BG_BOX_TILE", spec.bgTotalTiles - 96); + def("PS_BG_FONT_BASE", spec.bgTotalTiles - 95); +} +put("#else"); +put('#error "define exactly one of PS_TARGET_GBA / PS_TARGET_GB / PS_TARGET_NES"'); +put("#endif"); +put(); +put("#endif /* PS_SPEC_GEN_H */"); + +const out = join(import.meta.dir, "..", "runtime", "gen", "spec_gen.h"); +await Bun.write(out, lines.join("\n") + "\n"); +console.log(`wrote ${out} (${lines.length} lines)`); diff --git a/static/spec/isa.ts b/static/spec/isa.ts new file mode 100644 index 00000000..c1e5b13e --- /dev/null +++ b/static/spec/isa.ts @@ -0,0 +1,393 @@ +// static/spec/isa.ts — the Pocket Static core contract, single source of truth. +// +// Everything binary that is CATEGORY-INDEPENDENT lives here: the stack-VM ISA, +// the blob/text encodings, the per-target hardware table, and the runtime +// debug block. Category specs (spec/rpg.ts, ...) extend the opcode space from +// SYSCALL_BASE up and define their own record layouts. +// +// Both sides derive from this file: +// - the compiler (static/compiler/*) ENCODES these layouts, +// - every C runtime DECODES them via the generated gen/spec_gen.h +// (static/spec/gen-c.ts), so C can never drift from TS, +// - static/vm/ref.ts INTERPRETS the ISA on the host — the reference +// implementation the emulator suites hold the consoles to. +// +// Conventions (non-negotiable): +// - Little-endian everywhere (ARM7TDMI, SM83 and 6502 are all LE). +// - All VM values are signed 16-bit integers (i16). +// - Relative jumps are measured from AFTER the 2-byte operand. +// - Offsets in comments are from the start of the containing record. + +// --------------------------------------------------------------------------- +// Targets +// --------------------------------------------------------------------------- +export type TargetName = "gba" | "gb" | "nes"; + +export interface TargetSpec { + name: TargetName; + /** Visible screen in 8x8 tiles. */ + screenTilesW: number; + screenTilesH: number; + /** Bytes per 8x8 tile in the native format. */ + tileBytes: number; + /** Textbox text area: columns and lines per page (compile-time wrapping). */ + textCols: number; + textLines: number; + /** Max options per CHOICE menu (one line each). */ + maxChoices: number; + /** + * Total BG tiles in the runtime's tile table. The layout convention is + * FIXED on every target (no header fields needed): + * 0 = blank + * 1..total-99 = game art (bgArtTiles budget) + * total-97 = choice cursor + * total-96 = textbox fill + * total-95..total-1 = the 95 ASCII font glyphs + */ + bgTotalTiles: number; + /** BG tile budget for game art (= bgTotalTiles - 98). */ + bgArtTiles: number; + /** OBJ tile budget in 8x8 tiles. */ + objTiles: number; + /** Absolute bus address of the debug block. */ + debugAddr: number; + /** Max map size in tiles. */ + maxMapW: number; + maxMapH: number; + /** Whether the camera may scroll (NES v1 is one static nametable). */ + scrolls: boolean; + /** ROM file extension. */ + ext: string; +} + +export const TARGETS: Record = { + gba: { + name: "gba", + screenTilesW: 30, + screenTilesH: 20, + tileBytes: 32, // 4bpp + textCols: 28, + textLines: 3, + maxChoices: 4, + bgTotalTiles: 512, // charblock 0 + bgArtTiles: 414, + objTiles: 1024, + debugAddr: 0x02000000, // EWRAM base; linker keeps EWRAM empty + maxMapW: 32, + maxMapH: 30, + scrolls: true, + ext: ".gba", + }, + gb: { + name: "gb", + screenTilesW: 20, + screenTilesH: 18, + tileBytes: 16, // 2bpp interleaved + textCols: 18, + textLines: 3, + maxChoices: 4, + bgTotalTiles: 256, // signed addressing window 0x8800-0x97FF + bgArtTiles: 158, + objTiles: 128, // OBJ block 0x8000-0x87FF + debugAddr: 0xdf00, // top of WRAM; our crt0 parks SP just below + maxMapW: 32, + maxMapH: 30, + scrolls: true, + ext: ".gb", + }, + nes: { + name: "nes", + screenTilesW: 32, + screenTilesH: 30, + tileBytes: 16, // 2bpp planar + textCols: 28, + textLines: 3, + maxChoices: 4, + bgTotalTiles: 256, // CHR-RAM pattern table 0 + bgArtTiles: 158, + objTiles: 256, // pattern table 1 + debugAddr: 0x0700, // top page of the 2 KB CPU RAM + maxMapW: 32, + // One nametable is 32x30, but the textbox permanently owns tile rows + // 24-29 (attribute cells are 16px — the box must own whole attr rows), + // so maps stop at 24 and closing the box restores blank fills only. + maxMapH: 24, + scrolls: false, + ext: ".nes", + }, +}; + +export const TILE_PX = 8; + +/** Fixed BG tile layout (see TargetSpec.bgTotalTiles). */ +export const bgLayout = (t: TargetSpec) => ({ + blank: 0, + artBase: 1, + cursorTile: t.bgTotalTiles - 97, + boxTile: t.bgTotalTiles - 96, + fontBase: t.bgTotalTiles - 95, +}); + +// --------------------------------------------------------------------------- +// Core stack-VM ISA — opcodes 0x00..0x3F. Categories own 0x40+. +// +// The VM: operand stack of i16 (depth VM_STACK), call stack of VM_FRAMES +// frames, each frame owning VM_LOCALS fresh local slots. Globals: VM_VARS +// i16 vars + VM_FLAGS single-bit flags. One script runs at a time; blocking +// ops SUSPEND the VM (the frame loop resumes it when the condition clears). +// The interpreter executes at most VM_BURST ops per frame between suspensions +// (runaway-loop guard: a script that never suspends still yields the frame). +// --------------------------------------------------------------------------- +export const OP = { + END: 0x00, // terminate the whole script (any depth) + NOP: 0x01, + PUSH8: 0x02, // i8 push sign-extended + PUSH16: 0x03, // i16 push + POP: 0x04, // discard top + DUP: 0x05, // duplicate top + JMP: 0x06, // rel16 ip += rel + JZ: 0x07, // rel16 a=pop; if a==0 ip += rel + JNZ: 0x08, // rel16 a=pop; if a!=0 ip += rel + CALL: 0x09, // u16 scriptId push frame, jump to script entry + RET: 0x0a, // pop frame; at depth 0 acts like END + LDV: 0x0b, // u8 varId push vars[id] + STV: 0x0c, // u8 varId vars[id] = pop + LDL: 0x0d, // u8 slot push locals[slot] + STL: 0x0e, // u8 slot locals[slot] = pop + FLAG: 0x0f, // u8 flagId push flags[id] (0/1) + SETF: 0x10, // u8 flagId flags[id] = 1 + CLRF: 0x11, // u8 flagId flags[id] = 0 + STF: 0x12, // u8 flagId flags[id] = (pop != 0) + ADD: 0x13, // b=pop,a=pop, push a+b (i16 wrap) + SUB: 0x14, // push a-b + MUL: 0x15, // push a*b (low 16) + DIV: 0x16, // push a/b trunc; b==0 -> 0 + MOD: 0x17, // push a%b (sign of a); b==0 -> 0 + NEG: 0x18, // push -pop + EQ: 0x19, // push a==b + NE: 0x1a, + LT: 0x1b, // signed + GT: 0x1c, + LE: 0x1d, + GE: 0x1e, + NOT: 0x1f, // push pop==0 + RND: 0x20, // n=pop, push uniform 0..n-1 (n<=0 -> 0) + WAIT: 0x21, // n=pop, SUSPEND n frames +} as const; +export type CoreOpName = keyof typeof OP; + +/** First category-owned opcode. */ +export const SYSCALL_BASE = 0x40; + +/** Operand bytes AFTER the opcode byte, for core ops. */ +export const OP_OPERANDS: Record = { + [OP.END]: 0, + [OP.NOP]: 0, + [OP.PUSH8]: 1, + [OP.PUSH16]: 2, + [OP.POP]: 0, + [OP.DUP]: 0, + [OP.JMP]: 2, + [OP.JZ]: 2, + [OP.JNZ]: 2, + [OP.CALL]: 2, + [OP.RET]: 0, + [OP.LDV]: 1, + [OP.STV]: 1, + [OP.LDL]: 1, + [OP.STL]: 1, + [OP.FLAG]: 1, + [OP.SETF]: 1, + [OP.CLRF]: 1, + [OP.STF]: 1, + [OP.ADD]: 0, + [OP.SUB]: 0, + [OP.MUL]: 0, + [OP.DIV]: 0, + [OP.MOD]: 0, + [OP.NEG]: 0, + [OP.EQ]: 0, + [OP.NE]: 0, + [OP.LT]: 0, + [OP.GT]: 0, + [OP.LE]: 0, + [OP.GE]: 0, + [OP.NOT]: 0, + [OP.RND]: 0, + [OP.WAIT]: 0, +}; + +export const VM_STACK = 16; // operand stack depth (i16 entries) +export const VM_FRAMES = 4; // call depth (top-level script = frame 0) +export const VM_LOCALS = 16; // local slots per frame +export const VM_VARS = 64; // global i16 vars +export const VM_FLAGS = 256; // global flags (bit set) +export const VM_BURST = 64; // max ops per frame between suspensions + +// Vars 60..63 are compiler scratch (text interpolation slots etc). +export const VAR_SCRATCH_BASE = 60; +export const VAR_USER_MAX = VAR_SCRATCH_BASE; // user vars must intern below this + +/** + * Cross-target key bitmask (the HAL normalizes each console's pad to this; + * harness scenarios use the names). + */ +export const KEYS = { + A: 0x01, + B: 0x02, + SELECT: 0x04, + START: 0x08, + RIGHT: 0x10, + LEFT: 0x20, + UP: 0x40, + DOWN: 0x80, +} as const; +export type KeyName = keyof typeof KEYS; + +/** Suspension reasons (debug block WAITING field + runtime state). */ +export const WAITING = { + NONE: 0, + TEXT: 1, // SAY page on screen, waiting for A + CHOICE: 2, // CHOICE menu up, waiting for pick + FRAMES: 3, // WAIT counting down +} as const; + +// Deterministic RNG: xorshift16, advanced ONLY by OP.RND. Same seed, same +// script => same story on every console (and in vm/ref.ts). +// x ^= x << 7; x ^= x >> 9; x ^= x << 8; (all 16-bit) +export const RNG_SEED = 0x2a17; + +export function rngNext(state: number): number { + let x = state & 0xffff; + x ^= (x << 7) & 0xffff; + x ^= x >> 9; + x ^= (x << 8) & 0xffff; + return x & 0xffff; +} + +// --------------------------------------------------------------------------- +// Text encoding — token streams, wrapped and paginated at COMPILE TIME per +// target. The runtime never measures text. +// 0x00 end of page +// 0x0A newline (next line) +// 0x01, u8 v FMT slot: render vars[v] as signed decimal (typewriter +// reveals it as one unit); v is a VAR_SCRATCH_BASE.. slot +// 0x20..0x7E ASCII literal -> font glyph (byte - 0x20) +// --------------------------------------------------------------------------- +export const TOK_END = 0x00; +export const TOK_FMT = 0x01; +export const TOK_NEWLINE = 0x0a; +export const TOK_ASCII_MIN = 0x20; +export const TOK_ASCII_MAX = 0x7e; +export const FONT_GLYPHS = TOK_ASCII_MAX - TOK_ASCII_MIN + 1; // 95 +/** Cells reserved when wrapping a FMT slot (i16 max is "-32768" = 6; we + * assume gameplay numbers and reserve 5). */ +export const FMT_CELLS = 5; + +// --------------------------------------------------------------------------- +// Blobs — the unit of data placement. GBA concatenates them flat; GB/NES +// assign each blob wholly into one 16 KB switchable bank and latch per +// access. Blob ids are u8, dense from 0, in BLOB_KIND declaration order per +// game (the compiler emits a per-target directory; see targets/*). +// --------------------------------------------------------------------------- +export const BANK_SIZE = 0x4000; // GB/NES switchable window +export const BLOB_KIND = { + SCRIPTS: 0, // bytecode, all scripts concatenated (single blob, <= 16 KB) + TEXT: 1, // token streams (may be several TEXT blobs) + MAP: 2, // one blob per map (grid + collision + actors + warps + triggers) + TILES_BG: 3, // native-encoded BG art tiles + TILES_OBJ: 4, // native-encoded OBJ tiles + FONT: 5, // 95 glyphs, native encoding +} as const; + +// Script table (fixed region): u16 byte offset into the SCRIPTS blob per id. +// Text table (fixed region): per text id, 3 bytes: u8 blobId, u16 offset. +export const TEXT_ENTRY_SIZE = 3; + +// --------------------------------------------------------------------------- +// Debug block — every runtime mirrors this to TARGETS[t].debugAddr each +// frame; emulator harnesses read it over the bus. Same layout on all targets. +// --------------------------------------------------------------------------- +export const DBG = { + MAGIC: 0x00, // u32 'PSDB' bytes P,S,D,B + BOOTED: 0x04, // u8 1 once the main loop runs + PLAYER_DIR: 0x05, // u8 DIR.* + CUR_MAP: 0x06, // u8 + TEXT_ACTIVE: 0x07, // u8 + SCRIPT_ACTIVE: 0x08, // u8 + CHOICE_CURSOR: 0x09, // u8 + WAITING: 0x0a, // u8 WAITING.* + RESERVED0: 0x0b, // u8 + PLAYER_X: 0x0c, // u16 tile + PLAYER_Y: 0x0e, // u16 tile + CUR_TEXT: 0x10, // u16 text id shown (0xFFFF none) + CUR_SCRIPT: 0x12, // u16 running script id (0xFFFF none) + FRAME: 0x14, // u32 + RNG: 0x18, // u16 rng state + RESERVED1: 0x1a, // u16 + FLAGS: 0x1c, // u8[32] flag n -> byte n>>3, bit n&7 + VARS: 0x3c, // i16[64] +} as const; +export const DEBUG_MAGIC_BYTES = [0x50, 0x53, 0x44, 0x42] as const; // P,S,D,B +export const DEBUG_BLOCK_SIZE = DBG.VARS + VM_VARS * 2; // 0xBC = 188 + +export const dbgAddr = (t: TargetName, field: keyof typeof DBG): number => + TARGETS[t].debugAddr + DBG[field]; +export const dbgFlagAddr = (t: TargetName, flagId: number): { addr: number; bit: number } => ({ + addr: TARGETS[t].debugAddr + DBG.FLAGS + (flagId >> 3), + bit: flagId & 7, +}); +export const dbgVarAddr = (t: TargetName, varId: number): number => + TARGETS[t].debugAddr + DBG.VARS + varId * 2; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- +export const i16 = (v: number): number => { + const x = v & 0xffff; + return x >= 0x8000 ? x - 0x10000 : x; +}; + +export function rgb555(r: number, g: number, b: number): number { + return (((b >> 3) & 0x1f) << 10) | (((g >> 3) & 0x1f) << 5) | ((r >> 3) & 0x1f); +} + +/** Little-endian byte builder used by every compiler stage. */ +export class ByteWriter { + private buf: number[] = []; + get length(): number { + return this.buf.length; + } + u8(v: number): this { + this.buf.push(v & 0xff); + return this; + } + u16(v: number): this { + this.buf.push(v & 0xff, (v >> 8) & 0xff); + return this; + } + i16(v: number): this { + return this.u16(v & 0xffff); + } + u32(v: number): this { + this.buf.push(v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff); + return this; + } + bytes(b: ArrayLike): this { + for (let i = 0; i < b.length; i++) this.buf.push(b[i] & 0xff); + return this; + } + ascii(s: string, fixedLen?: number): this { + const n = fixedLen ?? s.length; + for (let i = 0; i < n; i++) this.buf.push(i < s.length ? s.charCodeAt(i) & 0xff : 0); + return this; + } + patchU16(at: number, v: number): this { + this.buf[at] = v & 0xff; + this.buf[at + 1] = (v >> 8) & 0xff; + return this; + } + toUint8Array(): Uint8Array { + return Uint8Array.from(this.buf); + } +} diff --git a/static/spec/rpg.ts b/static/spec/rpg.ts new file mode 100644 index 00000000..4389fe17 --- /dev/null +++ b/static/spec/rpg.ts @@ -0,0 +1,168 @@ +// static/spec/rpg.ts — the RPG category contract: syscall opcodes 0x40+ and +// the binary record layouts of the RPG game model. Extends spec/isa.ts; the +// same derivation rules apply (compiler encodes, gen/spec_gen.h keeps C +// honest, vm/ref.ts + rpg host stubs interpret on the host). +// +// A category is: syscalls + records + budgets + a portable runtime module. +// This file is the whole binary surface of the RPG category. + +import { SYSCALL_BASE } from "./isa.ts"; + +// --------------------------------------------------------------------------- +// Syscalls (0x40..). Blocking ops SUSPEND the VM; value ops push a result. +// --------------------------------------------------------------------------- +export const RPG_OP = { + SAY: SYSCALL_BASE + 0x00, // u16 textId page up, SUSPEND until A + CHOICE: SYSCALL_BASE + 0x01, // u8 n, u16 t0.. menu, SUSPEND, push index + LOCK: SYSCALL_BASE + 0x02, // player input off (movement) + RELEASE: SYSCALL_BASE + 0x03, // player input on + FACE: SYSCALL_BASE + 0x04, // u8 slot actor faces the player; 0xFF = the actor that started this script + AVIS: SYSCALL_BASE + 0x05, // u8 slot, u8 on actor visible/hidden (hidden = no draw, no collide, no talk) + WARP: SYSCALL_BASE + 0x06, // u8 map, u8 x, u8 y, u8 dir move player (loads map if different) + SFX: SYSCALL_BASE + 0x07, // u8 id square-wave blip (SFX.*) +} as const; + +export const RPG_OP_OPERANDS: Record = { + [RPG_OP.SAY]: 2, + [RPG_OP.CHOICE]: -1, // variable: 1 + 2*n + [RPG_OP.LOCK]: 0, + [RPG_OP.RELEASE]: 0, + [RPG_OP.FACE]: 1, + [RPG_OP.AVIS]: 2, + [RPG_OP.WARP]: 4, + [RPG_OP.SFX]: 1, +}; + +export const FACE_SELF = 0xff; + +export const SFX = { + CONFIRM: 0, + DENY: 1, + DAMAGE: 2, + HEAL: 3, + FANFARE: 4, +} as const; +export type SfxName = keyof typeof SFX; + +// --------------------------------------------------------------------------- +// Directions / movement +// --------------------------------------------------------------------------- +export const DIR = { DOWN: 0, UP: 1, LEFT: 2, RIGHT: 3 } as const; +export type DirName = "down" | "up" | "left" | "right"; +export const DIR_BY_NAME: Record = { down: 0, up: 1, left: 2, right: 3 }; +export const DIR_DX = [0, 0, -1, 1] as const; +export const DIR_DY = [1, -1, 0, 0] as const; + +export const MOVE = { STATIC: 0, WANDER: 1 } as const; +export type MoveName = "static" | "wander"; +// Wander cadence: an actor attempts one random step every WANDER_PERIOD +// frames (per-slot phase offset = slot * WANDER_PHASE), using its own tiny +// LCG so gameplay RNG (OP.RND) stays script-deterministic. +export const WANDER_PERIOD = 96; +export const WANDER_PHASE = 17; + +// Player/actor movement: 8px logical grid, 2px per frame => 4 frames per +// tile step. Walk animation flips frame every 8px moved. +export const STEP_PX = 2; + +// --------------------------------------------------------------------------- +// Game header (fixed region, GAME_HEADER_SIZE bytes) +// 0 u8[16] title (ascii, null-padded; also feeds cart headers) +// 16 u8 start_map +// 17 u8 start_x (tile) +// 18 u8 start_y +// 19 u8 start_dir +// 20 u8 map_count +// 21 u8 sprite_count +// 22 u16 text_count +// 24 u16 script_count +// 26 u8 player_sprite +// 27 u8 reserved +// = 28 bytes +// --------------------------------------------------------------------------- +export const GAME_TITLE_LEN = 16; +export const GAME_HEADER_SIZE = 28; + +// --------------------------------------------------------------------------- +// Map blob (one BLOB_KIND.MAP per map). Header, then tables; *_off relative +// to blob start. +// 0 u8 width (tiles) +// 1 u8 height +// 2 u8 actor_count +// 3 u8 warp_count +// 4 u8 trigger_count +// 5 u8 reserved +// 6 u16 on_enter (script id, 0xFFFF = none; runs on map load) +// 8 u16 tiles_off -> u8[w*h] tile ids (row-major) +// 10 u16 collision_off -> u8[ceil(w*h/8)] solidity bitset (bit = x + y*w) +// 12 u16 actors_off -> Actor[actor_count] +// 14 u16 warps_off -> Warp[warp_count] +// 16 u16 triggers_off -> Trigger[trigger_count] +// 18 u16 reserved +// = 20 bytes +// --------------------------------------------------------------------------- +export const MAP_HEADER_SIZE = 20; + +// Actor (8 bytes): +// 0 u8 x (tile) +// 1 u8 y +// 2 u8 sprite_id +// 3 u8 facing DIR.* +// 4 u8 move MOVE.* +// 5 u8 flags ACTOR_F.* (SOLID | HIDDEN) +// 6 u16 on_talk script id, 0xFFFF = none +export const ACTOR_SIZE = 8; +export const ACTOR_F = { SOLID: 1, HIDDEN: 2 } as const; +export const MAX_ACTORS_PER_MAP = 16; + +// Warp (6 bytes): stepping onto (x,y) relocates the player. +// 0 u8 x +// 1 u8 y +// 2 u8 dest_map +// 3 u8 dest_x +// 4 u8 dest_y +// 5 u8 dest_dir +export const WARP_SIZE = 6; + +// Trigger (6 bytes): stepping onto (x,y) runs a script. +// 0 u8 x +// 1 u8 y +// 2 u16 script (0xFFFF = none) +// 4 u8 flags TRIGGER_F.* +// 5 u8 once_flag (flag id armed when TRIGGER_F.ONCE; skip if set) +export const TRIGGER_SIZE = 6; +export const TRIGGER_F = { ONCE: 1 } as const; + +export const SCRIPT_NONE = 0xffff; +export const TEXT_NONE = 0xffff; + +// --------------------------------------------------------------------------- +// Sprites — 16x16 actors, 4 facings, 1..2 walk frames per facing. +// Sprite table entry (4 bytes), OBJ tiles indexed in 8x8 units: +// 0 u16 tile_base (facing/frame block: dir*frames + frame, 4 tiles each, +// order TL,TR,BL,BR) +// 2 u8 frames (1 or 2) +// 3 u8 palette (OBJ palette bank / NES OBJ subpalette) +// LEFT renders as RIGHT h-flipped via OAM attributes on every target (GBA +// OBJ attr, GB OAM flags, NES OAM byte 2 — all have H-flip bits). +// Facing order in tile data: DOWN, UP, RIGHT. The per-frame TILE ORDER is +// target-chosen by the packager (GBA 1D: TL,TR,BL,BR; GB/NES 8x16 pairs: +// TL,BL,TR,BR) — tile_base counts in that native order. +// --------------------------------------------------------------------------- +export const SPRITE_ENTRY_SIZE = 4; +export const SPRITE_PX = 16; +export const SPRITE_FACINGS = 3; // DOWN, UP, RIGHT (LEFT mirrors RIGHT) +export const SPRITE_TILES_PER_FRAME = 4; + +// --------------------------------------------------------------------------- +// Budgets (compile-time errors) +// --------------------------------------------------------------------------- +export const RPG_BUDGET = { + MAX_MAPS: 32, + MAX_SPRITES: 12, + MAX_TEXTS: 512, + MAX_SCRIPTS: 256, + MAX_SCRIPT_BLOB: 0x4000, // one bank + MAX_TEXT_BLOB: 0x4000, // per TEXT blob; the compiler splits + MAX_TILESET_TILES: 158, // shared GB/NES ceiling; GBA checked separately +} as const; diff --git a/static/test/e2e.ts b/static/test/e2e.ts new file mode 100644 index 00000000..26ce69df --- /dev/null +++ b/static/test/e2e.ts @@ -0,0 +1,266 @@ +#!/usr/bin/env bun +// static/test/e2e.ts — the cross-target contract suite. +// +// Build the smoke game for each target, then drive the same logical +// playthrough through each console's headless emulator (libmgba for gba/gb, +// jsnes for nes) and assert on the shared debug block. The REFERENCE VM is +// the oracle: the expected choice-round count, page counts and final +// vars/flags are computed by playing the story on vm/ref.ts first — the +// consoles just have to agree with it. +// +// bun static/test/e2e.ts # all targets with a runtime +// bun static/test/e2e.ts gba gb # subset + +import { $ } from "bun"; +import { join } from "node:path"; +import { compileGame, type CompileOutput } from "../compiler/index.ts"; +import { buildGba } from "../compiler/targets/gba.ts"; +import { buildGb } from "../compiler/targets/gb.ts"; +import { buildNes } from "../compiler/targets/nes.ts"; +import { DBG, KEYS, TARGETS, dbgFlagAddr, dbgVarAddr, type TargetName } from "../spec/isa.ts"; +import { RefVM } from "../vm/ref.ts"; +import { AutoRpgHost } from "../vm/rpg-host.ts"; + +const HERE = import.meta.dir; +const DIST = join(HERE, "..", "dist"); +const SHOTS = join(DIST, "shots"); +const ENTRY = join(HERE, "smoke", "game.ts"); +const RUNNER = join(HERE, "harness", "mgba_runner"); +const NES_RUNNER = join(HERE, "harness", "nes_runner.ts"); + +let passed = 0; +let failed = 0; +function check(name: string, got: unknown, want: unknown): void { + const ok = got === want; + console.log(` ${ok ? "\x1b[32mPASS\x1b[0m" : "\x1b[31mFAIL\x1b[0m"} ${name}: ${got}${ok ? "" : ` (want ${want})`}`); + ok ? passed++ : failed++; +} + +// --------------------------------------------------------------------------- +// Scenario builder (line protocol shared by both harnesses) +// --------------------------------------------------------------------------- +class Scenario { + lines: string[] = []; + advance(frames: number): this { + this.lines.push(`A ${frames}`); + return this; + } + press(keys: (keyof typeof KEYS)[], hold = 2, release = 6): this { + const mask = keys.reduce((m, k) => m | KEYS[k], 0); + this.lines.push(`P ${mask.toString(16)} ${hold} ${release}`); + return this; + } + read(name: string, addr: number, size: 1 | 2 | 4): this { + this.lines.push(`R ${name} 0x${addr.toString(16)} ${size}`); + return this; + } + shot(path: string): this { + this.lines.push(`S ${path}`); + return this; + } +} + +async function run(target: TargetName, rom: string, sc: Scenario): Promise> { + const file = join(DIST, `scenario-${target}.txt`); + await Bun.write(file, sc.lines.join("\n") + "\n"); + const out = + target === "nes" ? await $`bun ${NES_RUNNER} ${rom} ${file}`.text() : await $`${RUNNER} ${rom} ${file}`.text(); + const line = out.trim().split("\n").reverse().find((l) => l.startsWith("{")); + if (!line) throw new Error(`runner produced no JSON:\n${out}`); + const parsed = JSON.parse(line); + if (!parsed.ok) throw new Error(`runner error: ${line}`); + return parsed.reads ?? {}; +} + +// --------------------------------------------------------------------------- +// Oracle: play the guide fight on the reference VM +// --------------------------------------------------------------------------- +interface Oracle { + /** say-page text ids in on-screen order */ + sayPages: number[]; + choiceCount: number; + hp: number; + cheers: number; + subCalls: number; +} + +function oracle(out: CompileOutput): Oracle { + const code = out.linked.blobs[out.linked.scriptBlobIndex].bytes; + const host = new AutoRpgHost(Array(30).fill(0)); + const vm = new RefVM(code, out.linked.scriptTable, host); + host.play(vm, out.debug.scripts.GuideTalk); + return { + sayPages: host.events.filter((e) => e.kind === "say").map((e) => (e as { textId: number }).textId), + choiceCount: host.events.filter((e) => e.kind === "choice").length, + hp: vm.getVar(out.debug.vars.hp), + cheers: vm.getVar(out.debug.vars.cheers), + subCalls: vm.getVar(out.debug.vars.sub_calls), + }; +} + +// --------------------------------------------------------------------------- +// The playthrough, per target +// --------------------------------------------------------------------------- +async function testTarget(target: TargetName): Promise { + console.log(`\n=== ${target.toUpperCase()} ===`); + const out = await compileGame(ENTRY, target); + const rom = join(DIST, `smoke${TARGETS[target].ext}`); + if (target === "gba") await buildGba(out.linked, rom); + else if (target === "gb") await buildGb(out.linked, rom); + else await buildNes(out.linked, rom); + await $`mkdir -p ${SHOTS}`.quiet(); + + const A = (f: keyof typeof DBG) => TARGETS[target].debugAddr + DBG[f]; + const orc = oracle(out); + const { vars, flags } = out.debug; + const shot = (n: string) => join(SHOTS, `${target}_${n}.ppm`); + // Generous page-reveal wait: longest page is < 90 tokens at 2/frame, + // plus queued-clear latency on the 8-bit consoles. + const REVEAL = 70; + + console.log("boot & spawn"); + { + const r = await run( + target, + rom, + new Scenario() + .advance(30) + .read("boot", A("BOOTED"), 1) + .read("x", A("PLAYER_X"), 2) + .read("y", A("PLAYER_Y"), 2) + .read("map", A("CUR_MAP"), 1) + .read("dir", A("PLAYER_DIR"), 1) + .shot(shot("01_boot")), + ); + check("booted", r.boot, 1); + check("spawn x", r.x, 5); + check("spawn y", r.y, 1); + check("map", r.map, 0); + check("dir down", r.dir, 0); + } + + console.log("walk, collide with npc, talk, choice, battle, flag"); + { + const sc = new Scenario() + .advance(30) + // two tiles left; third is the guide (solid) — hold long enough for 3 + .press(["LEFT"], 20, 4) + .read("blockedX", A("PLAYER_X"), 2) + .read("dirLeft", A("PLAYER_DIR"), 1) + .press(["A"], 2, 6) // talk + .read("script1", A("SCRIPT_ACTIVE"), 1) + .advance(REVEAL) + .read("text1", A("TEXT_ACTIVE"), 1) + .read("page1", A("CUR_TEXT"), 2) + .shot(shot("02_dialogue")) + .press(["A"], 2, 6) // dismiss page -> choice + .advance(10) + .read("waitChoice", A("WAITING"), 1) + .read("cursor0", A("CHOICE_CURSOR"), 1) + .press(["DOWN"], 2, 4) + .read("cursor1", A("CHOICE_CURSOR"), 1) + .press(["UP"], 2, 4) + .read("cursorBack", A("CHOICE_CURSOR"), 1) + .shot(shot("03_choice")) + .press(["A"], 2, 8); // pick "Spar" + // battle rounds: one choice each (pick "Strike"), menu redraws between + for (let i = 1; i < orc.choiceCount; i++) sc.advance(30).press(["A"], 2, 8); + // win pages (FMT slot included), one A per page + for (let i = 1; i < orc.sayPages.length; i++) sc.advance(REVEAL).shot(shot("04_win")).press(["A"], 2, 6); + sc.advance(10) + .read("scriptEnd", A("SCRIPT_ACTIVE"), 1) + .read("textEnd", A("TEXT_ACTIVE"), 1) + .read("flagWon", dbgFlagAddr(target, flags.beat_guide).addr, 1) + .read("hp", dbgVarAddr(target, vars.hp), 2) + .read("cheers", dbgVarAddr(target, vars.cheers), 2) + .read("subCalls", dbgVarAddr(target, vars.sub_calls), 2) + // re-talk takes the short flag branch + .press(["A"], 2, 6) + .advance(REVEAL) + .read("retalkText", A("TEXT_ACTIVE"), 1) + .read("retalkPage", A("CUR_TEXT"), 2); + const r = await run(target, rom, sc); + check("blocked at guide (x=4)", r.blockedX, 4); + check("faces left", r.dirLeft, 2); + check("script active", r.script1, 1); + check("textbox up", r.text1, 1); + check("first page id", r.page1, orc.sayPages[0]); + check("choice waiting", r.waitChoice, 2); + check("cursor 0", r.cursor0, 0); + check("cursor down", r.cursor1, 1); + check("cursor up", r.cursorBack, 0); + check("script ended", r.scriptEnd, 0); + check("textbox closed", r.textEnd, 0); + const fw = dbgFlagAddr(target, flags.beat_guide); + check("beat_guide set", (r.flagWon >> fw.bit) & 1, 1); + check("hp matches reference vm", (r.hp << 16) >> 16, orc.hp); + check("cheers (macro unroll)", r.cheers, orc.cheers); + check("sub calls (CALL/RET)", r.subCalls, orc.subCalls); + check("re-talk textbox", r.retalkText, 1); + const retalk = out.debug.texts.findIndex((t) => t.replace(/\n/g, " ").includes("already won")); + check("re-talk page", r.retalkPage, retalk); + } + + console.log("trigger (once), reveal actor, talk, scripted warp, walk-on warp"); + { + const r = await run( + target, + rom, + new Scenario() + .advance(30) + // (5,1) -> (5,4): hold DOWN through 3 tiles + .press(["DOWN"], 24, 4) + .read("y4", A("PLAYER_Y"), 2) + // left onto the trigger at (4,4) + .press(["LEFT"], 8, 4) + .advance(10) + .read("trigFlag", dbgFlagAddr(target, flags.trigger_hit).addr, 1) + // walk to the revealed intern at (8,3): right along row 4 until the + // east wall stops us (overshoot-safe), then face up into the intern. + .press(["RIGHT"], 28, 4) + .read("x8", A("PLAYER_X"), 2) + .press(["UP"], 8, 4) + .read("faceY", A("PLAYER_Y"), 2) + .read("dirUp", A("PLAYER_DIR"), 1) + .press(["A"], 2, 6) // talk to intern + .advance(REVEAL) + .read("internSay", A("TEXT_ACTIVE"), 1) + .press(["A"], 2, 6) // dismiss -> script warps to street + .advance(10) + .read("mapStreet", A("CUR_MAP"), 1) + .read("sx", A("PLAYER_X"), 2) + .read("sy", A("PLAYER_Y"), 2) + .shot(shot("05_street")) + // walk-on warp back: exactly one step down (the office south entrance + // sits directly above the office->street warp, so a long hold would + // ping-pong through both doors) + .press(["DOWN"], 4, 8) + .advance(10) + .read("mapBack", A("CUR_MAP"), 1) + .read("bx", A("PLAYER_X"), 2) + .read("by", A("PLAYER_Y"), 2) + // trigger tile is where we land (4,4) but once-flag is set: no re-run + .read("script0", A("SCRIPT_ACTIVE"), 1), + ); + check("walked down to y=4", r.y4, 4); + const tf = dbgFlagAddr(target, flags.trigger_hit); + check("trigger fired", (r.trigFlag >> tf.bit) & 1, 1); + check("wall-stopped at x=8", r.x8, 8); + check("blocked by revealed intern (y stays 4)", r.faceY, 4); + check("faces up", r.dirUp, 1); + check("intern talk textbox", r.internSay, 1); + check("scripted warp -> street", r.mapStreet, 1); + check("street door x", r.sx, 5); + check("street door y", r.sy, 2); + check("walk-on warp back -> office", r.mapBack, 0); + check("south entrance x", r.bx, 4); + check("south entrance y", r.by, 4); + check("once-trigger did not rerun", r.script0, 0); + } +} + +const requested = process.argv.slice(2) as TargetName[]; +const targets: TargetName[] = requested.length ? requested : (["gba", "gb", "nes"] as TargetName[]); +for (const t of targets) await testTarget(t); +console.log(`\n${failed === 0 ? "\x1b[32m" : "\x1b[31m"}${passed} passed, ${failed} failed\x1b[0m`); +process.exit(failed === 0 ? 0 : 1); diff --git a/static/test/harness/build.ts b/static/test/harness/build.ts new file mode 100644 index 00000000..58fa3b0b --- /dev/null +++ b/static/test/harness/build.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env bun +// static/test/harness/build.ts — build mgba_runner against Homebrew libmgba. +// bun static/test/harness/build.ts + +import { $ } from "bun"; +import { join } from "node:path"; + +const prefix = (await $`brew --prefix mgba`.text()).trim(); +const here = import.meta.dir; +const out = join(here, "mgba_runner"); + +await $`clang -O2 -Wall -I${prefix}/include ${join(here, "mgba_runner.c")} -L${prefix}/lib -lmgba -Wl,-rpath,${prefix}/lib -o ${out}`; +console.log(`built ${out}`); diff --git a/static/test/harness/mgba_runner.c b/static/test/harness/mgba_runner.c new file mode 100644 index 00000000..6e62d708 --- /dev/null +++ b/static/test/harness/mgba_runner.c @@ -0,0 +1,128 @@ +/* static/test/harness/mgba_runner.c — headless GBA/GB scenario driver on + * libmgba (Homebrew). One binary serves both consoles: mCoreFind picks the + * core from the ROM. + * + * mgba_runner + * + * Scenario: one command per line (machine-generated by test/e2e.ts): + * A advance + * P press keys, run hold frames, release, + * run release frames + * R read 1/2/4 bytes little-endian + * S screenshot (PPM P6) + * Output: one JSON object on stdout: {"ok":true,"reads":{...}}. + * The key mask uses the PS_KEY_* bit order, which matches mGBA's GBA_KEY + * order for the low 8 bits on both cores. + */ +#include +#include +#include +#include + +#include +#include + +static struct mCore *core; +static uint32_t *videoBuffer; +static unsigned vw, vh; + +static void run_frames(int n) { + int i; + for (i = 0; i < n; i++) core->runFrame(core); +} + +static void screenshot(const char *path) { + FILE *f = fopen(path, "wb"); + unsigned x, y; + if (!f) return; + fprintf(f, "P6\n%u %u\n255\n", vw, vh); + for (y = 0; y < vh; y++) { + for (x = 0; x < vw; x++) { + uint32_t p = videoBuffer[y * vw + x]; + unsigned char rgb[3]; + rgb[0] = p & 0xff; + rgb[1] = (p >> 8) & 0xff; + rgb[2] = (p >> 16) & 0xff; + fwrite(rgb, 1, 3, f); + } + } + fclose(f); +} + +int main(int argc, char **argv) { + FILE *sc; + char line[1024]; + int first_read = 1; + + if (argc != 3) { + fprintf(stderr, "usage: mgba_runner \n"); + return 2; + } + + core = mCoreFind(argv[1]); + if (!core) { + printf("{\"ok\":false,\"error\":\"no core for rom\"}\n"); + return 1; + } + core->init(core); + mCoreConfigInit(&core->config, "mgba_runner"); + mCoreConfigSetDefaultValue(&core->config, "idleOptimization", "ignore"); + core->loadConfig(core, &core->config); + + core->desiredVideoDimensions(core, &vw, &vh); + videoBuffer = malloc((size_t)vw * vh * 4); + core->setVideoBuffer(core, (color_t *)videoBuffer, vw); + core->setAudioBufferSize(core, 0x4000); + + if (!mCoreLoadFile(core, argv[1])) { + printf("{\"ok\":false,\"error\":\"rom load failed\"}\n"); + return 1; + } + core->reset(core); + + sc = fopen(argv[2], "r"); + if (!sc) { + printf("{\"ok\":false,\"error\":\"scenario open failed\"}\n"); + return 1; + } + + printf("{\"ok\":true,\"reads\":{"); + while (fgets(line, sizeof line, sc)) { + char op = line[0]; + if (op == 'A') { + int n = 0; + sscanf(line + 1, "%d", &n); + run_frames(n); + } else if (op == 'P') { + unsigned mask = 0; + int hold = 0, release = 0; + sscanf(line + 1, "%x %d %d", &mask, &hold, &release); + core->setKeys(core, mask); + run_frames(hold); + core->setKeys(core, 0); + run_frames(release); + } else if (op == 'R') { + char name[256]; + unsigned addr = 0; + int size = 1; + uint32_t v = 0; + sscanf(line + 1, "%255s %x %d", name, &addr, &size); + v = core->busRead8(core, addr); + if (size >= 2) v |= (uint32_t)core->busRead8(core, addr + 1) << 8; + if (size == 4) { + v |= (uint32_t)core->busRead8(core, addr + 2) << 16; + v |= (uint32_t)core->busRead8(core, addr + 3) << 24; + } + printf("%s\"%s\":%u", first_read ? "" : ",", name, v); + first_read = 0; + } else if (op == 'S') { + char path[512]; + sscanf(line + 1, "%511s", path); + screenshot(path); + } + } + printf("}}\n"); + fclose(sc); + core->deinit(core); + return 0; +} diff --git a/static/test/harness/nes_runner.ts b/static/test/harness/nes_runner.ts new file mode 100644 index 00000000..e50c0df8 --- /dev/null +++ b/static/test/harness/nes_runner.ts @@ -0,0 +1,103 @@ +#!/usr/bin/env bun +// static/test/harness/nes_runner.ts — headless NES scenario driver on jsnes. +// +// bun nes_runner.ts +// +// Same line protocol as mgba_runner, with one deliberate difference: A/P +// counts are ENGINE TICKS, not video frames. cc65 code can take more than a +// video frame per rpg_tick, so the runner paces on the debug block's FRAME +// counter — scenarios stay tick-accurate at any emulation speed. + +import { Controller, NES } from "jsnes"; +import { DBG, KEYS, TARGETS } from "../../spec/isa.ts"; + +const [rom, scenarioPath] = process.argv.slice(2); +if (!rom || !scenarioPath) { + console.log(JSON.stringify({ ok: false, error: "usage: nes_runner " })); + process.exit(2); +} + +let frameBuffer: number[] | null = null; +const nes = new NES({ + onFrame: (buf: number[]) => { + frameBuffer = buf; + }, + onAudioSample: () => {}, +}); + +const bytes = new Uint8Array(await Bun.file(rom).arrayBuffer()); +nes.loadROM(Buffer.from(bytes).toString("latin1")); + +const DEBUG = TARGETS.nes.debugAddr; +const mem = (addr: number): number => nes.cpu.mem[addr] & 0xff; +const tickCount = (): number => + mem(DEBUG + DBG.FRAME) | + (mem(DEBUG + DBG.FRAME + 1) << 8) | + (mem(DEBUG + DBG.FRAME + 2) << 16) | + (mem(DEBUG + DBG.FRAME + 3) << 24); + +function advanceTicks(n: number): void { + const target = tickCount() + n; + let guard = n * 12 + 1200; // cc65 headroom: a tick may span several frames + while (tickCount() < target && guard-- > 0) nes.frame(); + if (guard <= 0) throw new Error(`tick advance stalled (wanted ${n} ticks)`); +} + +const BUTTONS: [number, number][] = [ + [KEYS.A, Controller.BUTTON_A], + [KEYS.B, Controller.BUTTON_B], + [KEYS.SELECT, Controller.BUTTON_SELECT], + [KEYS.START, Controller.BUTTON_START], + [KEYS.UP, Controller.BUTTON_UP], + [KEYS.DOWN, Controller.BUTTON_DOWN], + [KEYS.LEFT, Controller.BUTTON_LEFT], + [KEYS.RIGHT, Controller.BUTTON_RIGHT], +]; + +async function screenshot(path: string): Promise { + if (!frameBuffer) return; + const w = 256; + const h = 240; + const out = new Uint8Array(15 + w * h * 3); + const header = `P6\n${w} ${h}\n255\n`; + out.set(new TextEncoder().encode(header), 0); + let at = header.length; + for (let i = 0; i < w * h; i++) { + const p = frameBuffer[i]; + out[at++] = p & 0xff; + out[at++] = (p >> 8) & 0xff; + out[at++] = (p >> 16) & 0xff; + } + await Bun.write(path, out.subarray(0, at)); +} + +const reads: Record = {}; +const lines = (await Bun.file(scenarioPath).text()).split("\n"); +try { + for (const line of lines) { + const op = line[0]; + if (op === "A") { + advanceTicks(Number(line.slice(1).trim())); + } else if (op === "P") { + const [maskHex, hold, release] = line.slice(1).trim().split(/\s+/); + const mask = parseInt(maskHex, 16); + for (const [k, btn] of BUTTONS) if (mask & k) nes.buttonDown(1, btn); + advanceTicks(Number(hold)); + for (const [k, btn] of BUTTONS) if (mask & k) nes.buttonUp(1, btn); + advanceTicks(Number(release)); + } else if (op === "R") { + const [name, addrHex, size] = line.slice(1).trim().split(/\s+/); + const addr = parseInt(addrHex, 16); + let v = mem(addr); + if (Number(size) >= 2) v |= mem(addr + 1) << 8; + if (Number(size) === 4) v = (v | (mem(addr + 2) << 16) | (mem(addr + 3) << 24)) >>> 0; + reads[name] = v; + } else if (op === "S") { + await screenshot(line.slice(1).trim()); + } + } + console.log(JSON.stringify({ ok: true, reads })); +} catch (e) { + console.log(JSON.stringify({ ok: false, error: String(e), reads })); + process.exit(1); +} diff --git a/static/test/pipeline.test.ts b/static/test/pipeline.test.ts new file mode 100644 index 00000000..ac368518 --- /dev/null +++ b/static/test/pipeline.test.ts @@ -0,0 +1,97 @@ +// static/test/pipeline.test.ts — the whole host-side pipeline on the smoke +// game: evaluate -> compile -> model -> link, then PLAY THE STORY on the +// reference VM straight out of the linked script blob (fixups included). +// This is the game running with the consoles removed. + +import { describe, expect, test } from "bun:test"; +import { compileGame } from "../compiler/index.ts"; +import { BLOB_KIND } from "../spec/isa.ts"; +import { MAP_HEADER_SIZE, SCRIPT_NONE } from "../spec/rpg.ts"; +import { RefVM } from "../vm/ref.ts"; +import { AutoRpgHost } from "../vm/rpg-host.ts"; + +const ENTRY = new URL("./smoke/game.ts", import.meta.url).pathname; + +describe("compile pipeline (smoke game)", () => { + test("compiles for all three targets with coherent link output", async () => { + for (const target of ["gba", "gb", "nes"] as const) { + const out = await compileGame(ENTRY, target); + const { linked, debug } = out; + + expect(linked.model.title).toBe("POCKET SMOKE"); + expect(linked.blobs[linked.scriptBlobIndex].kind).toBe(BLOB_KIND.SCRIPTS); + expect(linked.scriptTable.length).toBe(5); + expect(Object.keys(debug.maps)).toEqual(["office", "street"]); + expect(debug.actors.guide).toEqual({ map: 0, slot: 0 }); + expect(debug.actors.intern).toEqual({ map: 0, slot: 1 }); + + // map blob sanity: header fields match the model + const officeBlob = linked.blobs[linked.mapBlobIndex[0]].bytes; + expect(officeBlob[0]).toBe(10); // width + expect(officeBlob[1]).toBe(6); // height + expect(officeBlob[2]).toBe(2); // actors + expect(officeBlob[3]).toBe(1); // warps + expect(officeBlob[4]).toBe(1); // triggers + expect(officeBlob[6] | (officeBlob[7] << 8)).not.toBe(SCRIPT_NONE); // onEnter + expect(officeBlob.length).toBeGreaterThan(MAP_HEADER_SIZE + 60 + 8); + + // every text stream is addressable + expect(linked.textTable.length).toBe(debug.texts.length); + for (const t of linked.textTable) { + const blob = linked.blobs[t.blob]; + expect(blob.kind).toBe(BLOB_KIND.TEXT); + expect(t.off).toBeLessThan(blob.bytes.length); + } + } + }); + + test("warp fixups point at real entrances after patching", async () => { + const { linked, debug } = await compileGame(ENTRY, "gba"); + const code = linked.blobs[linked.scriptBlobIndex].bytes; + // InternTalk ends with WARP street:door -> map 1, x 5, y 2, dir 0 + expect(linked.ctx.warpFixups).toHaveLength(1); + const at = linked.ctx.warpFixups[0].at; + expect(code[at]).toBe(debug.maps.street); + expect(code[at + 1]).toBe(5); + expect(code[at + 2]).toBe(2); + }); + + test("the story plays to completion on the reference VM", async () => { + const { linked, debug } = await compileGame(ENTRY, "gba"); + const code = linked.blobs[linked.scriptBlobIndex].bytes; + + // Playthrough: talk to the guide, pick Spar, always Strike. + const host = new AutoRpgHost(Array(24).fill(0)); + const vm = new RefVM(code, linked.scriptTable, host); + host.play(vm, debug.scripts.GuideTalk); + + expect(vm.status).toBe("done"); + expect(vm.getFlag(debug.flags.beat_guide)).toBe(1); + expect(vm.getVar(debug.vars.sub_calls)).toBe(1); // s.call(Fanfare) + expect(vm.getVar(debug.vars.cheers)).toBe(3); // macro unroll + expect(vm.getVar(debug.vars.foe)).toBeLessThanOrEqual(0); + expect(vm.getVar(debug.vars.hp)).toBeGreaterThan(0); + // The win line interpolates remaining HP via a FMT slot. + const winPage = host.events + .filter((e) => e.kind === "say") + .map((e) => debug.texts[(e as { textId: number }).textId]) + .find((t) => t.includes("You win")); + expect(winPage).toContain("{v60}"); + + // Re-talk takes the flag branch and stays short. + const host2 = new AutoRpgHost([]); + const vm2 = new RefVM(code, linked.scriptTable, host2); + vm2.flags[debug.flags.beat_guide] = 1; + host2.play(vm2, debug.scripts.GuideTalk); + expect(host2.events.filter((e) => e.kind === "say")).toHaveLength(1); + }); + + test("gb pagination differs from gba (narrower box)", async () => { + const gba = await compileGame(ENTRY, "gba"); + const gb = await compileGame(ENTRY, "gb"); + const gbaLines = gba.debug.texts.flatMap((t) => t.split("\n")); + const gbLines = gb.debug.texts.flatMap((t) => t.split("\n")); + expect(Math.max(...gbaLines.map((l) => l.length))).toBeLessThanOrEqual(28); + expect(Math.max(...gbLines.map((l) => l.length))).toBeLessThanOrEqual(18); + }); +}); diff --git a/static/test/ppm2png.ts b/static/test/ppm2png.ts new file mode 100644 index 00000000..4234eb05 --- /dev/null +++ b/static/test/ppm2png.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env bun +// static/test/ppm2png.ts — convert harness PPM screenshots to PNG. +// bun static/test/ppm2png.ts (writes siblings with .png) + +import { deflateSync } from "node:zlib"; + +function crc32(buf: Uint8Array): number { + let c = ~0; + for (let i = 0; i < buf.length; i++) { + c ^= buf[i]; + for (let k = 0; k < 8; k++) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); + } + return ~c >>> 0; +} + +function chunk(type: string, data: Uint8Array): Uint8Array { + const out = new Uint8Array(12 + data.length); + const dv = new DataView(out.buffer); + dv.setUint32(0, data.length); + for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i); + out.set(data, 8); + dv.setUint32(8 + data.length, crc32(out.subarray(4, 8 + data.length))); + return out; +} + +export function ppmToPng(ppm: Uint8Array): Uint8Array { + const header = new TextDecoder().decode(ppm.subarray(0, 64)); + const m = header.match(/^P6\s+(\d+)\s+(\d+)\s+(\d+)\s/); + if (!m) throw new Error("not a P6 PPM"); + const [, ws, hs] = m; + const w = Number(ws); + const h = Number(hs); + const dataStart = m[0].length; + const pixels = ppm.subarray(dataStart, dataStart + w * h * 3); + + const raw = new Uint8Array(h * (1 + w * 3)); + for (let y = 0; y < h; y++) { + raw[y * (1 + w * 3)] = 0; + raw.set(pixels.subarray(y * w * 3, (y + 1) * w * 3), y * (1 + w * 3) + 1); + } + const ihdr = new Uint8Array(13); + const dv = new DataView(ihdr.buffer); + dv.setUint32(0, w); + dv.setUint32(4, h); + ihdr[8] = 8; // bit depth + ihdr[9] = 2; // truecolor + const sig = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const parts = [sig, chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", new Uint8Array(0))]; + const total = parts.reduce((n, p) => n + p.length, 0); + const out = new Uint8Array(total); + let at = 0; + for (const p of parts) { + out.set(p, at); + at += p.length; + } + return out; +} + +if (import.meta.main) { + for (const arg of process.argv.slice(2)) { + const ppm = new Uint8Array(await Bun.file(arg).arrayBuffer()); + const png = ppmToPng(ppm); + const out = arg.replace(/\.ppm$/, ".png"); + await Bun.write(out, png); + console.log(`${out} (${png.length} bytes)`); + } +} diff --git a/static/test/script-compiler.test.ts b/static/test/script-compiler.test.ts new file mode 100644 index 00000000..f3f676d1 --- /dev/null +++ b/static/test/script-compiler.test.ts @@ -0,0 +1,369 @@ +// static/test/script-compiler.test.ts — the script compiler, proven against +// the reference VM. Every test: TypeScript source -> bytecode -> ref VM run +// with the auto-playing RPG host -> assert on VM state + event transcript. +// No emulator, no consoles — this is the layer that makes the console +// runtimes boring. + +import { describe, expect, test } from "bun:test"; +import { compileScriptSource } from "../compiler/compile-scripts.ts"; +import { RefVM } from "../vm/ref.ts"; +import { AutoRpgHost, type RpgEvent } from "../vm/rpg-host.ts"; + +function run(source: string, picks: number[] = [], scriptId = 0) { + const r = compileScriptSource(source); + const host = new AutoRpgHost(picks); + const vm = new RefVM(r.blob, r.table, host); + host.play(vm, scriptId); + return { vm, host, ctx: r.ctx }; +} + +/** Text of the page shown by the i-th say event. */ +const sayPages = (host: AutoRpgHost, ctx: { textDebug: string[] }): string[] => + host.events.filter((e): e is Extract => e.kind === "say").map((e) => ctx.textDebug[e.textId]); + +describe("expressions and locals", () => { + test("arithmetic with locals, vars, precedence", () => { + const { vm, ctx } = run(` + const S = script(function* (s, v, f) { + let a = 2 + 3 * 4; // 14 + let b = (a - 4) / 5; // 2 + v.result = a * 10 + b % 2; // 140 + v.neg = -b; + }); + `); + expect(vm.getVar(ctx.varNames.result)).toBe(140); + expect(vm.getVar(ctx.varNames.neg)).toBe(-2); + }); + + test("&& || ! short-circuit with JS value semantics", () => { + const { vm, ctx } = run(` + const S = script(function* (s, v, f) { + v.a = 0 || 7; + v.b = 3 && 5; + v.c = 0 && 9; + v.d = !0; + v.e = !!42; + f.armed = true; + v.g = f.armed && 11; + }); + `); + expect(vm.getVar(ctx.varNames.a)).toBe(7); + expect(vm.getVar(ctx.varNames.b)).toBe(5); + expect(vm.getVar(ctx.varNames.c)).toBe(0); + expect(vm.getVar(ctx.varNames.d)).toBe(1); + expect(vm.getVar(ctx.varNames.e)).toBe(1); + expect(vm.getVar(ctx.varNames.g)).toBe(11); + }); + + test("ternary", () => { + const { vm, ctx } = run(` + const S = script(function* (s, v, f) { + let hp = 3; + v.msg = hp > 5 ? 100 : 200; + }); + `); + expect(vm.getVar(ctx.varNames.msg)).toBe(200); + }); + + test("compound assignment and ++/--", () => { + const { vm, ctx } = run(` + const S = script(function* (s, v, f) { + v.n = 10; + v.n += 5; + v.n -= 2; + v.n *= 3; + v.n /= 2; // trunc + let i = 0; + i++; + i++; + i--; + v.i = i; + }); + `); + expect(vm.getVar(ctx.varNames.n)).toBe(19); // ((10+5-2)*3)/2 = 19.5 -> 19 + expect(vm.getVar(ctx.varNames.i)).toBe(1); + }); +}); + +describe("control flow", () => { + test("if/else chains on runtime values", () => { + const src = (n: number) => ` + const S = script(function* (s, v, f) { + v.n = ${n}; + if (v.n > 10) { v.r = 1; } + else if (v.n > 5) { v.r = 2; } + else { v.r = 3; } + }); + `; + expect(run(src(20)).vm.getVar(1)).toBe(1); + expect(run(src(7)).vm.getVar(1)).toBe(2); + expect(run(src(1)).vm.getVar(1)).toBe(3); + }); + + test("while with break/continue", () => { + const { vm, ctx } = run(` + const S = script(function* (s, v, f) { + let i = 0; + while (true) { + i += 1; + if (i === 3) { continue; } + if (i >= 6) { break; } + v.sum += i; + } + v.i = i; + }); + `); + expect(vm.getVar(ctx.varNames.sum)).toBe(1 + 2 + 4 + 5); + expect(vm.getVar(ctx.varNames.i)).toBe(6); + }); + + test("classic for loop", () => { + const { vm, ctx } = run(` + const S = script(function* (s, v, f) { + for (let i = 1; i <= 5; i++) { v.sum += i; } + }); + `); + expect(vm.getVar(ctx.varNames.sum)).toBe(15); + }); + + test("switch over numbers with default + fallthrough", () => { + const src = (n: number) => ` + const S = script(function* (s, v, f) { + v.n = ${n}; + switch (v.n) { + case 1: + case 2: v.r = 12; break; + case 3: v.r = 3; break; + default: v.r = 99; + } + }); + `; + expect(run(src(1)).vm.getVar(1)).toBe(12); + expect(run(src(2)).vm.getVar(1)).toBe(12); + expect(run(src(3)).vm.getVar(1)).toBe(3); + expect(run(src(7)).vm.getVar(1)).toBe(99); + }); +}); + +describe("rpg ops", () => { + test("say wraps to pages per target", () => { + const long = "The board no longer has confidence in his ability to continue leading the company forward."; + const { host, ctx } = run(` + const S = script(function* (s, v, f) { + yield* s.say(${JSON.stringify(long)}); + }); + `); + const pages = sayPages(host, ctx); + expect(pages.length).toBeGreaterThan(0); + for (const p of pages) { + for (const line of p.split("\n")) expect(line.length).toBeLessThanOrEqual(28); + expect(p.split("\n").length).toBeLessThanOrEqual(3); + } + expect(pages.join(" ").replace(/\n/g, " ")).toBe(long); + }); + + test("choice drives branches; string comparison sugar", () => { + const src = ` + const S = script(function* (s, v, f) { + const pick = yield* s.choose(["Push back", "Stay calm"]); + if (pick === "Push back") { v.r = 1; } else { v.r = 2; } + }); + `; + expect(run(src, [0]).vm.getVar(0)).toBe(1); + expect(run(src, [1]).vm.getVar(0)).toBe(2); + }); + + test("switch over choice with string labels", () => { + const src = ` + const S = script(function* (s, v, f) { + switch (yield* s.choose(["Fight", "Talk", "Leave"])) { + case "Fight": v.r = 1; break; + case "Talk": v.r = 2; break; + case "Leave": v.r = 3; break; + } + }); + `; + expect(run(src, [0]).vm.getVar(0)).toBe(1); + expect(run(src, [1]).vm.getVar(0)).toBe(2); + expect(run(src, [2]).vm.getVar(0)).toBe(3); + }); + + test("template interpolation: static folds, runtime becomes FMT slot", () => { + const { host, ctx } = run(` + const S = script(function* (s, v, f) { + const WHO = "SAM"; + let resolve = 40 + 2; + yield* s.say(\`\${WHO}: resolve at \${resolve} percent\`); + }); + `); + const pages = sayPages(host, ctx); + expect(pages[0]).toContain("SAM: resolve at {v60}"); + // scratch var 60 holds the runtime value at say-time + expect(run(` + const S = script(function* (s, v, f) { + let x = 42; + yield* s.say(\`n=\${x}\`); + }); + `).vm.getVar(60)).toBe(42); + }); + + test("flags, sfx, lock/release, wait, warp fixup recorded", () => { + const { host, ctx } = run(` + const S = script(function* (s, v, f) { + yield* s.lock(); + f.fired = true; + if (f.fired) { yield* s.sfx("fanfare"); } + yield* s.wait(30); + yield* s.warp("hq:door"); + yield* s.release(); + }); + `); + const kinds = host.events.map((e) => e.kind); + expect(kinds).toEqual(["lock", "sfx", "wait", "warp", "release"]); + expect(ctx.warpFixups).toHaveLength(1); + expect(ctx.warpFixups[0].dest).toBe("hq:door"); + }); + + test("rnd in expressions is deterministic", () => { + const a = run(` + const S = script(function* (s, v, f) { + v.d = 1 + (yield* s.rnd(6)) + (yield* s.rnd(6)); + }); + `).vm.getVar(0); + const b = run(` + const S = script(function* (s, v, f) { + v.d = 1 + (yield* s.rnd(6)) + (yield* s.rnd(6)); + }); + `).vm.getVar(0); + expect(a).toBe(b); + expect(a).toBeGreaterThanOrEqual(1); + expect(a).toBeLessThanOrEqual(11); + }); +}); + +describe("subroutines and macros", () => { + test("s.call() runs another script and returns", () => { + const { vm, ctx } = run( + ` + const Greet = script(function* (s, v, f) { + v.calls += 1; + }); + const Main = script(function* (s, v, f) { + yield* s.call(Greet); + yield* s.call(Greet); + v.done = 1; + }); + `, + [], + 1, + ); + expect(vm.getVar(ctx.varNames.calls)).toBe(2); + expect(vm.getVar(ctx.varNames.done)).toBe(1); + }); + + test("macro inlining with static config, for...of unroll, static if fold", () => { + const { vm, host, ctx } = run(` + function* fanfare(s, v, cfg) { + for (const step of cfg.steps) { + if (step.loud) { yield* s.sfx("fanfare"); } + v.total += step.n; + } + } + const S = script(function* (s, v, f) { + yield* fanfare(s, v, { steps: [ { n: 1, loud: true }, { n: 2, loud: false }, { n: 3, loud: true } ] }); + }); + `); + expect(vm.getVar(ctx.varNames.total)).toBe(6); + expect(host.events.filter((e) => e.kind === "sfx")).toHaveLength(2); + }); + + test("value macro: return value via result slot, early return", () => { + const src = (pick: number) => ` + function* damage(s, base) { + const crit = yield* s.rnd(2); + if (crit === 1) { return base * 2; } + return base; + } + const S = script(function* (s, v, f) { + const pick = yield* s.choose(["hit", "skip"]); + if (pick === 0) { v.dmg = yield* damage(s, 10); } + else { v.dmg = -1; } + }); + `; + const { vm } = run(src(0), [0]); + expect([10, 20]).toContain(vm.getVar(0)); + expect(run(src(1), [1]).vm.getVar(0)).toBe(-1); + }); + + test("a battle loop written as plain DSL survives compilation", () => { + // The shape the boardroom finale uses: menus, RNG damage, HP vars. + const { vm, host, ctx } = run( + ` + const Battle = script(function* (s, v, f) { + v.board = 12; + v.sam = 10; + while (v.board > 0 && v.sam > 0) { + const move = yield* s.choose(["Tender offer", "Heart emoji"]); + if (move === "Tender offer") { v.board -= 2 + (yield* s.rnd(3)); } + else { v.board -= 1; v.sam += 1; } + if (v.board > 0) { v.sam -= 1 + (yield* s.rnd(2)); } + } + if (v.sam > 0) { f.won = true; yield* s.say("The board folds."); } + else { yield* s.say("Resolve exhausted."); } + }); + `, + Array(40).fill(0), + ); + expect(vm.status).toBe("done"); + const flags = ctx.flagNames; + // Deterministic RNG: this exact playthrough always lands the same way. + expect(vm.getFlag(flags.won)).toBe(1); + expect(host.events.some((e) => e.kind === "say")).toBe(true); + }); + + test("recursive macros are rejected", () => { + expect(() => + run(` + function* loop(s) { yield* loop(s); } + const S = script(function* (s, v, f) { yield* loop(s); }); + `), + ).toThrow(/recursive macro/); + }); +}); + +describe("diagnostics", () => { + test("unknown identifier errors with location", () => { + expect(() => + run(` + const S = script(function* (s, v, f) { + v.x = mystery; + }); + `), + ).toThrow(/unknown identifier "mystery"/); + }); + + test("string case label without choice metadata errors", () => { + expect(() => + run(` + const S = script(function* (s, v, f) { + switch (v.x) { case "nope": break; } + }); + `), + ).toThrow(/string case labels/); + }); + + test("non-ASCII text is rejected in v1", () => { + expect(() => + run(` + const S = script(function* (s, v, f) { + yield* s.say("你好"); + }); + `), + ).toThrow(/ASCII/); + }); + + test("too many locals errors", () => { + const decls = Array.from({ length: 20 }, (_, i) => `let x${i} = v.n + ${i};`).join("\n"); + expect(() => run(`const S = script(function* (s, v, f) { ${decls} });`)).toThrow(/too many locals/); + }); +}); diff --git a/static/test/smoke/game.ts b/static/test/smoke/game.ts new file mode 100644 index 00000000..eb786d59 --- /dev/null +++ b/static/test/smoke/game.ts @@ -0,0 +1,200 @@ +// static/test/smoke/game.ts — the cross-target contract game. +// +// Small on purpose, but it exercises every RPG mechanism the runtimes must +// agree on: spawn, walking, collision (tiles + solid actor), talk scripts, +// choices, flags, vars, a battle-style while loop over RNG, template FMT +// text, warps (walk-on + scripted), triggers (incl. once), actor show/hide, +// subroutine calls, wander movement, lock/release and SFX. The three console +// E2E suites and the reference-VM story test all drive THIS module. +// +// Art is deliberately procedural (declaration zone = plain TypeScript). + +import { + defineGame, + defineMap, + defineSprite, + defineTileset, + npc, + script, + trigger, + warp, + type Ops, + type Vars, +} from "@pocketjs/static/rpg"; + +// --- procedural art --------------------------------------------------------- +const row = (c: string) => c.repeat(8); +const rows8 = (c: string) => Array.from({ length: 8 }, () => row(c)); +const framed = (edge: string, fill: string) => [ + row(edge), + ...Array.from({ length: 6 }, () => edge + fill.repeat(6) + edge), + row(edge), +]; + +const office = defineTileset("office", { + palette: [ + [24, 26, 30], // 0 backdrop + [92, 148, 252], // 1 floor blue + [56, 56, 72], // 2 wall dark + [200, 76, 12], // 3 desk orange + [252, 224, 168], // 4 door light + [0, 168, 68], // 5 plant green + ], + tiles: { + floor: { px: rows8("1") }, + wall: { px: framed("2", "2"), solid: true }, + desk: { px: framed("2", "3"), solid: true }, + door: { px: rows8("4") }, + plant: { px: framed("5", "1"), solid: true }, + }, +}); + +const frame16 = (c: string) => Array.from({ length: 16 }, () => c.repeat(16)); +const twoTone = (top: string, bottom: string) => [ + ...Array.from({ length: 8 }, () => top.repeat(16)), + ...Array.from({ length: 8 }, () => bottom.repeat(16)), +]; + +const hero = defineSprite("hero", { + palette: [ + [0, 0, 0], // 0 transparent + [248, 248, 248], // 1 white + [216, 40, 40], // 2 red + [40, 40, 216], // 3 blue + ], + facings: { + down: [twoTone("2", "1"), twoTone("1", "2")], + up: [twoTone("3", "1"), twoTone("1", "3")], + right: [twoTone("2", "3"), twoTone("3", "2")], + }, +}); + +const guide = defineSprite("guide", { + palette: [ + [0, 0, 0], + [252, 216, 96], // yellow + [96, 96, 96], + ], + facings: { + down: [frame16("1")], + up: [frame16("2")], + right: [twoTone("1", "2")], + }, +}); + +// --- scripts ----------------------------------------------------------------- +function* cheer(s: Ops, v: Vars, effects: readonly ("confirm" | "fanfare")[]) { + for (const fx of effects) { + yield* s.sfx(fx); + v.cheers += 1; + } +} + +const Fanfare = script(function* (s, v, f) { + v.sub_calls += 1; +}); + +const GuideTalk = script(function* (s, v, f) { + yield* s.lock(); + yield* s.face(); + if (f.beat_guide) { + yield* s.say("GUIDE: You already won. Take the door north."); + yield* s.release(); + return; + } + yield* s.say("GUIDE: New build! Want to spar?"); + const pick = yield* s.choose(["Spar", "Later"]); + if (pick === "Spar") { + v.hp = 8; + v.foe = 6; + while (v.foe > 0 && v.hp > 0) { + const move = yield* s.choose(["Strike", "Guard"]); + if (move === "Strike") { + v.foe -= 2 + (yield* s.rnd(2)); + } else { + v.hp += 1; + } + if (v.foe > 0) { + v.hp -= 1; + } + } + if (v.hp > 0) { + f.beat_guide = true; + yield* s.call(Fanfare); + yield* cheer(s, v, ["confirm", "fanfare", "fanfare"]); + yield* s.say(`GUIDE: You win with ${v.hp} HP left.`); + } else { + yield* s.say("GUIDE: Rest and try again."); + } + } else { + yield* s.say("GUIDE: The road is tougher than it looks."); + } + yield* s.release(); +}); + +const RevealSign = script(function* (s, v, f) { + f.trigger_hit = true; + yield* s.show("intern"); + yield* s.sfx("confirm"); +}); + +const OfficeEnter = script(function* (s, v, f) { + v.office_enters += 1; + if (f.beat_guide) { + yield* s.hide("guide"); + } +}); + +const InternTalk = script(function* (s, v, f) { + yield* s.say("INTERN: I was hiding here all along."); + yield* s.warp("street:door"); +}); + +// --- maps ---------------------------------------------------------------------- +const officeMap = defineMap("office", { + tileset: office, + layout: ` + ########## + #....d...# + #..~..p..# + #........# + #...T....# + ####.##### + `, + legend: { "#": "wall", ".": "floor", d: "door", "~": "desk", p: "plant", T: "floor" }, + entrances: { + door: { at: [5, 1], dir: "down" }, + south: { at: [4, 4], dir: "up" }, + }, + actors: [ + npc("guide", { sprite: guide, at: [3, 1], facing: "down", talk: GuideTalk }), + npc("intern", { sprite: guide, at: [8, 3], facing: "down", hidden: true, talk: InternTalk }), + ], + warps: [warp({ at: [4, 5], to: "street:door" })], + triggers: [trigger({ at: [4, 4], run: RevealSign, once: true })], + onEnter: OfficeEnter, +}); + +const streetMap = defineMap("street", { + tileset: office, + layout: ` + p........p + .......... + .....d.... + .......... + p........p + `, + legend: { p: "plant", ".": "floor", d: "door" }, + entrances: { + door: { at: [5, 2], dir: "down" }, + }, + actors: [npc("walker", { sprite: guide, at: [1, 1], move: "wander", solid: false })], + warps: [warp({ at: [5, 3], to: "office:south" })], +}); + +defineGame({ + title: "POCKET SMOKE", + start: "office:door", + player: hero, + maps: [officeMap, streetMap], +}); diff --git a/static/test/vm.test.ts b/static/test/vm.test.ts new file mode 100644 index 00000000..eef49127 --- /dev/null +++ b/static/test/vm.test.ts @@ -0,0 +1,247 @@ +// static/test/vm.test.ts — reference VM semantics, pinned. +// +// These programs pin the ISA behavior that vm/core.c must reproduce +// bit-for-bit on all three consoles. If you change an expectation here you +// are changing the console contract — regenerate spec_gen.h and touch every +// runtime. + +import { describe, expect, test } from "bun:test"; +import { RNG_SEED, WAITING, rngNext } from "../spec/isa.ts"; +import { assemble } from "../vm/asm.ts"; +import { RefVM } from "../vm/ref.ts"; +import { AutoRpgHost } from "../vm/rpg-host.ts"; + +function runProgram(program: Parameters[0], picks: number[] = []) { + const code = assemble(program); + const host = new AutoRpgHost(picks); + const vm = new RefVM(code, [0], host); + host.play(vm, 0); + return { vm, host }; +} + +describe("core ops", () => { + test("arithmetic folds and wraps at i16", () => { + const { vm } = runProgram([ + ["PUSH16", 30000], + ["PUSH16", 10000], + ["ADD"], // 40000 -> -25536 + ["STV", 0], + ["PUSH8", -7], + ["PUSH8", 3], + ["DIV"], // trunc toward zero -> -2 + ["STV", 1], + ["PUSH8", -7], + ["PUSH8", 3], + ["MOD"], // sign of dividend -> -1 + ["STV", 2], + ["PUSH8", 5], + ["PUSH8", 0], + ["DIV"], // div by zero -> 0 + ["STV", 3], + ["END"], + ]); + expect(vm.getVar(0)).toBe(-25536); + expect(vm.getVar(1)).toBe(-2); + expect(vm.getVar(2)).toBe(-1); + expect(vm.getVar(3)).toBe(0); + }); + + test("comparisons are signed", () => { + const { vm } = runProgram([ + ["PUSH16", -1], + ["PUSH16", 1], + ["LT"], + ["STV", 0], // -1 < 1 -> 1 + ["PUSH16", -32768], + ["PUSH16", 32767], + ["GT"], + ["STV", 1], // 0 + ["END"], + ]); + expect(vm.getVar(0)).toBe(1); + expect(vm.getVar(1)).toBe(0); + }); + + test("jumps, labels, loop", () => { + // var0 = sum 1..5 via a while loop + const { vm } = runProgram([ + ["PUSH8", 1], + ["STL", 0], // i = 1 + "loop", + ["LDL", 0], + ["PUSH8", 5], + ["GT"], + ["JNZ", "done"], + ["LDV", 0], + ["LDL", 0], + ["ADD"], + ["STV", 0], + ["LDL", 0], + ["PUSH8", 1], + ["ADD"], + ["STL", 0], + ["JMP", "loop"], + "done", + ["END"], + ]); + expect(vm.getVar(0)).toBe(15); + }); + + test("flags set/clear/store/read", () => { + const { vm } = runProgram([ + ["SETF", 3], + ["FLAG", 3], + ["STV", 0], + ["CLRF", 3], + ["FLAG", 3], + ["STV", 1], + ["PUSH8", 42], + ["STF", 200], + ["FLAG", 200], + ["STV", 2], + ["END"], + ]); + expect(vm.getVar(0)).toBe(1); + expect(vm.getVar(1)).toBe(0); + expect(vm.getVar(2)).toBe(1); + }); + + test("RND sequence is the pinned xorshift16 stream", () => { + let s = RNG_SEED; + const expected: number[] = []; + for (let i = 0; i < 4; i++) { + s = rngNext(s); + expected.push(s % 6); + } + const { vm } = runProgram([ + ["PUSH8", 6], ["RND"], ["STV", 0], + ["PUSH8", 6], ["RND"], ["STV", 1], + ["PUSH8", 6], ["RND"], ["STV", 2], + ["PUSH8", 6], ["RND"], ["STV", 3], + ["PUSH8", 0], ["RND"], ["STV", 4], // n<=0 -> 0, no state advance + ["END"], + ]); + expect([vm.getVar(0), vm.getVar(1), vm.getVar(2), vm.getVar(3)]).toEqual(expected); + expect(vm.getVar(4)).toBe(0); + expect(vm.rng).toBe(s); + }); +}); + +describe("calls and locals", () => { + test("CALL/RET with per-frame locals", () => { + // script 1 clobbers ITS local 0; caller's local 0 survives. + const code = assemble([ + // script 0 @0 + ["PUSH8", 7], + ["STL", 0], + ["CALL", 1], + ["LDL", 0], + ["STV", 0], // still 7 + ["END"], + // script 1 + "sub", + ["PUSH8", 99], + ["STL", 0], + ["LDL", 0], + ["STV", 1], // 99 + ["RET"], + ]); + // entry of script 1 = offset of label "sub": compute by assembling prefix + const prefix = assemble([ + ["PUSH8", 7], + ["STL", 0], + ["CALL", 1], + ["LDL", 0], + ["STV", 0], + ["END"], + ]); + const host = new AutoRpgHost(); + const vm = new RefVM(code, [0, prefix.length], host); + host.play(vm, 0); + expect(vm.getVar(0)).toBe(7); + expect(vm.getVar(1)).toBe(99); + }); + + test("call stack overflow throws (depth 4)", () => { + // script 0 calls itself forever + const code = assemble([["CALL", 0], ["END"]]); + const vm = new RefVM(code, [0], new AutoRpgHost()); + expect(() => vm.start(0)).toThrow(/call stack overflow/); + }); +}); + +describe("suspension + rpg syscalls", () => { + test("SAY suspends and resumes; transcript ordered", () => { + const { host } = runProgram([ + ["SAY", 5], + ["SAY", 6], + ["END"], + ]); + expect(host.events).toEqual([ + { kind: "say", textId: 5 }, + { kind: "say", textId: 6 }, + ]); + }); + + test("CHOICE pushes the picked index on resume", () => { + const { vm, host } = runProgram( + [ + ["CHOICE", 10, 11, 12], + ["STV", 0], + ["END"], + ], + [2], + ); + expect(vm.getVar(0)).toBe(2); + expect(host.events[0]).toEqual({ kind: "choice", textIds: [10, 11, 12], picked: 2 }); + }); + + test("WAIT suspends with frame count; WAIT 0 does not", () => { + const { vm, host } = runProgram([ + ["PUSH8", 30], + ["WAIT"], + ["PUSH8", 0], + ["WAIT"], + ["END"], + ]); + expect(host.events).toEqual([{ kind: "wait", frames: 30 }]); + expect(vm.status).toBe("done"); + }); + + test("full syscall surface round-trips operands", () => { + const { host } = runProgram([ + ["LOCK"], + ["FACE", 0xff], + ["AVIS", 3, 0], + ["SFX", 2], + ["WARP", 1, 12, 9, 1], + ["RELEASE"], + ["END"], + ]); + expect(host.events).toEqual([ + { kind: "lock" }, + { kind: "face", slot: 0xff }, + { kind: "avis", slot: 3, visible: false }, + { kind: "sfx", id: 2 }, + { kind: "warp", map: 1, x: 12, y: 9, dir: 1 }, + { kind: "release" }, + ]); + }); +}); + +describe("strictness", () => { + test("stack underflow throws", () => { + const vm = new RefVM(assemble([["POP"], ["END"]]), [0], new AutoRpgHost()); + expect(() => vm.start(0)).toThrow(/underflow/); + }); + + test("illegal opcode throws", () => { + const vm = new RefVM(Uint8Array.from([0x3f]), [0], new AutoRpgHost()); + expect(() => vm.start(0)).toThrow(/illegal opcode/); + }); + + test("runaway loop trips the guard", () => { + const vm = new RefVM(assemble(["top", ["JMP", "top"], ["END"]]), [0], new AutoRpgHost()); + expect(() => vm.start(0)).toThrow(/runaway/); + }); +}); diff --git a/static/vm/asm.ts b/static/vm/asm.ts new file mode 100644 index 00000000..cb1ae812 --- /dev/null +++ b/static/vm/asm.ts @@ -0,0 +1,124 @@ +// static/vm/asm.ts — assembler/disassembler for Pocket Static bytecode. +// +// The assembler exists for VM unit tests (hand-built programs) and the +// disassembler for debugging compiler output (`pocket-static dis`). Neither +// ships in a cartridge. + +import { OP, OP_OPERANDS, SYSCALL_BASE, i16 } from "../spec/isa.ts"; +import { RPG_OP, RPG_OP_OPERANDS } from "../spec/rpg.ts"; + +type Ins = [string, ...(number | string)[]]; + +const NAME_TO_OP: Record = {}; +for (const [name, code] of Object.entries(OP)) NAME_TO_OP[name] = code; +for (const [name, code] of Object.entries(RPG_OP)) NAME_TO_OP[name] = code; +const OP_TO_NAME: Record = {}; +for (const [name, code] of Object.entries(NAME_TO_OP)) OP_TO_NAME[code] = name; + +const OPERANDS: Record = { ...OP_OPERANDS, ...RPG_OP_OPERANDS }; + +/** + * Assemble a program. Instructions are [mnemonic, ...operands]; a bare + * string is a label; jump operands may be label strings (encoded rel16 from + * after the operand). CHOICE takes its option count implicitly: + * ["CHOICE", t0, t1, ...]. + */ +export function assemble(program: (Ins | string)[]): Uint8Array { + const bytes: number[] = []; + const labels = new Map(); + const fixups: { at: number; label: string }[] = []; + + for (const item of program) { + if (typeof item === "string") { + labels.set(item, bytes.length); + continue; + } + const [name, ...args] = item; + const op = NAME_TO_OP[name]; + if (op === undefined) throw new Error(`unknown mnemonic ${name}`); + bytes.push(op); + if (op === RPG_OP.CHOICE) { + bytes.push(args.length); + for (const a of args) { + const v = a as number; + bytes.push(v & 0xff, (v >> 8) & 0xff); + } + continue; + } + const width = OPERANDS[op]; + if (op === OP.JMP || op === OP.JZ || op === OP.JNZ) { + const a = args[0]; + if (typeof a === "string") { + fixups.push({ at: bytes.length, label: a }); + bytes.push(0, 0); + } else { + bytes.push(a & 0xff, (a >> 8) & 0xff); + } + continue; + } + // Fixed-width numeric operands. Widths are per-op from the spec tables; + // multi-operand ops (AVIS, WARP) list one byte per operand. + const perOp: Record = { + [RPG_OP.AVIS]: [1, 1], + [RPG_OP.WARP]: [1, 1, 1, 1], + }; + const widths = perOp[op] ?? (width === 0 ? [] : width === 1 ? [1] : [width]); + if (args.length !== widths.length) { + throw new Error(`${name} wants ${widths.length} operands, got ${args.length}`); + } + widths.forEach((w, idx) => { + const v = args[idx] as number; + if (w === 1) bytes.push(v & 0xff); + else bytes.push(v & 0xff, (v >> 8) & 0xff); + }); + } + + for (const f of fixups) { + const target = labels.get(f.label); + if (target === undefined) throw new Error(`undefined label ${f.label}`); + const rel = target - (f.at + 2); + bytes[f.at] = rel & 0xff; + bytes[f.at + 1] = (rel >> 8) & 0xff; + } + return Uint8Array.from(bytes); +} + +/** Disassemble to text, one instruction per line, with byte offsets. */ +export function disassemble(code: Uint8Array, start = 0, end = code.length): string { + const lines: string[] = []; + let ip = start; + while (ip < end) { + const at = ip; + const op = code[ip++]; + const name = OP_TO_NAME[op] ?? `??0x${op.toString(16)}`; + let width = OPERANDS[op]; + const args: string[] = []; + if (op === RPG_OP.CHOICE) { + const n = code[ip++]; + for (let k = 0; k < n; k++) { + args.push(String(code[ip] | (code[ip + 1] << 8))); + ip += 2; + } + } else if (op === RPG_OP.AVIS) { + args.push(String(code[ip++]), String(code[ip++])); + } else if (op === RPG_OP.WARP) { + args.push(String(code[ip++]), String(code[ip++]), String(code[ip++]), String(code[ip++])); + } else if (width === 1) { + args.push(String(code[ip++])); + } else if (width === 2) { + const raw = code[ip] | (code[ip + 1] << 8); + ip += 2; + if (op === OP.JMP || op === OP.JZ || op === OP.JNZ) { + args.push(`-> ${ip + i16(raw)}`); + } else if (op === OP.PUSH16) { + args.push(String(i16(raw))); + } else { + args.push(String(raw)); + } + } else if (width === undefined) { + args.push(""); + } + lines.push(`${String(at).padStart(5)}: ${name}${args.length ? " " + args.join(", ") : ""}`); + } + return lines.join("\n"); +} diff --git a/static/vm/ref.ts b/static/vm/ref.ts new file mode 100644 index 00000000..f5d61a07 --- /dev/null +++ b/static/vm/ref.ts @@ -0,0 +1,359 @@ +// static/vm/ref.ts — the reference implementation of the Pocket Static VM. +// +// This is the semantic golden: the compiler's unit tests run compiled +// bytecode HERE (no emulator in the loop), and the three C runtimes are held +// to this behavior by the cross-target E2E suite. If ref.ts and vm/core.c +// ever disagree, core.c is wrong. +// +// The interpreter is deliberately strict: out-of-range anything throws, +// because on host we want compiler bugs loud (the C core clamps instead — +// cartridges don't get to crash). + +import { + OP, + OP_OPERANDS, + RNG_SEED, + SYSCALL_BASE, + VM_FLAGS, + VM_FRAMES, + VM_LOCALS, + VM_STACK, + VM_VARS, + WAITING, + i16, + rngNext, +} from "../spec/isa.ts"; + +export interface OperandReader { + u8(): number; + u16(): number; + i8(): number; + i16(): number; +} + +/** What a syscall tells the VM to do after its operands are consumed. */ +export interface SyscallResult { + /** Value to push immediately (non-suspending value ops). */ + push?: number; + /** Suspend with this reason; resume() continues. */ + suspend?: number; + /** With `suspend`: the resume(value) argument is pushed (CHOICE). */ + pushOnResume?: boolean; +} + +export interface RefHost { + /** Handle an opcode >= SYSCALL_BASE. MUST consume exactly its operands. */ + syscall(op: number, operands: OperandReader, vm: RefVM): SyscallResult; +} + +export type VmStatus = "idle" | "running" | "suspended" | "done"; + +export class RefVM { + readonly vars = new Int16Array(VM_VARS); + readonly flags = new Uint8Array(VM_FLAGS); + rng = RNG_SEED; + status: VmStatus = "idle"; + waiting: number = WAITING.NONE; + /** Ops executed since start() — the runaway guard for tests. */ + opCount = 0; + + private code: Uint8Array; + private table: number[]; + private host: RefHost; + private ip = 0; + private stack = new Int16Array(VM_STACK); + private sp = 0; + private locals = new Int16Array(VM_FRAMES * VM_LOCALS); + private frames: number[] = []; // return ips; frame N locals base = N*VM_LOCALS + private pendingPushOnResume = false; + scriptId = -1; + + constructor(code: Uint8Array, table: number[], host: RefHost) { + this.code = code; + this.table = table; + this.host = host; + } + + start(scriptId: number): void { + if (scriptId < 0 || scriptId >= this.table.length) throw new Error(`bad script id ${scriptId}`); + this.scriptId = scriptId; + this.ip = this.table[scriptId]; + this.sp = 0; + this.frames = []; + this.locals.fill(0); + this.status = "running"; + this.waiting = WAITING.NONE; + this.opCount = 0; + this.run(); + } + + /** Resume a suspended VM; `value` is pushed for value-suspends (CHOICE). */ + resume(value?: number): void { + if (this.status !== "suspended") throw new Error(`resume() while ${this.status}`); + if (this.pendingPushOnResume) { + if (value === undefined) throw new Error("this suspension resumes with a value"); + this.push(value); + } else if (value !== undefined) { + throw new Error("this suspension does not take a value"); + } + this.pendingPushOnResume = false; + this.status = "running"; + this.waiting = WAITING.NONE; + this.run(); + } + + // --- stack/locals/globals (strict) -------------------------------------- + push(v: number): void { + if (this.sp >= VM_STACK) throw new Error(`stack overflow at ip=${this.ip}`); + this.stack[this.sp++] = i16(v); + } + pop(): number { + if (this.sp <= 0) throw new Error(`stack underflow at ip=${this.ip}`); + return this.stack[--this.sp]; + } + getVar(id: number): number { + if (id < 0 || id >= VM_VARS) throw new Error(`var ${id} out of range`); + return this.vars[id]; + } + setVar(id: number, v: number): void { + if (id < 0 || id >= VM_VARS) throw new Error(`var ${id} out of range`); + this.vars[id] = i16(v); + } + getFlag(id: number): number { + if (id < 0 || id >= VM_FLAGS) throw new Error(`flag ${id} out of range`); + return this.flags[id]; + } + setFlag(id: number, v: boolean): void { + if (id < 0 || id >= VM_FLAGS) throw new Error(`flag ${id} out of range`); + this.flags[id] = v ? 1 : 0; + } + private localBase(): number { + return this.frames.length * VM_LOCALS; + } + + // --- operand reading ------------------------------------------------------ + private rdU8(): number { + return this.code[this.ip++]; + } + private rdU16(): number { + const v = this.code[this.ip] | (this.code[this.ip + 1] << 8); + this.ip += 2; + return v; + } + private rdI8(): number { + const v = this.rdU8(); + return v >= 0x80 ? v - 0x100 : v; + } + private rdI16(): number { + return i16(this.rdU16()); + } + private reader(): OperandReader { + return { + u8: () => this.rdU8(), + u16: () => this.rdU16(), + i8: () => this.rdI8(), + i16: () => this.rdI16(), + }; + } + + // --- the loop ------------------------------------------------------------- + private run(): void { + for (;;) { + if (this.ip < 0 || this.ip >= this.code.length) { + throw new Error(`ip ${this.ip} out of code (script ${this.scriptId})`); + } + const op = this.code[this.ip++]; + this.opCount++; + if (this.opCount > 1_000_000) throw new Error("runaway script (1M ops)"); + + if (op >= SYSCALL_BASE) { + const res = this.host.syscall(op, this.reader(), this); + if (res.suspend !== undefined) { + this.status = "suspended"; + this.waiting = res.suspend; + this.pendingPushOnResume = res.pushOnResume === true; + return; + } + if (res.push !== undefined) this.push(res.push); + continue; + } + + switch (op) { + case OP.END: + this.status = "done"; + this.waiting = WAITING.NONE; + return; + case OP.NOP: + break; + case OP.PUSH8: + this.push(this.rdI8()); + break; + case OP.PUSH16: + this.push(this.rdI16()); + break; + case OP.POP: + this.pop(); + break; + case OP.DUP: { + const v = this.pop(); + this.push(v); + this.push(v); + break; + } + case OP.JMP: { + const rel = this.rdI16(); + this.ip += rel; + break; + } + case OP.JZ: { + const rel = this.rdI16(); + if (this.pop() === 0) this.ip += rel; + break; + } + case OP.JNZ: { + const rel = this.rdI16(); + if (this.pop() !== 0) this.ip += rel; + break; + } + case OP.CALL: { + const id = this.rdU16(); + if (id >= this.table.length) throw new Error(`CALL bad script ${id}`); + if (this.frames.length >= VM_FRAMES - 1) throw new Error("call stack overflow"); + this.frames.push(this.ip); + const base = this.localBase(); + this.locals.fill(0, base, base + VM_LOCALS); + this.ip = this.table[id]; + break; + } + case OP.RET: { + const ret = this.frames.pop(); + if (ret === undefined) { + this.status = "done"; + this.waiting = WAITING.NONE; + return; + } + this.ip = ret; + break; + } + case OP.LDV: + this.push(this.getVar(this.rdU8())); + break; + case OP.STV: + this.setVar(this.rdU8(), this.pop()); + break; + case OP.LDL: { + const s = this.rdU8(); + if (s >= VM_LOCALS) throw new Error(`local ${s} out of range`); + this.push(this.locals[this.localBase() + s]); + break; + } + case OP.STL: { + const s = this.rdU8(); + if (s >= VM_LOCALS) throw new Error(`local ${s} out of range`); + this.locals[this.localBase() + s] = i16(this.pop()); + break; + } + case OP.FLAG: + this.push(this.getFlag(this.rdU8())); + break; + case OP.SETF: + this.setFlag(this.rdU8(), true); + break; + case OP.CLRF: + this.setFlag(this.rdU8(), false); + break; + case OP.STF: + this.setFlag(this.rdU8(), this.pop() !== 0); + break; + case OP.ADD: { + const b = this.pop(), a = this.pop(); + this.push(a + b); + break; + } + case OP.SUB: { + const b = this.pop(), a = this.pop(); + this.push(a - b); + break; + } + case OP.MUL: { + const b = this.pop(), a = this.pop(); + this.push(Math.imul(a, b)); + break; + } + case OP.DIV: { + const b = this.pop(), a = this.pop(); + this.push(b === 0 ? 0 : Math.trunc(a / b)); + break; + } + case OP.MOD: { + const b = this.pop(), a = this.pop(); + this.push(b === 0 ? 0 : a % b); + break; + } + case OP.NEG: + this.push(-this.pop()); + break; + case OP.EQ: { + const b = this.pop(), a = this.pop(); + this.push(a === b ? 1 : 0); + break; + } + case OP.NE: { + const b = this.pop(), a = this.pop(); + this.push(a !== b ? 1 : 0); + break; + } + case OP.LT: { + const b = this.pop(), a = this.pop(); + this.push(a < b ? 1 : 0); + break; + } + case OP.GT: { + const b = this.pop(), a = this.pop(); + this.push(a > b ? 1 : 0); + break; + } + case OP.LE: { + const b = this.pop(), a = this.pop(); + this.push(a <= b ? 1 : 0); + break; + } + case OP.GE: { + const b = this.pop(), a = this.pop(); + this.push(a >= b ? 1 : 0); + break; + } + case OP.NOT: + this.push(this.pop() === 0 ? 1 : 0); + break; + case OP.RND: { + const n = this.pop(); + if (n <= 0) { + this.push(0); + break; + } + this.rng = rngNext(this.rng); + this.push(this.rng % n); + break; + } + case OP.WAIT: { + const n = this.pop(); + if (n > 0) { + this.status = "suspended"; + this.waiting = WAITING.FRAMES; + this.pendingPushOnResume = false; + // The host is told how long via lastWait (frame simulation is + // the driver's business, not the VM's). + this.lastWait = n; + return; + } + break; + } + default: + throw new Error(`illegal opcode 0x${op.toString(16)} at ip=${this.ip - 1}`); + } + } + } + + lastWait = 0; +} diff --git a/static/vm/rpg-host.ts b/static/vm/rpg-host.ts new file mode 100644 index 00000000..9445762b --- /dev/null +++ b/static/vm/rpg-host.ts @@ -0,0 +1,115 @@ +// static/vm/rpg-host.ts — host-side RPG syscalls for the reference VM. +// +// Used by compiler unit tests and the story simulator: it executes SAY/CHOICE +// /WARP/... by recording them into an event log and answering CHOICEs from a +// scripted queue. This is "the game with the consoles removed" — if a story +// plays correctly here, the C runtimes only have to render it. + +import { WAITING } from "../spec/isa.ts"; +import { RPG_OP, RPG_OP_OPERANDS } from "../spec/rpg.ts"; +import type { OperandReader, RefHost, RefVM, SyscallResult } from "./ref.ts"; + +export type RpgEvent = + | { kind: "say"; textId: number } + | { kind: "choice"; textIds: number[]; picked: number } + | { kind: "lock" } + | { kind: "release" } + | { kind: "face"; slot: number } + | { kind: "avis"; slot: number; visible: boolean } + | { kind: "warp"; map: number; x: number; y: number; dir: number } + | { kind: "sfx"; id: number } + | { kind: "wait"; frames: number }; + +/** + * Auto-playing host: SAY pages are dismissed immediately, CHOICEs answered + * from `picks` (in order; throws when exhausted), WAITs elapse instantly. + * `events` is the full ordered transcript. + */ +export class AutoRpgHost implements RefHost { + events: RpgEvent[] = []; + private picks: number[]; + private pickAt = 0; + + constructor(picks: number[] = []) { + this.picks = picks; + } + + /** Convenience: run a script to completion under this host. */ + play(vm: RefVM, scriptId: number): RpgEvent[] { + vm.start(scriptId); + while (vm.status === "suspended") { + if (vm.waiting === WAITING.FRAMES) { + this.events.push({ kind: "wait", frames: vm.lastWait }); + vm.resume(); + } else if (vm.waiting === WAITING.TEXT) { + vm.resume(); + } else if (vm.waiting === WAITING.CHOICE) { + const last = this.events[this.events.length - 1]; + if (!last || last.kind !== "choice") throw new Error("choice suspend without event"); + vm.resume(last.picked); + } else { + throw new Error(`unknown waiting state ${vm.waiting}`); + } + } + return this.events; + } + + syscall(op: number, r: OperandReader, _vm: RefVM): SyscallResult { + switch (op) { + case RPG_OP.SAY: { + const textId = r.u16(); + this.events.push({ kind: "say", textId }); + return { suspend: WAITING.TEXT }; + } + case RPG_OP.CHOICE: { + const n = r.u8(); + const textIds: number[] = []; + for (let i = 0; i < n; i++) textIds.push(r.u16()); + if (this.pickAt >= this.picks.length) { + throw new Error(`CHOICE #${this.pickAt + 1} but only ${this.picks.length} picks scripted`); + } + const picked = this.picks[this.pickAt++]; + if (picked < 0 || picked >= n) throw new Error(`scripted pick ${picked} out of 0..${n - 1}`); + this.events.push({ kind: "choice", textIds, picked }); + return { suspend: WAITING.CHOICE, pushOnResume: true }; + } + case RPG_OP.LOCK: + this.events.push({ kind: "lock" }); + return {}; + case RPG_OP.RELEASE: + this.events.push({ kind: "release" }); + return {}; + case RPG_OP.FACE: + this.events.push({ kind: "face", slot: r.u8() }); + return {}; + case RPG_OP.AVIS: { + const slot = r.u8(); + const visible = r.u8() !== 0; + this.events.push({ kind: "avis", slot, visible }); + return {}; + } + case RPG_OP.WARP: { + const map = r.u8(), x = r.u8(), y = r.u8(), dir = r.u8(); + this.events.push({ kind: "warp", map, x, y, dir }); + return {}; + } + case RPG_OP.SFX: + this.events.push({ kind: "sfx", id: r.u8() }); + return {}; + default: + throw new Error(`unknown RPG syscall 0x${op.toString(16)}`); + } + } + + /** say/choice text ids in order — handy for asserting story flow. */ + get textTrace(): number[] { + const out: number[] = []; + for (const e of this.events) { + if (e.kind === "say") out.push(e.textId); + if (e.kind === "choice") out.push(...e.textIds); + } + return out; + } +} + +export { RPG_OP, RPG_OP_OPERANDS }; diff --git a/tsconfig.json b/tsconfig.json index de1449a7..4b9e8c89 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,11 +17,11 @@ "ignoreDeprecations": "5.0", "baseUrl": ".", "paths": { - "@pocketjs/aot": ["./aot/dsl/index.ts"], - "@pocketjs/aot/compiler": ["./aot/compiler/index.ts"], - "@pocketjs/aot/jsx-runtime": ["./aot/dsl/jsx-runtime.ts"], - "@pocketjs/aot/jsx-dev-runtime": ["./aot/dsl/jsx-dev-runtime.ts"], - "@pocketjs/aot/spec": ["./aot/spec/pjgb.ts"], + "@pocketjs/static": ["./static/rpg/dsl.ts"], + "@pocketjs/static/rpg": ["./static/rpg/dsl.ts"], + "@pocketjs/static/rpg/battle": ["./static/rpg/battle.ts"], + "@pocketjs/static/compiler": ["./static/compiler/index.ts"], + "@pocketjs/static/spec": ["./static/spec/isa.ts"], "@pocketjs/framework": ["./src/index.ts"], "@pocketjs/framework/animation": ["./src/animation.ts"], "@pocketjs/framework/config": ["./src/config.ts"], @@ -44,5 +44,5 @@ "@pocketjs/framework/vue-vapor/renderer": ["./src/renderer-vue-vapor.ts"] } }, - "include": ["src", "compiler", "demos", "test", "spec", "host-web", "aot"] + "include": ["src", "compiler", "demos", "test", "spec", "host-web", "static"] }